Add persisted LogStorm attendance integration
This commit is contained in:
parent
78e758aedf
commit
625468090b
@ -1,5 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from api.v1.employees.serializers import EmployeeBriefSerializer
|
||||
|
||||
|
||||
class DateOverrideSerializer(serializers.Serializer):
|
||||
date = serializers.DateField()
|
||||
@ -33,3 +36,141 @@ class LogStormAttendanceAnalyzeSerializer(serializers.Serializer):
|
||||
"period_start must be less than or equal to period_end"
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer):
|
||||
employee_ids = serializers.CharField()
|
||||
month = serializers.RegexField(r"^\d{4}-\d{2}$")
|
||||
|
||||
def validate_employee_ids(self, value):
|
||||
employee_ids = []
|
||||
for raw_item in value.split(","):
|
||||
item = raw_item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
employee_id = int(item)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError(
|
||||
"employee_ids must be a comma-separated list of integers"
|
||||
) from exc
|
||||
if employee_id not in employee_ids:
|
||||
employee_ids.append(employee_id)
|
||||
|
||||
if not employee_ids:
|
||||
raise serializers.ValidationError("employee_ids is required")
|
||||
return employee_ids
|
||||
|
||||
def validate_month(self, value):
|
||||
year, month = [int(part) for part in value.split("-")]
|
||||
if month < 1 or month > 12:
|
||||
raise serializers.ValidationError("month must be between 01 and 12")
|
||||
return value
|
||||
|
||||
|
||||
class AttendanceAnalysisRunSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AttendanceAnalysisRun
|
||||
fields = (
|
||||
"id",
|
||||
"employee",
|
||||
"period_start",
|
||||
"period_end",
|
||||
"status",
|
||||
"schedule_payload",
|
||||
"request_payload",
|
||||
"response_payload",
|
||||
"error",
|
||||
"triggered_by",
|
||||
"created_at",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class AttendanceRecordSerializer(serializers.ModelSerializer):
|
||||
analysis_run_id = serializers.IntegerField(read_only=True)
|
||||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||||
non_working_reason = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AttendanceRecord
|
||||
fields = (
|
||||
"id",
|
||||
"analysis_run_id",
|
||||
"employee",
|
||||
"date",
|
||||
"display_name",
|
||||
"arrival_time",
|
||||
"departure_time",
|
||||
"work_hours",
|
||||
"expected_hours",
|
||||
"is_workday",
|
||||
"effective_is_workday",
|
||||
"non_working_reason",
|
||||
"is_late",
|
||||
"late_minutes",
|
||||
"is_early_leave",
|
||||
"early_leave_minutes",
|
||||
"is_underwork",
|
||||
"underwork_hours",
|
||||
"is_overtime",
|
||||
"overtime_hours",
|
||||
"is_absent",
|
||||
"statuses",
|
||||
"employee_issues",
|
||||
"technical_issues",
|
||||
"personnel_status",
|
||||
"personnel_status_label",
|
||||
"personnel_action",
|
||||
"is_manually_edited",
|
||||
"manual_edit_payload",
|
||||
"manual_edited_by",
|
||||
"manual_edited_at",
|
||||
"raw_data",
|
||||
"comments_count",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
def get_non_working_reason(self, obj):
|
||||
if obj.effective_is_workday:
|
||||
return ""
|
||||
if obj.personnel_status_label:
|
||||
return obj.personnel_status_label
|
||||
if not obj.is_workday:
|
||||
return "Выходной по графику/календарю"
|
||||
return "Нерабочий день"
|
||||
|
||||
|
||||
class AttendanceRecordUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AttendanceRecord
|
||||
fields = (
|
||||
"arrival_time",
|
||||
"departure_time",
|
||||
"work_hours",
|
||||
"expected_hours",
|
||||
"is_workday",
|
||||
"effective_is_workday",
|
||||
"is_late",
|
||||
"late_minutes",
|
||||
"is_early_leave",
|
||||
"early_leave_minutes",
|
||||
"is_underwork",
|
||||
"underwork_hours",
|
||||
"is_overtime",
|
||||
"overtime_hours",
|
||||
"is_absent",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"arrival_time": {"required": False, "allow_blank": True, "allow_null": True},
|
||||
"departure_time": {"required": False, "allow_blank": True, "allow_null": True},
|
||||
}
|
||||
|
||||
|
||||
class AttendanceRecordCommentSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
record = serializers.IntegerField(read_only=True)
|
||||
author = EmployeeBriefSerializer(read_only=True)
|
||||
text = serializers.CharField()
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
@ -1,25 +1,100 @@
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from datetime import date
|
||||
|
||||
from common.logstorm_attendance import analyze_employee_attendance
|
||||
from common.logstorm_client import LogStormClientError
|
||||
from attendance.models import AttendanceRecord
|
||||
from attendance.services import (
|
||||
build_monthly_attendance_matrix,
|
||||
normalize_attendance_record_manual_issues,
|
||||
save_logstorm_attendance_result,
|
||||
)
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from common.logstorm_attendance import (
|
||||
analyze_employee_attendance,
|
||||
build_logstorm_attendance_payload,
|
||||
)
|
||||
from common.logstorm_client import LogStormClient, LogStormClientError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from employees.models import Employee
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .serializers import LogStormAttendanceAnalyzeSerializer
|
||||
from .serializers import (
|
||||
AttendanceMonthlyMatrixQuerySerializer,
|
||||
AttendanceRecordCommentSerializer,
|
||||
AttendanceRecordSerializer,
|
||||
AttendanceRecordUpdateSerializer,
|
||||
LogStormAttendanceAnalyzeSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _can_access_employee(user, employee_id) -> bool:
|
||||
try:
|
||||
normalized_employee_id = int(employee_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
user
|
||||
and user.is_authenticated
|
||||
and (user.is_staff or normalized_employee_id == int(user.id))
|
||||
)
|
||||
|
||||
|
||||
def _check_employee_access(user, employee_id) -> None:
|
||||
if not _can_access_employee(user, employee_id):
|
||||
raise PermissionDenied(
|
||||
"You don't have permission to access this attendance data"
|
||||
)
|
||||
|
||||
|
||||
def _records_visible_to(user):
|
||||
queryset = AttendanceRecord.objects.all()
|
||||
if user.is_staff:
|
||||
return queryset
|
||||
return queryset.filter(employee_id=user.id)
|
||||
|
||||
|
||||
def _comments_count_annotation():
|
||||
record_ct = ContentType.objects.get_for_model(AttendanceRecord)
|
||||
comments_subquery = (
|
||||
Message.objects.filter(
|
||||
chat__type="comments",
|
||||
chat__context_content_type=record_ct,
|
||||
chat__context_object_id=OuterRef("pk"),
|
||||
is_deleted=False,
|
||||
)
|
||||
.values("chat")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
return {
|
||||
"comments_count": Coalesce(
|
||||
Subquery(comments_subquery, output_field=IntegerField()),
|
||||
Value(0),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class LogStormAttendanceAnalyzeAPIView(APIView):
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
serializer = LogStormAttendanceAnalyzeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
_check_employee_access(request.user, data["employee_id"])
|
||||
|
||||
try:
|
||||
employee = Employee.objects.get(pk=data["employee_id"])
|
||||
@ -47,4 +122,239 @@ class LogStormAttendanceAnalyzeAPIView(APIView):
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
request_payload = build_logstorm_attendance_payload(
|
||||
employee=employee,
|
||||
period_start=data["period_start"],
|
||||
period_end=data["period_end"],
|
||||
schedule=data.get("schedule"),
|
||||
)
|
||||
save_logstorm_attendance_result(
|
||||
employee=employee,
|
||||
period_start=data["period_start"],
|
||||
period_end=data["period_end"],
|
||||
schedule_payload=request_payload.get("schedule"),
|
||||
request_payload=request_payload,
|
||||
response_payload=result,
|
||||
triggered_by=request.user,
|
||||
)
|
||||
|
||||
return Response(result)
|
||||
|
||||
|
||||
class AttendanceRecordListAPIView(ListAPIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = AttendanceRecordSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = (
|
||||
_records_visible_to(self.request.user)
|
||||
.select_related("employee", "analysis_run")
|
||||
.annotate(**_comments_count_annotation())
|
||||
.all()
|
||||
.order_by("-date", "-id")
|
||||
)
|
||||
|
||||
employee_id = self.request.query_params.get("employee_id")
|
||||
if employee_id:
|
||||
_check_employee_access(self.request.user, employee_id)
|
||||
queryset = queryset.filter(employee_id=employee_id)
|
||||
|
||||
date_from = self.request.query_params.get("date_from")
|
||||
if date_from:
|
||||
queryset = queryset.filter(date__gte=date_from)
|
||||
|
||||
date_to = self.request.query_params.get("date_to")
|
||||
if date_to:
|
||||
queryset = queryset.filter(date__lte=date_to)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class AttendanceMonthlyMatrixAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
serializer = AttendanceMonthlyMatrixQuerySerializer(
|
||||
data=request.query_params,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
employee_ids = serializer.validated_data["employee_ids"]
|
||||
year, month = [
|
||||
int(part) for part in serializer.validated_data["month"].split("-")
|
||||
]
|
||||
|
||||
for employee_id in employee_ids:
|
||||
_check_employee_access(request.user, employee_id)
|
||||
|
||||
employees = list(Employee.objects.filter(id__in=employee_ids))
|
||||
employees_by_id = {employee.id: employee for employee in employees}
|
||||
employees = [
|
||||
employees_by_id[employee_id]
|
||||
for employee_id in employee_ids
|
||||
if employee_id in employees_by_id
|
||||
]
|
||||
|
||||
start_date = date(year, month, 1)
|
||||
end_date = date(year, month, monthrange(year, month)[1])
|
||||
records = list(
|
||||
_records_visible_to(request.user)
|
||||
.filter(
|
||||
employee_id__in=[employee.id for employee in employees],
|
||||
date__gte=start_date,
|
||||
date__lte=end_date,
|
||||
)
|
||||
.select_related("employee")
|
||||
.annotate(**_comments_count_annotation())
|
||||
.order_by("employee_id", "date", "id")
|
||||
)
|
||||
|
||||
return Response(
|
||||
build_monthly_attendance_matrix(
|
||||
employees=employees,
|
||||
records=records,
|
||||
year=year,
|
||||
month=month,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AttendanceRecordDetailAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def patch(self, request, record_id):
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied(
|
||||
"You don't have permission to edit attendance records"
|
||||
)
|
||||
|
||||
record = get_object_or_404(
|
||||
_records_visible_to(request.user)
|
||||
.select_related("employee", "analysis_run")
|
||||
.annotate(**_comments_count_annotation()),
|
||||
pk=record_id,
|
||||
)
|
||||
serializer = AttendanceRecordUpdateSerializer(
|
||||
record,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
manual_payload = dict(serializer.validated_data)
|
||||
|
||||
try:
|
||||
logstorm_override = LogStormClient().update_attendance_override(
|
||||
employee_id=str(record.employee_id),
|
||||
record_date=record.date,
|
||||
payload={**manual_payload, "source": "eusrr"},
|
||||
)
|
||||
except LogStormClientError as exc:
|
||||
logger.warning("LogStorm attendance override failed: %s", exc)
|
||||
return Response(
|
||||
{"detail": str(exc), "error": "logstorm_unavailable"},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
record = serializer.save(
|
||||
is_manually_edited=True,
|
||||
manual_edit_payload=manual_payload,
|
||||
manual_edited_by=request.user,
|
||||
manual_edited_at=timezone.now(),
|
||||
)
|
||||
normalize_attendance_record_manual_issues(record, manual_payload)
|
||||
record.raw_data = {
|
||||
**record.raw_data,
|
||||
"manual_edited": True,
|
||||
"manual_edit_payload": manual_payload,
|
||||
"logstorm_override": logstorm_override,
|
||||
}
|
||||
record.save(
|
||||
update_fields=[
|
||||
"statuses",
|
||||
"employee_issues",
|
||||
"raw_data",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
record = (
|
||||
_records_visible_to(request.user)
|
||||
.select_related("employee", "analysis_run")
|
||||
.annotate(**_comments_count_annotation())
|
||||
.get(pk=record.pk)
|
||||
)
|
||||
return Response(AttendanceRecordSerializer(record).data)
|
||||
|
||||
|
||||
class AttendanceRecordCommentsAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_record(self, request, record_id):
|
||||
return get_object_or_404(_records_visible_to(request.user), pk=record_id)
|
||||
|
||||
@staticmethod
|
||||
def serialize_message(record, message):
|
||||
payload = {
|
||||
"id": message.id,
|
||||
"record": record.id,
|
||||
"author": message.author,
|
||||
"text": message.content,
|
||||
"created_at": message.created_at,
|
||||
}
|
||||
return AttendanceRecordCommentSerializer(payload).data
|
||||
|
||||
def get(self, request, record_id):
|
||||
record = self.get_record(request, record_id)
|
||||
messages = comments_helpers.get_comments(record)
|
||||
return Response(
|
||||
[self.serialize_message(record, message) for message in messages]
|
||||
)
|
||||
|
||||
def post(self, request, record_id):
|
||||
record = self.get_record(request, record_id)
|
||||
serializer = AttendanceRecordCommentSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
message = comments_helpers.create_comment(
|
||||
obj=record,
|
||||
author=request.user,
|
||||
content=serializer.validated_data["text"].strip(),
|
||||
)
|
||||
|
||||
return Response(
|
||||
self.serialize_message(record, message),
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class AttendanceRecordCommentDetailAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def delete(self, request, record_id, comment_id):
|
||||
record = get_object_or_404(_records_visible_to(request.user), pk=record_id)
|
||||
chat = comments_helpers.get_comments_chat_if_exists(record)
|
||||
if chat is None:
|
||||
return Response(
|
||||
{"detail": "Comment not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
message = Message.objects.get(id=comment_id, chat=chat)
|
||||
except Message.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Comment not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if message.author != request.user:
|
||||
return Response(
|
||||
{"detail": "You don't have permission to delete this comment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
comments_helpers.delete_comment(
|
||||
message=message,
|
||||
deleted_by=request.user,
|
||||
soft_delete=True,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@ -29,7 +29,14 @@ from .employees.views import (
|
||||
from .feed.views import PostViewSet
|
||||
from .requests_app.views import RequestViewSet
|
||||
from .search.views import search_api_view
|
||||
from .attendance.views import LogStormAttendanceAnalyzeAPIView
|
||||
from .attendance.views import (
|
||||
AttendanceMonthlyMatrixAPIView,
|
||||
AttendanceRecordCommentDetailAPIView,
|
||||
AttendanceRecordCommentsAPIView,
|
||||
AttendanceRecordDetailAPIView,
|
||||
AttendanceRecordListAPIView,
|
||||
LogStormAttendanceAnalyzeAPIView,
|
||||
)
|
||||
|
||||
app_name = "v1"
|
||||
|
||||
@ -94,6 +101,31 @@ urlpatterns = [
|
||||
LogStormAttendanceAnalyzeAPIView.as_view(),
|
||||
name="logstorm-attendance-analyze",
|
||||
),
|
||||
path(
|
||||
"attendance/records/",
|
||||
AttendanceRecordListAPIView.as_view(),
|
||||
name="attendance-records",
|
||||
),
|
||||
path(
|
||||
"attendance/monthly-matrix/",
|
||||
AttendanceMonthlyMatrixAPIView.as_view(),
|
||||
name="attendance-monthly-matrix",
|
||||
),
|
||||
path(
|
||||
"attendance/records/<int:record_id>/",
|
||||
AttendanceRecordDetailAPIView.as_view(),
|
||||
name="attendance-record-detail",
|
||||
),
|
||||
path(
|
||||
"attendance/records/<int:record_id>/comments/",
|
||||
AttendanceRecordCommentsAPIView.as_view(),
|
||||
name="attendance-record-comments",
|
||||
),
|
||||
path(
|
||||
"attendance/records/<int:record_id>/comments/<int:comment_id>/",
|
||||
AttendanceRecordCommentDetailAPIView.as_view(),
|
||||
name="attendance-record-comment-detail",
|
||||
),
|
||||
path("directory/", include("api.v1.directory.urls")),
|
||||
# Notifications API (из самого модуля notifications)
|
||||
path("notifications/", include("notifications.api.urls")),
|
||||
|
||||
1
backend/attendance/__init__.py
Normal file
1
backend/attendance/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
44
backend/attendance/admin.py
Normal file
44
backend/attendance/admin.py
Normal file
@ -0,0 +1,44 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
|
||||
|
||||
@admin.register(AttendanceAnalysisRun)
|
||||
class AttendanceAnalysisRunAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"employee",
|
||||
"period_start",
|
||||
"period_end",
|
||||
"status",
|
||||
"triggered_by",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("status", "created_at")
|
||||
search_fields = ("employee__email", "employee__first_name", "employee__last_name")
|
||||
readonly_fields = ("created_at",)
|
||||
|
||||
|
||||
@admin.register(AttendanceRecord)
|
||||
class AttendanceRecordAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"employee",
|
||||
"date",
|
||||
"is_workday",
|
||||
"arrival_time",
|
||||
"departure_time",
|
||||
"work_hours",
|
||||
"is_late",
|
||||
"is_absent",
|
||||
"is_overtime",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = (
|
||||
"is_workday",
|
||||
"is_late",
|
||||
"is_absent",
|
||||
"is_overtime",
|
||||
"date",
|
||||
)
|
||||
search_fields = ("employee__email", "employee__first_name", "employee__last_name")
|
||||
readonly_fields = ("updated_at",)
|
||||
7
backend/attendance/apps.py
Normal file
7
backend/attendance/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AttendanceConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "attendance"
|
||||
verbose_name = "Посещаемость"
|
||||
260
backend/attendance/migrations/0001_initial.py
Normal file
260
backend/attendance/migrations/0001_initial.py
Normal file
@ -0,0 +1,260 @@
|
||||
# Generated by Django 5.2.4 on 2026-04-21 14:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AttendanceAnalysisRun",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("period_start", models.DateField(verbose_name="Начало периода")),
|
||||
("period_end", models.DateField(verbose_name="Конец периода")),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[("success", "Успешно"), ("failed", "Ошибка")],
|
||||
default="success",
|
||||
max_length=16,
|
||||
verbose_name="Статус",
|
||||
),
|
||||
),
|
||||
(
|
||||
"schedule_payload",
|
||||
models.JSONField(blank=True, null=True, verbose_name="График"),
|
||||
),
|
||||
(
|
||||
"request_payload",
|
||||
models.JSONField(blank=True, default=dict, verbose_name="Запрос"),
|
||||
),
|
||||
(
|
||||
"response_payload",
|
||||
models.JSONField(blank=True, default=dict, verbose_name="Ответ"),
|
||||
),
|
||||
("error", models.TextField(blank=True, verbose_name="Ошибка")),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
|
||||
),
|
||||
(
|
||||
"employee",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="attendance_analysis_runs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Сотрудник",
|
||||
),
|
||||
),
|
||||
(
|
||||
"triggered_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="triggered_attendance_analysis_runs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Запустил",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Запуск анализа посещаемости",
|
||||
"verbose_name_plural": "Запуски анализа посещаемости",
|
||||
"ordering": ["-created_at", "-id"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AttendanceRecord",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date", models.DateField(verbose_name="Дата")),
|
||||
(
|
||||
"display_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Имя из анализа"
|
||||
),
|
||||
),
|
||||
(
|
||||
"arrival_time",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=32,
|
||||
null=True,
|
||||
verbose_name="Время прихода",
|
||||
),
|
||||
),
|
||||
(
|
||||
"departure_time",
|
||||
models.CharField(
|
||||
blank=True, max_length=32, null=True, verbose_name="Время ухода"
|
||||
),
|
||||
),
|
||||
(
|
||||
"work_hours",
|
||||
models.FloatField(
|
||||
blank=True, null=True, verbose_name="Отработано часов"
|
||||
),
|
||||
),
|
||||
(
|
||||
"expected_hours",
|
||||
models.FloatField(
|
||||
blank=True, null=True, verbose_name="Ожидаемо часов"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_workday",
|
||||
models.BooleanField(default=True, verbose_name="Рабочий день"),
|
||||
),
|
||||
(
|
||||
"is_late",
|
||||
models.BooleanField(default=False, verbose_name="Опоздание"),
|
||||
),
|
||||
(
|
||||
"late_minutes",
|
||||
models.IntegerField(
|
||||
blank=True, null=True, verbose_name="Минут опоздания"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_early_leave",
|
||||
models.BooleanField(default=False, verbose_name="Ранний уход"),
|
||||
),
|
||||
(
|
||||
"early_leave_minutes",
|
||||
models.IntegerField(
|
||||
blank=True, null=True, verbose_name="Минут раннего ухода"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_underwork",
|
||||
models.BooleanField(default=False, verbose_name="Недоработка"),
|
||||
),
|
||||
(
|
||||
"underwork_hours",
|
||||
models.FloatField(
|
||||
blank=True, null=True, verbose_name="Часов недоработки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_overtime",
|
||||
models.BooleanField(default=False, verbose_name="Переработка"),
|
||||
),
|
||||
(
|
||||
"overtime_hours",
|
||||
models.FloatField(
|
||||
blank=True, null=True, verbose_name="Часов переработки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_absent",
|
||||
models.BooleanField(default=False, verbose_name="Отсутствие"),
|
||||
),
|
||||
(
|
||||
"statuses",
|
||||
models.JSONField(blank=True, default=list, verbose_name="Статусы"),
|
||||
),
|
||||
(
|
||||
"employee_issues",
|
||||
models.JSONField(
|
||||
blank=True, default=list, verbose_name="Проблемы сотрудника"
|
||||
),
|
||||
),
|
||||
(
|
||||
"technical_issues",
|
||||
models.JSONField(
|
||||
blank=True, default=list, verbose_name="Технические проблемы"
|
||||
),
|
||||
),
|
||||
(
|
||||
"raw_data",
|
||||
models.JSONField(
|
||||
blank=True, default=dict, verbose_name="Исходная запись анализа"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
|
||||
),
|
||||
(
|
||||
"analysis_run",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="records",
|
||||
to="attendance.attendanceanalysisrun",
|
||||
verbose_name="Запуск анализа",
|
||||
),
|
||||
),
|
||||
(
|
||||
"employee",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="attendance_records",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Сотрудник",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Запись посещаемости",
|
||||
"verbose_name_plural": "Записи посещаемости",
|
||||
"ordering": ["-date", "-id"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="attendanceanalysisrun",
|
||||
index=models.Index(
|
||||
fields=["employee", "period_start", "period_end"],
|
||||
name="attendance__employe_3d4cb2_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="attendanceanalysisrun",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="attendance__created_9a0a3e_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="attendancerecord",
|
||||
index=models.Index(
|
||||
fields=["employee", "date"], name="attendance__employe_b6a945_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="attendancerecord",
|
||||
index=models.Index(
|
||||
fields=["analysis_run"], name="attendance__analysi_db7fa7_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="attendancerecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("employee", "date"),
|
||||
name="attendance_record_employee_date_unique",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.2.4 on 2026-04-21 17:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("attendance", "0001_initial"),
|
||||
("employees", "0048_alter_employeeaction_action_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="effective_is_workday",
|
||||
field=models.BooleanField(
|
||||
default=True, verbose_name="Рабочий день с учетом кадрового состояния"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="personnel_action",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="attendance_records",
|
||||
to="employees.employeeaction",
|
||||
verbose_name="Кадровое событие",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="personnel_status",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="normal",
|
||||
max_length=64,
|
||||
verbose_name="Кадровое состояние",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="personnel_status_label",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=128,
|
||||
verbose_name="Кадровое состояние для отображения",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="attendancerecord",
|
||||
index=models.Index(
|
||||
fields=["personnel_status"], name="attendance__personn_dbbd98_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,57 @@
|
||||
# Generated by Codex on 2026-04-22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("attendance", "0002_attendancerecord_effective_is_workday_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="is_manually_edited",
|
||||
field=models.BooleanField(default=False, verbose_name="Изменено вручную"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="manual_edit_payload",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Ручная корректировка",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="manual_edited_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Время ручного изменения",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attendancerecord",
|
||||
name="manual_edited_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="manual_attendance_edits",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Изменил вручную",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="attendancerecord",
|
||||
index=models.Index(
|
||||
fields=["is_manually_edited"],
|
||||
name="attendance__is_manu_07f532_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/attendance/migrations/__init__.py
Normal file
1
backend/attendance/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
171
backend/attendance/models.py
Normal file
171
backend/attendance/models.py
Normal file
@ -0,0 +1,171 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class AttendanceAnalysisRun(models.Model):
|
||||
STATUS_SUCCESS = "success"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_SUCCESS, "Успешно"),
|
||||
(STATUS_FAILED, "Ошибка"),
|
||||
)
|
||||
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attendance_analysis_runs",
|
||||
verbose_name="Сотрудник",
|
||||
)
|
||||
period_start = models.DateField("Начало периода")
|
||||
period_end = models.DateField("Конец периода")
|
||||
status = models.CharField(
|
||||
"Статус",
|
||||
max_length=16,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_SUCCESS,
|
||||
)
|
||||
schedule_payload = models.JSONField("График", null=True, blank=True)
|
||||
request_payload = models.JSONField("Запрос", default=dict, blank=True)
|
||||
response_payload = models.JSONField("Ответ", default=dict, blank=True)
|
||||
error = models.TextField("Ошибка", blank=True)
|
||||
triggered_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="triggered_attendance_analysis_runs",
|
||||
verbose_name="Запустил",
|
||||
)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at", "-id"]
|
||||
verbose_name = "Запуск анализа посещаемости"
|
||||
verbose_name_plural = "Запуски анализа посещаемости"
|
||||
indexes = [
|
||||
models.Index(fields=["employee", "period_start", "period_end"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.employee_id}: {self.period_start} - {self.period_end}"
|
||||
|
||||
|
||||
class AttendanceRecord(models.Model):
|
||||
analysis_run = models.ForeignKey(
|
||||
AttendanceAnalysisRun,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="records",
|
||||
verbose_name="Запуск анализа",
|
||||
)
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attendance_records",
|
||||
verbose_name="Сотрудник",
|
||||
)
|
||||
date = models.DateField("Дата")
|
||||
display_name = models.CharField("Имя из анализа", max_length=255, blank=True)
|
||||
arrival_time = models.CharField(
|
||||
"Время прихода",
|
||||
max_length=32,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
departure_time = models.CharField(
|
||||
"Время ухода",
|
||||
max_length=32,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
work_hours = models.FloatField("Отработано часов", null=True, blank=True)
|
||||
expected_hours = models.FloatField("Ожидаемо часов", null=True, blank=True)
|
||||
is_workday = models.BooleanField("Рабочий день", default=True)
|
||||
is_late = models.BooleanField("Опоздание", default=False)
|
||||
late_minutes = models.IntegerField("Минут опоздания", null=True, blank=True)
|
||||
is_early_leave = models.BooleanField("Ранний уход", default=False)
|
||||
early_leave_minutes = models.IntegerField(
|
||||
"Минут раннего ухода",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
is_underwork = models.BooleanField("Недоработка", default=False)
|
||||
underwork_hours = models.FloatField("Часов недоработки", null=True, blank=True)
|
||||
is_overtime = models.BooleanField("Переработка", default=False)
|
||||
overtime_hours = models.FloatField("Часов переработки", null=True, blank=True)
|
||||
is_absent = models.BooleanField("Отсутствие", default=False)
|
||||
statuses = models.JSONField("Статусы", default=list, blank=True)
|
||||
employee_issues = models.JSONField("Проблемы сотрудника", default=list, blank=True)
|
||||
technical_issues = models.JSONField(
|
||||
"Технические проблемы", default=list, blank=True
|
||||
)
|
||||
personnel_status = models.CharField(
|
||||
"Кадровое состояние",
|
||||
max_length=64,
|
||||
default="normal",
|
||||
blank=True,
|
||||
)
|
||||
personnel_status_label = models.CharField(
|
||||
"Кадровое состояние для отображения",
|
||||
max_length=128,
|
||||
blank=True,
|
||||
)
|
||||
personnel_action = models.ForeignKey(
|
||||
"employees.EmployeeAction",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="attendance_records",
|
||||
verbose_name="Кадровое событие",
|
||||
)
|
||||
effective_is_workday = models.BooleanField(
|
||||
"Рабочий день с учетом кадрового состояния",
|
||||
default=True,
|
||||
)
|
||||
is_manually_edited = models.BooleanField(
|
||||
"Изменено вручную",
|
||||
default=False,
|
||||
)
|
||||
manual_edit_payload = models.JSONField(
|
||||
"Ручная корректировка",
|
||||
default=dict,
|
||||
blank=True,
|
||||
)
|
||||
manual_edited_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="manual_attendance_edits",
|
||||
verbose_name="Изменил вручную",
|
||||
)
|
||||
manual_edited_at = models.DateTimeField(
|
||||
"Время ручного изменения",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
raw_data = models.JSONField("Исходная запись анализа", default=dict, blank=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date", "-id"]
|
||||
verbose_name = "Запись посещаемости"
|
||||
verbose_name_plural = "Записи посещаемости"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["employee", "date"],
|
||||
name="attendance_record_employee_date_unique",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["employee", "date"]),
|
||||
models.Index(fields=["analysis_run"]),
|
||||
models.Index(fields=["personnel_status"]),
|
||||
models.Index(
|
||||
fields=["is_manually_edited"],
|
||||
name="attendance__is_manu_07f532_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.employee_id}: {self.date}"
|
||||
599
backend/attendance/services.py
Normal file
599
backend/attendance/services.py
Normal file
@ -0,0 +1,599 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from calendar import monthrange
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from employees.constants import (
|
||||
ACTION_DISMISSED,
|
||||
ACTION_ON_DAY_OFF,
|
||||
ACTION_ON_LEAVE,
|
||||
ACTION_ON_MATERNITY,
|
||||
ACTION_ON_SICK_LEAVE,
|
||||
)
|
||||
from employees.models import Employee
|
||||
|
||||
PERSONNEL_STATUS_NORMAL = "normal"
|
||||
|
||||
MONTHS_RU = {
|
||||
1: "Январь",
|
||||
2: "Февраль",
|
||||
3: "Март",
|
||||
4: "Апрель",
|
||||
5: "Май",
|
||||
6: "Июнь",
|
||||
7: "Июль",
|
||||
8: "Август",
|
||||
9: "Сентябрь",
|
||||
10: "Октябрь",
|
||||
11: "Ноябрь",
|
||||
12: "Декабрь",
|
||||
}
|
||||
|
||||
DAYS_RU = {
|
||||
0: "Пн",
|
||||
1: "Вт",
|
||||
2: "Ср",
|
||||
3: "Чт",
|
||||
4: "Пт",
|
||||
5: "Сб",
|
||||
6: "Вс",
|
||||
}
|
||||
|
||||
NON_WORKING_PERSONNEL_ACTIONS = {
|
||||
ACTION_ON_LEAVE,
|
||||
ACTION_ON_SICK_LEAVE,
|
||||
ACTION_ON_DAY_OFF,
|
||||
ACTION_ON_MATERNITY,
|
||||
ACTION_DISMISSED,
|
||||
}
|
||||
|
||||
SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = (
|
||||
"absence",
|
||||
"absent",
|
||||
"late",
|
||||
"early",
|
||||
"underwork",
|
||||
"отсутств",
|
||||
"опозд",
|
||||
"ранн",
|
||||
"недоработ",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonnelDayState:
|
||||
status: str = PERSONNEL_STATUS_NORMAL
|
||||
label: str = ""
|
||||
action_id: int | None = None
|
||||
is_non_working: bool = False
|
||||
|
||||
|
||||
def save_logstorm_attendance_result(
|
||||
*,
|
||||
employee: Employee,
|
||||
period_start: date,
|
||||
period_end: date,
|
||||
request_payload: dict[str, Any],
|
||||
response_payload: dict[str, Any],
|
||||
schedule_payload: dict[str, Any] | None = None,
|
||||
triggered_by: Employee | None = None,
|
||||
) -> AttendanceAnalysisRun:
|
||||
records = response_payload.get("records")
|
||||
if not isinstance(records, list):
|
||||
records = []
|
||||
|
||||
personnel_actions = list(
|
||||
employee.actions.filter(date__date__lte=period_end).order_by("date", "id")
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=employee,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
status=AttendanceAnalysisRun.STATUS_SUCCESS,
|
||||
schedule_payload=schedule_payload,
|
||||
request_payload=request_payload,
|
||||
response_payload=response_payload,
|
||||
triggered_by=triggered_by
|
||||
if getattr(triggered_by, "is_authenticated", False)
|
||||
else None,
|
||||
)
|
||||
|
||||
for raw_record in records:
|
||||
if not isinstance(raw_record, dict):
|
||||
continue
|
||||
|
||||
record_date = _parse_record_date(raw_record.get("date"))
|
||||
if record_date is None:
|
||||
continue
|
||||
personnel_state = resolve_employee_day_state(
|
||||
personnel_actions,
|
||||
record_date,
|
||||
)
|
||||
record_defaults = build_attendance_record_defaults(
|
||||
raw_record,
|
||||
personnel_state,
|
||||
)
|
||||
existing_record = AttendanceRecord.objects.filter(
|
||||
employee=employee,
|
||||
date=record_date,
|
||||
).first()
|
||||
if (
|
||||
existing_record
|
||||
and existing_record.is_manually_edited
|
||||
and not raw_record.get("manual_edited")
|
||||
):
|
||||
record_defaults = apply_manual_edit_payload_to_defaults(
|
||||
record_defaults,
|
||||
existing_record.manual_edit_payload,
|
||||
)
|
||||
record_defaults.update(
|
||||
{
|
||||
"is_manually_edited": True,
|
||||
"manual_edit_payload": existing_record.manual_edit_payload,
|
||||
"manual_edited_by": existing_record.manual_edited_by,
|
||||
"manual_edited_at": existing_record.manual_edited_at,
|
||||
}
|
||||
)
|
||||
|
||||
AttendanceRecord.objects.update_or_create(
|
||||
employee=employee,
|
||||
date=record_date,
|
||||
defaults={"analysis_run": run, **record_defaults},
|
||||
)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def build_monthly_attendance_matrix(
|
||||
*,
|
||||
employees,
|
||||
records,
|
||||
year: int,
|
||||
month: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an Excel-like monthly attendance matrix for API/UI usage."""
|
||||
employees = list(employees)
|
||||
records_by_key = {
|
||||
(record.employee_id, record.date): record
|
||||
for record in records
|
||||
}
|
||||
days_in_month = monthrange(year, month)[1]
|
||||
dates = [date(year, month, day) for day in range(1, days_in_month + 1)]
|
||||
|
||||
rows = []
|
||||
for current_date in dates:
|
||||
row = {
|
||||
"date": current_date.isoformat(),
|
||||
"label": f"{current_date.day} ({DAYS_RU[current_date.weekday()]})",
|
||||
"weekday": DAYS_RU[current_date.weekday()],
|
||||
"is_weekend": current_date.weekday() >= 5,
|
||||
"cells": {},
|
||||
}
|
||||
for employee in employees:
|
||||
record = records_by_key.get((employee.id, current_date))
|
||||
row["cells"][str(employee.id)] = _monthly_matrix_cell(record)
|
||||
rows.append(row)
|
||||
|
||||
summary_rows = [
|
||||
("accounted_days", "Учтено(дней)", _summary_accounted_days),
|
||||
("unaccounted_days", "Неучтено(дней)", _summary_unaccounted_days),
|
||||
("worked_hours", "Отработано(часов)", _summary_worked_hours),
|
||||
("late_days", "Опозданий", _summary_late_days),
|
||||
("early_leave_days", "Ранних уходов", _summary_early_leave_days),
|
||||
("overtime_days", "Переработок", _summary_overtime_days),
|
||||
("absent_days", "Отсутствий", _summary_absent_days),
|
||||
]
|
||||
summary = []
|
||||
for key, label, counter in summary_rows:
|
||||
summary.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"values": {
|
||||
str(employee.id): counter(
|
||||
[
|
||||
records_by_key[(employee.id, current_date)]
|
||||
for current_date in dates
|
||||
if (employee.id, current_date) in records_by_key
|
||||
]
|
||||
)
|
||||
for employee in employees
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"month": f"{year:04d}-{month:02d}",
|
||||
"month_label": f"{MONTHS_RU[month]} {year}",
|
||||
"employees": [
|
||||
{
|
||||
"id": employee.id,
|
||||
"name": _employee_display_name(employee),
|
||||
"email": getattr(employee, "email", "") or "",
|
||||
}
|
||||
for employee in employees
|
||||
],
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def resolve_employee_day_state(
|
||||
personnel_actions,
|
||||
record_date: date,
|
||||
) -> PersonnelDayState:
|
||||
day_actions = [
|
||||
action
|
||||
for action in personnel_actions
|
||||
if action.date and action.date.date() <= record_date
|
||||
]
|
||||
if not day_actions:
|
||||
return PersonnelDayState()
|
||||
|
||||
action = day_actions[-1]
|
||||
if action.action not in NON_WORKING_PERSONNEL_ACTIONS:
|
||||
return PersonnelDayState(action_id=action.id)
|
||||
|
||||
return PersonnelDayState(
|
||||
status=action.action,
|
||||
label=action.get_action_display(),
|
||||
action_id=action.id,
|
||||
is_non_working=True,
|
||||
)
|
||||
|
||||
|
||||
def build_attendance_record_defaults(
|
||||
raw_record: dict[str, Any],
|
||||
personnel_state: PersonnelDayState,
|
||||
) -> dict[str, Any]:
|
||||
work_hours = _nullable_float(raw_record.get("work_hours"))
|
||||
has_work = bool(
|
||||
_nullable_string(raw_record.get("arrival_time"))
|
||||
or _nullable_string(raw_record.get("departure_time"))
|
||||
or (work_hours is not None and work_hours > 0)
|
||||
)
|
||||
is_workday = bool(raw_record.get("is_workday", True))
|
||||
if "effective_is_workday" in raw_record:
|
||||
effective_is_workday = bool(raw_record.get("effective_is_workday"))
|
||||
else:
|
||||
effective_is_workday = is_workday and not personnel_state.is_non_working
|
||||
is_overtime = bool(raw_record.get("is_overtime", False))
|
||||
overtime_hours = _nullable_float(raw_record.get("overtime_hours"))
|
||||
|
||||
is_late = bool(raw_record.get("is_late", False))
|
||||
late_minutes = _nullable_int(raw_record.get("late_minutes"))
|
||||
is_early_leave = bool(raw_record.get("is_early_leave", False))
|
||||
early_leave_minutes = _nullable_int(raw_record.get("early_leave_minutes"))
|
||||
is_underwork = bool(raw_record.get("is_underwork", False))
|
||||
underwork_hours = _nullable_float(raw_record.get("underwork_hours"))
|
||||
is_absent = bool(raw_record.get("is_absent", False))
|
||||
|
||||
statuses = _list_value(raw_record.get("statuses"))
|
||||
employee_issues = _list_value(raw_record.get("employee_issues"))
|
||||
technical_issues = _list_value(raw_record.get("technical_issues"))
|
||||
non_working_reason = _non_working_reason(
|
||||
is_workday=is_workday,
|
||||
effective_is_workday=effective_is_workday,
|
||||
personnel_state=personnel_state,
|
||||
)
|
||||
|
||||
if not effective_is_workday:
|
||||
is_late = False
|
||||
late_minutes = None
|
||||
is_early_leave = False
|
||||
early_leave_minutes = None
|
||||
is_underwork = False
|
||||
underwork_hours = None
|
||||
is_absent = False
|
||||
statuses = _suppress_personnel_non_working_issues(statuses)
|
||||
employee_issues = _suppress_personnel_non_working_issues(employee_issues)
|
||||
technical_issues = _suppress_personnel_non_working_issues(technical_issues)
|
||||
|
||||
if has_work:
|
||||
is_overtime = True
|
||||
if overtime_hours is None:
|
||||
overtime_hours = work_hours
|
||||
statuses = _append_unique(statuses, "work_outside_personnel_schedule")
|
||||
|
||||
return {
|
||||
"display_name": str(raw_record.get("display_name") or ""),
|
||||
"arrival_time": _nullable_string(raw_record.get("arrival_time")),
|
||||
"departure_time": _nullable_string(raw_record.get("departure_time")),
|
||||
"work_hours": work_hours,
|
||||
"expected_hours": _nullable_float(raw_record.get("expected_hours")),
|
||||
"is_workday": is_workday,
|
||||
"effective_is_workday": effective_is_workday,
|
||||
"is_late": is_late,
|
||||
"late_minutes": late_minutes,
|
||||
"is_early_leave": is_early_leave,
|
||||
"early_leave_minutes": early_leave_minutes,
|
||||
"is_underwork": is_underwork,
|
||||
"underwork_hours": underwork_hours,
|
||||
"is_overtime": is_overtime,
|
||||
"overtime_hours": overtime_hours,
|
||||
"is_absent": is_absent,
|
||||
"statuses": statuses,
|
||||
"employee_issues": employee_issues,
|
||||
"technical_issues": technical_issues,
|
||||
"personnel_status": personnel_state.status,
|
||||
"personnel_status_label": personnel_state.label,
|
||||
"personnel_action_id": personnel_state.action_id,
|
||||
"is_manually_edited": bool(raw_record.get("manual_edited", False)),
|
||||
"manual_edit_payload": (
|
||||
raw_record.get("manual_edit_payload")
|
||||
if isinstance(raw_record.get("manual_edit_payload"), dict)
|
||||
else {}
|
||||
),
|
||||
"raw_data": {**raw_record, "non_working_reason": non_working_reason},
|
||||
}
|
||||
|
||||
|
||||
def apply_manual_edit_payload_to_defaults(
|
||||
defaults: dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Apply saved manual fields to a freshly analyzed record payload."""
|
||||
if not isinstance(payload, dict):
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
editable_fields = {
|
||||
"arrival_time",
|
||||
"departure_time",
|
||||
"work_hours",
|
||||
"expected_hours",
|
||||
"is_workday",
|
||||
"effective_is_workday",
|
||||
"is_late",
|
||||
"late_minutes",
|
||||
"is_early_leave",
|
||||
"early_leave_minutes",
|
||||
"is_underwork",
|
||||
"underwork_hours",
|
||||
"is_overtime",
|
||||
"overtime_hours",
|
||||
"is_absent",
|
||||
}
|
||||
for field in editable_fields:
|
||||
if field in payload:
|
||||
result[field] = payload[field]
|
||||
|
||||
_normalize_issues_after_manual_payload(result, payload)
|
||||
result["raw_data"] = {
|
||||
**result.get("raw_data", {}),
|
||||
"manual_edited": True,
|
||||
"manual_edit_payload": {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key in editable_fields
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def normalize_attendance_record_manual_issues(
|
||||
record: AttendanceRecord,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
fields = {
|
||||
"statuses": list(record.statuses),
|
||||
"employee_issues": list(record.employee_issues),
|
||||
}
|
||||
_normalize_issues_after_manual_payload(fields, payload)
|
||||
record.statuses = fields["statuses"]
|
||||
record.employee_issues = fields["employee_issues"]
|
||||
|
||||
|
||||
def _normalize_issues_after_manual_payload(
|
||||
defaults: dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
markers_by_flag = {
|
||||
"is_late": ("late", "опозд"),
|
||||
"is_early_leave": ("early", "ранн"),
|
||||
"is_underwork": ("underwork", "недоработ"),
|
||||
"is_absent": ("absence", "absent", "отсутств"),
|
||||
}
|
||||
for flag, markers in markers_by_flag.items():
|
||||
if payload.get(flag) is not False:
|
||||
continue
|
||||
for field in ("statuses", "employee_issues"):
|
||||
defaults[field] = [
|
||||
item
|
||||
for item in _list_value(defaults.get(field))
|
||||
if not any(marker in str(item).lower() for marker in markers)
|
||||
]
|
||||
|
||||
|
||||
def _parse_record_date(value: Any) -> date | None:
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
return parse_date(str(value))
|
||||
|
||||
|
||||
def _nullable_string(value: Any) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _nullable_float(value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _nullable_int(value: Any) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _list_value(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _suppress_personnel_non_working_issues(values: list[Any]) -> list[Any]:
|
||||
result = []
|
||||
for value in values:
|
||||
normalized = str(value).lower()
|
||||
if any(marker in normalized for marker in SUPPRESSED_EMPLOYEE_ISSUE_MARKERS):
|
||||
continue
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def _append_unique(values: list[Any], value: str) -> list[Any]:
|
||||
if value in values:
|
||||
return values
|
||||
return [*values, value]
|
||||
|
||||
|
||||
def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]:
|
||||
if record is None:
|
||||
return {
|
||||
"record_id": None,
|
||||
"arrival_time": None,
|
||||
"departure_time": None,
|
||||
"work_hours": None,
|
||||
"expected_hours": None,
|
||||
"status": "empty",
|
||||
"issues": [],
|
||||
"is_workday": None,
|
||||
"effective_is_workday": None,
|
||||
"non_working_reason": "",
|
||||
"comments_count": 0,
|
||||
}
|
||||
|
||||
issues = [
|
||||
*record.statuses,
|
||||
*record.employee_issues,
|
||||
*record.technical_issues,
|
||||
]
|
||||
comments_count = getattr(record, "comments_count", 0) or 0
|
||||
return {
|
||||
"record_id": record.id,
|
||||
"arrival_time": record.arrival_time,
|
||||
"departure_time": record.departure_time,
|
||||
"work_hours": record.work_hours,
|
||||
"expected_hours": record.expected_hours,
|
||||
"status": _monthly_matrix_status(record),
|
||||
"issues": issues,
|
||||
"is_workday": record.is_workday,
|
||||
"effective_is_workday": record.effective_is_workday,
|
||||
"non_working_reason": record.raw_data.get("non_working_reason")
|
||||
or _record_non_working_reason(record),
|
||||
"is_late": record.is_late,
|
||||
"is_early_leave": record.is_early_leave,
|
||||
"is_underwork": record.is_underwork,
|
||||
"is_overtime": record.is_overtime,
|
||||
"is_absent": record.is_absent,
|
||||
"personnel_status": record.personnel_status,
|
||||
"personnel_status_label": record.personnel_status_label,
|
||||
"comments_count": comments_count,
|
||||
}
|
||||
|
||||
|
||||
def _monthly_matrix_status(record: AttendanceRecord) -> str:
|
||||
if not record.effective_is_workday:
|
||||
if record.is_overtime:
|
||||
return "overtime"
|
||||
return "non_working"
|
||||
if record.technical_issues:
|
||||
return "technical"
|
||||
if record.is_underwork:
|
||||
return "underwork"
|
||||
if record.is_late:
|
||||
return "late"
|
||||
if record.is_overtime:
|
||||
return "overtime"
|
||||
if record.is_absent:
|
||||
return "absent"
|
||||
return "normal"
|
||||
|
||||
|
||||
def _non_working_reason(
|
||||
*,
|
||||
is_workday: bool,
|
||||
effective_is_workday: bool,
|
||||
personnel_state: PersonnelDayState,
|
||||
) -> str:
|
||||
if effective_is_workday:
|
||||
return ""
|
||||
if personnel_state.is_non_working:
|
||||
return personnel_state.label or "Кадровое нерабочее состояние"
|
||||
if not is_workday:
|
||||
return "Выходной по графику/календарю"
|
||||
return "Нерабочий день"
|
||||
|
||||
|
||||
def _record_non_working_reason(record: AttendanceRecord) -> str:
|
||||
if record.effective_is_workday:
|
||||
return ""
|
||||
if record.personnel_status_label:
|
||||
return record.personnel_status_label
|
||||
if not record.is_workday:
|
||||
return "Выходной по графику/календарю"
|
||||
return "Нерабочий день"
|
||||
|
||||
|
||||
def _is_valid_workday_record(record: AttendanceRecord) -> bool:
|
||||
return bool(record.effective_is_workday and not record.technical_issues)
|
||||
|
||||
|
||||
def _summary_accounted_days(records: list[AttendanceRecord]) -> int:
|
||||
return sum(1 for record in records if _is_valid_workday_record(record))
|
||||
|
||||
|
||||
def _summary_unaccounted_days(records: list[AttendanceRecord]) -> int:
|
||||
return sum(1 for record in records if record.effective_is_workday and record.technical_issues)
|
||||
|
||||
|
||||
def _summary_worked_hours(records: list[AttendanceRecord]) -> float:
|
||||
total = sum(
|
||||
record.work_hours or 0
|
||||
for record in records
|
||||
if _is_valid_workday_record(record)
|
||||
)
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def _summary_late_days(records: list[AttendanceRecord]) -> int:
|
||||
return sum(1 for record in records if _is_valid_workday_record(record) and record.is_late)
|
||||
|
||||
|
||||
def _summary_early_leave_days(records: list[AttendanceRecord]) -> int:
|
||||
return sum(
|
||||
1
|
||||
for record in records
|
||||
if _is_valid_workday_record(record) and record.is_early_leave
|
||||
)
|
||||
|
||||
|
||||
def _summary_overtime_days(records: list[AttendanceRecord]) -> int:
|
||||
return sum(1 for record in records if _is_valid_workday_record(record) and record.is_overtime)
|
||||
|
||||
|
||||
def _summary_absent_days(records: list[AttendanceRecord]) -> int:
|
||||
return sum(1 for record in records if _is_valid_workday_record(record) and record.is_absent)
|
||||
|
||||
|
||||
def _employee_display_name(employee: Employee) -> str:
|
||||
full_name = f"{employee.last_name or ''} {employee.first_name or ''}".strip()
|
||||
return full_name or employee.email or f"#{employee.id}"
|
||||
@ -1,6 +1,7 @@
|
||||
"""Client for the external LogStorm attendance analyzer API."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
@ -56,6 +57,29 @@ class LogStormClient:
|
||||
except ValueError as exc:
|
||||
raise LogStormClientError("LogStorm API returned invalid JSON") from exc
|
||||
|
||||
def update_attendance_override(
|
||||
self,
|
||||
*,
|
||||
employee_id: str,
|
||||
record_date: date | str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
response = self.session.patch(
|
||||
self._url(f"/attendance/overrides/{employee_id}/{record_date}"),
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
if not response.ok:
|
||||
raise LogStormClientError(
|
||||
f"LogStorm API returned HTTP {response.status_code}: "
|
||||
f"{response.text}"
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise LogStormClientError("LogStorm API returned invalid JSON") from exc
|
||||
|
||||
def health(self) -> bool:
|
||||
try:
|
||||
response = self.session.get(
|
||||
|
||||
@ -15,6 +15,7 @@ from django.contrib.auth import get_user_model
|
||||
# Опциональная зависимость от notifications (graceful degradation)
|
||||
try:
|
||||
from notifications.signals import notify
|
||||
|
||||
NOTIFICATIONS_AVAILABLE = True
|
||||
except ImportError:
|
||||
NOTIFICATIONS_AVAILABLE = False
|
||||
@ -61,13 +62,12 @@ def get_users_with_notifications_enabled(chat, users) -> dict[int, bool]:
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
# Один запрос для всех пользователей
|
||||
settings = ChatUserSettings.objects.filter(
|
||||
chat=chat,
|
||||
user_id__in=user_ids
|
||||
).values('user_id', 'notifications_enabled')
|
||||
settings = ChatUserSettings.objects.filter(chat=chat, user_id__in=user_ids).values(
|
||||
"user_id", "notifications_enabled"
|
||||
)
|
||||
|
||||
# Создаем словарь для быстрого доступа
|
||||
settings_dict = {s['user_id']: s['notifications_enabled'] for s in settings}
|
||||
settings_dict = {s["user_id"]: s["notifications_enabled"] for s in settings}
|
||||
|
||||
# Для пользователей без настроек - по умолчанию True
|
||||
return {uid: settings_dict.get(uid, True) for uid in user_ids}
|
||||
@ -94,12 +94,12 @@ def notify_new_message(message):
|
||||
content = message.content
|
||||
|
||||
# Специальная обработка для чатов комментариев
|
||||
if chat.type == 'comments':
|
||||
if chat.type == "comments":
|
||||
_notify_comment(message)
|
||||
return
|
||||
|
||||
# Получаем всех участников чата кроме автора
|
||||
if chat.type in ['announcement', 'channel', 'global']:
|
||||
if chat.type in ["announcement", "channel", "global"]:
|
||||
participants = chat.get_participants().exclude(id=author.id)
|
||||
else:
|
||||
participants = chat.participants.exclude(id=author.id)
|
||||
@ -110,7 +110,8 @@ def notify_new_message(message):
|
||||
return # Нет получателей
|
||||
|
||||
notification_settings = get_users_with_notifications_enabled(
|
||||
chat, participants_list)
|
||||
chat, participants_list
|
||||
)
|
||||
|
||||
# Отслеживаем, кому уже отправили уведомления
|
||||
notified_user_ids = set()
|
||||
@ -133,12 +134,12 @@ def notify_new_message(message):
|
||||
description=truncate_message(content, 100),
|
||||
action_url=ActionURLs.chat_detail(chat.id),
|
||||
data={
|
||||
'title': MessageTemplates.mention(author_name),
|
||||
'chat_id': chat.id,
|
||||
'chat_name': get_chat_name(chat),
|
||||
'message_id': message.id,
|
||||
'author_id': author.id,
|
||||
}
|
||||
"title": MessageTemplates.mention(author_name),
|
||||
"chat_id": chat.id,
|
||||
"chat_name": get_chat_name(chat),
|
||||
"message_id": message.id,
|
||||
"author_id": author.id,
|
||||
},
|
||||
)
|
||||
notified_user_ids.add(user.id)
|
||||
except User.DoesNotExist:
|
||||
@ -158,29 +159,28 @@ def notify_new_message(message):
|
||||
description=truncate_message(content, 100),
|
||||
action_url=ActionURLs.chat_detail(chat.id),
|
||||
data={
|
||||
'title': MessageTemplates.reply(author_name),
|
||||
'chat_id': chat.id,
|
||||
'chat_name': get_chat_name(chat),
|
||||
'message_id': message.id,
|
||||
'reply_to_id': message.reply_to.id,
|
||||
'author_id': author.id,
|
||||
}
|
||||
"title": MessageTemplates.reply(author_name),
|
||||
"chat_id": chat.id,
|
||||
"chat_name": get_chat_name(chat),
|
||||
"message_id": message.id,
|
||||
"reply_to_id": message.reply_to.id,
|
||||
"author_id": author.id,
|
||||
},
|
||||
)
|
||||
notified_user_ids.add(original_author.id)
|
||||
|
||||
# 3. ОБЫЧНЫЕ УВЕДОМЛЕНИЯ О НОВОМ СООБЩЕНИИ
|
||||
if chat.type == 'announcement':
|
||||
if chat.type == "announcement":
|
||||
notification_verb = NotificationVerbs.ANNOUNCEMENT
|
||||
title = MessageTemplates.announcement(author_name)
|
||||
max_length = 150
|
||||
is_announcement = True
|
||||
else:
|
||||
notification_verb = NotificationVerbs.NEW_MESSAGE
|
||||
if chat.type == 'private':
|
||||
if chat.type == "private":
|
||||
title = MessageTemplates.private_message(author_name)
|
||||
else:
|
||||
title = MessageTemplates.group_message(
|
||||
author_name, get_chat_name(chat))
|
||||
title = MessageTemplates.group_message(author_name, get_chat_name(chat))
|
||||
max_length = 100
|
||||
is_announcement = False
|
||||
|
||||
@ -194,13 +194,13 @@ def notify_new_message(message):
|
||||
continue
|
||||
|
||||
metadata = {
|
||||
'chat_id': chat.id,
|
||||
'chat_name': get_chat_name(chat),
|
||||
'message_id': message.id,
|
||||
'author_id': author.id,
|
||||
"chat_id": chat.id,
|
||||
"chat_name": get_chat_name(chat),
|
||||
"message_id": message.id,
|
||||
"author_id": author.id,
|
||||
}
|
||||
if is_announcement:
|
||||
metadata['is_announcement'] = True
|
||||
metadata["is_announcement"] = True
|
||||
|
||||
_send_notification(
|
||||
sender=author,
|
||||
@ -209,7 +209,7 @@ def notify_new_message(message):
|
||||
action_object=message,
|
||||
description=truncate_message(content, max_length),
|
||||
action_url=ActionURLs.chat_detail(chat.id),
|
||||
data={**metadata, 'title': title},
|
||||
data={**metadata, "title": title},
|
||||
)
|
||||
|
||||
|
||||
@ -244,15 +244,15 @@ def _notify_comment(message):
|
||||
# Проверяем тип объекта через модель
|
||||
model_name = context_obj.__class__.__name__
|
||||
|
||||
if model_name == 'Post':
|
||||
object_author = getattr(context_obj, 'author', None)
|
||||
if model_name == "Post":
|
||||
object_author = getattr(context_obj, "author", None)
|
||||
object_url = f"/?post={context_obj.id}"
|
||||
object_type = "публикации"
|
||||
elif model_name == 'Document':
|
||||
object_author = getattr(context_obj, 'uploaded_by', None)
|
||||
elif model_name == "Document":
|
||||
object_author = getattr(context_obj, "uploaded_by", None)
|
||||
object_url = f"/documents?document={context_obj.id}"
|
||||
object_type = "документа"
|
||||
elif model_name == 'Department':
|
||||
elif model_name == "Department":
|
||||
from employees.models import EmployeeDepartment, RoleAssignment
|
||||
|
||||
object_url = ActionURLs.chat_detail(chat.id)
|
||||
@ -269,19 +269,19 @@ def _notify_comment(message):
|
||||
is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
recipient_ids.update(role_only_ids)
|
||||
elif hasattr(context_obj, 'employee'): # Request
|
||||
object_author = getattr(context_obj, 'employee', None)
|
||||
elif model_name == "AttendanceRecord":
|
||||
object_author = getattr(context_obj, "employee", None)
|
||||
object_url = f"/users/{context_obj.employee_id}"
|
||||
object_type = "записи посещаемости"
|
||||
elif hasattr(context_obj, "employee"): # Request
|
||||
object_author = getattr(context_obj, "employee", None)
|
||||
object_url = f"/requests?request={context_obj.id}"
|
||||
object_type = "заявки"
|
||||
recipient_ids.update(
|
||||
context_obj.recipients.filter(is_active=True).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
context_obj.recipients.filter(is_active=True).values_list("id", flat=True)
|
||||
)
|
||||
recipient_ids.update(
|
||||
context_obj.cc_users.filter(is_active=True).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
context_obj.cc_users.filter(is_active=True).values_list("id", flat=True)
|
||||
)
|
||||
if getattr(context_obj, "approver_id", None):
|
||||
recipient_ids.add(context_obj.approver_id)
|
||||
@ -316,19 +316,19 @@ def _notify_comment(message):
|
||||
_send_notification(
|
||||
sender=author,
|
||||
recipient=recipient,
|
||||
verb='commented',
|
||||
verb="commented",
|
||||
action_object=message,
|
||||
target=context_obj,
|
||||
description=truncate_message(content, 100),
|
||||
action_url=object_url or f"/chat/{chat.id}/",
|
||||
data={
|
||||
'title': f'{author_name} оставил комментарий к {object_type}',
|
||||
'chat_id': chat.id,
|
||||
'message_id': message.id,
|
||||
'author_id': author.id,
|
||||
'object_type': model_name,
|
||||
'object_id': context_obj.id,
|
||||
}
|
||||
"title": f"{author_name} оставил комментарий к {object_type}",
|
||||
"chat_id": chat.id,
|
||||
"message_id": message.id,
|
||||
"author_id": author.id,
|
||||
"object_type": model_name,
|
||||
"object_id": context_obj.id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -342,8 +342,7 @@ def notify_chat_added(chat, new_users, added_by=None):
|
||||
added_by: Пользователь, который добавил (опционально)
|
||||
"""
|
||||
# ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом
|
||||
notification_settings = get_users_with_notifications_enabled(
|
||||
chat, new_users)
|
||||
notification_settings = get_users_with_notifications_enabled(chat, new_users)
|
||||
|
||||
# Создаем уведомления для новых участников
|
||||
for user in new_users:
|
||||
@ -363,9 +362,9 @@ def notify_chat_added(chat, new_users, added_by=None):
|
||||
description=MessageTemplates.added_to_chat(get_chat_name(chat)),
|
||||
action_url=ActionURLs.chat_detail(chat.id),
|
||||
data={
|
||||
'title': MessageTemplates.added_to_chat_title(),
|
||||
'chat_id': chat.id,
|
||||
'chat_name': get_chat_name(chat),
|
||||
'added_by_id': added_by.id if added_by else None,
|
||||
}
|
||||
"title": MessageTemplates.added_to_chat_title(),
|
||||
"chat_id": chat.id,
|
||||
"chat_name": get_chat_name(chat),
|
||||
"added_by_id": added_by.id if added_by else None,
|
||||
},
|
||||
)
|
||||
|
||||
@ -10,6 +10,10 @@ ACTION_HIRED = "hired"
|
||||
ACTION_DISMISSED = "dismissed"
|
||||
ACTION_ON_LEAVE = "on_leave"
|
||||
ACTION_RETURNED_FROM_LEAVE = "returned_from_leave"
|
||||
ACTION_ON_SICK_LEAVE = "on_sick_leave"
|
||||
ACTION_RETURNED_FROM_SICK_LEAVE = "returned_from_sick_leave"
|
||||
ACTION_ON_DAY_OFF = "on_day_off"
|
||||
ACTION_RETURNED_FROM_DAY_OFF = "returned_from_day_off"
|
||||
ACTION_ON_MATERNITY = "on_maternity"
|
||||
ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity"
|
||||
ACTION_TRANSFERRED = "transferred"
|
||||
@ -20,6 +24,10 @@ ACTION_CHOICES = [
|
||||
(ACTION_DISMISSED, "Уволен"),
|
||||
(ACTION_ON_LEAVE, "В отпуске"),
|
||||
(ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"),
|
||||
(ACTION_ON_SICK_LEAVE, "На больничном"),
|
||||
(ACTION_RETURNED_FROM_SICK_LEAVE, "Вернулся с больничного"),
|
||||
(ACTION_ON_DAY_OFF, "В отгуле"),
|
||||
(ACTION_RETURNED_FROM_DAY_OFF, "Вернулся из отгула"),
|
||||
(ACTION_ON_MATERNITY, "В декрете"),
|
||||
(ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"),
|
||||
(ACTION_TRANSFERRED, "Переведен"),
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.4 on 2026-04-21 17:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0047_alter_departmentpermission_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='employeeaction',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_sick_leave', 'На больничном'), ('returned_from_sick_leave', 'Вернулся с больничного'), ('on_day_off', 'В отгуле'), ('returned_from_day_off', 'Вернулся из отгула'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalemployeeaction',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_sick_leave', 'На больничном'), ('returned_from_sick_leave', 'Вернулся с больничного'), ('on_day_off', 'В отгуле'), ('returned_from_day_off', 'Вернулся из отгула'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
|
||||
),
|
||||
]
|
||||
@ -14,13 +14,33 @@ from importlib import import_module
|
||||
from django.conf import settings
|
||||
|
||||
from . import common, birthday
|
||||
from .common import (
|
||||
IMMEDIATE_ACTION_MAPPING,
|
||||
SCHEDULED_ACTION_MAPPING,
|
||||
_apply_action_effects,
|
||||
_schedule_delayed_action,
|
||||
)
|
||||
|
||||
if getattr(settings, 'LDAP_ENABLED', False):
|
||||
import_module('employees.signals.ldap.employee')
|
||||
import_module('employees.signals.ldap.department')
|
||||
import_module('employees.signals.ldap.group')
|
||||
import_module('employees.signals.ldap.position')
|
||||
import_module('employees.signals.ldap.role')
|
||||
__all__ = ['common', 'birthday']
|
||||
if getattr(settings, "LDAP_ENABLED", False):
|
||||
import_module("employees.signals.ldap.employee")
|
||||
import_module("employees.signals.ldap.department")
|
||||
import_module("employees.signals.ldap.group")
|
||||
import_module("employees.signals.ldap.position")
|
||||
import_module("employees.signals.ldap.role")
|
||||
__all__ = [
|
||||
"common",
|
||||
"birthday",
|
||||
"IMMEDIATE_ACTION_MAPPING",
|
||||
"SCHEDULED_ACTION_MAPPING",
|
||||
"_apply_action_effects",
|
||||
"_schedule_delayed_action",
|
||||
]
|
||||
else:
|
||||
__all__ = ['common', 'birthday']
|
||||
__all__ = [
|
||||
"common",
|
||||
"birthday",
|
||||
"IMMEDIATE_ACTION_MAPPING",
|
||||
"SCHEDULED_ACTION_MAPPING",
|
||||
"_apply_action_effects",
|
||||
"_schedule_delayed_action",
|
||||
]
|
||||
|
||||
@ -7,13 +7,18 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.constants import ACTION_HIRED
|
||||
from employees.constants import (
|
||||
ACTION_HIRED,
|
||||
ACTION_ON_DAY_OFF,
|
||||
ACTION_ON_LEAVE,
|
||||
ACTION_ON_SICK_LEAVE,
|
||||
)
|
||||
from employees.models import Employee, EmployeeAction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -25,14 +30,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Немедленные события (создаются сразу при одобрении)
|
||||
IMMEDIATE_ACTION_MAPPING = {
|
||||
"transfer": "transferred", # Перевод → Переведен
|
||||
"dismissal": "dismissed", # Увольнение → Уволен
|
||||
"transfer": "transferred", # Перевод → Переведен
|
||||
"dismissal": "dismissed", # Увольнение → Уволен
|
||||
}
|
||||
|
||||
# Отложенные события (создаются по Celery в date_from)
|
||||
SCHEDULED_ACTION_MAPPING = {
|
||||
"vacation": "on_leave", # Отпуск → В отпуске
|
||||
"sick_leave": "on_sick_leave", # Больничный → На больничном
|
||||
"vacation": ACTION_ON_LEAVE, # Отпуск → В отпуске
|
||||
"sick_leave": ACTION_ON_SICK_LEAVE, # Больничный → На больничном
|
||||
"day_off": ACTION_ON_DAY_OFF, # Отгул → В отгуле
|
||||
}
|
||||
|
||||
|
||||
@ -54,7 +60,8 @@ def create_hired_action(sender, instance: Employee, created, **kwargs):
|
||||
# Обработчики заявок (requests_app)
|
||||
# ============================================================
|
||||
|
||||
@receiver(pre_save, sender='requests_app.Request')
|
||||
|
||||
@receiver(pre_save, sender="requests_app.Request")
|
||||
def track_request_status_change(sender, instance, **kwargs):
|
||||
"""Сохраняем старый статус перед обновлением для отслеживания изменений."""
|
||||
if instance.pk:
|
||||
@ -66,7 +73,7 @@ def track_request_status_change(sender, instance, **kwargs):
|
||||
instance._old_status = None
|
||||
|
||||
|
||||
@receiver(post_save, sender='requests_app.Request')
|
||||
@receiver(post_save, sender="requests_app.Request")
|
||||
def create_action_on_request_approval(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Автоматически создает EmployeeAction при одобрении заявок.
|
||||
@ -87,7 +94,7 @@ def create_action_on_request_approval(sender, instance, created, **kwargs):
|
||||
return
|
||||
|
||||
# Проверяем изменение статуса
|
||||
old_status = getattr(instance, '_old_status', None)
|
||||
old_status = getattr(instance, "_old_status", None)
|
||||
if old_status == RequestStatus.APPROVED:
|
||||
return # Уже была одобрена
|
||||
|
||||
@ -117,16 +124,15 @@ def _create_immediate_action(request):
|
||||
|
||||
# Проверяем дубли
|
||||
if EmployeeAction.objects.filter(
|
||||
extra__request_id=request.id,
|
||||
action=action_type
|
||||
extra__request_id=request.id, action=action_type
|
||||
).exists():
|
||||
logger.warning(
|
||||
f"EmployeeAction already exists for Request #{request.id}"
|
||||
)
|
||||
logger.warning(f"EmployeeAction already exists for Request #{request.id}")
|
||||
return
|
||||
|
||||
# Создаем событие
|
||||
action_date = request.date_from or request.decided_at or timezone.now()
|
||||
action_date = _as_action_datetime(
|
||||
request.date_from or request.decided_at or timezone.now()
|
||||
)
|
||||
action_comment = f"Заявление #{request.id}"
|
||||
if request.comment:
|
||||
action_comment += f": {request.comment[:200]}"
|
||||
@ -138,12 +144,10 @@ def _create_immediate_action(request):
|
||||
date=action_date,
|
||||
comment=action_comment,
|
||||
extra={
|
||||
'request_id': request.id,
|
||||
'approved_by': (
|
||||
request.approver.id if request.approver else None
|
||||
),
|
||||
'immediate': True
|
||||
}
|
||||
"request_id": request.id,
|
||||
"approved_by": (request.approver.id if request.approver else None),
|
||||
"immediate": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Применяем эффекты (деактивация, LDAP sync)
|
||||
@ -157,7 +161,7 @@ def _create_immediate_action(request):
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create EmployeeAction for Request #{request.id}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@ -188,34 +192,37 @@ def _schedule_delayed_action(request):
|
||||
return
|
||||
|
||||
# Иначе планируем на date_from в 00:00
|
||||
eta = timezone.make_aware(
|
||||
datetime.combine(request.date_from, datetime.min.time())
|
||||
)
|
||||
eta = timezone.make_aware(datetime.combine(request.date_from, datetime.min.time()))
|
||||
|
||||
try:
|
||||
task = create_scheduled_action.apply_async(
|
||||
args=[request.id],
|
||||
eta=eta
|
||||
)
|
||||
task = create_scheduled_action.apply_async(args=[request.id], eta=eta)
|
||||
logger.info(
|
||||
f"Scheduled EmployeeAction creation for Request #{request.id} "
|
||||
f"at {eta} (task_id={task.id})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to schedule action for Request #{request.id}: {e}",
|
||||
exc_info=True
|
||||
f"Failed to schedule action for Request #{request.id}: {e}", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def _as_action_datetime(value):
|
||||
if isinstance(value, datetime):
|
||||
return value if timezone.is_aware(value) else timezone.make_aware(value)
|
||||
if isinstance(value, date):
|
||||
return timezone.make_aware(datetime.combine(value, time(hour=12)))
|
||||
return value
|
||||
|
||||
|
||||
def _apply_action_effects(action):
|
||||
"""Применяет эффекты кадрового события (как в EmployeeActionViewSet)."""
|
||||
try:
|
||||
from api.v1.employees.views.actions import EmployeeActionViewSet
|
||||
|
||||
viewset = EmployeeActionViewSet()
|
||||
viewset._apply_effects(action)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to apply effects for EmployeeAction #{action.id}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@ -59,6 +59,7 @@ INSTALLED_APPS = [
|
||||
"scheduling.apps.SchedulingConfig",
|
||||
"documents.apps.DocumentsConfig",
|
||||
"requests_app.apps.RequestsAppConfig",
|
||||
"attendance.apps.AttendanceConfig",
|
||||
"feed.apps.FeedConfig",
|
||||
"realtime.apps.RealtimeConfig", # WebSocket consumers для real-time
|
||||
"communications.apps.CommunicationsConfig",
|
||||
@ -204,7 +205,7 @@ LOGIN_REDIRECT_URL = "/admin/"
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
|
||||
|
||||
# LogStorm attendance analyzer integration.
|
||||
LOGSTORM_API_URL = os.getenv("LOGSTORM_API_URL", "http://127.0.0.1:8000")
|
||||
LOGSTORM_API_URL = os.getenv("LOGSTORM_API_URL", "http://127.0.0.1:8010")
|
||||
LOGSTORM_API_TOKEN = os.getenv("LOGSTORM_API_TOKEN", "")
|
||||
LOGSTORM_TIMEOUT_SECONDS = int(os.getenv("LOGSTORM_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
|
||||
@ -95,9 +95,7 @@ class Request(models.Model):
|
||||
verbose_name=_("Отделы-получатели"),
|
||||
blank=True,
|
||||
related_name="received_requests",
|
||||
help_text=_(
|
||||
"Заявка будет видна всем уполномоченным сотрудникам этих отделов"
|
||||
),
|
||||
help_text=_("Заявка будет видна всем уполномоченным сотрудникам этих отделов"),
|
||||
)
|
||||
|
||||
recipients = models.ManyToManyField(
|
||||
@ -113,9 +111,7 @@ class Request(models.Model):
|
||||
verbose_name=_("Копия (CC)"),
|
||||
blank=True,
|
||||
related_name="requests_cc",
|
||||
help_text=_(
|
||||
"Сотрудники в копии (уведомления без обязанности рассмотрения)"
|
||||
),
|
||||
help_text=_("Сотрудники в копии (уведомления без обязанности рассмотрения)"),
|
||||
)
|
||||
|
||||
sent_to_all_department = models.BooleanField(
|
||||
@ -153,9 +149,7 @@ class Request(models.Model):
|
||||
|
||||
created_at = models.DateTimeField(_("Создано"), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_("Обновлено"), auto_now=True)
|
||||
decided_at = models.DateTimeField(
|
||||
_("Решение принято"), null=True, blank=True
|
||||
)
|
||||
decided_at = models.DateTimeField(_("Решение принято"), null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
permissions = [
|
||||
@ -203,9 +197,11 @@ class Request(models.Model):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
if self.type in {RequestType.VACATION, RequestType.SICK_LEAVE} and not (
|
||||
self.date_from and self.date_to
|
||||
):
|
||||
if self.type in {
|
||||
RequestType.VACATION,
|
||||
RequestType.SICK_LEAVE,
|
||||
RequestType.DAY_OFF,
|
||||
} and not (self.date_from and self.date_to):
|
||||
raise ValidationError(
|
||||
_("Для выбранного типа укажите даты начала и окончания.")
|
||||
)
|
||||
@ -213,14 +209,10 @@ class Request(models.Model):
|
||||
self.type in {RequestType.TRANSFER, RequestType.DISMISSAL}
|
||||
and not self.date_from
|
||||
):
|
||||
raise ValidationError(
|
||||
_("Для перевода/увольнения укажите дату начала.")
|
||||
)
|
||||
raise ValidationError(_("Для перевода/увольнения укажите дату начала."))
|
||||
|
||||
if self.approver_id and self.approver_id == self.employee_id:
|
||||
raise ValidationError(
|
||||
_("Согласующий не может совпадать с автором заявки.")
|
||||
)
|
||||
raise ValidationError(_("Согласующий не может совпадать с автором заявки."))
|
||||
|
||||
@property
|
||||
def display_title(self) -> str:
|
||||
@ -281,26 +273,20 @@ class Request(models.Model):
|
||||
self.status = RequestStatus.APPROVED
|
||||
self.approver = by_user
|
||||
self.decided_at = timezone.now()
|
||||
self.save(
|
||||
update_fields=["status", "approver", "decided_at", "updated_at"]
|
||||
)
|
||||
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
|
||||
|
||||
def reject(self, by_user):
|
||||
self.status = RequestStatus.REJECTED
|
||||
self.approver = by_user
|
||||
self.decided_at = timezone.now()
|
||||
self.save(
|
||||
update_fields=["status", "approver", "decided_at", "updated_at"]
|
||||
)
|
||||
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
|
||||
|
||||
def cancel(self):
|
||||
self.status = RequestStatus.CANCELLED
|
||||
if self.approver_id is not None:
|
||||
self.approver = None
|
||||
self.decided_at = timezone.now()
|
||||
self.save(
|
||||
update_fields=["status", "approver", "decided_at", "updated_at"]
|
||||
)
|
||||
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
"""Синхронизация decided_at/approver с текущим статусом.
|
||||
@ -323,6 +309,6 @@ class Request(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_type_display()} — {self.employee} ({
|
||||
self.get_status_display()
|
||||
})"
|
||||
return (
|
||||
f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Celery tasks для автоматизации кадровых событий из заявок.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
@ -15,13 +16,18 @@ def create_scheduled_action(self, request_id):
|
||||
"""
|
||||
Создать кадровое событие для заявки по расписанию.
|
||||
|
||||
Вызывается в date_from для vacation/sick_leave.
|
||||
Вызывается в date_from для vacation/sick_leave/day_off.
|
||||
|
||||
Args:
|
||||
request_id: ID заявки
|
||||
"""
|
||||
from .models import Request
|
||||
from .enums import RequestStatus
|
||||
from employees.constants import (
|
||||
ACTION_ON_DAY_OFF,
|
||||
ACTION_ON_LEAVE,
|
||||
ACTION_ON_SICK_LEAVE,
|
||||
)
|
||||
from employees.models import EmployeeAction
|
||||
|
||||
try:
|
||||
@ -37,8 +43,9 @@ def create_scheduled_action(self, request_id):
|
||||
|
||||
# Определяем тип события
|
||||
action_mapping = {
|
||||
'vacation': 'on_leave',
|
||||
'sick_leave': 'on_sick_leave',
|
||||
"vacation": ACTION_ON_LEAVE,
|
||||
"sick_leave": ACTION_ON_SICK_LEAVE,
|
||||
"day_off": ACTION_ON_DAY_OFF,
|
||||
}
|
||||
|
||||
action_type = action_mapping.get(request.type)
|
||||
@ -51,12 +58,9 @@ def create_scheduled_action(self, request_id):
|
||||
|
||||
# Проверяем, что событие еще не создано
|
||||
if EmployeeAction.objects.filter(
|
||||
extra__request_id=request_id,
|
||||
action=action_type
|
||||
extra__request_id=request_id, action=action_type
|
||||
).exists():
|
||||
logger.info(
|
||||
f"Action already exists for Request #{request_id}, skipping"
|
||||
)
|
||||
logger.info(f"Action already exists for Request #{request_id}, skipping")
|
||||
return
|
||||
|
||||
# Создаем событие
|
||||
@ -70,12 +74,10 @@ def create_scheduled_action(self, request_id):
|
||||
date=timezone.now(),
|
||||
comment=action_comment,
|
||||
extra={
|
||||
'request_id': request.id,
|
||||
'approved_by': (
|
||||
request.approver.id if request.approver else None
|
||||
),
|
||||
'scheduled': True
|
||||
}
|
||||
"request_id": request.id,
|
||||
"approved_by": (request.approver.id if request.approver else None),
|
||||
"scheduled": True,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@ -90,9 +92,9 @@ def create_scheduled_action(self, request_id):
|
||||
eta=timezone.make_aware(
|
||||
timezone.datetime.combine(
|
||||
request.date_to + timedelta(days=1),
|
||||
timezone.datetime.min.time()
|
||||
timezone.datetime.min.time(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
return f"Created action {action.id}"
|
||||
@ -103,7 +105,7 @@ def create_scheduled_action(self, request_id):
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create scheduled action for Request #{request_id}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
# Retry через 5 минут
|
||||
raise self.retry(exc=e, countdown=300)
|
||||
@ -112,7 +114,7 @@ def create_scheduled_action(self, request_id):
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def schedule_auto_return(self, request_id):
|
||||
"""
|
||||
Автоматически создать событие возврата из отпуска/больничного.
|
||||
Автоматически создать событие возврата из отпуска/больничного/отгула.
|
||||
|
||||
Вызывается в date_to + 1 день.
|
||||
|
||||
@ -121,6 +123,11 @@ def schedule_auto_return(self, request_id):
|
||||
"""
|
||||
from .models import Request
|
||||
from .enums import RequestStatus
|
||||
from employees.constants import (
|
||||
ACTION_RETURNED_FROM_DAY_OFF,
|
||||
ACTION_RETURNED_FROM_LEAVE,
|
||||
ACTION_RETURNED_FROM_SICK_LEAVE,
|
||||
)
|
||||
from employees.models import EmployeeAction
|
||||
|
||||
try:
|
||||
@ -129,15 +136,15 @@ def schedule_auto_return(self, request_id):
|
||||
# Проверяем, что заявка все еще одобрена
|
||||
if request.status != RequestStatus.APPROVED:
|
||||
logger.info(
|
||||
f"Request #{request_id} is no longer approved, "
|
||||
f"skipping auto-return"
|
||||
f"Request #{request_id} is no longer approved, skipping auto-return"
|
||||
)
|
||||
return
|
||||
|
||||
# Определяем тип возврата
|
||||
return_mapping = {
|
||||
'vacation': 'returned_from_leave',
|
||||
'sick_leave': 'returned_from_leave',
|
||||
"vacation": ACTION_RETURNED_FROM_LEAVE,
|
||||
"sick_leave": ACTION_RETURNED_FROM_SICK_LEAVE,
|
||||
"day_off": ACTION_RETURNED_FROM_DAY_OFF,
|
||||
}
|
||||
|
||||
return_action = return_mapping.get(request.type)
|
||||
@ -150,12 +157,9 @@ def schedule_auto_return(self, request_id):
|
||||
|
||||
# Проверяем, что событие возврата еще не создано
|
||||
if EmployeeAction.objects.filter(
|
||||
extra__request_id=request_id,
|
||||
action=return_action
|
||||
extra__request_id=request_id, action=return_action
|
||||
).exists():
|
||||
logger.info(
|
||||
f"Return action already exists for Request #{request_id}"
|
||||
)
|
||||
logger.info(f"Return action already exists for Request #{request_id}")
|
||||
return
|
||||
|
||||
# Создаем событие возврата
|
||||
@ -170,10 +174,7 @@ def schedule_auto_return(self, request_id):
|
||||
action=return_action,
|
||||
date=timezone.now(),
|
||||
comment=return_comment,
|
||||
extra={
|
||||
'request_id': request.id,
|
||||
'auto_return': True
|
||||
}
|
||||
extra={"request_id": request.id, "auto_return": True},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@ -189,7 +190,7 @@ def schedule_auto_return(self, request_id):
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create auto-return for Request #{request_id}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
raise self.retry(exc=e, countdown=300)
|
||||
|
||||
@ -214,9 +215,9 @@ def cleanup_missed_returns():
|
||||
# Ищем одобренные заявки, которые закончились вчера или раньше
|
||||
ended_requests = Request.objects.filter(
|
||||
status=RequestStatus.APPROVED,
|
||||
type__in=['vacation', 'sick_leave'],
|
||||
type__in=["vacation", "sick_leave", "day_off"],
|
||||
date_to__lt=today,
|
||||
date_to__gte=yesterday - timedelta(days=7) # За последнюю неделю
|
||||
date_to__gte=yesterday - timedelta(days=7), # За последнюю неделю
|
||||
)
|
||||
|
||||
created_count = 0
|
||||
@ -224,8 +225,9 @@ def cleanup_missed_returns():
|
||||
for req in ended_requests:
|
||||
# Определяем тип возврата
|
||||
return_action = {
|
||||
'vacation': 'returned_from_leave',
|
||||
'sick_leave': 'returned_from_leave',
|
||||
"vacation": "returned_from_leave",
|
||||
"sick_leave": "returned_from_sick_leave",
|
||||
"day_off": "returned_from_day_off",
|
||||
}.get(req.type)
|
||||
|
||||
if not return_action:
|
||||
@ -233,8 +235,7 @@ def cleanup_missed_returns():
|
||||
|
||||
# Проверяем, что события возврата нет
|
||||
if EmployeeAction.objects.filter(
|
||||
extra__request_id=req.id,
|
||||
action=return_action
|
||||
extra__request_id=req.id, action=return_action
|
||||
).exists():
|
||||
continue
|
||||
|
||||
@ -248,23 +249,14 @@ def cleanup_missed_returns():
|
||||
f"Автоматически восстановлено: окончание "
|
||||
f"{req.get_type_display().lower()} (заявка #{req.id})"
|
||||
),
|
||||
extra={
|
||||
'request_id': req.id,
|
||||
'auto_return': True,
|
||||
'cleanup': True
|
||||
}
|
||||
extra={"request_id": req.id, "auto_return": True, "cleanup": True},
|
||||
)
|
||||
created_count += 1
|
||||
logger.info(
|
||||
f"Cleanup: created return action #{action.id} "
|
||||
f"for Request #{req.id}"
|
||||
f"Cleanup: created return action #{action.id} for Request #{req.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Cleanup: failed to create return for Request #{req.id}: {e}"
|
||||
)
|
||||
logger.error(f"Cleanup: failed to create return for Request #{req.id}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"cleanup_missed_returns completed: {created_count} actions created"
|
||||
)
|
||||
logger.info(f"cleanup_missed_returns completed: {created_count} actions created")
|
||||
return created_count
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Тесты для автоматического создания EmployeeAction из заявок (signals).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import date, timedelta
|
||||
from unittest.mock import patch
|
||||
@ -24,16 +25,14 @@ class TestRequestSignals:
|
||||
# Создаём заявку на увольнение
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='dismissal',
|
||||
type="dismissal",
|
||||
status=RequestStatus.PENDING,
|
||||
date_from=date.today(),
|
||||
comment='Увольнение по собственному желанию'
|
||||
comment="Увольнение по собственному желанию",
|
||||
)
|
||||
|
||||
# Проверяем что события ещё нет
|
||||
assert not EmployeeAction.objects.filter(
|
||||
extra__request_id=request.id
|
||||
).exists()
|
||||
assert not EmployeeAction.objects.filter(extra__request_id=request.id).exists()
|
||||
|
||||
# Одобряем заявку
|
||||
request.status = RequestStatus.APPROVED
|
||||
@ -43,16 +42,14 @@ class TestRequestSignals:
|
||||
|
||||
# Проверяем что событие создалось
|
||||
action = EmployeeAction.objects.get(
|
||||
employee=employee,
|
||||
action='dismissed',
|
||||
extra__request_id=request.id
|
||||
employee=employee, action="dismissed", extra__request_id=request.id
|
||||
)
|
||||
|
||||
assert action.action == 'dismissed'
|
||||
assert action.extra['request_id'] == request.id
|
||||
assert action.extra['approved_by'] == approver.id
|
||||
assert action.extra['immediate'] is True
|
||||
assert 'Заявление #' in action.comment
|
||||
assert action.action == "dismissed"
|
||||
assert action.extra["request_id"] == request.id
|
||||
assert action.extra["approved_by"] == approver.id
|
||||
assert action.extra["immediate"] is True
|
||||
assert "Заявление #" in action.comment
|
||||
|
||||
def test_immediate_action_transfer(self, user_factory):
|
||||
"""Перевод создаёт EmployeeAction немедленно при одобрении."""
|
||||
@ -61,7 +58,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='transfer',
|
||||
type="transfer",
|
||||
status=RequestStatus.PENDING,
|
||||
date_from=date.today(),
|
||||
)
|
||||
@ -73,15 +70,13 @@ class TestRequestSignals:
|
||||
|
||||
# Проверяем событие
|
||||
action = EmployeeAction.objects.get(
|
||||
employee=employee,
|
||||
action='transferred',
|
||||
extra__request_id=request.id
|
||||
employee=employee, action="transferred", extra__request_id=request.id
|
||||
)
|
||||
|
||||
assert action.action == 'transferred'
|
||||
assert action.extra['immediate'] is True
|
||||
assert action.action == "transferred"
|
||||
assert action.extra["immediate"] is True
|
||||
|
||||
@patch('employees.signals._schedule_delayed_action')
|
||||
@patch("employees.signals.common._schedule_delayed_action")
|
||||
def test_scheduled_action_vacation(self, mock_schedule, user_factory):
|
||||
"""Отпуск запускает планирование через Celery."""
|
||||
employee = user_factory()
|
||||
@ -90,7 +85,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='vacation',
|
||||
type="vacation",
|
||||
status=RequestStatus.PENDING,
|
||||
date_from=future_date,
|
||||
date_to=future_date + timedelta(days=10),
|
||||
@ -106,7 +101,7 @@ class TestRequestSignals:
|
||||
called_request = mock_schedule.call_args[0][0]
|
||||
assert called_request.id == request.id
|
||||
|
||||
@patch('employees.signals._schedule_delayed_action')
|
||||
@patch("employees.signals.common._schedule_delayed_action")
|
||||
def test_scheduled_action_sick_leave(self, mock_schedule, user_factory):
|
||||
"""Больничный запускает планирование через Celery."""
|
||||
employee = user_factory()
|
||||
@ -115,7 +110,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='sick_leave',
|
||||
type="sick_leave",
|
||||
status=RequestStatus.PENDING,
|
||||
date_from=future_date,
|
||||
)
|
||||
@ -133,7 +128,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='dismissal',
|
||||
type="dismissal",
|
||||
status=RequestStatus.PENDING,
|
||||
date_from=date.today(),
|
||||
)
|
||||
@ -144,22 +139,24 @@ class TestRequestSignals:
|
||||
request.save()
|
||||
|
||||
# Первое событие создалось
|
||||
assert EmployeeAction.objects.filter(
|
||||
employee=employee,
|
||||
action='dismissed',
|
||||
extra__request_id=request.id
|
||||
).count() == 1
|
||||
assert (
|
||||
EmployeeAction.objects.filter(
|
||||
employee=employee, action="dismissed", extra__request_id=request.id
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
|
||||
# Меняем комментарий и сохраняем снова
|
||||
request.comment = 'Обновлённый комментарий'
|
||||
request.comment = "Обновлённый комментарий"
|
||||
request.save()
|
||||
|
||||
# Дубликат не создался
|
||||
assert EmployeeAction.objects.filter(
|
||||
employee=employee,
|
||||
action='dismissed',
|
||||
extra__request_id=request.id
|
||||
).count() == 1
|
||||
assert (
|
||||
EmployeeAction.objects.filter(
|
||||
employee=employee, action="dismissed", extra__request_id=request.id
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_signal_not_fired_on_creation(self, user_factory):
|
||||
"""Сигнал не срабатывает при создании (только при одобрении)."""
|
||||
@ -167,14 +164,12 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='dismissal',
|
||||
type="dismissal",
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
|
||||
# События нет
|
||||
assert not EmployeeAction.objects.filter(
|
||||
extra__request_id=request.id
|
||||
).exists()
|
||||
assert not EmployeeAction.objects.filter(extra__request_id=request.id).exists()
|
||||
|
||||
def test_signal_not_fired_on_rejection(self, user_factory):
|
||||
"""Сигнал не срабатывает при отклонении."""
|
||||
@ -183,7 +178,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='dismissal',
|
||||
type="dismissal",
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
|
||||
@ -193,9 +188,7 @@ class TestRequestSignals:
|
||||
request.save()
|
||||
|
||||
# События нет
|
||||
assert not EmployeeAction.objects.filter(
|
||||
extra__request_id=request.id
|
||||
).exists()
|
||||
assert not EmployeeAction.objects.filter(extra__request_id=request.id).exists()
|
||||
|
||||
def test_unknown_request_type_no_action(self, user_factory):
|
||||
"""Неизвестный тип заявки не создаёт события."""
|
||||
@ -205,7 +198,7 @@ class TestRequestSignals:
|
||||
# Используем тип, который не в маппингах
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='other', # Не в IMMEDIATE/SCHEDULED маппингах
|
||||
type="other", # Не в IMMEDIATE/SCHEDULED маппингах
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
|
||||
@ -216,8 +209,7 @@ class TestRequestSignals:
|
||||
|
||||
# События нет
|
||||
assert not EmployeeAction.objects.filter(
|
||||
employee=employee,
|
||||
extra__request_id=request.id
|
||||
employee=employee, extra__request_id=request.id
|
||||
).exists()
|
||||
|
||||
def test_action_date_from_request_date_from(self, user_factory):
|
||||
@ -228,7 +220,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='dismissal',
|
||||
type="dismissal",
|
||||
status=RequestStatus.PENDING,
|
||||
date_from=specific_date,
|
||||
)
|
||||
@ -239,16 +231,16 @@ class TestRequestSignals:
|
||||
request.save()
|
||||
|
||||
action = EmployeeAction.objects.get(
|
||||
employee=employee,
|
||||
action='dismissed',
|
||||
extra__request_id=request.id
|
||||
employee=employee, action="dismissed", extra__request_id=request.id
|
||||
)
|
||||
|
||||
# Проверяем дату (может быть datetime, поэтому берём .date())
|
||||
action_date = action.date.date() if hasattr(action.date, 'date') else action.date
|
||||
action_date = (
|
||||
action.date.date() if hasattr(action.date, "date") else action.date
|
||||
)
|
||||
assert action_date == specific_date
|
||||
|
||||
@patch('employees.signals._apply_action_effects')
|
||||
@patch("employees.signals.common._apply_action_effects")
|
||||
def test_effects_applied_for_immediate_actions(self, mock_apply, user_factory):
|
||||
"""Эффекты применяются для немедленных действий."""
|
||||
employee = user_factory()
|
||||
@ -256,7 +248,7 @@ class TestRequestSignals:
|
||||
|
||||
request = Request.objects.create(
|
||||
employee=employee,
|
||||
type='dismissal',
|
||||
type="dismissal",
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
|
||||
@ -268,7 +260,7 @@ class TestRequestSignals:
|
||||
# Проверяем что _apply_action_effects была вызвана
|
||||
mock_apply.assert_called_once()
|
||||
created_action = mock_apply.call_args[0][0]
|
||||
assert created_action.action == 'dismissed'
|
||||
assert created_action.action == "dismissed"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -278,15 +270,16 @@ class TestRequestMappings:
|
||||
def test_immediate_mapping_complete(self):
|
||||
"""Проверка что все немедленные маппинги правильные."""
|
||||
assert IMMEDIATE_ACTION_MAPPING == {
|
||||
'transfer': 'transferred',
|
||||
'dismissal': 'dismissed',
|
||||
"transfer": "transferred",
|
||||
"dismissal": "dismissed",
|
||||
}
|
||||
|
||||
def test_scheduled_mapping_complete(self):
|
||||
"""Проверка что все отложенные маппинги правильные."""
|
||||
assert SCHEDULED_ACTION_MAPPING == {
|
||||
'vacation': 'on_leave',
|
||||
'sick_leave': 'on_sick_leave',
|
||||
"vacation": "on_leave",
|
||||
"sick_leave": "on_sick_leave",
|
||||
"day_off": "on_day_off",
|
||||
}
|
||||
|
||||
def test_no_overlap_in_mappings(self):
|
||||
@ -294,5 +287,6 @@ class TestRequestMappings:
|
||||
immediate_keys = set(IMMEDIATE_ACTION_MAPPING.keys())
|
||||
scheduled_keys = set(SCHEDULED_ACTION_MAPPING.keys())
|
||||
|
||||
assert immediate_keys.isdisjoint(scheduled_keys), \
|
||||
assert immediate_keys.isdisjoint(scheduled_keys), (
|
||||
"Request types должны быть либо immediate, либо scheduled, но не оба"
|
||||
)
|
||||
|
||||
695
backend/tests/api/v1/attendance/test_attendance_storage.py
Normal file
695
backend/tests/api/v1/attendance/test_attendance_storage.py
Normal file
@ -0,0 +1,695 @@
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE
|
||||
from employees.models import EmployeeAction
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _analyze_url():
|
||||
return reverse("api:v1:logstorm-attendance-analyze")
|
||||
|
||||
|
||||
def _records_url():
|
||||
return reverse("api:v1:attendance-records")
|
||||
|
||||
|
||||
def _monthly_matrix_url():
|
||||
return reverse("api:v1:attendance-monthly-matrix")
|
||||
|
||||
|
||||
def _record_detail_url(record_id):
|
||||
return reverse("api:v1:attendance-record-detail", args=[record_id])
|
||||
|
||||
|
||||
def _record_comments_url(record_id):
|
||||
return reverse("api:v1:attendance-record-comments", args=[record_id])
|
||||
|
||||
|
||||
def _record_comment_detail_url(record_id, comment_id):
|
||||
return reverse(
|
||||
"api:v1:attendance-record-comment-detail", args=[record_id, comment_id]
|
||||
)
|
||||
|
||||
|
||||
def _payload(employee_id, **overrides):
|
||||
payload = {
|
||||
"employee_id": employee_id,
|
||||
"period_start": "2026-04-20",
|
||||
"period_end": "2026-04-21",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _logstorm_result():
|
||||
return {
|
||||
"records": [
|
||||
{
|
||||
"date": "2026-04-20",
|
||||
"employee_id": "42",
|
||||
"display_name": "Ivan Petrov",
|
||||
"arrival_time": "09:15",
|
||||
"departure_time": "18:00",
|
||||
"work_hours": 8.75,
|
||||
"expected_hours": 9,
|
||||
"is_workday": True,
|
||||
"is_late": True,
|
||||
"late_minutes": 15,
|
||||
"is_early_leave": False,
|
||||
"early_leave_minutes": None,
|
||||
"is_underwork": True,
|
||||
"underwork_hours": 0.25,
|
||||
"is_overtime": False,
|
||||
"overtime_hours": None,
|
||||
"is_absent": False,
|
||||
"statuses": ["late", "underwork"],
|
||||
"employee_issues": ["late"],
|
||||
"technical_issues": [],
|
||||
},
|
||||
{
|
||||
"date": "2026-04-21",
|
||||
"employee_id": "42",
|
||||
"display_name": "Ivan Petrov",
|
||||
"arrival_time": None,
|
||||
"departure_time": None,
|
||||
"work_hours": 0,
|
||||
"expected_hours": 9,
|
||||
"is_workday": True,
|
||||
"is_absent": True,
|
||||
"statuses": ["absence"],
|
||||
"employee_issues": ["absence"],
|
||||
"technical_issues": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _aware_datetime(year, month, day):
|
||||
return timezone.make_aware(datetime(year, month, day))
|
||||
|
||||
|
||||
def _move_auto_hired_before_test_period(employee):
|
||||
employee.actions.filter(action="hired").update(date=_aware_datetime(2026, 1, 1))
|
||||
|
||||
|
||||
def test_analyze_saves_run_and_records(auth_client_factory, user_factory):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory(first_name="Ivan", last_name="Petrov")
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
response = client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert AttendanceAnalysisRun.objects.count() == 1
|
||||
assert AttendanceRecord.objects.count() == 2
|
||||
|
||||
run = AttendanceAnalysisRun.objects.get()
|
||||
assert run.employee == employee
|
||||
assert run.triggered_by == staff
|
||||
assert run.period_start.isoformat() == "2026-04-20"
|
||||
assert run.period_end.isoformat() == "2026-04-21"
|
||||
assert run.request_payload["employee_id"] == str(employee.id)
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
assert record.analysis_run == run
|
||||
assert record.arrival_time == "09:15"
|
||||
assert record.is_late is True
|
||||
assert record.effective_is_workday is True
|
||||
assert record.personnel_status == "normal"
|
||||
assert record.statuses == ["late", "underwork"]
|
||||
assert record.raw_data["employee_id"] == "42"
|
||||
|
||||
|
||||
def test_analyze_updates_existing_daily_record(auth_client_factory, user_factory):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
_move_auto_hired_before_test_period(employee)
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
first_result = _logstorm_result()
|
||||
second_result = {
|
||||
"records": [
|
||||
{
|
||||
**first_result["records"][0],
|
||||
"arrival_time": "08:55",
|
||||
"is_late": False,
|
||||
"late_minutes": None,
|
||||
"statuses": [],
|
||||
"employee_issues": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=first_result,
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=second_result,
|
||||
):
|
||||
response = client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert AttendanceAnalysisRun.objects.count() == 2
|
||||
assert (
|
||||
AttendanceRecord.objects.filter(employee=employee, date="2026-04-20").count()
|
||||
== 1
|
||||
)
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
assert record.arrival_time == "08:55"
|
||||
assert record.is_late is False
|
||||
assert record.statuses == []
|
||||
|
||||
|
||||
def test_records_endpoint_returns_saved_records(auth_client_factory, user_factory):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
other = user_factory()
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
client.post(_analyze_url(), _payload(other.id), format="json")
|
||||
|
||||
response = client.get(
|
||||
_records_url(),
|
||||
{
|
||||
"employee_id": employee.id,
|
||||
"date_from": "2026-04-20",
|
||||
"date_to": "2026-04-21",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {item["employee"] for item in results} == {employee.id}
|
||||
assert {item["date"] for item in results} == {"2026-04-20", "2026-04-21"}
|
||||
|
||||
|
||||
def test_monthly_matrix_endpoint_returns_excel_like_month(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory(first_name="Ivan", last_name="Petrov")
|
||||
other = user_factory(first_name="Anna", last_name="Sidorova")
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
client.post(_analyze_url(), _payload(other.id), format="json")
|
||||
|
||||
response = client.get(
|
||||
_monthly_matrix_url(),
|
||||
{
|
||||
"employee_ids": f"{employee.id},{other.id}",
|
||||
"month": "2026-04",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["month"] == "2026-04"
|
||||
assert payload["month_label"] == "Апрель 2026"
|
||||
assert [item["id"] for item in payload["employees"]] == [employee.id, other.id]
|
||||
assert len(payload["rows"]) == 30
|
||||
|
||||
target_row = next(row for row in payload["rows"] if row["date"] == "2026-04-20")
|
||||
employee_cell = target_row["cells"][str(employee.id)]
|
||||
assert employee_cell["arrival_time"] == "09:15"
|
||||
assert employee_cell["departure_time"] == "18:00"
|
||||
assert employee_cell["status"] == "underwork"
|
||||
|
||||
summary_by_key = {item["key"]: item for item in payload["summary"]}
|
||||
assert summary_by_key["late_days"]["values"][str(employee.id)] == 1
|
||||
assert summary_by_key["absent_days"]["values"][str(employee.id)] == 1
|
||||
assert summary_by_key["worked_hours"]["values"][str(employee.id)] == 8.75
|
||||
|
||||
|
||||
def test_user_can_read_own_attendance_records(auth_client_factory, user_factory):
|
||||
user = user_factory(staff=False)
|
||||
other = user_factory()
|
||||
staff = user_factory(staff=True)
|
||||
staff_client = auth_client_factory(staff)
|
||||
client = auth_client_factory(user)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
staff_client.post(_analyze_url(), _payload(user.id), format="json")
|
||||
staff_client.post(_analyze_url(), _payload(other.id), format="json")
|
||||
|
||||
response = client.get(_records_url())
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {item["employee"] for item in results} == {user.id}
|
||||
|
||||
|
||||
def test_user_cannot_read_other_employee_attendance_records(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
user = user_factory(staff=False)
|
||||
other = user_factory()
|
||||
client = auth_client_factory(user)
|
||||
|
||||
response = client.get(_records_url(), {"employee_id": other.id})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_staff_can_update_attendance_record(auth_client_factory, user_factory):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
patch_payload = {
|
||||
"arrival_time": "09:00",
|
||||
"departure_time": "18:05",
|
||||
"work_hours": 9.08,
|
||||
"is_late": False,
|
||||
"late_minutes": None,
|
||||
"is_underwork": False,
|
||||
"underwork_hours": None,
|
||||
}
|
||||
with patch(
|
||||
"api.v1.attendance.views.LogStormClient.update_attendance_override",
|
||||
return_value={"id": 1, "patch": patch_payload},
|
||||
) as logstorm_update:
|
||||
response = client.patch(
|
||||
_record_detail_url(record.id),
|
||||
patch_payload,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
logstorm_update.assert_called_once()
|
||||
record.refresh_from_db()
|
||||
assert record.arrival_time == "09:00"
|
||||
assert record.work_hours == 9.08
|
||||
assert record.is_late is False
|
||||
assert record.is_manually_edited is True
|
||||
assert record.manual_edit_payload["arrival_time"] == "09:00"
|
||||
assert "late" not in record.statuses
|
||||
assert response.json()["arrival_time"] == "09:00"
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
record.refresh_from_db()
|
||||
assert record.arrival_time == "09:00"
|
||||
assert record.is_manually_edited is True
|
||||
assert record.manual_edit_payload["arrival_time"] == "09:00"
|
||||
|
||||
|
||||
def test_user_cannot_update_attendance_record(auth_client_factory, user_factory):
|
||||
user = user_factory(staff=False)
|
||||
staff = user_factory(staff=True)
|
||||
staff_client = auth_client_factory(staff)
|
||||
client = auth_client_factory(user)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
staff_client.post(_analyze_url(), _payload(user.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=user, date="2026-04-20")
|
||||
response = client.patch(
|
||||
_record_detail_url(record.id),
|
||||
{"arrival_time": "09:00"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_user_can_analyze_own_attendance(auth_client_factory, user_factory):
|
||||
user = user_factory(staff=False)
|
||||
client = auth_client_factory(user)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
response = client.post(_analyze_url(), _payload(user.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert AttendanceRecord.objects.filter(employee=user).count() == 2
|
||||
|
||||
|
||||
def test_user_cannot_analyze_other_employee_attendance(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
user = user_factory(staff=False)
|
||||
other = user_factory()
|
||||
client = auth_client_factory(user)
|
||||
|
||||
response = client.post(_analyze_url(), _payload(other.id), format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_personnel_non_working_state_is_saved_and_suppresses_absence(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
_move_auto_hired_before_test_period(employee)
|
||||
client = auth_client_factory(staff)
|
||||
leave_action = EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=ACTION_ON_LEAVE,
|
||||
date=_aware_datetime(2026, 4, 20),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
response = client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
absent_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21")
|
||||
assert absent_record.personnel_status == ACTION_ON_LEAVE
|
||||
assert absent_record.personnel_action == leave_action
|
||||
assert absent_record.effective_is_workday is False
|
||||
assert absent_record.is_absent is False
|
||||
assert absent_record.employee_issues == []
|
||||
assert absent_record.statuses == []
|
||||
|
||||
|
||||
def test_calendar_non_working_day_suppresses_absence_without_personnel_state(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
client = auth_client_factory(staff)
|
||||
weekend_absence_result = {
|
||||
"records": [
|
||||
{
|
||||
"date": "2026-04-19",
|
||||
"employee_id": str(employee.id),
|
||||
"display_name": "Weekend User",
|
||||
"arrival_time": None,
|
||||
"departure_time": None,
|
||||
"work_hours": 0,
|
||||
"expected_hours": 9,
|
||||
"is_workday": False,
|
||||
"is_absent": True,
|
||||
"statuses": ["День критического отсутствия (100% отсутствуют)"],
|
||||
"employee_issues": ["absence"],
|
||||
"technical_issues": [
|
||||
"День критического отсутствия (100% отсутствуют)"
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=weekend_absence_result,
|
||||
):
|
||||
response = client.post(
|
||||
_analyze_url(),
|
||||
_payload(employee.id, period_start="2026-04-19", period_end="2026-04-19"),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-19")
|
||||
assert record.is_workday is False
|
||||
assert record.effective_is_workday is False
|
||||
assert record.is_absent is False
|
||||
assert record.statuses == []
|
||||
assert record.employee_issues == []
|
||||
assert record.technical_issues == []
|
||||
|
||||
records_response = client.get(
|
||||
_records_url(),
|
||||
{
|
||||
"employee_id": employee.id,
|
||||
"date_from": "2026-04-19",
|
||||
"date_to": "2026-04-19",
|
||||
},
|
||||
)
|
||||
assert records_response.status_code == 200
|
||||
assert records_response.json()["results"][0]["non_working_reason"] == (
|
||||
"Выходной по графику/календарю"
|
||||
)
|
||||
|
||||
matrix_response = client.get(
|
||||
_monthly_matrix_url(),
|
||||
{
|
||||
"employee_ids": str(employee.id),
|
||||
"month": "2026-04",
|
||||
},
|
||||
)
|
||||
matrix_row = next(
|
||||
row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-19"
|
||||
)
|
||||
matrix_cell = matrix_row["cells"][str(employee.id)]
|
||||
assert matrix_cell["status"] == "non_working"
|
||||
assert matrix_cell["issues"] == []
|
||||
|
||||
|
||||
def test_personnel_non_working_state_counts_work_as_overtime(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
_move_auto_hired_before_test_period(employee)
|
||||
client = auth_client_factory(staff)
|
||||
EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=ACTION_ON_LEAVE,
|
||||
date=_aware_datetime(2026, 4, 20),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
response = client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
worked_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
assert worked_record.personnel_status == ACTION_ON_LEAVE
|
||||
assert worked_record.effective_is_workday is False
|
||||
assert worked_record.is_late is False
|
||||
assert worked_record.is_underwork is False
|
||||
assert worked_record.is_overtime is True
|
||||
assert worked_record.overtime_hours == worked_record.work_hours
|
||||
assert worked_record.employee_issues == []
|
||||
assert worked_record.statuses == ["work_outside_personnel_schedule"]
|
||||
|
||||
|
||||
def test_personnel_state_is_recalculated_when_actions_change(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
_move_auto_hired_before_test_period(employee)
|
||||
client = auth_client_factory(staff)
|
||||
EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=ACTION_ON_LEAVE,
|
||||
date=_aware_datetime(2026, 4, 20),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
first_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21")
|
||||
assert first_record.personnel_status == ACTION_ON_LEAVE
|
||||
assert first_record.is_absent is False
|
||||
|
||||
EmployeeAction.objects.create(
|
||||
employee=employee,
|
||||
action=ACTION_RETURNED_FROM_LEAVE,
|
||||
date=_aware_datetime(2026, 4, 21),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
response = client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
updated_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21")
|
||||
assert updated_record.personnel_status == "normal"
|
||||
assert updated_record.effective_is_workday is True
|
||||
assert updated_record.is_absent is True
|
||||
assert updated_record.employee_issues == ["absence"]
|
||||
|
||||
|
||||
def test_attendance_record_comments_use_communications(
|
||||
auth_client_factory, user_factory
|
||||
):
|
||||
staff = user_factory(staff=True, first_name="Admin", last_name="User")
|
||||
employee = user_factory()
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
|
||||
create_response = client.post(
|
||||
_record_comments_url(record.id),
|
||||
{"text": "Проверить причину опоздания"},
|
||||
format="json",
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
comment = create_response.json()
|
||||
assert comment["record"] == record.id
|
||||
assert comment["text"] == "Проверить причину опоздания"
|
||||
assert comment["author"]["id"] == staff.id
|
||||
|
||||
list_response = client.get(_record_comments_url(record.id))
|
||||
assert list_response.status_code == 200
|
||||
assert [item["text"] for item in list_response.json()] == [
|
||||
"Проверить причину опоздания"
|
||||
]
|
||||
|
||||
records_response = client.get(_records_url(), {"employee_id": employee.id})
|
||||
assert records_response.status_code == 200
|
||||
target = next(
|
||||
item for item in records_response.json()["results"] if item["id"] == record.id
|
||||
)
|
||||
assert target["comments_count"] == 1
|
||||
|
||||
|
||||
def test_attendance_record_comment_delete_is_author_only(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
author = user_factory(staff=True)
|
||||
other_staff = user_factory(staff=True)
|
||||
employee = user_factory()
|
||||
author_client = auth_client_factory(author)
|
||||
other_client = auth_client_factory(other_staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
author_client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
comment_id = author_client.post(
|
||||
_record_comments_url(record.id),
|
||||
{"text": "Комментарий автора"},
|
||||
format="json",
|
||||
).json()["id"]
|
||||
|
||||
forbidden_response = other_client.delete(
|
||||
_record_comment_detail_url(record.id, comment_id)
|
||||
)
|
||||
assert forbidden_response.status_code == 403
|
||||
|
||||
delete_response = author_client.delete(
|
||||
_record_comment_detail_url(record.id, comment_id)
|
||||
)
|
||||
assert delete_response.status_code == 204
|
||||
assert author_client.get(_record_comments_url(record.id)).json() == []
|
||||
|
||||
|
||||
def test_user_can_comment_own_attendance_record(auth_client_factory, user_factory):
|
||||
user = user_factory(staff=False)
|
||||
staff = user_factory(staff=True)
|
||||
staff_client = auth_client_factory(staff)
|
||||
client = auth_client_factory(user)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
staff_client.post(_analyze_url(), _payload(user.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=user, date="2026-04-20")
|
||||
|
||||
create_response = client.post(
|
||||
_record_comments_url(record.id),
|
||||
{"text": "Мой комментарий"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
assert create_response.json()["author"]["id"] == user.id
|
||||
assert client.get(_record_comments_url(record.id)).json()[0]["text"] == (
|
||||
"Мой комментарий"
|
||||
)
|
||||
|
||||
|
||||
def test_user_cannot_comment_other_employee_attendance_record(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
user = user_factory(staff=False)
|
||||
other = user_factory()
|
||||
staff = user_factory(staff=True)
|
||||
staff_client = auth_client_factory(staff)
|
||||
client = auth_client_factory(user)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
staff_client.post(_analyze_url(), _payload(other.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=other, date="2026-04-20")
|
||||
|
||||
response = client.post(
|
||||
_record_comments_url(record.id),
|
||||
{"text": "Чужая запись"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@ -22,14 +22,31 @@ def _payload(employee_id, **overrides):
|
||||
return payload
|
||||
|
||||
|
||||
def test_logstorm_attendance_api_requires_staff(
|
||||
def test_logstorm_attendance_api_allows_user_self_analysis(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
user = user_factory(staff=False)
|
||||
client = auth_client_factory(user)
|
||||
|
||||
response = client.post(_url(), _payload(user.id), format="json")
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value={"records": []},
|
||||
):
|
||||
response = client.post(_url(), _payload(user.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_logstorm_attendance_api_rejects_other_employee_for_user(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
user = user_factory(staff=False)
|
||||
other = user_factory()
|
||||
client = auth_client_factory(user)
|
||||
|
||||
response = client.post(_url(), _payload(other.id), format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@ -84,9 +101,9 @@ def test_logstorm_attendance_api_passes_schedule(
|
||||
|
||||
assert response.status_code == 200
|
||||
assert analyze.call_args.kwargs["schedule"]["start_time"] == "09:00"
|
||||
assert analyze.call_args.kwargs["schedule"]["date_overrides"][0][
|
||||
"is_workday"
|
||||
] is False
|
||||
assert (
|
||||
analyze.call_args.kwargs["schedule"]["date_overrides"][0]["is_workday"] is False
|
||||
)
|
||||
|
||||
|
||||
def test_logstorm_attendance_api_rejects_invalid_period(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -310,10 +310,16 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--muted-foreground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-badge-accent {
|
||||
@ -333,14 +339,18 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-avatar-frame {
|
||||
@ -453,6 +463,11 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-secondary);
|
||||
color: var(--foreground);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-pill:hover {
|
||||
@ -463,6 +478,11 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 22%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));
|
||||
color: var(--foreground);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-pill-count {
|
||||
|
||||
@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import { Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
|
||||
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
|
||||
import {
|
||||
ProfileContactsPanel,
|
||||
@ -186,6 +187,8 @@ export default function ProfilePage() {
|
||||
truncateCommentLength={120}
|
||||
expandedCommentLength={240}
|
||||
/>
|
||||
|
||||
<EmployeeAttendanceCard employeeActions={user.actions} employeeId={user.id} />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "../../../components/AppShell";
|
||||
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
|
||||
import ChangeUserPositionModal from "@/components/users/ChangeUserPositionModal";
|
||||
import EditUserProfileModal from "@/components/users/EditUserProfileModal";
|
||||
import EmployeeActionModal from "@/components/users/EmployeeActionModal";
|
||||
@ -523,6 +524,8 @@ export default function UserDetailPage() {
|
||||
truncateCommentLength={120}
|
||||
expandedCommentLength={240}
|
||||
/>
|
||||
|
||||
<EmployeeAttendanceCard employeeActions={person.actions} employeeId={person.id} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
1367
frontend/src/components/attendance/EmployeeAttendanceCard.tsx
Normal file
1367
frontend/src/components/attendance/EmployeeAttendanceCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
||||
import { type RequestFn } from './utils';
|
||||
import { buildQuery, type RequestFn } from './utils';
|
||||
import type { User } from '@/types/api';
|
||||
|
||||
export type LogStormDateOverridePayload = {
|
||||
date: string;
|
||||
@ -25,6 +26,7 @@ export type LogStormAttendanceAnalyzePayload = {
|
||||
};
|
||||
|
||||
export type LogStormAttendanceRecord = {
|
||||
id?: number;
|
||||
date?: string;
|
||||
employee_id?: string;
|
||||
display_name?: string;
|
||||
@ -33,6 +35,8 @@ export type LogStormAttendanceRecord = {
|
||||
work_hours?: number | string | null;
|
||||
expected_hours?: number | string | null;
|
||||
is_workday?: boolean;
|
||||
effective_is_workday?: boolean;
|
||||
non_working_reason?: string;
|
||||
is_late?: boolean;
|
||||
late_minutes?: number | null;
|
||||
is_early_leave?: boolean;
|
||||
@ -45,14 +49,118 @@ export type LogStormAttendanceRecord = {
|
||||
employee_issues?: string[];
|
||||
technical_issues?: string[];
|
||||
statuses?: string[];
|
||||
comments_count?: number;
|
||||
personnel_status?: string;
|
||||
personnel_status_label?: string;
|
||||
personnel_action?: number | null;
|
||||
is_manually_edited?: boolean;
|
||||
manual_edit_payload?: Record<string, unknown>;
|
||||
manual_edited_by?: number | null;
|
||||
manual_edited_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AttendanceRecordComment = {
|
||||
id: number;
|
||||
record: number;
|
||||
author: User;
|
||||
text: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type LogStormAttendanceResponse = {
|
||||
records?: LogStormAttendanceRecord[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type LogStormAttendanceRecordsQuery = {
|
||||
employee_id?: number | string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type LogStormAttendanceRecordUpdatePayload = Partial<Pick<
|
||||
LogStormAttendanceRecord,
|
||||
| 'arrival_time'
|
||||
| 'departure_time'
|
||||
| 'work_hours'
|
||||
| 'expected_hours'
|
||||
| 'is_workday'
|
||||
| 'effective_is_workday'
|
||||
| 'is_late'
|
||||
| 'late_minutes'
|
||||
| 'is_early_leave'
|
||||
| 'early_leave_minutes'
|
||||
| 'is_underwork'
|
||||
| 'underwork_hours'
|
||||
| 'is_overtime'
|
||||
| 'overtime_hours'
|
||||
| 'is_absent'
|
||||
>>;
|
||||
|
||||
export type PaginatedLogStormAttendanceRecords = {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: LogStormAttendanceRecord[];
|
||||
};
|
||||
|
||||
export type MonthlyAttendanceMatrixCell = {
|
||||
record_id: number | null;
|
||||
arrival_time: string | null;
|
||||
departure_time: string | null;
|
||||
work_hours: number | string | null;
|
||||
expected_hours: number | string | null;
|
||||
status: 'empty' | 'technical' | 'underwork' | 'late' | 'overtime' | 'absent' | 'non_working' | 'normal' | string;
|
||||
issues: string[];
|
||||
is_workday: boolean | null;
|
||||
effective_is_workday: boolean | null;
|
||||
non_working_reason?: string;
|
||||
is_late?: boolean;
|
||||
is_early_leave?: boolean;
|
||||
is_underwork?: boolean;
|
||||
is_overtime?: boolean;
|
||||
is_absent?: boolean;
|
||||
personnel_status?: string;
|
||||
personnel_status_label?: string;
|
||||
comments_count: number;
|
||||
};
|
||||
|
||||
export type MonthlyAttendanceMatrixEmployee = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type MonthlyAttendanceMatrixRow = {
|
||||
date: string;
|
||||
label: string;
|
||||
weekday: string;
|
||||
is_weekend: boolean;
|
||||
cells: Record<string, MonthlyAttendanceMatrixCell>;
|
||||
};
|
||||
|
||||
export type MonthlyAttendanceMatrixSummaryRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
values: Record<string, number>;
|
||||
};
|
||||
|
||||
export type MonthlyAttendanceMatrix = {
|
||||
month: string;
|
||||
month_label: string;
|
||||
employees: MonthlyAttendanceMatrixEmployee[];
|
||||
rows: MonthlyAttendanceMatrixRow[];
|
||||
summary: MonthlyAttendanceMatrixSummaryRow[];
|
||||
};
|
||||
|
||||
export type MonthlyAttendanceMatrixQuery = {
|
||||
employee_ids: string;
|
||||
month: string;
|
||||
};
|
||||
|
||||
export function createAttendanceApi(request: RequestFn) {
|
||||
return {
|
||||
analyzeLogStormAttendance: (data: LogStormAttendanceAnalyzePayload) =>
|
||||
@ -60,5 +168,25 @@ export function createAttendanceApi(request: RequestFn) {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}) as Promise<LogStormAttendanceResponse>,
|
||||
getLogStormAttendanceRecords: (params?: LogStormAttendanceRecordsQuery) =>
|
||||
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedLogStormAttendanceRecords>,
|
||||
getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) =>
|
||||
request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise<MonthlyAttendanceMatrix>,
|
||||
updateAttendanceRecord: (recordId: number, data: LogStormAttendanceRecordUpdatePayload) =>
|
||||
request(`/api/v1/attendance/records/${recordId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}) as Promise<LogStormAttendanceRecord>,
|
||||
getAttendanceRecordComments: (recordId: number) =>
|
||||
request(`/api/v1/attendance/records/${recordId}/comments/`) as Promise<AttendanceRecordComment[]>,
|
||||
addAttendanceRecordComment: (recordId: number, text: string) =>
|
||||
request(`/api/v1/attendance/records/${recordId}/comments/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
}) as Promise<AttendanceRecordComment>,
|
||||
deleteAttendanceRecordComment: (recordId: number, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/attendance/records/${recordId}/comments/${commentId}/`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,11 +20,30 @@ export function isStandaloneDisplayMode(): boolean {
|
||||
return Boolean(standaloneMatch || navigatorStandalone);
|
||||
}
|
||||
|
||||
async function unregisterDevelopmentServiceWorker(): Promise<void> {
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
await registration?.unregister().catch(() => undefined);
|
||||
|
||||
if ("caches" in window) {
|
||||
const cacheNames = await caches.keys().catch(() => []);
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter((cacheName) => cacheName.startsWith("eusrr-"))
|
||||
.map((cacheName) => caches.delete(cacheName)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAppServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
await unregisterDevelopmentServiceWorker();
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
|
||||
if (existingRegistration) {
|
||||
await existingRegistration.update().catch(() => undefined);
|
||||
|
||||
@ -42,6 +42,10 @@ export const employeeActionTypes = [
|
||||
{ value: "dismissed", label: "Уволен" },
|
||||
{ value: "on_leave", label: "В отпуске" },
|
||||
{ value: "returned_from_leave", label: "Вернулся из отпуска" },
|
||||
{ value: "on_sick_leave", label: "На больничном" },
|
||||
{ value: "returned_from_sick_leave", label: "Вернулся с больничного" },
|
||||
{ value: "on_day_off", label: "В отгуле" },
|
||||
{ value: "returned_from_day_off", label: "Вернулся из отгула" },
|
||||
{ value: "on_maternity", label: "В декрете" },
|
||||
{ value: "returned_from_maternity", label: "Вернулся из декрета" },
|
||||
{ value: "transferred", label: "Переведен" },
|
||||
@ -217,6 +221,8 @@ export function getEmployeeActionTone(actionType?: string): {
|
||||
} {
|
||||
switch (actionType) {
|
||||
case "on_leave":
|
||||
case "on_sick_leave":
|
||||
case "on_day_off":
|
||||
case "on_maternity":
|
||||
return {
|
||||
badgeClass: "app-feedback-warning",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user