2026-09-07 19:43:48 +03:00

567 lines
17 KiB
Python

from __future__ import annotations
from datetime import datetime, time, timedelta
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from notifications.models import Notification
from notifications.signals import notify
from tasks.models import (
Task,
TaskActivityAction,
TaskActivityObjectKind,
TaskBoardPlacement,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from .config import ActionURLs, MessageTemplates, NotificationVerbs
User = get_user_model()
def _actor_id(actor) -> int | None:
return getattr(actor, "id", None)
def _actor_name(actor) -> str:
if not actor:
return "Система"
return actor.get_full_name() or getattr(actor, "email", "") or str(actor)
def _task_data(task: Task, title: str, extra: dict | None = None) -> dict:
data = {
"title": title,
"task_id": task.id,
"task_title": task.title,
"board_id": task.board_id,
"board_name": task.board.name,
"column_id": task.column_id,
"column_name": task.column.name,
}
if extra:
data.update(extra)
return data
def _send_task_notification(
*,
recipient,
verb: str,
task: Task,
title: str,
description: str,
actor=None,
action_object=None,
extra_data: dict | None = None,
action_url: str | None = None,
preference_checked: bool = False,
):
if not recipient or not getattr(recipient, "is_active", False):
return False
if _actor_id(actor) and recipient.id == _actor_id(actor):
return False
if (
not preference_checked
and TaskBoardPlacement.objects.filter(
board_id=task.board_id,
user_id=recipient.id,
notifications_enabled=False,
).exists()
):
return False
notify.send(
sender=actor,
recipient=recipient,
verb=verb,
action_object=action_object or task,
target=task,
description=description,
action_url=action_url or ActionURLs.task_detail(task),
data=_task_data(task, title, extra_data),
)
return True
def _notify_many(
recipients,
*,
verb: str,
task: Task,
title: str,
description: str,
actor=None,
action_object=None,
extra_data: dict | None = None,
action_url: str | None = None,
):
recipient_list = [
recipient
for recipient in recipients
if recipient and getattr(recipient, "is_active", False)
]
muted_ids = set(
TaskBoardPlacement.objects.filter(
board_id=task.board_id,
user_id__in=[recipient.id for recipient in recipient_list],
notifications_enabled=False,
).values_list("user_id", flat=True)
)
seen = set()
for recipient in recipient_list:
if recipient.id in seen or recipient.id in muted_ids:
continue
seen.add(recipient.id)
_send_task_notification(
recipient=recipient,
verb=verb,
task=task,
title=title,
description=description,
actor=actor,
action_object=action_object,
extra_data=extra_data,
action_url=action_url,
preference_checked=True,
)
def _linked_employee_ids(task: Task) -> set[int]:
employee_ct = ContentType.objects.get_for_model(User)
return set(
task.linked_objects.filter(
kind=TaskLinkedObjectKind.EMPLOYEE,
content_type=employee_ct,
).values_list("object_id", flat=True)
)
def _task_recipient_ids(
task: Task,
*,
include_created_by: bool = True,
include_assignee: bool = True,
include_linked_employees: bool = True,
include_commenters: bool = False,
) -> set[int]:
recipient_ids = set(task.participants.values_list("id", flat=True))
if include_created_by and task.created_by_id:
recipient_ids.add(task.created_by_id)
if include_assignee and task.assignee_id:
recipient_ids.add(task.assignee_id)
if include_linked_employees:
recipient_ids.update(_linked_employee_ids(task))
if include_commenters:
from communications import comments_helpers
recipient_ids.update(
message.author_id
for message in comments_helpers.get_comments(task)
if message.author_id
)
return recipient_ids
def _task_recipients(task: Task, **kwargs):
recipient_ids = _task_recipient_ids(task, **kwargs)
if not recipient_ids:
return []
return User.objects.filter(id__in=recipient_ids, is_active=True)
def notify_task_created(task: Task, actor=None):
recipient_ids = _task_recipient_ids(task)
if task.assignee_id:
recipient_ids.discard(task.assignee_id)
title, description = MessageTemplates.task_created(task, _actor_name(actor))
_notify_many(
User.objects.filter(id__in=recipient_ids, is_active=True),
verb=NotificationVerbs.TASK_CREATED,
task=task,
title=title,
description=description,
actor=actor,
extra_data={"assignee_id": task.assignee_id},
)
if task.assignee_id:
title, description = MessageTemplates.task_assigned(
task,
_actor_name(actor),
)
_send_task_notification(
recipient=task.assignee,
verb=NotificationVerbs.TASK_ASSIGNED,
task=task,
title=title,
description=description,
actor=actor,
extra_data={"assignee_id": task.assignee_id},
)
def notify_task_updated(task: Task, actor, previous: dict, current: dict):
previous_assignee_id = previous.get("assignee_id")
current_assignee_id = current.get("assignee_id")
if previous_assignee_id != current_assignee_id:
title, description = (
MessageTemplates.task_assigned(task, _actor_name(actor))
if previous_assignee_id is None
else MessageTemplates.task_reassigned(task, _actor_name(actor))
)
recipient_ids = _task_recipient_ids(task, include_commenters=True)
if previous_assignee_id:
recipient_ids.add(previous_assignee_id)
_notify_many(
User.objects.filter(id__in=recipient_ids, is_active=True),
verb=(
NotificationVerbs.TASK_ASSIGNED
if previous_assignee_id is None
else NotificationVerbs.TASK_REASSIGNED
),
task=task,
title=title,
description=description,
actor=actor,
extra_data={
"old_assignee_id": previous_assignee_id,
"assignee_id": current_assignee_id,
},
)
if previous.get("due_date") != current.get("due_date"):
title, description = MessageTemplates.task_due_date_changed(
task,
_actor_name(actor),
previous.get("due_date"),
current.get("due_date"),
)
_notify_many(
_task_recipients(task, include_commenters=True),
verb=NotificationVerbs.TASK_DUE_DATE_CHANGED,
task=task,
title=title,
description=description,
actor=actor,
extra_data={
"old_due_date": previous.get("due_date"),
"due_date": current.get("due_date"),
},
)
participant_ids_changed = previous.get("participant_ids") != current.get(
"participant_ids"
)
if participant_ids_changed and previous_assignee_id != current_assignee_id:
previous_participants = set(previous.get("participant_ids") or [])
current_participants = set(current.get("participant_ids") or [])
for assignee_id in {previous_assignee_id, current_assignee_id}:
if assignee_id:
previous_participants.discard(assignee_id)
current_participants.discard(assignee_id)
participant_ids_changed = previous_participants != current_participants
generic_fields = [
field
for field in (
"title",
"description",
"priority",
"label_ids",
"cover_attachment_id",
"cover",
)
if previous.get(field) != current.get(field)
]
if participant_ids_changed:
generic_fields.append("participant_ids")
if generic_fields:
title, description = MessageTemplates.task_updated(
task,
_actor_name(actor),
generic_fields,
)
recipient_ids = _task_recipient_ids(task, include_commenters=True)
recipient_ids.update(previous.get("participant_ids") or [])
_notify_many(
User.objects.filter(id__in=recipient_ids, is_active=True),
verb=NotificationVerbs.TASK_UPDATED,
task=task,
title=title,
description=description,
actor=actor,
extra_data={"fields": generic_fields},
)
def notify_task_moved(task: Task, actor, old_column, new_column):
if old_column.id == new_column.id:
return
if new_column.is_done and not old_column.is_done:
title, description = MessageTemplates.task_completed(
task,
_actor_name(actor),
)
verb = NotificationVerbs.TASK_COMPLETED
elif old_column.is_done and not new_column.is_done:
title, description = MessageTemplates.task_reopened(
task,
_actor_name(actor),
)
verb = NotificationVerbs.TASK_REOPENED
else:
title, description = MessageTemplates.task_moved(
task,
_actor_name(actor),
old_column,
new_column,
)
verb = NotificationVerbs.TASK_MOVED
_notify_many(
_task_recipients(task, include_commenters=True),
verb=verb,
task=task,
title=title,
description=description,
actor=actor,
extra_data={
"from_column_id": old_column.id,
"from_column": old_column.name,
"to_column_id": new_column.id,
"to_column": new_column.name,
},
)
def notify_task_deleted(task: Task, actor):
title, description = MessageTemplates.task_deleted(
task,
_actor_name(actor),
)
_notify_many(
_task_recipients(task, include_commenters=True),
verb=NotificationVerbs.TASK_DELETED,
task=task,
title=title,
description=description,
actor=actor,
extra_data={"deleted": True},
action_url=ActionURLs.board_detail(task.board),
)
def notify_task_comment(task: Task, message):
actor = message.author
title, description = MessageTemplates.task_comment(
task,
_actor_name(actor),
message.content or "",
)
_notify_many(
_task_recipients(task, include_commenters=True),
verb=NotificationVerbs.TASK_COMMENT,
task=task,
title=title,
description=description,
actor=actor,
action_object=message,
extra_data={"comment_id": message.id},
)
def notify_task_linked_object(link: TaskLinkedObject):
task = link.task
actor = link.created_by
object_type = link.get_kind_display()
title, description = MessageTemplates.task_linked_object_added(
task,
_actor_name(actor),
object_type,
)
recipient_ids = _task_recipient_ids(task, include_commenters=True)
if link.kind == TaskLinkedObjectKind.EMPLOYEE:
recipient_ids.add(int(link.object_id))
recipients = User.objects.filter(id__in=recipient_ids, is_active=True)
_notify_many(
recipients,
verb=NotificationVerbs.TASK_LINKED_OBJECT_ADDED,
task=task,
title=title,
description=description,
actor=actor,
action_object=link,
extra_data={
"linked_object_id": link.id,
"linked_object_kind": link.kind,
"linked_object_type": object_type,
"linked_object_pk": link.object_id,
},
)
def notify_task_activity_change(
task: Task,
actor,
action: str,
*,
object_kind: str = "",
metadata: dict | None = None,
):
metadata = metadata or {}
if action in {
TaskActivityAction.ATTACHMENT_ADDED,
TaskActivityAction.ATTACHMENT_REMOVED,
}:
verb = (
NotificationVerbs.TASK_ATTACHMENT_ADDED
if action == TaskActivityAction.ATTACHMENT_ADDED
else NotificationVerbs.TASK_ATTACHMENT_REMOVED
)
elif action in {
TaskActivityAction.CHECKLIST_ITEM_ADDED,
TaskActivityAction.CHECKLIST_ITEM_UPDATED,
TaskActivityAction.CHECKLIST_ITEM_COMPLETED,
TaskActivityAction.CHECKLIST_ITEM_REOPENED,
TaskActivityAction.CHECKLIST_ITEM_REMOVED,
}:
verb = NotificationVerbs.TASK_CHECKLIST_CHANGED
elif action == TaskActivityAction.UNLINKED:
verb = NotificationVerbs.TASK_LINKED_OBJECT_REMOVED
elif (
action == TaskActivityAction.LINKED
and object_kind == TaskActivityObjectKind.EXTERNAL_LINK
):
verb = NotificationVerbs.TASK_LINKED_OBJECT_ADDED
elif action in {
TaskActivityAction.COMMENT_EDITED,
TaskActivityAction.COMMENT_REMOVED,
}:
verb = NotificationVerbs.TASK_COMMENT_CHANGED
else:
return
title, description = MessageTemplates.task_activity_changed(
task,
_actor_name(actor),
action,
metadata,
)
_notify_many(
_task_recipients(task, include_commenters=True),
verb=verb,
task=task,
title=title,
description=description,
actor=actor,
extra_data={
"activity_action": action,
"object_kind": object_kind,
**metadata,
},
)
def notify_board_members_added(board, actor, members):
title, description = MessageTemplates.board_member_added(
board,
_actor_name(actor),
)
for member in members:
if not member or not getattr(member, "is_active", False):
continue
if _actor_id(actor) and member.id == _actor_id(actor):
continue
if TaskBoardPlacement.objects.filter(
board=board,
user=member,
notifications_enabled=False,
).exists():
continue
notify.send(
sender=actor,
recipient=member,
verb=NotificationVerbs.TASK_BOARD_MEMBER_ADDED,
action_object=board,
description=description,
action_url=ActionURLs.board_detail(board),
data={
"title": title,
"board_id": board.id,
"board_name": board.name,
},
)
def _already_sent_today(*, recipient_id: int, verb: str, task: Task) -> bool:
task_ct = ContentType.objects.get_for_model(Task)
today = timezone.localdate()
start = timezone.make_aware(datetime.combine(today, time.min))
end = start + timedelta(days=1)
return Notification.objects.filter(
recipient_id=recipient_id,
verb=verb,
target_content_type=task_ct,
target_object_id=str(task.id),
timestamp__gte=start,
timestamp__lt=end,
).exists()
def dispatch_task_due_notifications() -> dict[str, int]:
today = timezone.localdate()
due_soon_count = 0
overdue_count = 0
queryset = (
Task.objects.filter(
due_date__isnull=False,
completed_at__isnull=True,
column__is_done=False,
board__is_archived=False,
)
.select_related("board", "column", "assignee", "created_by")
.order_by("id")
)
for task in queryset:
if task.due_date < today:
verb = NotificationVerbs.TASK_OVERDUE
title, description = MessageTemplates.task_overdue(task)
elif task.due_date == today:
verb = NotificationVerbs.TASK_DUE_SOON
title, description = MessageTemplates.task_due_soon(task)
else:
continue
sent = False
for recipient in _task_recipients(task, include_commenters=True):
if _already_sent_today(
recipient_id=recipient.id,
verb=verb,
task=task,
):
continue
sent = (
_send_task_notification(
recipient=recipient,
verb=verb,
task=task,
title=title,
description=description,
actor=None,
extra_data={"due_date": task.due_date.isoformat()},
)
or sent
)
if not sent:
continue
if verb == NotificationVerbs.TASK_OVERDUE:
overdue_count += 1
else:
due_soon_count += 1
return {"due_soon": due_soon_count, "overdue": overdue_count}