feat(tasks): expand board collaboration and notifications
This commit is contained in:
parent
a226fd3b17
commit
02c72089a0
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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)
|
||||
|
||||
84
backend/tasks/migrations/0019_task_participants.py
Normal file
84
backend/tasks/migrations/0019_task_participants.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
83
backend/tasks/notifications/read_service.py
Normal file
83
backend/tasks/notifications/read_service.py
Normal 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()
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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
@ -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 },
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: 'Новый сотрудник в отделе',
|
||||
|
||||
@ -204,6 +204,7 @@ export interface TaskCard {
|
||||
description?: string;
|
||||
created_by?: User;
|
||||
assignee?: User | null;
|
||||
participants?: User[];
|
||||
labels?: TaskLabel[];
|
||||
priority: TaskPriority;
|
||||
priority_display?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user