567 lines
17 KiB
Python
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}
|