diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index 06677d0e..fd5d69c6 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -11,6 +11,7 @@ """ from django.contrib.auth import get_user_model +from django.db.models import Q # Опциональная зависимость от notifications (graceful degradation) try: @@ -240,6 +241,7 @@ def _notify_comment(message): object_author = None object_url = None object_type = "объекта" + extra_data = {} # Проверяем тип объекта через модель model_name = context_obj.__class__.__name__ @@ -271,8 +273,21 @@ def _notify_comment(message): recipient_ids.update(role_only_ids) elif model_name == "AttendanceRecord": object_author = getattr(context_obj, "employee", None) - object_url = f"/users/{context_obj.employee_id}" + object_url = f"/attendance?record={context_obj.id}&comments=1" object_type = "записи посещаемости" + admin_ids = User.objects.filter( + is_active=True, + ).filter( + Q(is_staff=True) | Q(is_superuser=True) + ).values_list("id", flat=True) + recipient_ids.update(admin_ids) + extra_data = { + "attendance_record_id": context_obj.id, + "employee_id": context_obj.employee_id, + "record_date": context_obj.date.isoformat() + if getattr(context_obj, "date", None) + else None, + } elif hasattr(context_obj, "employee"): # Request object_author = getattr(context_obj, "employee", None) object_url = f"/requests?request={context_obj.id}" @@ -328,6 +343,7 @@ def _notify_comment(message): "author_id": author.id, "object_type": model_name, "object_id": context_obj.id, + **extra_data, }, ) diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 9f02b926..9a0f30ee 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -1507,6 +1507,59 @@ def test_attendance_record_comments_use_communications( assert target["comments_count"] == 1 +def test_attendance_record_comment_notifies_employee_and_admins( + auth_client_factory, + monkeypatch, + user_factory, +): + author = user_factory(staff=True, first_name="Admin", last_name="Author") + other_staff = user_factory(staff=True) + superuser = user_factory(superuser=True) + employee = user_factory() + inactive_staff = user_factory(staff=True, active=False) + client = auth_client_factory(author) + + 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") + sent_notifications = [] + + def capture_notification(**kwargs): + sent_notifications.append(kwargs) + + monkeypatch.setattr( + "communications.notifications.handlers._send_notification", + capture_notification, + ) + + response = client.post( + _record_comments_url(record.id), + {"text": "Проверить причину опоздания"}, + format="json", + ) + + assert response.status_code == 201 + recipient_ids = {payload["recipient"].id for payload in sent_notifications} + assert recipient_ids == {employee.id, other_staff.id, superuser.id} + assert author.id not in recipient_ids + assert inactive_staff.id not in recipient_ids + assert sent_notifications + + for payload in sent_notifications: + assert payload["verb"] == "commented" + assert payload["action_url"] == f"/attendance?record={record.id}&comments=1" + assert payload["target"] == record + assert payload["data"]["object_type"] == "AttendanceRecord" + assert payload["data"]["object_id"] == record.id + assert payload["data"]["attendance_record_id"] == record.id + assert payload["data"]["employee_id"] == employee.id + assert payload["data"]["record_date"] == "2026-04-20" + + def test_attendance_record_comment_delete_is_author_only( auth_client_factory, user_factory, @@ -1569,6 +1622,48 @@ def test_user_can_comment_own_attendance_record(auth_client_factory, user_factor ) +def test_own_attendance_record_comment_notifies_admins_but_not_author( + auth_client_factory, + monkeypatch, + 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") + sent_notifications = [] + + def capture_notification(**kwargs): + sent_notifications.append(kwargs) + + monkeypatch.setattr( + "communications.notifications.handlers._send_notification", + capture_notification, + ) + + response = client.post( + _record_comments_url(record.id), + {"text": "Мой комментарий"}, + format="json", + ) + + assert response.status_code == 201 + recipient_ids = {payload["recipient"].id for payload in sent_notifications} + assert recipient_ids == {staff.id} + assert user.id not in recipient_ids + assert sent_notifications[0]["action_url"] == ( + f"/attendance?record={record.id}&comments=1" + ) + + def test_user_cannot_comment_other_employee_attendance_record( auth_client_factory, user_factory, diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index 9a472e62..8d74cf08 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -535,9 +535,11 @@ export default function AttendancePage() { }, []); useEffect(() => { - if (!canManageAttendance || openedRecordFromUrl) return; + if (openedRecordFromUrl) return; const params = new URLSearchParams(window.location.search); - if (params.get("events") !== "1") return; + const shouldOpenEvents = params.get("events") === "1"; + const shouldOpenComments = params.get("comments") === "1"; + if (!shouldOpenEvents && !shouldOpenComments) return; const recordId = Number(params.get("record")); if (!Number.isInteger(recordId) || recordId <= 0) return; @@ -549,13 +551,23 @@ export default function AttendancePage() { setError(null); const record = await apiClient.getAttendanceRecord(recordId); if (cancelled) return; - setDayEventsRecordPreview({ + const preview = { recordId, employeeName: record.display_name || `Сотрудник ${record.employee || record.employee_id || recordId}`, date: `${record.date || ""}${record.date ? ` · ${weekdayLabelFromDate(record.date)}` : ""}`, statusLabel: attendanceRecordStatusLabel(record), displayText: attendanceRecordDisplay(record), detailLines: attendanceRecordDetails(record), + }; + if (shouldOpenComments) { + setCommentsRecordPreview({ + ...preview, + commentsCount: Number(record.comments_count || 0), + }); + return; + } + setDayEventsRecordPreview({ + ...preview, issues: attendanceRecordIssueLabels(record), isManuallyEdited: Boolean(record.is_manually_edited), }); @@ -571,7 +583,7 @@ export default function AttendancePage() { return () => { cancelled = true; }; - }, [canManageAttendance, openedRecordFromUrl]); + }, [openedRecordFromUrl]); const filteredEmployees = useMemo(() => { const query = employeeSearch.trim().toLowerCase(); @@ -933,6 +945,23 @@ export default function AttendancePage() { }); } + const attendanceRecordModals = ( + <> + setCommentsRecordPreview(null)} + onCommentCountChange={updateSelectedCellCommentCount} + record={commentsRecordPreview} + /> + setDayEventsRecordPreview(null)} + record={dayEventsRecordPreview} + /> + + ); + if (!canManageAttendance) { return ( @@ -950,6 +979,7 @@ export default function AttendancePage() { + {attendanceRecordModals} ); } @@ -1787,18 +1817,7 @@ export default function AttendancePage() { )} - setCommentsRecordPreview(null)} - onCommentCountChange={updateSelectedCellCommentCount} - record={commentsRecordPreview} - /> - setDayEventsRecordPreview(null)} - record={dayEventsRecordPreview} - /> + {attendanceRecordModals} ); } diff --git a/frontend/src/lib/notifications/actionUrl.ts b/frontend/src/lib/notifications/actionUrl.ts index 843def08..454da326 100644 --- a/frontend/src/lib/notifications/actionUrl.ts +++ b/frontend/src/lib/notifications/actionUrl.ts @@ -99,6 +99,13 @@ export function resolveNotificationActionUrl( return buildUrl("/", { post: postId }); } + if (objectType === "attendancerecord") { + const attendanceRecordId = parseNumericId(data?.attendance_record_id) ?? objectId; + if (attendanceRecordId !== null) { + return buildUrl("/attendance", { record: attendanceRecordId, comments: 1 }); + } + } + if (chatId !== null) { return buildUrl(`/messages/${chatId}`, { message: messageId }); } @@ -146,6 +153,17 @@ export function resolveNotificationActionUrl( return buildUrl("/calendar", { event: rawLinkedEventId }); } + if (url.pathname === "/attendance") { + const rawLinkedRecordId = parseNumericId(url.searchParams.get("record")); + const openComments = url.searchParams.get("comments") === "1"; + const openEvents = url.searchParams.get("events") === "1"; + return buildUrl("/attendance", { + record: rawLinkedRecordId, + comments: openComments ? 1 : null, + events: openEvents ? 1 : null, + }); + } + if (url.pathname === "/") { const rawLinkedPostId = parseNumericId(url.searchParams.get("post")); return buildUrl("/", { post: rawLinkedPostId });