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}