Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-27 10:10:07 +03:00
commit 4447200ef6
4 changed files with 165 additions and 17 deletions

View File

@ -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,
},
)

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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 });