feat(tasks): expand board collaboration and notifications

This commit is contained in:
lizerk 2026-08-05 09:34:15 +03:00
parent a226fd3b17
commit 02c72089a0
22 changed files with 2046 additions and 107 deletions

View File

@ -135,6 +135,14 @@ class TaskColumnSerializer(serializers.ModelSerializer):
class TaskSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
assignee = EmployeeBriefSerializer(read_only=True)
participants = EmployeeBriefSerializer(many=True, read_only=True)
participant_ids = serializers.PrimaryKeyRelatedField(
source="participants",
queryset=User.objects.filter(is_active=True),
many=True,
required=False,
write_only=True,
)
assignee_id = serializers.PrimaryKeyRelatedField(
source="assignee",
queryset=User.objects.all(),
@ -187,6 +195,8 @@ class TaskSerializer(serializers.ModelSerializer):
"created_by",
"assignee",
"assignee_id",
"participants",
"participant_ids",
"labels",
"label_ids",
"priority",
@ -215,6 +225,7 @@ class TaskSerializer(serializers.ModelSerializer):
read_only_fields = [
"id",
"created_by",
"participants",
"board_name",
"column_name",
"column_color",
@ -360,6 +371,7 @@ class TaskSerializer(serializers.ModelSerializer):
board = attrs.get("board") or getattr(self.instance, "board", None)
column = attrs.get("column") or getattr(self.instance, "column", None)
labels = attrs.get("labels")
participants = attrs.get("participants")
if board and not user_can_access_task_board(user, board):
raise serializers.ValidationError(
@ -380,10 +392,27 @@ class TaskSerializer(serializers.ModelSerializer):
{"label_ids": "Метки должны принадлежать выбранной доске."}
)
if participants is not None and board:
inaccessible_participants = [
participant.id
for participant in participants
if not user_can_access_task_board(participant, board)
]
if inaccessible_participants:
raise serializers.ValidationError(
{
"participant_ids": (
"Участниками могут быть только сотрудники с доступом "
"к этой доске."
)
}
)
return attrs
def create(self, validated_data):
labels = validated_data.pop("labels", [])
participants = list(validated_data.pop("participants", []))
if "position" not in validated_data:
column = validated_data["column"]
max_position = (
@ -394,13 +423,37 @@ class TaskSerializer(serializers.ModelSerializer):
)
validated_data["position"] = max_position + 1000
task = Task.objects.create(**validated_data)
participant_ids = {participant.id for participant in participants}
participant_ids.add(task.created_by_id)
if task.assignee_id:
participant_ids.add(task.assignee_id)
task.participants.set(participant_ids)
if labels:
task.labels.set(labels)
return task
def update(self, instance, validated_data):
previous_assignee_id = instance.assignee_id
labels = validated_data.pop("labels", None)
participants = validated_data.pop("participants", None)
task = super().update(instance, validated_data)
if participants is not None:
participant_ids = {participant.id for participant in participants}
participant_ids.add(task.created_by_id)
if previous_assignee_id:
participant_ids.add(previous_assignee_id)
if task.assignee_id:
participant_ids.add(task.assignee_id)
task.participants.set(participant_ids)
else:
retained_participant_ids = [task.assignee_id, previous_assignee_id]
task.participants.add(
*[
participant_id
for participant_id in retained_participant_ids
if participant_id
]
)
if labels is not None:
task.labels.set(labels)
return task

View File

@ -61,11 +61,14 @@ from tasks.models import (
)
from tasks.notifications import (
notify_board_members_added,
notify_task_activity_change,
notify_task_comment,
notify_task_created,
notify_task_deleted,
notify_task_moved,
notify_task_updated,
)
from tasks.notifications.read_service import TaskBoardNotificationReadService
from tasks.realtime import (
get_task_board_recipient_ids,
send_task_board_update,
@ -116,6 +119,8 @@ def create_task_activity(
object_id=None,
metadata=None,
):
if actor and getattr(actor, "is_authenticated", False):
task.participants.add(actor)
TaskActivity.objects.create(
task=task,
actor=actor if getattr(actor, "is_authenticated", False) else None,
@ -124,6 +129,13 @@ def create_task_activity(
object_id=object_id,
metadata=metadata or {},
)
notify_task_activity_change(
task,
actor,
action,
object_kind=object_kind or "",
metadata=metadata or {},
)
def normalize_column_positions(column):
@ -169,6 +181,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
"labels",
"tasks__created_by",
"tasks__assignee",
"tasks__participants",
"tasks__labels",
"tasks__linked_objects",
"tasks__external_links",
@ -226,6 +239,22 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
board_id=board_id,
)
@action(detail=True, methods=["get"], url_path="participant-candidates")
def participant_candidates(self, request, pk=None):
board = self.get_object()
recipient_ids = get_task_board_recipient_ids(board)
candidates = Employee.objects.filter(
id__in=recipient_ids,
is_active=True,
).order_by("last_name", "first_name", "id")
return Response(
EmployeeBriefSerializer(
candidates,
many=True,
context=self.get_serializer_context(),
).data
)
@action(detail=False, methods=["get"], url_path="default")
def default(self, request):
queryset = self.get_queryset()
@ -263,6 +292,21 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="notifications/read")
def mark_notifications_read(self, request, pk=None):
board = self.get_object()
result = TaskBoardNotificationReadService().mark_board_notifications_as_read(
user=request.user,
board=board,
)
return Response(
{
"status": "success",
"count": result.count,
"notification_ids": result.notification_ids,
}
)
class TaskColumnViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
@ -293,12 +337,111 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
column = serializer.save()
send_task_board_update(column.board, "updated", "column", column.id)
def destroy(self, request, *args, **kwargs):
column = self.get_object()
if column.tasks.exists():
return Response(
{
"detail": (
"Переместите задачи в другую колонку перед удалением."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if column.board.columns.filter(is_archived=False).count() <= 1:
return Response(
{"detail": "На доске должна остаться хотя бы одна колонка."},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
def perform_destroy(self, instance):
board = instance.board
column_id = instance.id
instance.delete()
send_task_board_update(board, "deleted", "column", column_id)
@action(detail=False, methods=["post"], url_path="reorder")
def reorder(self, request):
"""Сохраняет единый для всех участников порядок колонок доски."""
raw_board_id = request.data.get("board")
raw_column_ids = request.data.get("column_ids")
if not raw_board_id:
return Response(
{"board": ["Укажите доску."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
board_id = int(raw_board_id)
except (TypeError, ValueError):
return Response(
{"board": ["Идентификатор доски должен быть числом."]},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(raw_column_ids, list) or not raw_column_ids:
return Response(
{"column_ids": ["Передайте полный порядок активных колонок."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
column_ids = [int(column_id) for column_id in raw_column_ids]
except (TypeError, ValueError):
return Response(
{"column_ids": ["Идентификаторы колонок должны быть числами."]},
status=status.HTTP_400_BAD_REQUEST,
)
if len(column_ids) != len(set(column_ids)):
return Response(
{"column_ids": ["Колонки в списке не должны повторяться."]},
status=status.HTTP_400_BAD_REQUEST,
)
board = get_object_or_404(
TaskBoard.objects.filter(
task_board_access_q(request.user),
is_archived=False,
),
pk=board_id,
)
with transaction.atomic():
columns = list(
TaskColumn.objects.select_for_update()
.filter(board=board, is_archived=False)
.order_by("position", "id")
)
columns_by_id = {column.id: column for column in columns}
if set(column_ids) != set(columns_by_id):
return Response(
{
"column_ids": [
"Список должен содержать все активные колонки доски."
]
},
status=status.HTTP_400_BAD_REQUEST,
)
updated_at = timezone.now()
ordered_columns = []
for index, column_id in enumerate(column_ids, start=1):
column = columns_by_id[column_id]
column.position = index * 1000
column.updated_at = updated_at
ordered_columns.append(column)
TaskColumn.objects.bulk_update(
ordered_columns,
["position", "updated_at"],
)
send_task_board_update(
board,
"reordered",
"column",
extra={"column_ids": column_ids},
)
return Response({"board": board.id, "column_ids": column_ids})
class TaskLabelViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
@ -363,7 +506,7 @@ class TaskViewSet(viewsets.ModelViewSet):
)
)
.select_related("board", "column", "created_by", "assignee")
.prefetch_related("labels", "checklist_items")
.prefetch_related("labels", "participants", "checklist_items")
.annotate(
linked_posts_count=Count(
"linked_objects",
@ -485,6 +628,9 @@ class TaskViewSet(viewsets.ModelViewSet):
"priority": instance.priority,
"due_date": instance.due_date.isoformat() if instance.due_date else None,
"label_ids": sorted(instance.labels.values_list("id", flat=True)),
"participant_ids": sorted(
instance.participants.values_list("id", flat=True)
),
}
task = serializer.save()
current = {
@ -494,6 +640,9 @@ class TaskViewSet(viewsets.ModelViewSet):
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
"label_ids": sorted(task.labels.values_list("id", flat=True)),
"participant_ids": sorted(
task.participants.values_list("id", flat=True)
),
}
changed_fields = [
field for field, value in current.items() if previous[field] != value
@ -511,6 +660,7 @@ class TaskViewSet(viewsets.ModelViewSet):
def perform_destroy(self, instance):
board = instance.board
task_id = instance.id
notify_task_deleted(instance, self.request.user)
instance.delete()
send_task_board_update(board, "deleted", "task", task_id)
@ -621,6 +771,7 @@ class TaskViewSet(viewsets.ModelViewSet):
task.assignee = request.user
task.save(update_fields=["assignee", "updated_at"])
task.participants.add(request.user)
create_task_activity(
task,
request.user,

View File

@ -51,6 +51,11 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer):
unread = serializers.IntegerField()
class UnreadSummaryTaskBoardSerializer(serializers.Serializer):
board_id = serializers.IntegerField()
unread = serializers.IntegerField()
class UnreadSummaryRegulationDepartmentSerializer(serializers.Serializer):
department_id = serializers.IntegerField()
unread = serializers.IntegerField()
@ -60,6 +65,7 @@ class UnreadSummaryResponseSerializer(serializers.Serializer):
total = serializers.IntegerField()
verbs = UnreadSummaryVerbSerializer(many=True)
procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True)
task_boards = UnreadSummaryTaskBoardSerializer(many=True)
regulation_departments = UnreadSummaryRegulationDepartmentSerializer(
many=True,
)

View File

@ -72,6 +72,76 @@ def _build_procurement_request_counts(user_id):
]
def _build_task_board_counts(user_id):
from django.contrib.contenttypes.models import ContentType
from tasks.models import Task, TaskBoard
from .models import Notification
task_content_type_id = ContentType.objects.get_for_model(Task).id
board_content_type_id = ContentType.objects.get_for_model(TaskBoard).id
counts = {}
unresolved_task_ids = set()
unresolved_notifications = []
notifications = Notification.objects.filter(
recipient_id=user_id,
unread=True,
deleted=False,
verb__startswith="task_",
).only(
"data",
"target_content_type_id",
"target_object_id",
"action_object_content_type_id",
"action_object_object_id",
)
for notification in notifications:
data = notification.data if isinstance(notification.data, dict) else {}
board_id = _coerce_positive_int(data.get("board_id"))
if board_id is None and (
notification.action_object_content_type_id == board_content_type_id
):
board_id = _coerce_positive_int(
notification.action_object_object_id,
)
if board_id is not None:
counts[board_id] = counts.get(board_id, 0) + 1
continue
task_id = None
if notification.target_content_type_id == task_content_type_id:
task_id = _coerce_positive_int(notification.target_object_id)
elif notification.action_object_content_type_id == task_content_type_id:
task_id = _coerce_positive_int(
notification.action_object_object_id,
)
if task_id is not None:
unresolved_task_ids.add(task_id)
unresolved_notifications.append(task_id)
if unresolved_task_ids:
task_boards = dict(
Task.objects.filter(id__in=unresolved_task_ids).values_list(
"id",
"board_id",
)
)
for task_id in unresolved_notifications:
board_id = task_boards.get(task_id)
if board_id is not None:
counts[board_id] = counts.get(board_id, 0) + 1
return [
{"board_id": board_id, "unread": unread}
for board_id, unread in sorted(counts.items())
]
def _build_regulation_counts(user_id):
"""Группирует непрочитанные уведомления о регламентах по отделам."""
from .models import Notification
@ -139,6 +209,7 @@ def build_unread_summary(user_id):
"total": sum(item["unread"] for item in verbs),
"verbs": verbs,
"procurement_requests": _build_procurement_request_counts(user_id),
"task_boards": _build_task_board_counts(user_id),
"regulation_departments": regulation_counts["departments"],
"regulation_company_unread": regulation_counts["company_unread"],
"regulation_personal_unread": regulation_counts["personal_unread"],

View File

@ -68,7 +68,7 @@ class TaskAdmin(admin.ModelAdmin):
]
list_filter = ["board", "column", "priority", "due_date"]
search_fields = ["title", "description", "assignee__email"]
filter_horizontal = ["labels"]
filter_horizontal = ["labels", "participants"]
@admin.register(TaskAttachment)

View File

@ -0,0 +1,84 @@
# Generated by Django 5.2.4 on 2026-08-05 05:23
from django.conf import settings
from django.db import migrations, models
def populate_task_participants(apps, schema_editor):
Task = apps.get_model("tasks", "Task")
TaskActivity = apps.get_model("tasks", "TaskActivity")
TaskAttachment = apps.get_model("tasks", "TaskAttachment")
TaskChecklistItem = apps.get_model("tasks", "TaskChecklistItem")
TaskExternalLink = apps.get_model("tasks", "TaskExternalLink")
TaskLinkedObject = apps.get_model("tasks", "TaskLinkedObject")
through = Task.participants.through
task_field = next(
field
for field in through._meta.fields
if field.is_relation and field.related_model == Task
)
employee_field = next(
field
for field in through._meta.fields
if field.is_relation and field.related_model != Task
)
pairs = set()
for task_id, created_by_id, assignee_id in Task.objects.values_list(
"id",
"created_by_id",
"assignee_id",
):
pairs.add((task_id, created_by_id))
if assignee_id:
pairs.add((task_id, assignee_id))
contributor_fields = [
(TaskActivity, "actor_id"),
(TaskAttachment, "uploaded_by_id"),
(TaskChecklistItem, "created_by_id"),
(TaskChecklistItem, "completed_by_id"),
(TaskExternalLink, "created_by_id"),
(TaskLinkedObject, "created_by_id"),
]
for model, employee_id_field in contributor_fields:
pairs.update(
(task_id, employee_id)
for task_id, employee_id in model.objects.exclude(
**{f"{employee_id_field}__isnull": True}
).values_list("task_id", employee_id_field)
)
through.objects.bulk_create(
[
through(
**{
task_field.attname: task_id,
employee_field.attname: employee_id,
}
)
for task_id, employee_id in pairs
if task_id and employee_id
],
ignore_conflicts=True,
)
class Migration(migrations.Migration):
dependencies = [
('tasks', '0018_task_claim_activity'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='task',
name='participants',
field=models.ManyToManyField(blank=True, related_name='participating_tasks', to=settings.AUTH_USER_MODEL, verbose_name='Участники'),
),
migrations.RunPython(
populate_task_participants,
migrations.RunPython.noop,
),
]

View File

@ -169,6 +169,12 @@ class Task(models.Model):
related_name="assigned_tasks",
verbose_name="Исполнитель",
)
participants = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="participating_tasks",
verbose_name="Участники",
)
labels = models.ManyToManyField(
TaskLabel,
blank=True,

View File

@ -3,6 +3,8 @@ from .handlers import (
notify_board_members_added,
notify_task_comment,
notify_task_created,
notify_task_deleted,
notify_task_activity_change,
notify_task_linked_object,
notify_task_moved,
notify_task_updated,
@ -15,6 +17,8 @@ __all__ = [
"notify_board_members_added",
"notify_task_comment",
"notify_task_created",
"notify_task_deleted",
"notify_task_activity_change",
"notify_task_linked_object",
"notify_task_moved",
"notify_task_updated",

View File

@ -1,4 +1,5 @@
class NotificationVerbs:
TASK_CREATED = "task_created"
TASK_ASSIGNED = "task_assigned"
TASK_REASSIGNED = "task_reassigned"
TASK_COMMENT = "task_comment"
@ -7,6 +8,14 @@ class NotificationVerbs:
TASK_OVERDUE = "task_overdue"
TASK_COMPLETED = "task_completed"
TASK_REOPENED = "task_reopened"
TASK_UPDATED = "task_updated"
TASK_MOVED = "task_moved"
TASK_DELETED = "task_deleted"
TASK_ATTACHMENT_ADDED = "task_attachment_added"
TASK_ATTACHMENT_REMOVED = "task_attachment_removed"
TASK_CHECKLIST_CHANGED = "task_checklist_changed"
TASK_LINKED_OBJECT_REMOVED = "task_linked_object_removed"
TASK_COMMENT_CHANGED = "task_comment_changed"
TASK_LINKED_OBJECT_ADDED = "task_linked_object_added"
TASK_BOARD_MEMBER_ADDED = "task_board_member_added"
@ -22,6 +31,13 @@ class ActionURLs:
class MessageTemplates:
@staticmethod
def task_created(task, actor_name: str) -> tuple[str, str]:
return (
"Новая задача",
f'{actor_name} создал задачу #{task.id} "{task.title}".',
)
@staticmethod
def task_assigned(task, actor_name: str) -> tuple[str, str]:
return (
@ -89,6 +105,99 @@ class MessageTemplates:
f'{actor_name} вернул в работу задачу #{task.id} "{task.title}".',
)
@staticmethod
def task_moved(task, actor_name: str, old_column, new_column) -> tuple[str, str]:
return (
"Задача перемещена",
(
f'{actor_name} переместил задачу #{task.id} "{task.title}": '
f'"{old_column.name}" -> "{new_column.name}".'
),
)
@staticmethod
def task_updated(task, actor_name: str, fields: list[str]) -> tuple[str, str]:
field_names = {
"title": "название",
"description": "описание",
"priority": "приоритет",
"label_ids": "метки",
"participant_ids": "участников",
}
changed = ", ".join(field_names.get(field, field) for field in fields)
return (
"Задача изменена",
(
f'{actor_name} изменил задачу #{task.id} "{task.title}"'
f'{f": {changed}" if changed else ""}.'
),
)
@staticmethod
def task_deleted(task, actor_name: str) -> tuple[str, str]:
return (
"Задача удалена",
f'{actor_name} удалил задачу #{task.id} "{task.title}".',
)
@staticmethod
def task_activity_changed(
task,
actor_name: str,
action: str,
metadata: dict,
) -> tuple[str, str]:
file_name = metadata.get("file_name") or "файл"
item_title = metadata.get("item_title") or "пункт"
object_label = metadata.get("object_label") or metadata.get("object_type") or "объект"
messages = {
"attachment_added": (
"К задаче добавлен файл",
f'{actor_name} добавил файл "{file_name}" к задаче #{task.id} "{task.title}".',
),
"attachment_removed": (
"Из задачи удален файл",
f'{actor_name} удалил файл "{file_name}" из задачи #{task.id} "{task.title}".',
),
"checklist_item_added": (
"Чек-лист задачи изменен",
f'{actor_name} добавил пункт "{item_title}" в задачу #{task.id} "{task.title}".',
),
"checklist_item_updated": (
"Чек-лист задачи изменен",
f'{actor_name} изменил пункт "{item_title}" в задаче #{task.id} "{task.title}".',
),
"checklist_item_completed": (
"Пункт чек-листа выполнен",
f'{actor_name} выполнил пункт "{item_title}" в задаче #{task.id} "{task.title}".',
),
"checklist_item_reopened": (
"Пункт чек-листа возвращен",
f'{actor_name} вернул пункт "{item_title}" в задаче #{task.id} "{task.title}".',
),
"checklist_item_removed": (
"Чек-лист задачи изменен",
f'{actor_name} удалил пункт "{item_title}" из задачи #{task.id} "{task.title}".',
),
"linked": (
"К задаче добавлена ссылка",
f'{actor_name} добавил "{object_label}" к задаче #{task.id} "{task.title}".',
),
"unlinked": (
"Связь с задачей удалена",
f'{actor_name} удалил связь "{object_label}" из задачи #{task.id} "{task.title}".',
),
"comment_edited": (
"Комментарий к задаче изменен",
f'{actor_name} изменил комментарий в задаче #{task.id} "{task.title}".',
),
"comment_removed": (
"Комментарий к задаче удален",
f'{actor_name} удалил комментарий из задачи #{task.id} "{task.title}".',
),
}
return messages[action]
@staticmethod
def task_linked_object_added(
task,

View File

@ -8,7 +8,13 @@ from django.utils import timezone
from notifications.models import Notification
from notifications.signals import notify
from tasks.models import Task, TaskLinkedObject, TaskLinkedObjectKind
from tasks.models import (
Task,
TaskActivityAction,
TaskActivityObjectKind,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from .config import ActionURLs, MessageTemplates, NotificationVerbs
@ -50,6 +56,7 @@ def _send_task_notification(
actor=None,
action_object=None,
extra_data: dict | None = None,
action_url: str | None = None,
):
if not recipient or not getattr(recipient, "is_active", False):
return
@ -63,7 +70,7 @@ def _send_task_notification(
action_object=action_object or task,
target=task,
description=description,
action_url=ActionURLs.task_detail(task),
action_url=action_url or ActionURLs.task_detail(task),
data=_task_data(task, title, extra_data),
)
@ -78,6 +85,7 @@ def _notify_many(
actor=None,
action_object=None,
extra_data: dict | None = None,
action_url: str | None = None,
):
seen = set()
for recipient in recipients:
@ -93,6 +101,7 @@ def _notify_many(
actor=actor,
action_object=action_object,
extra_data=extra_data,
action_url=action_url,
)
@ -114,7 +123,7 @@ def _task_recipient_ids(
include_linked_employees: bool = True,
include_commenters: bool = False,
) -> set[int]:
recipient_ids = set()
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:
@ -140,18 +149,34 @@ def _task_recipients(task: Task, **kwargs):
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,
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):
@ -163,13 +188,11 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict):
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))
recipient_ids = _task_recipient_ids(task, include_commenters=True)
if previous_assignee_id:
recipients.extend(User.objects.filter(id=previous_assignee_id))
recipient_ids.add(previous_assignee_id)
_notify_many(
recipients,
User.objects.filter(id__in=recipient_ids, is_active=True),
verb=(
NotificationVerbs.TASK_ASSIGNED
if previous_assignee_id is None
@ -205,6 +228,43 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict):
},
)
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")
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:
@ -222,7 +282,13 @@ def notify_task_moved(task: Task, actor, old_column, new_column):
)
verb = NotificationVerbs.TASK_REOPENED
else:
return
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),
@ -240,6 +306,23 @@ def notify_task_moved(task: Task, actor, old_column, new_column):
)
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(
@ -289,6 +372,68 @@ def notify_task_linked_object(link: TaskLinkedObject):
)
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,
@ -338,7 +483,6 @@ def dispatch_task_due_notifications() -> dict[str, int]:
queryset = (
Task.objects.filter(
due_date__isnull=False,
assignee__isnull=False,
completed_at__isnull=True,
column__is_done=False,
board__is_archived=False,
@ -356,21 +500,26 @@ def dispatch_task_due_notifications() -> dict[str, int]:
else:
continue
if _already_sent_today(
recipient_id=task.assignee_id,
verb=verb,
task=task,
):
sent = False
for recipient in _task_recipients(task, include_commenters=True):
if _already_sent_today(
recipient_id=recipient.id,
verb=verb,
task=task,
):
continue
_send_task_notification(
recipient=recipient,
verb=verb,
task=task,
title=title,
description=description,
actor=None,
extra_data={"due_date": task.due_date.isoformat()},
)
sent = True
if not sent:
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:

View File

@ -0,0 +1,83 @@
from dataclasses import dataclass
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from notifications.cache import invalidate_unread_summary
from notifications.models import Notification
from notifications.realtime import send_notifications_read_all_event
from tasks.models import Task, TaskBoard
@dataclass(frozen=True)
class TaskBoardNotificationReadResult:
notification_ids: list[int]
count: int
class TaskBoardNotificationReadService:
"""Marks only notifications related to one task board as read."""
def mark_board_notifications_as_read(
self,
*,
user,
board: TaskBoard,
) -> TaskBoardNotificationReadResult:
queryset = self._get_unread_queryset(user_id=user.id, board=board)
notification_ids = list(queryset.values_list("id", flat=True))
if not notification_ids:
return TaskBoardNotificationReadResult([], 0)
count = Notification.objects.filter(id__in=notification_ids).update(
unread=False,
timestamp_read=timezone.now(),
)
invalidate_unread_summary(user.id)
def notify_after_commit():
invalidate_unread_summary(user.id)
send_notifications_read_all_event(user.id, notification_ids)
transaction.on_commit(notify_after_commit)
return TaskBoardNotificationReadResult(notification_ids, count)
def _get_unread_queryset(self, *, user_id: int, board: TaskBoard):
task_content_type = ContentType.objects.get_for_model(Task)
board_content_type = ContentType.objects.get_for_model(TaskBoard)
board_id = board.id
board_id_text = str(board_id)
task_ids = [
str(task_id)
for task_id in board.tasks.values_list("id", flat=True)
]
board_filter = (
Q(data__board_id=board_id)
| Q(data__board_id=board_id_text)
| Q(
action_object_content_type=board_content_type,
action_object_object_id=board_id_text,
)
)
if task_ids:
board_filter |= Q(
target_content_type=task_content_type,
target_object_id__in=task_ids,
) | Q(
action_object_content_type=task_content_type,
action_object_object_id__in=task_ids,
)
return (
Notification.objects.filter(
recipient_id=user_id,
unread=True,
deleted=False,
verb__startswith="task_",
)
.filter(board_filter)
.distinct()
)

View File

@ -1,8 +1,16 @@
from django.db import transaction
from django.db.models.signals import post_delete
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import TaskAttachment
from .models import Task, TaskAttachment
@receiver(post_save, sender=Task)
def ensure_task_core_participants(sender, instance, **kwargs):
participant_ids = [instance.created_by_id]
if instance.assignee_id:
participant_ids.append(instance.assignee_id)
instance.participants.add(*participant_ids)
@receiver(post_delete, sender=TaskAttachment)

View File

@ -35,6 +35,24 @@ class TestNotificationUnreadSummary:
verb="chat_new_message",
description="Сообщение",
)
Notification.objects.create(
recipient=user,
verb="task_assigned",
description="Задача на первой доске",
data={"board_id": 21},
)
Notification.objects.create(
recipient=user,
verb="task_comment",
description="Еще одна задача на первой доске",
data={"board_id": "21"},
)
Notification.objects.create(
recipient=user,
verb="task_board_member_added",
description="Добавление на вторую доску",
data={"board_id": 22},
)
Notification.objects.create(
recipient=user,
verb="regulation_ready",
@ -74,13 +92,16 @@ class TestNotificationUnreadSummary:
response = api_client.get("/api/v1/notifications/summary/")
assert response.status_code == 200
assert response.data["total"] == 6
assert response.data["total"] == 9
assert {
item["verb"]: item["unread"]
for item in response.data["verbs"]
} == {
"procurement_department_request": 2,
"chat_new_message": 1,
"task_assigned": 1,
"task_comment": 1,
"task_board_member_added": 1,
"regulation_ready": 2,
"regulation_signed_all": 1,
}
@ -88,6 +109,10 @@ class TestNotificationUnreadSummary:
{"request_id": 10, "unread": 1},
{"request_id": 12, "unread": 1},
]
assert response.data["task_boards"] == [
{"board_id": 21, "unread": 2},
{"board_id": 22, "unread": 1},
]
assert response.data["regulation_departments"] == [
{"department_id": 7, "unread": 1},
{"department_id": 8, "unread": 1},

View File

@ -2,6 +2,7 @@ from datetime import timedelta
import pytest
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
@ -107,7 +108,10 @@ def test_reassign_task_notifies_old_and_new_assignees(api_client):
response = api_client.patch(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}),
{"assignee_id": new_assignee.id},
{
"assignee_id": new_assignee.id,
"participant_ids": [],
},
format="json",
)
@ -118,6 +122,16 @@ def test_reassign_task_notifies_old_and_new_assignees(api_client):
).values_list("recipient_id", flat=True)
)
assert recipients == {old_assignee.id, new_assignee.id}
task.refresh_from_db()
assert set(task.participants.values_list("id", flat=True)) == {
owner.id,
old_assignee.id,
new_assignee.id,
}
assert not Notification.objects.filter(
verb=NotificationVerbs.TASK_UPDATED,
data__task_id=task.id,
).exists()
def test_task_comment_notifies_connected_users_except_author(api_client):
@ -222,3 +236,161 @@ def test_due_notification_dispatches_once_per_day():
recipient=assignee,
verb=NotificationVerbs.TASK_OVERDUE,
).count() == 1
assert Notification.objects.filter(
recipient=owner,
verb=NotificationVerbs.TASK_DUE_SOON,
).count() == 1
assert Notification.objects.filter(
recipient=owner,
verb=NotificationVerbs.TASK_OVERDUE,
).count() == 1
def test_task_participants_receive_file_and_move_notifications(
api_client,
tmp_path,
settings,
):
settings.MEDIA_ROOT = tmp_path
owner = make_user("task-participant-owner@example.com", "+79995550014")
assignee = make_user("task-participant-assignee@example.com", "+79995550015")
watcher = make_user("task-participant-watcher@example.com", "+79995550016")
contributor = make_user("task-participant-contributor@example.com", "+79995550017")
board, source_column, _done = make_board(owner)
target_column = TaskColumn.objects.create(
board=board,
name="В работе",
position=1500,
)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board.id,
"column": source_column.id,
"title": "Задача с участниками",
"assignee_id": assignee.id,
"participant_ids": [watcher.id],
},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
task = Task.objects.get(id=create_response.data["id"])
assert set(task.participants.values_list("id", flat=True)) == {
owner.id,
assignee.id,
watcher.id,
}
assert Notification.objects.filter(
recipient=assignee,
verb=NotificationVerbs.TASK_ASSIGNED,
data__task_id=task.id,
).exists()
assert Notification.objects.filter(
recipient=watcher,
verb=NotificationVerbs.TASK_CREATED,
data__task_id=task.id,
).exists()
Notification.objects.all().delete()
api_client.force_authenticate(user=contributor)
attachment_response = api_client.post(
reverse("api:v1:tasks:task-attachments", kwargs={"pk": task.id}),
{
"files": SimpleUploadedFile(
"details.zip",
b"archive-content",
content_type="application/zip",
)
},
format="multipart",
)
assert attachment_response.status_code == status.HTTP_201_CREATED
assert task.participants.filter(id=contributor.id).exists()
attachment_recipients = set(
Notification.objects.filter(
verb=NotificationVerbs.TASK_ATTACHMENT_ADDED,
data__task_id=task.id,
).values_list("recipient_id", flat=True)
)
assert attachment_recipients == {owner.id, assignee.id, watcher.id}
Notification.objects.all().delete()
move_response = api_client.post(
reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}),
{"column": target_column.id},
format="json",
)
assert move_response.status_code == status.HTTP_200_OK
moved_recipients = set(
Notification.objects.filter(
verb=NotificationVerbs.TASK_MOVED,
data__task_id=task.id,
).values_list("recipient_id", flat=True)
)
assert moved_recipients == {owner.id, assignee.id, watcher.id}
def test_due_notification_reaches_creator_without_assignee():
owner = make_user("task-due-owner-only@example.com", "+79995550023")
_board, column, _done = make_board(owner)
task = make_task(
owner,
column,
title="Срок задачи без исполнителя",
due_date=timezone.localdate(),
)
result = dispatch_task_due_notifications()
assert result == {"due_soon": 1, "overdue": 0}
assert Notification.objects.filter(
recipient=owner,
verb=NotificationVerbs.TASK_DUE_SOON,
data__task_id=task.id,
).exists()
def test_task_creator_and_assignee_are_automatic_participants():
owner = make_user("task-auto-owner@example.com", "+79995550018")
assignee = make_user("task-auto-assignee@example.com", "+79995550019")
_board, column, _done = make_board(owner)
task = make_task(owner, column, assignee=assignee)
assert set(task.participants.values_list("id", flat=True)) == {
owner.id,
assignee.id,
}
def test_delete_task_notifies_participants_and_links_to_board(api_client):
owner = make_user("task-delete-owner@example.com", "+79995550020")
assignee = make_user("task-delete-assignee@example.com", "+79995550021")
participant = make_user("task-delete-participant@example.com", "+79995550022")
board, column, _done = make_board(owner)
task = make_task(owner, column, assignee=assignee)
task.participants.add(participant)
task_id = task.id
api_client.force_authenticate(user=owner)
response = api_client.delete(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
notifications = Notification.objects.filter(
verb=NotificationVerbs.TASK_DELETED,
data__task_id=task_id,
)
assert set(notifications.values_list("recipient_id", flat=True)) == {
assignee.id,
participant.id,
}
assert set(notifications.values_list("action_url", flat=True)) == {
f"/tasks?board={board.id}"
}

View File

@ -18,6 +18,7 @@ from feed.models import Post
from guests.models import Guest, GuestVisit
from procurement.constants import ProcurementStatus, UrgencyLevel
from procurement.models import ProcurementRequest
from notifications.models import Notification
from requests_app.models import Request as EmployeeRequest
from schedule.models import Calendar, CalendarRelation, Event
from tasks.models import (
@ -142,6 +143,228 @@ def test_user_cannot_set_inaccessible_board_as_default(api_client):
assert not TaskUserSettings.objects.filter(user=outsider).exists()
def test_opening_board_marks_only_its_notifications_as_read(
api_client,
user,
django_capture_on_commit_callbacks,
):
first_board = TaskBoard.objects.create(name="Первая", created_by=user)
second_board = TaskBoard.objects.create(name="Вторая", created_by=user)
first_task = Task.objects.create(
board=first_board,
column=TaskColumn.objects.create(
board=first_board,
name="Новые",
position=1000,
),
title="Первая задача",
created_by=user,
)
TaskColumn.objects.create(
board=second_board,
name="Новые",
position=1000,
)
first_notification = Notification.objects.create(
recipient=user,
verb="task_comment",
description="Первая доска",
target=first_task,
data={"board_id": first_board.id},
)
second_notification = Notification.objects.create(
recipient=user,
verb="task_assigned",
description="Вторая доска",
data={"board_id": second_board.id},
)
other_notification = Notification.objects.create(
recipient=user,
verb="request_new",
description="Заявление",
)
api_client.force_authenticate(user=user)
with patch(
"tasks.notifications.read_service.send_notifications_read_all_event"
) as send_event:
with django_capture_on_commit_callbacks(execute=True):
response = api_client.post(
reverse(
"api:v1:tasks:task-board-mark-notifications-read",
kwargs={"pk": first_board.id},
)
)
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["notification_ids"] == [first_notification.id]
first_notification.refresh_from_db()
second_notification.refresh_from_db()
other_notification.refresh_from_db()
assert first_notification.unread is False
assert second_notification.unread is True
assert other_notification.unread is True
send_event.assert_called_once_with(user.id, [first_notification.id])
def test_board_member_reorders_columns_for_everyone(api_client, user):
other_user = make_user("task-column-order@example.com", "+79994440093")
board = TaskBoard.objects.create(name="Общий порядок", created_by=user)
board.members.add(user, other_user)
first = TaskColumn.objects.create(
board=board,
name="Первая",
position=1000,
)
second = TaskColumn.objects.create(
board=board,
name="Вторая",
position=2000,
)
third = TaskColumn.objects.create(
board=board,
name="Третья",
position=3000,
)
api_client.force_authenticate(user=other_user)
with patch("api.v1.tasks.views.send_task_board_update") as send_update:
response = api_client.post(
reverse("api:v1:tasks:task-column-reorder"),
{
"board": board.id,
"column_ids": [third.id, first.id, second.id],
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["column_ids"] == [third.id, first.id, second.id]
assert list(
TaskColumn.objects.filter(board=board).values_list("id", flat=True)
) == [third.id, first.id, second.id]
send_update.assert_called_once_with(
board,
"reordered",
"column",
extra={"column_ids": [third.id, first.id, second.id]},
)
api_client.force_authenticate(user=user)
board_response = api_client.get(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
)
assert board_response.status_code == status.HTTP_200_OK
assert [column["id"] for column in board_response.data["columns"]] == [
third.id,
first.id,
second.id,
]
def test_reorder_columns_requires_complete_board_column_list(api_client, user):
board = TaskBoard.objects.create(name="Проверка порядка", created_by=user)
first = TaskColumn.objects.create(
board=board,
name="Первая",
position=1000,
)
second = TaskColumn.objects.create(
board=board,
name="Вторая",
position=2000,
)
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:tasks:task-column-reorder"),
{"board": board.id, "column_ids": [second.id]},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "column_ids" in response.data
assert list(
TaskColumn.objects.filter(board=board).values_list("id", flat=True)
) == [first.id, second.id]
def test_user_can_edit_and_delete_empty_task_column(api_client, user):
board = TaskBoard.objects.create(name="Управление колонками", created_by=user)
TaskColumn.objects.create(board=board, name="Основная", position=1000)
editable = TaskColumn.objects.create(
board=board,
name="Изменяемая",
position=2000,
)
api_client.force_authenticate(user=user)
update_response = api_client.patch(
reverse("api:v1:tasks:task-column-detail", kwargs={"pk": editable.id}),
{
"name": "Обновлённая",
"color": "#ef4444",
"is_done": True,
},
format="json",
)
delete_response = api_client.delete(
reverse("api:v1:tasks:task-column-detail", kwargs={"pk": editable.id})
)
assert update_response.status_code == status.HTTP_200_OK
assert update_response.data["name"] == "Обновлённая"
assert update_response.data["color"] == "#ef4444"
assert update_response.data["is_done"] is True
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskColumn.objects.filter(id=editable.id).exists()
def test_user_cannot_delete_occupied_or_last_task_column(api_client, user):
board = TaskBoard.objects.create(name="Защита колонок", created_by=user)
occupied = TaskColumn.objects.create(
board=board,
name="С задачей",
position=1000,
)
TaskColumn.objects.create(
board=board,
name="Пустая",
position=2000,
)
Task.objects.create(
board=board,
column=occupied,
title="Защищённая задача",
created_by=user,
)
api_client.force_authenticate(user=user)
occupied_response = api_client.delete(
reverse("api:v1:tasks:task-column-detail", kwargs={"pk": occupied.id})
)
single_board = TaskBoard.objects.create(
name="Единственная колонка",
created_by=user,
)
only_column = TaskColumn.objects.create(
board=single_board,
name="Единственная",
position=1000,
)
last_response = api_client.delete(
reverse("api:v1:tasks:task-column-detail", kwargs={"pk": only_column.id})
)
assert occupied_response.status_code == status.HTTP_400_BAD_REQUEST
assert "Переместите задачи" in occupied_response.data["detail"]
assert last_response.status_code == status.HTTP_400_BAD_REQUEST
assert "хотя бы одна колонка" in last_response.data["detail"]
assert TaskColumn.objects.filter(id=occupied.id).exists()
assert TaskColumn.objects.filter(id=only_column.id).exists()
def test_create_task_and_move_to_done_column(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(
@ -905,6 +1128,32 @@ def test_public_task_board_realtime_recipients_include_active_users():
assert inactive_user.id not in recipients
def test_task_participant_candidates_follow_board_access(api_client):
owner = make_user("task-candidates-owner@example.com", "+79994440097")
member = make_user("task-candidates-member@example.com", "+79994440098")
outsider = make_user("task-candidates-outsider@example.com", "+79994440099")
board = TaskBoard.objects.create(
name="Кандидаты в участники",
created_by=owner,
access_scope="restricted",
)
board.members.add(member)
api_client.force_authenticate(user=owner)
response = api_client.get(
reverse(
"api:v1:tasks:task-board-participant-candidates",
kwargs={"pk": board.id},
)
)
assert response.status_code == status.HTTP_200_OK
candidate_ids = {candidate["id"] for candidate in response.data}
assert owner.id in candidate_ids
assert member.id in candidate_ids
assert outsider.id not in candidate_ids
def test_task_linked_message_is_visible_by_task_access(api_client):
owner = make_user("task-link-owner@example.com", "+79994440019")
board_member = make_user("task-link-member@example.com", "+79994440020")

File diff suppressed because it is too large Load Diff

View File

@ -61,7 +61,7 @@ const navItems = [
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests },
// { href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks },
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks, autoReadOnNavigate: false },
{ href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const },
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const },
{ href: "/finances", label: "Финансы", icon: Wallet },

View File

@ -40,6 +40,7 @@ interface NotificationsContextType {
unreadCount: number;
unreadCategoryCounts: Record<string, number>;
unreadProcurementRequestCounts: Record<number, number>;
unreadTaskBoardCounts: Record<number, number>;
unreadRegulationDepartmentCounts: Record<number, number>;
unreadCompanyRegulationCount: number;
unreadPersonalRegulationCount: number;
@ -49,6 +50,7 @@ interface NotificationsContextType {
markAsRead: (id: number) => Promise<void>;
markAllAsRead: () => Promise<void>;
markCategoryAsRead: (category: string) => Promise<void>;
markTaskBoardAsRead: (boardId: number) => Promise<void>;
deleteNotification: (id: number) => Promise<void>;
deleteAllRead: () => Promise<number>;
}
@ -64,6 +66,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
const [unreadCount, setUnreadCount] = useState(0);
const [unreadCategoryCounts, setUnreadCategoryCounts] = useState<Record<string, number>>({});
const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState<Record<number, number>>({});
const [unreadTaskBoardCounts, setUnreadTaskBoardCounts] = useState<Record<number, number>>({});
const [unreadRegulationDepartmentCounts, setUnreadRegulationDepartmentCounts] = useState<Record<number, number>>({});
const [unreadCompanyRegulationCount, setUnreadCompanyRegulationCount] = useState(0);
const [unreadPersonalRegulationCount, setUnreadPersonalRegulationCount] = useState(0);
@ -78,6 +81,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
total?: number;
verbs?: Array<{ verb?: string; unread?: number }>;
procurement_requests?: Array<{ request_id?: number; unread?: number }>;
task_boards?: Array<{ board_id?: number; unread?: number }>;
regulation_departments?: Array<{ department_id?: number; unread?: number }>;
regulation_company_unread?: number;
regulation_personal_unread?: number;
@ -88,6 +92,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
const procurementRequests = Array.isArray(summary.procurement_requests)
? summary.procurement_requests
: [];
const taskBoards = Array.isArray(summary.task_boards)
? summary.task_boards
: [];
const regulationDepartments = Array.isArray(summary.regulation_departments)
? summary.regulation_departments
: [];
@ -108,6 +115,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
nextProcurementRequestCounts[requestId] = item.unread;
});
const nextTaskBoardCounts: Record<number, number> = {};
taskBoards.forEach((item) => {
const boardId = Number(item.board_id);
if (!Number.isFinite(boardId) || boardId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
return;
}
nextTaskBoardCounts[boardId] = item.unread;
});
const nextRegulationDepartmentCounts: Record<number, number> = {};
regulationDepartments.forEach((item) => {
const departmentId = Number(item.department_id);
@ -119,6 +135,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
setUnreadCategoryCounts(nextCategoryCounts);
setUnreadProcurementRequestCounts(nextProcurementRequestCounts);
setUnreadTaskBoardCounts(nextTaskBoardCounts);
setUnreadRegulationDepartmentCounts(nextRegulationDepartmentCounts);
setUnreadCompanyRegulationCount(
typeof summary.regulation_company_unread === 'number'
@ -203,6 +220,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
setUnreadCount(0);
setUnreadCategoryCounts({});
setUnreadProcurementRequestCounts({});
setUnreadTaskBoardCounts({});
setUnreadRegulationDepartmentCounts({});
setUnreadCompanyRegulationCount(0);
setUnreadPersonalRegulationCount(0);
@ -259,6 +277,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
}));
}
}
if (category === 'Доска') {
const boardId = Number(incomingNotification.data?.board_id);
if (Number.isFinite(boardId) && boardId > 0) {
setUnreadTaskBoardCounts(prev => ({
...prev,
[boardId]: (prev[boardId] || 0) + 1,
}));
}
}
}
}
void syncUnreadSummary();
@ -381,6 +408,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
setUnreadCount(0);
setUnreadCategoryCounts({});
setUnreadProcurementRequestCounts({});
setUnreadTaskBoardCounts({});
setUnreadRegulationDepartmentCounts({});
setUnreadCompanyRegulationCount(0);
setUnreadPersonalRegulationCount(0);
@ -407,6 +435,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
if (category === 'Закупки') {
setUnreadProcurementRequestCounts({});
}
if (category === 'Доска') {
setUnreadTaskBoardCounts({});
}
if (category === 'Регламенты') {
setUnreadRegulationDepartmentCounts({});
setUnreadCompanyRegulationCount(0);
@ -415,6 +446,25 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
void syncUnreadSummary();
}, [syncUnreadSummary]);
const markTaskBoardAsRead = useCallback(async (boardId: number) => {
const result = await apiClient.markTaskBoardNotificationsRead(boardId);
const notificationIds = new Set(result.notification_ids || []);
if (notificationIds.size > 0) {
setNotifications(prev => prev.map(notification => (
notificationIds.has(notification.id)
? { ...notification, is_read: true, unread: false }
: notification
)));
}
setUnreadTaskBoardCounts(prev => {
if (!(boardId in prev)) return prev;
const next = { ...prev };
delete next[boardId];
return next;
});
void syncUnreadSummary();
}, [syncUnreadSummary]);
const deleteNotification = useCallback(async (id: number) => {
await apiClient.deleteNotification(id);
setNotifications(prev => prev.filter(n => n.id !== id));
@ -432,6 +482,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
unreadCount,
unreadCategoryCounts,
unreadProcurementRequestCounts,
unreadTaskBoardCounts,
unreadRegulationDepartmentCounts,
unreadCompanyRegulationCount,
unreadPersonalRegulationCount,
@ -441,6 +492,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
markAsRead,
markAllAsRead,
markCategoryAsRead,
markTaskBoardAsRead,
deleteNotification,
deleteAllRead,
};

View File

@ -11,6 +11,10 @@ export type UnreadNotificationsSummary = {
request_id: number;
unread: number;
}>;
task_boards?: Array<{
board_id: number;
unread: number;
}>;
regulation_departments?: Array<{
department_id: number;
unread: number;

View File

@ -19,6 +19,7 @@ import type {
TaskLinkedProcurementRequest,
TaskLinkedRequest,
TaskExternalLink,
User,
} from "@/types/api";
import { buildQuery, type RawRequestFn, type RequestFn } from "./utils";
@ -39,6 +40,8 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request("/api/v1/tasks/boards/default/"),
getTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/`),
getTaskBoardParticipantCandidates: (id: number): Promise<User[]> =>
request(`/api/v1/tasks/boards/${id}/participant-candidates/`),
createTaskBoard: (data: Record<string, any>): Promise<TaskBoard> =>
request("/api/v1/tasks/boards/", {
method: "POST",
@ -53,11 +56,35 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }),
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
markTaskBoardNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>
request(`/api/v1/tasks/boards/${id}/notifications/read/`, {
method: "POST",
}),
createTaskColumn: (data: Record<string, any>): Promise<TaskColumn> =>
request("/api/v1/tasks/columns/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskColumn: (
id: number,
data: Record<string, any>,
): Promise<TaskColumn> =>
request(`/api/v1/tasks/columns/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
}),
deleteTaskColumn: (id: number): Promise<void> =>
request(`/api/v1/tasks/columns/${id}/`, { method: "DELETE" }),
reorderTaskColumns: (
board: number,
columnIds: number[],
): Promise<{ board: number; column_ids: number[] }> =>
request("/api/v1/tasks/columns/reorder/", {
method: "POST",
body: JSON.stringify({ board, column_ids: columnIds }),
}),
createTaskLabel: (data: Record<string, any>): Promise<TaskLabel> =>
request("/api/v1/tasks/labels/", {
method: "POST",

View File

@ -96,6 +96,7 @@ export const VERB_CATEGORIES: Record<string, string> = {
feed_post_reaction: 'Новости',
// Доска
task_created: 'Доска',
task_assigned: 'Доска',
task_reassigned: 'Доска',
task_comment: 'Доска',
@ -106,6 +107,14 @@ export const VERB_CATEGORIES: Record<string, string> = {
task_reopened: 'Доска',
task_linked_object_added: 'Доска',
task_board_member_added: 'Доска',
task_updated: 'Доска',
task_moved: 'Доска',
task_deleted: 'Доска',
task_attachment_added: 'Доска',
task_attachment_removed: 'Доска',
task_checklist_changed: 'Доска',
task_linked_object_removed: 'Доска',
task_comment_changed: 'Доска',
// Отделы
department_new_employee: 'Отдел',
@ -222,6 +231,7 @@ export const VERB_NAMES: Record<string, string> = {
feed_post_reaction: 'Реакция на публикацию',
// Доска
task_created: 'Новая задача',
task_assigned: 'Вам назначена задача',
task_reassigned: 'Исполнитель задачи изменён',
task_comment: 'Комментарий к задаче',
@ -232,6 +242,14 @@ export const VERB_NAMES: Record<string, string> = {
task_reopened: 'Задача возвращена в работу',
task_linked_object_added: 'К задаче добавлен связанный объект',
task_board_member_added: 'Вас добавили на доску',
task_updated: 'Задача изменена',
task_moved: 'Задача перемещена',
task_deleted: 'Задача удалена',
task_attachment_added: 'К задаче добавлен файл',
task_attachment_removed: 'Из задачи удалён файл',
task_checklist_changed: 'Чек-лист задачи изменён',
task_linked_object_removed: 'Связь с задачей удалена',
task_comment_changed: 'Комментарий к задаче изменён',
// Отделы
department_new_employee: 'Новый сотрудник в отделе',

View File

@ -204,6 +204,7 @@ export interface TaskCard {
description?: string;
created_by?: User;
assignee?: User | null;
participants?: User[];
labels?: TaskLabel[];
priority: TaskPriority;
priority_display?: string;