Add attendance comment notifications
This commit is contained in:
parent
ddbfa987ba
commit
cbe7f22735
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = (
|
||||
<>
|
||||
<AttendanceRecordCommentsModal
|
||||
currentUserId={user?.id}
|
||||
isOpen={Boolean(commentsRecordPreview)}
|
||||
onClose={() => setCommentsRecordPreview(null)}
|
||||
onCommentCountChange={updateSelectedCellCommentCount}
|
||||
record={commentsRecordPreview}
|
||||
/>
|
||||
<AttendanceDayEventsModal
|
||||
isOpen={Boolean(dayEventsRecordPreview)}
|
||||
onClose={() => setDayEventsRecordPreview(null)}
|
||||
record={dayEventsRecordPreview}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!canManageAttendance) {
|
||||
return (
|
||||
<AppShell>
|
||||
@ -950,6 +979,7 @@ export default function AttendancePage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{attendanceRecordModals}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -1787,18 +1817,7 @@ export default function AttendancePage() {
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<AttendanceRecordCommentsModal
|
||||
currentUserId={user?.id}
|
||||
isOpen={Boolean(commentsRecordPreview)}
|
||||
onClose={() => setCommentsRecordPreview(null)}
|
||||
onCommentCountChange={updateSelectedCellCommentCount}
|
||||
record={commentsRecordPreview}
|
||||
/>
|
||||
<AttendanceDayEventsModal
|
||||
isOpen={Boolean(dayEventsRecordPreview)}
|
||||
onClose={() => setDayEventsRecordPreview(null)}
|
||||
record={dayEventsRecordPreview}
|
||||
/>
|
||||
{attendanceRecordModals}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user