826 lines
28 KiB
Python

import logging
from calendar import monthrange
from datetime import date, timedelta
from urllib.parse import quote
from attendance.models import (
AttendanceRecord,
EmployeeWorkSchedule,
StandardWorkSchedule,
)
from attendance.services import (
build_attendance_matrix_export_workbook,
build_monthly_attendance_matrix,
get_attendance_auto_sync_settings,
get_employee_work_schedule_payload,
get_standard_work_schedule_payload,
normalize_attendance_record_manual_issues,
run_attendance_auto_sync,
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.http import HttpResponse
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.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 (
AttendanceAutoSyncSettingsSerializer,
AttendanceMonthlyMatrixExportQuerySerializer,
AttendanceMonthlyMatrixQuerySerializer,
AttendanceRecordCommentSerializer,
AttendanceRecordSerializer,
AttendanceRecordUpdateSerializer,
EmployeeWorkScheduleSerializer,
LogStormAttendanceAnalyzeSerializer,
StandardWorkScheduleSerializer,
default_work_schedule_response,
default_standard_work_schedule_response,
)
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 user.is_superuser
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 or user.is_superuser:
return queryset
return queryset.filter(employee_id=user.id)
def _check_staff_access(user) -> None:
if not (user and user.is_authenticated and (user.is_staff or user.is_superuser)):
raise PermissionDenied("You don't have permission to manage attendance")
def _has_attendance_pass(record: AttendanceRecord) -> bool:
return bool(
record.arrival_time
or record.departure_time
or (record.work_hours is not None and record.work_hours > 0)
)
def _has_absence_marker(record: AttendanceRecord) -> bool:
values = [
record.statuses,
record.employee_issues,
]
return bool(
record.is_absent
or any(
"absent" in normalized
or "absence" in normalized
or "отсутств" in normalized
for items in values
for normalized in [str(item).strip().lower() for item in (items or [])]
)
)
def _employee_attendance_aliases(record: AttendanceRecord) -> list[str]:
return [
str(alias).strip()
for alias in (getattr(record.employee, "attendance_aliases", None) or [])
if str(alias).strip()
]
def _attendance_employee_summary(employee: Employee) -> dict[str, str | int]:
full_name = f"{employee.last_name or ''} {employee.first_name or ''}".strip()
if getattr(employee, "patronymic", ""):
full_name = f"{full_name} {employee.patronymic}".strip()
return {
"id": employee.id,
"name": full_name or employee.email or f"Сотрудник #{employee.id}",
"email": employee.email or "",
}
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),
)
}
def _comment_author_name(author) -> str:
if not author:
return "Сотрудник"
first_name = getattr(author, "first_name", "") or ""
last_name = getattr(author, "last_name", "") or ""
full_name = f"{last_name} {first_name}".strip()
return full_name or getattr(author, "email", "") or "Сотрудник"
def _attendance_record_comments_map(record_ids: list[int]) -> dict[int, list[dict[str, str]]]:
if not record_ids:
return {}
record_ct = ContentType.objects.get_for_model(AttendanceRecord)
messages = (
Message.objects.filter(
chat__type="comments",
chat__context_content_type=record_ct,
chat__context_object_id__in=record_ids,
is_deleted=False,
)
.select_related("author", "chat")
.order_by("created_at", "id")
)
result: dict[int, list[dict[str, str]]] = {}
for message in messages:
record_id = int(message.chat.context_object_id)
result.setdefault(record_id, []).append(
{
"author": _comment_author_name(message.author),
"text": message.content,
"created_at": timezone.localtime(message.created_at).strftime(
"%d.%m.%Y %H:%M"
),
}
)
return result
class LogStormAttendanceAnalyzeAPIView(APIView):
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"])
except Employee.DoesNotExist:
return Response(
{"detail": "Employee not found"},
status=status.HTTP_404_NOT_FOUND,
)
schedule_payload = data.get("schedule")
if schedule_payload is None:
schedule_payload = (
get_employee_work_schedule_payload(employee)
or get_standard_work_schedule_payload()
)
aliases = (
data["aliases"]
if "aliases" in data
else list(getattr(employee, "attendance_aliases", None) or [])
)
try:
result = analyze_employee_attendance(
employee=employee,
period_start=data["period_start"],
period_end=data["period_end"],
schedule=schedule_payload,
aliases=aliases,
client=None,
)
except LogStormClientError as exc:
logger.warning("LogStorm attendance analysis failed: %s", exc)
return Response(
{"detail": str(exc), "error": "logstorm_unavailable"},
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=schedule_payload,
aliases=aliases,
)
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 EmployeeWorkScheduleAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_employee(self, employee_id):
_check_employee_access(self.request.user, employee_id)
return get_object_or_404(Employee, pk=employee_id)
def get(self, request, employee_id):
employee = self.get_employee(employee_id)
try:
schedule = employee.work_schedule
except EmployeeWorkSchedule.DoesNotExist:
schedule = None
if schedule is None:
return Response(default_work_schedule_response(employee.id))
return Response(EmployeeWorkScheduleSerializer(schedule).data)
def patch(self, request, employee_id):
if not (request.user.is_staff or request.user.is_superuser):
raise PermissionDenied("You don't have permission to edit schedules")
employee = get_object_or_404(Employee, pk=employee_id)
try:
schedule = employee.work_schedule
except EmployeeWorkSchedule.DoesNotExist:
schedule = None
serializer = EmployeeWorkScheduleSerializer(
schedule,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(employee=employee, updated_by=request.user)
return Response(EmployeeWorkScheduleSerializer(instance).data)
class StandardWorkScheduleAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
_check_staff_access(request.user)
schedule = StandardWorkSchedule.objects.order_by("id").first()
if schedule is None:
return Response(default_standard_work_schedule_response())
return Response(StandardWorkScheduleSerializer(schedule).data)
def patch(self, request):
_check_staff_access(request.user)
schedule = StandardWorkSchedule.objects.order_by("id").first()
serializer = StandardWorkScheduleSerializer(
schedule,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(updated_by=request.user)
return Response(StandardWorkScheduleSerializer(instance).data)
class AttendanceAutoSyncSettingsAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
_check_staff_access(request.user)
return Response(
AttendanceAutoSyncSettingsSerializer(
get_attendance_auto_sync_settings()
).data
)
def patch(self, request):
_check_staff_access(request.user)
settings = get_attendance_auto_sync_settings()
was_enabled = settings.enabled
old_frequency = settings.frequency_minutes
serializer = AttendanceAutoSyncSettingsSerializer(
settings,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(updated_by=request.user)
update_fields = ["updated_by", "updated_at"]
if not instance.enabled:
instance.next_run_at = None
update_fields.append("next_run_at")
elif not was_enabled or old_frequency != instance.frequency_minutes:
instance.next_run_at = timezone.now()
update_fields.append("next_run_at")
if update_fields != ["updated_by", "updated_at"]:
instance.save(update_fields=update_fields)
return Response(AttendanceAutoSyncSettingsSerializer(instance).data)
class AttendanceAutoSyncRunNowAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
_check_staff_access(request.user)
settings = run_attendance_auto_sync(force=True)
return Response(AttendanceAutoSyncSettingsSerializer(settings).data)
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 AttendanceWeeklySummaryAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
_check_staff_access(request.user)
week_start = self._week_start(request.query_params.get("week_start"))
week_end = week_start + timedelta(days=6)
records = (
_records_visible_to(request.user)
.filter(
employee__is_active=True,
date__gte=week_start,
date__lte=week_end,
)
.select_related("employee")
.only(
"employee_id",
"employee__id",
"employee__first_name",
"employee__last_name",
"employee__patronymic",
"employee__email",
"date",
"arrival_time",
"departure_time",
"work_hours",
"is_absent",
"statuses",
"employee_issues",
)
.order_by("date", "employee_id")
)
by_date = {
week_start + timedelta(days=day_offset): {
"present": {},
"absent": {},
}
for day_offset in range(7)
}
for record in records:
bucket = by_date.get(record.date)
if bucket is None:
continue
employee_summary = _attendance_employee_summary(record.employee)
if _has_attendance_pass(record):
bucket["present"][record.employee_id] = employee_summary
bucket["absent"].pop(record.employee_id, None)
elif _has_absence_marker(record):
if record.employee_id not in bucket["present"]:
bucket["absent"][record.employee_id] = employee_summary
return Response(
{
"week_start": week_start,
"week_end": week_end,
"days": [
{
"date": day.isoformat(),
"weekday": day.strftime("%A"),
"present": len(counts["present"]),
"absent": len(counts["absent"]),
"present_people": list(counts["present"].values()),
"absent_people": list(counts["absent"].values()),
}
for day, counts in by_date.items()
],
}
)
def _week_start(self, value: str | None) -> date:
if value:
try:
parsed = date.fromisoformat(value)
except ValueError:
parsed = timezone.localdate()
else:
parsed = timezone.localdate()
return parsed - timedelta(days=parsed.weekday())
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 AttendanceMonthlyMatrixExportAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
_check_staff_access(request.user)
serializer = AttendanceMonthlyMatrixExportQuerySerializer(
data=request.query_params,
)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
employee_ids = data["employee_ids"]
period_start = data["period_start"]
period_end = data["period_end"]
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
]
records = list(
_records_visible_to(request.user)
.filter(
employee_id__in=[employee.id for employee in employees],
date__gte=period_start,
date__lte=period_end,
)
.select_related("employee")
.annotate(**_comments_count_annotation())
.order_by("employee_id", "date", "id")
)
content = build_attendance_matrix_export_workbook(
employees=employees,
records=records,
period_start=period_start,
period_end=period_end,
record_comments=_attendance_record_comments_map(
[record.id for record in records]
),
)
filename = f"attendance-{period_start.isoformat()}_{period_end.isoformat()}.xlsx"
response = HttpResponse(
content,
content_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
class AttendanceRecordDetailAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, record_id):
record = get_object_or_404(
_records_visible_to(request.user)
.select_related("employee", "analysis_run")
.annotate(**_comments_count_annotation()),
pk=record_id,
)
return Response(AttendanceRecordSerializer(record).data)
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 AttendanceRecordDayEventsAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_record(self, request, record_id):
return get_object_or_404(
_records_visible_to(request.user).select_related("employee"),
pk=record_id,
)
def get(self, request, record_id):
record = self.get_record(request, record_id)
try:
events = LogStormClient().get_attendance_day_events(
employee_id=str(record.employee_id),
record_date=record.date,
aliases=_employee_attendance_aliases(record),
)
except LogStormClientError as exc:
logger.warning("LogStorm attendance day events failed: %s", exc)
return Response(
{"detail": str(exc), "error": "logstorm_unavailable"},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(
[
self._with_proxy_photo_url(request, record, event)
for event in events
if isinstance(event, dict)
]
)
@staticmethod
def _with_proxy_photo_url(request, record, event):
result = dict(event)
event_key = str(result.get("event_key") or "")
if result.get("has_photo") and event_key:
result["photo_url"] = (
f"/api/v1/attendance/records/{record.id}/day-events/"
f"{quote(event_key)}/photo/"
)
else:
result["photo_url"] = None
return result
class AttendanceRecordDayEventPhotoAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_record(self, request, record_id):
return get_object_or_404(
_records_visible_to(request.user).select_related("employee"),
pk=record_id,
)
def get(self, request, record_id, event_key):
record = self.get_record(request, record_id)
client = LogStormClient()
try:
events = client.get_attendance_day_events(
employee_id=str(record.employee_id),
record_date=record.date,
aliases=_employee_attendance_aliases(record),
)
event = next(
(
item
for item in events
if isinstance(item, dict)
and str(item.get("event_key")) == str(event_key)
),
None,
)
if not event or not event.get("has_photo"):
return Response(
{"detail": "Event photo not found"},
status=status.HTTP_404_NOT_FOUND,
)
photo_response = client.get_attendance_event_photo(event_key)
except LogStormClientError as exc:
if exc.status_code == status.HTTP_404_NOT_FOUND:
return Response(
{"detail": "Event photo not found"},
status=status.HTTP_404_NOT_FOUND,
)
logger.warning("LogStorm attendance event photo failed: %s", exc)
return Response(
{"detail": str(exc), "error": "logstorm_unavailable"},
status=status.HTTP_502_BAD_GATEWAY,
)
return HttpResponse(
photo_response.content,
content_type=photo_response.headers.get("Content-Type", "image/jpeg"),
)
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)