diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 7e314b93..ebeed54e 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -48,6 +48,13 @@ from tasks.models import ( TaskLinkedObjectKind, TaskUserSettings, ) +from tasks.notifications import ( + notify_board_members_added, + notify_task_comment, + notify_task_created, + notify_task_moved, + notify_task_updated, +) from tasks.realtime import ( get_task_board_recipient_ids, send_task_board_update, @@ -143,12 +150,27 @@ class TaskBoardViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): board = serializer.save(created_by=self.request.user) create_default_columns(board) + notify_board_members_added( + board, + self.request.user, + board.members.filter(is_active=True), + ) send_task_board_update(board, "created", "board", board.id) def perform_update(self, serializer): board = self.get_object() previous_recipient_ids = get_task_board_recipient_ids(board) + previous_member_ids = set(board.members.values_list("id", flat=True)) board = serializer.save() + added_member_ids = set(board.members.values_list("id", flat=True)) - ( + previous_member_ids + ) + if added_member_ids: + notify_board_members_added( + board, + self.request.user, + Employee.objects.filter(id__in=added_member_ids, is_active=True), + ) recipient_ids = previous_recipient_ids | get_task_board_recipient_ids(board) send_task_board_update( board, @@ -408,6 +430,7 @@ class TaskViewSet(viewsets.ModelViewSet): TaskActivityAction.CREATED, metadata={"column": task.column.name}, ) + notify_task_created(task, self.request.user) send_task_board_update(task.board, "created", "task", task.id) def perform_update(self, serializer): @@ -439,6 +462,7 @@ class TaskViewSet(viewsets.ModelViewSet): TaskActivityAction.UPDATED, metadata={"fields": changed_fields}, ) + notify_task_updated(task, self.request.user, previous, current) send_task_board_update(task.board, "updated", "task", task.id) def perform_destroy(self, instance): @@ -509,6 +533,7 @@ class TaskViewSet(viewsets.ModelViewSet): "to_column": column.name, }, ) + notify_task_moved(task, request.user, old_column, column) send_task_board_update( task.board, "moved", @@ -559,6 +584,7 @@ class TaskViewSet(viewsets.ModelViewSet): author=request.user, content=text, ) + notify_task_comment(task, message) send_task_board_update( task.board, "commented", diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 66b9bddb..4cb1d656 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -719,6 +719,10 @@ CELERY_BEAT_SCHEDULE = { "schedule": crontab(hour=9, minute=0, day_of_week="monday"), "args": ("weekly",), }, + "tasks-due-notifications": { + "task": "tasks.dispatch_task_due_notifications", + "schedule": crontab(hour=9, minute=5), + }, "process-ldap-sync-queue": { "task": "employees.tasks.process_ldap_queue", "schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry diff --git a/backend/tasks/apps.py b/backend/tasks/apps.py index 5780cdb6..ea933ceb 100644 --- a/backend/tasks/apps.py +++ b/backend/tasks/apps.py @@ -5,3 +5,6 @@ class TasksConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "tasks" verbose_name = "Задачи" + + def ready(self): + import tasks.notifications.signals # noqa: F401 diff --git a/backend/tasks/notifications/__init__.py b/backend/tasks/notifications/__init__.py new file mode 100644 index 00000000..c847087f --- /dev/null +++ b/backend/tasks/notifications/__init__.py @@ -0,0 +1,21 @@ +from .config import ActionURLs, MessageTemplates, NotificationVerbs +from .handlers import ( + notify_board_members_added, + notify_task_comment, + notify_task_created, + notify_task_linked_object, + notify_task_moved, + notify_task_updated, +) + +__all__ = [ + "ActionURLs", + "MessageTemplates", + "NotificationVerbs", + "notify_board_members_added", + "notify_task_comment", + "notify_task_created", + "notify_task_linked_object", + "notify_task_moved", + "notify_task_updated", +] diff --git a/backend/tasks/notifications/config.py b/backend/tasks/notifications/config.py new file mode 100644 index 00000000..67941055 --- /dev/null +++ b/backend/tasks/notifications/config.py @@ -0,0 +1,111 @@ +class NotificationVerbs: + TASK_ASSIGNED = "task_assigned" + TASK_REASSIGNED = "task_reassigned" + TASK_COMMENT = "task_comment" + TASK_DUE_DATE_CHANGED = "task_due_date_changed" + TASK_DUE_SOON = "task_due_soon" + TASK_OVERDUE = "task_overdue" + TASK_COMPLETED = "task_completed" + TASK_REOPENED = "task_reopened" + TASK_LINKED_OBJECT_ADDED = "task_linked_object_added" + TASK_BOARD_MEMBER_ADDED = "task_board_member_added" + + +class ActionURLs: + @staticmethod + def task_detail(task) -> str: + return f"/tasks?board={task.board_id}&task={task.id}" + + @staticmethod + def board_detail(board) -> str: + return f"/tasks?board={board.id}" + + +class MessageTemplates: + @staticmethod + def task_assigned(task, actor_name: str) -> tuple[str, str]: + return ( + "Вам назначена задача", + f'{actor_name} назначил вам задачу #{task.id} "{task.title}".', + ) + + @staticmethod + def task_reassigned(task, actor_name: str) -> tuple[str, str]: + return ( + "Исполнитель задачи изменен", + f'{actor_name} изменил исполнителя задачи #{task.id} "{task.title}".', + ) + + @staticmethod + def task_comment(task, actor_name: str, text: str) -> tuple[str, str]: + preview = text[:160] + return ( + "Новый комментарий к задаче", + f'{actor_name} прокомментировал задачу #{task.id} "{task.title}": {preview}', + ) + + @staticmethod + def task_due_date_changed( + task, + actor_name: str, + old_due_date, + new_due_date, + ) -> tuple[str, str]: + old_value = old_due_date or "без срока" + new_value = new_due_date or "без срока" + return ( + "Срок задачи изменен", + ( + f'{actor_name} изменил срок задачи #{task.id} "{task.title}": ' + f"{old_value} -> {new_value}." + ), + ) + + @staticmethod + def task_due_soon(task) -> tuple[str, str]: + return ( + "Срок задачи сегодня", + f'Сегодня срок задачи #{task.id} "{task.title}".', + ) + + @staticmethod + def task_overdue(task) -> tuple[str, str]: + return ( + "Задача просрочена", + f'Просрочена задача #{task.id} "{task.title}".', + ) + + @staticmethod + def task_completed(task, actor_name: str) -> tuple[str, str]: + return ( + "Задача завершена", + f'{actor_name} завершил задачу #{task.id} "{task.title}".', + ) + + @staticmethod + def task_reopened(task, actor_name: str) -> tuple[str, str]: + return ( + "Задача возвращена в работу", + f'{actor_name} вернул в работу задачу #{task.id} "{task.title}".', + ) + + @staticmethod + def task_linked_object_added( + task, + actor_name: str, + object_type: str, + ) -> tuple[str, str]: + return ( + "К задаче добавлен связанный объект", + ( + f'{actor_name} добавил объект "{object_type}" ' + f'к задаче #{task.id} "{task.title}".' + ), + ) + + @staticmethod + def board_member_added(board, actor_name: str) -> tuple[str, str]: + return ( + "Вас добавили на доску", + f'{actor_name} добавил вас на доску "{board.name}".', + ) diff --git a/backend/tasks/notifications/handlers.py b/backend/tasks/notifications/handlers.py new file mode 100644 index 00000000..0b4fdc10 --- /dev/null +++ b/backend/tasks/notifications/handlers.py @@ -0,0 +1,379 @@ +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, 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, +): + if not recipient or not getattr(recipient, "is_active", False): + return + if _actor_id(actor) and recipient.id == _actor_id(actor): + return + + notify.send( + sender=actor, + recipient=recipient, + verb=verb, + action_object=action_object or task, + target=task, + description=description, + action_url=ActionURLs.task_detail(task), + data=_task_data(task, title, extra_data), + ) + + +def _notify_many( + recipients, + *, + verb: str, + task: Task, + title: str, + description: str, + actor=None, + action_object=None, + extra_data: dict | None = None, +): + seen = set() + for recipient in recipients: + if not recipient or recipient.id in seen: + 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, + ) + + +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() + 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): + if not task.assignee_id: + return + 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)) + ) + recipients = [] + if current_assignee_id: + recipients.extend(User.objects.filter(id=current_assignee_id)) + if previous_assignee_id: + recipients.extend(User.objects.filter(id=previous_assignee_id)) + _notify_many( + recipients, + 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"), + }, + ) + + +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: + return + + _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_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_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 + 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, + assignee__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 + + if _already_sent_today( + recipient_id=task.assignee_id, + verb=verb, + task=task, + ): + continue + _send_task_notification( + recipient=task.assignee, + verb=verb, + task=task, + title=title, + description=description, + actor=None, + extra_data={"due_date": task.due_date.isoformat()}, + ) + if verb == NotificationVerbs.TASK_OVERDUE: + overdue_count += 1 + else: + due_soon_count += 1 + + return {"due_soon": due_soon_count, "overdue": overdue_count} diff --git a/backend/tasks/notifications/signals.py b/backend/tasks/notifications/signals.py new file mode 100644 index 00000000..4006f3d3 --- /dev/null +++ b/backend/tasks/notifications/signals.py @@ -0,0 +1,22 @@ +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from tasks.models import TaskLinkedObject +from .handlers import notify_task_linked_object + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=TaskLinkedObject) +def notify_on_task_link_created(sender, instance, created, **kwargs): + if not created: + return + try: + notify_task_linked_object(instance) + except Exception: + logger.exception( + "Failed to send task linked object notification for link %s", + instance.id, + ) diff --git a/backend/tasks/tasks.py b/backend/tasks/tasks.py new file mode 100644 index 00000000..87df4269 --- /dev/null +++ b/backend/tasks/tasks.py @@ -0,0 +1,8 @@ +from celery import shared_task + +from tasks.notifications.handlers import dispatch_task_due_notifications + + +@shared_task(name="tasks.dispatch_task_due_notifications") +def dispatch_task_due_notifications_task(): + return dispatch_task_due_notifications() diff --git a/backend/tests/api/v1/tasks/test_task_notifications.py b/backend/tests/api/v1/tasks/test_task_notifications.py new file mode 100644 index 00000000..349fdb0a --- /dev/null +++ b/backend/tests/api/v1/tasks/test_task_notifications.py @@ -0,0 +1,224 @@ +from datetime import timedelta + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from notifications.models import Notification +from tasks.models import Task, TaskBoard, TaskColumn +from tasks.notifications.config import NotificationVerbs +from tasks.notifications.handlers import dispatch_task_due_notifications + + +pytestmark = pytest.mark.django_db + +User = get_user_model() + + +@pytest.fixture +def api_client(): + return APIClient() + + +def make_user(email, phone_number, **extra): + defaults = { + "password": "testpass123", + "phone_number": phone_number, + "first_name": "Тест", + "last_name": "Сотрудник", + "is_active": True, + "email_verified": True, + "send_activation_email": False, + } + defaults.update(extra) + return User.objects.create_user(email=email, **defaults) + + +def make_board(owner, *, name="Доска уведомлений"): + board = TaskBoard.objects.create(name=name, created_by=owner) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + ) + done = TaskColumn.objects.create( + board=board, + name="Готово", + position=2000, + is_done=True, + ) + return board, column, done + + +def make_task(owner, column, **extra): + return Task.objects.create( + board=column.board, + column=column, + title=extra.pop("title", "Проверить уведомления"), + created_by=owner, + **extra, + ) + + +def test_create_task_notifies_assignee(api_client): + owner = make_user("task-notify-owner@example.com", "+79995550001") + assignee = make_user("task-notify-assignee@example.com", "+79995550002") + board, column, _done = make_board(owner) + api_client.force_authenticate(user=owner) + + response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": column.id, + "title": "Назначенная задача", + "assignee_id": assignee.id, + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + notification = Notification.objects.get( + recipient=assignee, + verb=NotificationVerbs.TASK_ASSIGNED, + ) + assert notification.target_object_id == str(response.data["id"]) + assert notification.data["task_id"] == response.data["id"] + assert notification.action_url == ( + f"/tasks?board={board.id}&task={response.data['id']}" + ) + assert not Notification.objects.filter( + recipient=owner, + verb=NotificationVerbs.TASK_ASSIGNED, + ).exists() + + +def test_reassign_task_notifies_old_and_new_assignees(api_client): + owner = make_user("task-reassign-owner@example.com", "+79995550003") + old_assignee = make_user("task-reassign-old@example.com", "+79995550004") + new_assignee = make_user("task-reassign-new@example.com", "+79995550005") + board, column, _done = make_board(owner) + task = make_task(owner, column, assignee=old_assignee) + Notification.objects.all().delete() + api_client.force_authenticate(user=owner) + + response = api_client.patch( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}), + {"assignee_id": new_assignee.id}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + recipients = set( + Notification.objects.filter( + verb=NotificationVerbs.TASK_REASSIGNED, + ).values_list("recipient_id", flat=True) + ) + assert recipients == {old_assignee.id, new_assignee.id} + + +def test_task_comment_notifies_connected_users_except_author(api_client): + owner = make_user("task-comment-owner@example.com", "+79995550006") + assignee = make_user("task-comment-assignee@example.com", "+79995550007") + board, column, _done = make_board(owner) + task = make_task(owner, column, assignee=assignee) + Notification.objects.all().delete() + api_client.force_authenticate(user=assignee) + + response = api_client.post( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), + {"text": "Нужно проверить детали"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + notification = Notification.objects.get( + recipient=owner, + verb=NotificationVerbs.TASK_COMMENT, + ) + assert notification.data["comment_id"] == response.data["id"] + assert not Notification.objects.filter( + recipient=assignee, + verb=NotificationVerbs.TASK_COMMENT, + ).exists() + + +def test_board_member_added_notification(api_client): + owner = make_user("task-board-notify-owner@example.com", "+79995550008") + member = make_user("task-board-notify-member@example.com", "+79995550009") + board, _column, _done = make_board(owner) + api_client.force_authenticate(user=owner) + + response = api_client.patch( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}), + {"members": [member.id]}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + notification = Notification.objects.get( + recipient=member, + verb=NotificationVerbs.TASK_BOARD_MEMBER_ADDED, + ) + assert notification.data["board_id"] == board.id + assert notification.action_url == f"/tasks?board={board.id}" + + +def test_link_employee_notifies_linked_employee(api_client): + owner = make_user("task-link-notify-owner@example.com", "+79995550010") + employee = make_user("task-link-notify-employee@example.com", "+79995550011") + board, column, _done = make_board(owner) + task = make_task(owner, column) + api_client.force_authenticate(user=owner) + + response = api_client.post( + reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id}), + {"employee_id": employee.id}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + notification = Notification.objects.get( + recipient=employee, + verb=NotificationVerbs.TASK_LINKED_OBJECT_ADDED, + ) + assert notification.data["linked_object_kind"] == "employee" + assert notification.data["task_id"] == task.id + + +def test_due_notification_dispatches_once_per_day(): + owner = make_user("task-due-owner@example.com", "+79995550012") + assignee = make_user("task-due-assignee@example.com", "+79995550013") + board, column, _done = make_board(owner) + today = timezone.localdate() + make_task( + owner, + column, + title="Срок сегодня", + assignee=assignee, + due_date=today, + ) + make_task( + owner, + column, + title="Просрочена", + assignee=assignee, + due_date=today - timedelta(days=1), + ) + + first_result = dispatch_task_due_notifications() + second_result = dispatch_task_due_notifications() + + assert first_result == {"due_soon": 1, "overdue": 1} + assert second_result == {"due_soon": 0, "overdue": 0} + assert Notification.objects.filter( + recipient=assignee, + verb=NotificationVerbs.TASK_DUE_SOON, + ).count() == 1 + assert Notification.objects.filter( + recipient=assignee, + verb=NotificationVerbs.TASK_OVERDUE, + ).count() == 1 diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 68d71b56..d93b7aec 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -26,7 +26,9 @@ import { Kanban, Link2, Loader2, + Maximize2, MessageSquare, + Minimize2, Newspaper, Paperclip, Pencil, @@ -108,6 +110,8 @@ type TaskBoardSocketEvent = { }; }; +const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode"; + const emptyForm: TaskFormState = { id: null, title: "", @@ -467,6 +471,7 @@ function BoardColumn({ menuRef, onToggleTaskMenu, onColumnMount, + fillAvailableHeight = false, }: { column: TaskColumn; tasks: TaskCard[]; @@ -478,6 +483,7 @@ function BoardColumn({ menuRef: RefObject; onToggleTaskMenu: (taskId: number) => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; + fillAvailableHeight?: boolean; }) { const { setNodeRef, isOver } = useDroppable({ id: `column-${column.id}`, @@ -491,7 +497,7 @@ function BoardColumn({ return (
@@ -542,12 +548,18 @@ function BoardColumn({ ); } -function AddColumnCard({ onClick }: { onClick: () => void }) { +function AddColumnCard({ + onClick, + fillAvailableHeight = false, +}: { + onClick: () => void; + fillAvailableHeight?: boolean; +}) { return ( + + + + ); + } + return (