feat: add task board module

This commit is contained in:
lizerk 2026-07-06 13:47:03 +03:00
parent 0dbd817c60
commit cb5b520e01
33 changed files with 6238 additions and 68 deletions

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,423 @@
from django.db.models import Max
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from api.v1.employees.serializers import EmployeeBriefSerializer
from api.v1.employees.serializers.department import DepartmentBriefSerializer
from communications.models import Message
from communications.serialization import serialize_message
from communications.utils import user_can_access_chat
from employees.models import Department
from schedule.models import Event
from api.v1.schedule.serializers import EventSerializer
from tasks.access import (
user_can_access_calendar_event,
user_can_access_task_board,
)
from tasks.models import (
Task,
TaskActivity,
TaskBoard,
TaskColumn,
TaskLabel,
TaskLinkedObject,
TaskLinkedObjectKind,
)
User = get_user_model()
DEFAULT_COLUMNS = [
("Новые", "#38bdf8", False),
("В работе", "#f59e0b", False),
("На проверке", "#8b5cf6", False),
("Готово", "#22c55e", True),
]
def create_default_columns(board):
if board.columns.exists():
return
TaskColumn.objects.bulk_create(
[
TaskColumn(
board=board,
name=name,
color=color,
is_done=is_done,
position=index * 1000,
)
for index, (name, color, is_done) in enumerate(DEFAULT_COLUMNS)
]
)
def validate_board_access(serializer, board):
request = serializer.context.get("request")
user = getattr(request, "user", None)
if not user_can_access_task_board(user, board):
raise serializers.ValidationError("У вас нет доступа к этой доске.")
return board
class TaskLabelSerializer(serializers.ModelSerializer):
class Meta:
model = TaskLabel
fields = ["id", "board", "name", "color"]
read_only_fields = ["id"]
def validate_board(self, board):
return validate_board_access(self, board)
class TaskColumnSerializer(serializers.ModelSerializer):
tasks_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = TaskColumn
fields = [
"id",
"board",
"name",
"position",
"color",
"is_done",
"is_archived",
"tasks_count",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"created_by",
"member_details",
"department_details",
"columns",
"labels",
"tasks",
"tasks_count",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at", "tasks_count"]
def validate_board(self, board):
return validate_board_access(self, board)
class TaskSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
assignee = EmployeeBriefSerializer(read_only=True)
assignee_id = serializers.PrimaryKeyRelatedField(
source="assignee",
queryset=User.objects.all(),
required=False,
allow_null=True,
write_only=True,
)
labels = TaskLabelSerializer(many=True, read_only=True)
label_ids = serializers.PrimaryKeyRelatedField(
source="labels",
queryset=TaskLabel.objects.all(),
many=True,
required=False,
write_only=True,
)
priority_display = serializers.CharField(
source="get_priority_display",
read_only=True,
)
column_name = serializers.CharField(source="column.name", read_only=True)
linked_messages_count = serializers.SerializerMethodField()
linked_events_count = serializers.SerializerMethodField()
linked_objects_count = serializers.SerializerMethodField()
class Meta:
model = Task
fields = [
"id",
"board",
"column",
"column_name",
"title",
"description",
"created_by",
"assignee",
"assignee_id",
"labels",
"label_ids",
"priority",
"priority_display",
"due_date",
"position",
"completed_at",
"linked_messages_count",
"linked_events_count",
"linked_objects_count",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"created_by",
"column_name",
"priority_display",
"completed_at",
"linked_messages_count",
"linked_events_count",
"linked_objects_count",
"created_at",
"updated_at",
]
@extend_schema_field(serializers.IntegerField())
def get_linked_messages_count(self, obj):
if hasattr(obj, "linked_messages_count"):
return obj.linked_messages_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.MESSAGE,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_events_count(self, obj):
if hasattr(obj, "linked_events_count"):
return obj.linked_events_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.CALENDAR_EVENT,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_objects_count(self, obj):
if hasattr(obj, "linked_objects_count"):
return obj.linked_objects_count
return obj.linked_objects.count()
def validate(self, attrs):
request = self.context.get("request")
user = getattr(request, "user", None)
board = attrs.get("board") or getattr(self.instance, "board", None)
column = attrs.get("column") or getattr(self.instance, "column", None)
labels = attrs.get("labels")
if board and not user_can_access_task_board(user, board):
raise serializers.ValidationError(
{"board": "У вас нет доступа к этой доске."}
)
if column and board and column.board_id != board.id:
raise serializers.ValidationError(
{"column": "Колонка должна принадлежать выбранной доске."}
)
if labels is not None and board:
invalid_labels = [
label.id for label in labels if label.board_id != board.id
]
if invalid_labels:
raise serializers.ValidationError(
{"label_ids": "Метки должны принадлежать выбранной доске."}
)
return attrs
def create(self, validated_data):
labels = validated_data.pop("labels", [])
if "position" not in validated_data:
column = validated_data["column"]
max_position = (
Task.objects.filter(column=column).aggregate(Max("position"))[
"position__max"
]
or 0
)
validated_data["position"] = max_position + 1000
task = Task.objects.create(**validated_data)
if labels:
task.labels.set(labels)
return task
def update(self, instance, validated_data):
labels = validated_data.pop("labels", None)
task = super().update(instance, validated_data)
if labels is not None:
task.labels.set(labels)
return task
class TaskBoardSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
members = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
many=True,
required=False,
)
member_details = EmployeeBriefSerializer(
source="members",
many=True,
read_only=True,
)
departments = serializers.PrimaryKeyRelatedField(
queryset=Department.objects.all(),
many=True,
required=False,
)
department_details = DepartmentBriefSerializer(
source="departments",
many=True,
read_only=True,
)
columns = TaskColumnSerializer(many=True, read_only=True)
labels = TaskLabelSerializer(many=True, read_only=True)
tasks = TaskSerializer(many=True, read_only=True)
tasks_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = TaskBoard
fields = [
"id",
"name",
"description",
"created_by",
"members",
"member_details",
"departments",
"department_details",
"is_archived",
"columns",
"labels",
"tasks",
"tasks_count",
"created_at",
"updated_at",
]
class TaskLinkedMessageSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
message = serializers.SerializerMethodField()
message_id = serializers.IntegerField(source="object_id", read_only=True)
can_open = serializers.SerializerMethodField()
object_url = serializers.SerializerMethodField()
class Meta:
model = TaskLinkedObject
fields = [
"id",
"kind",
"message_id",
"message",
"can_open",
"object_url",
"created_by",
"created_at",
]
read_only_fields = fields
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_message(self, obj):
if obj.kind != TaskLinkedObjectKind.MESSAGE:
return None
message = getattr(obj, "content_object", None)
if message is None:
return None
return serialize_message(message, include_linked_tasks=False)
@extend_schema_field(serializers.BooleanField())
def get_can_open(self, obj):
message = getattr(obj, "content_object", None)
request = self.context.get("request")
user = getattr(request, "user", None)
if obj.kind != TaskLinkedObjectKind.MESSAGE or message is None or not user:
return False
return user_can_access_chat(message.chat, user)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_object_url(self, obj):
message = getattr(obj, "content_object", None)
if message is None or not self.get_can_open(obj):
return None
return f"/messages/{message.chat_id}?message={message.id}"
def get_message_content_type():
return ContentType.objects.get_for_model(Message)
class TaskActivitySerializer(serializers.ModelSerializer):
actor = EmployeeBriefSerializer(read_only=True)
action_display = serializers.CharField(source="get_action_display", read_only=True)
class Meta:
model = TaskActivity
fields = [
"id",
"action",
"action_display",
"actor",
"object_kind",
"object_id",
"metadata",
"created_at",
]
read_only_fields = fields
class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
event = serializers.SerializerMethodField()
event_id = serializers.IntegerField(source="object_id", read_only=True)
can_open = serializers.SerializerMethodField()
object_url = serializers.SerializerMethodField()
class Meta:
model = TaskLinkedObject
fields = [
"id",
"kind",
"event_id",
"event",
"can_open",
"object_url",
"created_by",
"created_at",
]
read_only_fields = fields
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_event(self, obj):
if obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT:
return None
event = getattr(obj, "content_object", None)
if event is None:
return None
return EventSerializer(event, context=self.context).data
@extend_schema_field(serializers.BooleanField())
def get_can_open(self, obj):
event = getattr(obj, "content_object", None)
request = self.context.get("request")
user = getattr(request, "user", None)
if (
obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT
or event is None
or not user
):
return False
return user_can_access_calendar_event(user, event)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_object_url(self, obj):
event = getattr(obj, "content_object", None)
if event is None or not self.get_can_open(obj):
return None
return f"/calendar?calendar={event.calendar_id}&event={event.id}"
def get_calendar_event_content_type():
return ContentType.objects.get_for_model(Event)

View File

@ -0,0 +1,21 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import (
TaskBoardViewSet,
TaskColumnViewSet,
TaskLabelViewSet,
TaskViewSet,
)
app_name = "tasks"
router = DefaultRouter()
router.register(r"boards", TaskBoardViewSet, basename="task-board")
router.register(r"columns", TaskColumnViewSet, basename="task-column")
router.register(r"labels", TaskLabelViewSet, basename="task-label")
router.register(r"", TaskViewSet, basename="task")
urlpatterns = [
path("", include(router.urls)),
]

View File

@ -0,0 +1,632 @@
from django.db.models import Count, Max, Q
from communications.models import Message
from communications.utils import user_can_access_chat
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from schedule.models import Event
from tasks.access import task_board_access_q, user_can_access_calendar_event
from tasks.models import (
Task,
TaskActivity,
TaskActivityAction,
TaskBoard,
TaskColumn,
TaskLabel,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from tasks.realtime import (
get_task_board_recipient_ids,
send_task_board_update,
)
from .serializers import (
TaskActivitySerializer,
TaskBoardSerializer,
TaskColumnSerializer,
TaskLabelSerializer,
TaskLinkedCalendarEventSerializer,
TaskLinkedMessageSerializer,
TaskSerializer,
create_default_columns,
get_calendar_event_content_type,
get_message_content_type,
)
def create_task_activity(
task,
actor,
action,
*,
object_kind="",
object_id=None,
metadata=None,
):
TaskActivity.objects.create(
task=task,
actor=actor if getattr(actor, "is_authenticated", False) else None,
action=action,
object_kind=object_kind or "",
object_id=object_id,
metadata=metadata or {},
)
def normalize_column_positions(column):
task_ids = list(
column.tasks.order_by("position", "-created_at", "id").values_list(
"id",
flat=True,
)
)
for index, task_id in enumerate(task_ids):
Task.objects.filter(id=task_id).update(position=(index + 1) * 1000)
class TaskBoardViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskBoardSerializer
def get_queryset(self):
return (
TaskBoard.objects.filter(is_archived=False)
.filter(task_board_access_q(self.request.user))
.select_related("created_by")
.prefetch_related(
"members",
"departments",
"columns",
"labels",
"tasks__created_by",
"tasks__assignee",
"tasks__labels",
"tasks__linked_objects",
)
.annotate(tasks_count=Count("tasks", distinct=True))
.distinct()
.order_by("name", "id")
)
def perform_create(self, serializer):
board = serializer.save(created_by=self.request.user)
create_default_columns(board)
send_task_board_update(board, "created", "board", board.id)
def perform_update(self, serializer):
board = self.get_object()
previous_recipient_ids = get_task_board_recipient_ids(board)
board = serializer.save()
recipient_ids = previous_recipient_ids | get_task_board_recipient_ids(board)
send_task_board_update(
board,
"updated",
"board",
board.id,
recipient_ids=recipient_ids,
)
def perform_destroy(self, instance):
recipient_ids = get_task_board_recipient_ids(instance)
board_id = instance.id
instance.delete()
send_task_board_update(
None,
"deleted",
"board",
board_id,
recipient_ids=recipient_ids,
board_id=board_id,
)
@action(detail=False, methods=["get"], url_path="default")
def default(self, request):
board = self.get_queryset().order_by("id").first()
if not board:
board = TaskBoard.objects.create(
name="Рабочая доска",
description="",
created_by=request.user,
)
create_default_columns(board)
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
return Response(serializer.data)
class TaskColumnViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskColumnSerializer
def get_queryset(self):
queryset = (
TaskColumn.objects.filter(board__is_archived=False)
.filter(
board__in=TaskBoard.objects.filter(
task_board_access_q(self.request.user)
)
)
.select_related("board")
.annotate(tasks_count=Count("tasks", distinct=True))
.order_by("board_id", "position", "id")
)
board = self.request.query_params.get("board")
if board:
queryset = queryset.filter(board_id=board)
return queryset
def perform_create(self, serializer):
column = serializer.save()
send_task_board_update(column.board, "created", "column", column.id)
def perform_update(self, serializer):
column = serializer.save()
send_task_board_update(column.board, "updated", "column", column.id)
def perform_destroy(self, instance):
board = instance.board
column_id = instance.id
instance.delete()
send_task_board_update(board, "deleted", "column", column_id)
class TaskLabelViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskLabelSerializer
def get_queryset(self):
queryset = (
TaskLabel.objects.filter(board__is_archived=False)
.filter(
board__in=TaskBoard.objects.filter(
task_board_access_q(self.request.user)
)
)
.select_related("board")
)
board = self.request.query_params.get("board")
if board:
queryset = queryset.filter(board_id=board)
return queryset.order_by("name", "id")
def perform_create(self, serializer):
label = serializer.save()
send_task_board_update(label.board, "created", "label", label.id)
def perform_update(self, serializer):
label = serializer.save()
send_task_board_update(label.board, "updated", "label", label.id)
def perform_destroy(self, instance):
board = instance.board
label_id = instance.id
instance.delete()
send_task_board_update(board, "deleted", "label", label_id)
class TaskViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["title", "description"]
ordering_fields = ["position", "created_at", "updated_at", "due_date"]
ordering = ["position", "-created_at"]
def get_queryset(self):
queryset = (
Task.objects.filter(board__is_archived=False)
.filter(
board__in=TaskBoard.objects.filter(
task_board_access_q(self.request.user)
)
)
.select_related("board", "column", "created_by", "assignee")
.prefetch_related("labels")
.annotate(
linked_messages_count=Count(
"linked_objects",
filter=Q(linked_objects__kind=TaskLinkedObjectKind.MESSAGE),
distinct=True,
),
linked_events_count=Count(
"linked_objects",
filter=Q(
linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT
),
distinct=True,
),
linked_objects_count=Count("linked_objects", distinct=True),
)
)
params = self.request.query_params
if board := params.get("board"):
queryset = queryset.filter(board_id=board)
if column := params.get("column"):
queryset = queryset.filter(column_id=column)
if assignee := params.get("assignee"):
queryset = queryset.filter(assignee_id=assignee)
if priority := params.get("priority"):
queryset = queryset.filter(priority=priority)
if params.get("unassigned") == "true":
queryset = queryset.filter(assignee__isnull=True)
return queryset
def perform_create(self, serializer):
task = serializer.save(created_by=self.request.user)
create_task_activity(
task,
self.request.user,
TaskActivityAction.CREATED,
metadata={"column": task.column.name},
)
send_task_board_update(task.board, "created", "task", task.id)
def perform_update(self, serializer):
instance = serializer.instance
previous = {
"title": instance.title,
"description": instance.description,
"assignee_id": instance.assignee_id,
"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)),
}
task = serializer.save()
current = {
"title": task.title,
"description": task.description,
"assignee_id": task.assignee_id,
"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)),
}
changed_fields = [
field for field, value in current.items() if previous[field] != value
]
if changed_fields:
create_task_activity(
task,
self.request.user,
TaskActivityAction.UPDATED,
metadata={"fields": changed_fields},
)
send_task_board_update(task.board, "updated", "task", task.id)
def perform_destroy(self, instance):
board = instance.board
task_id = instance.id
instance.delete()
send_task_board_update(board, "deleted", "task", task_id)
@action(detail=True, methods=["post"])
def move(self, request, pk=None):
task = self.get_object()
column_id = request.data.get("column")
position = request.data.get("position")
if not column_id:
return Response(
{"column": ["Это поле обязательно."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
column = TaskColumn.objects.get(
id=column_id,
board_id=task.board_id,
is_archived=False,
)
except TaskColumn.DoesNotExist:
return Response(
{"column": ["Колонка не найдена на этой доске."]},
status=status.HTTP_400_BAD_REQUEST,
)
old_column = task.column
if position in (None, ""):
max_position = (
Task.objects.filter(column=column).exclude(id=task.id).aggregate(
Max("position")
)["position__max"]
or 0
)
position = max_position + 1000
else:
try:
position = int(position)
except (TypeError, ValueError):
return Response(
{"position": ["Позиция должна быть числом."]},
status=status.HTTP_400_BAD_REQUEST,
)
task.column = column
task.position = position
task.save(update_fields=["column", "position", "completed_at", "updated_at"])
normalize_column_positions(column)
if old_column.id != column.id:
normalize_column_positions(old_column)
task.refresh_from_db()
serializer = self.get_serializer(task)
create_task_activity(
task,
request.user,
TaskActivityAction.MOVED,
metadata={
"from_column_id": old_column.id,
"from_column": old_column.name,
"to_column_id": column.id,
"to_column": column.name,
},
)
send_task_board_update(
task.board,
"moved",
"task",
task.id,
extra={"column_id": task.column_id},
)
return Response(serializer.data)
@action(detail=True, methods=["get"], url_path="activity")
def activity(self, request, pk=None):
task = self.get_object()
queryset = task.activities.select_related("actor").order_by("-created_at", "-id")
serializer = TaskActivitySerializer(queryset, many=True)
return Response(serializer.data)
def _message_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
task=task,
kind=TaskLinkedObjectKind.MESSAGE,
)
.select_related("created_by", "content_type")
.order_by("-created_at", "-id")
)
def _event_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
task=task,
kind=TaskLinkedObjectKind.CALENDAR_EVENT,
)
.select_related("created_by", "content_type")
.order_by("-created_at", "-id")
)
@action(detail=True, methods=["get", "post"], url_path="linked-messages")
def linked_messages(self, request, pk=None):
task = self.get_object()
if request.method == "GET":
serializer = TaskLinkedMessageSerializer(
self._message_links_queryset(task),
many=True,
context={"request": request},
)
return Response(serializer.data)
message_id = request.data.get("message_id")
try:
message_id = int(message_id)
except (TypeError, ValueError):
return Response(
{"message_id": ["Укажите ID сообщения."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
message = (
Message.objects.select_related(
"author",
"chat",
"reply_to",
"reply_to__author",
"poll",
)
.prefetch_related(
"attachments",
"reactions",
"reactions__user",
"poll__options",
)
.get(id=message_id)
)
except Message.DoesNotExist:
return Response(
{"message_id": ["Сообщение не найдено."]},
status=status.HTTP_404_NOT_FOUND,
)
if message.is_deleted:
return Response(
{"message_id": ["Удаленное сообщение нельзя связать с задачей."]},
status=status.HTTP_400_BAD_REQUEST,
)
if not user_can_access_chat(message.chat, request.user):
return Response(
{"detail": "Нет доступа к этому сообщению."},
status=status.HTTP_403_FORBIDDEN,
)
link, created = TaskLinkedObject.objects.get_or_create(
task=task,
kind=TaskLinkedObjectKind.MESSAGE,
content_type=get_message_content_type(),
object_id=message.id,
defaults={"created_by": request.user},
)
serializer = TaskLinkedMessageSerializer(
link,
context={"request": request},
)
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskLinkedObjectKind.MESSAGE,
object_id=message.id,
metadata={
"object_label": message.content[:120],
"object_type": "Сообщение",
},
)
send_task_board_update(
task.board,
"linked",
"message",
message.id,
extra={"task_id": task.id, "link_id": link.id},
)
return Response(
serializer.data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
@action(
detail=True,
methods=["delete"],
url_path=r"linked-messages/(?P<link_id>\d+)",
)
def unlink_message(self, request, pk=None, link_id=None):
task = self.get_object()
try:
link = self._message_links_queryset(task).get(id=link_id)
except TaskLinkedObject.DoesNotExist:
return Response(
{"detail": "Связь не найдена."},
status=status.HTTP_404_NOT_FOUND,
)
message_id = link.object_id
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskLinkedObjectKind.MESSAGE,
object_id=message_id,
metadata={"object_type": "Сообщение"},
)
send_task_board_update(
task.board,
"unlinked",
"message",
message_id,
extra={"task_id": task.id, "link_id": int(link_id)},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "post"], url_path="linked-events")
def linked_events(self, request, pk=None):
task = self.get_object()
if request.method == "GET":
serializer = TaskLinkedCalendarEventSerializer(
self._event_links_queryset(task),
many=True,
context={"request": request},
)
return Response(serializer.data)
event_id = request.data.get("event_id")
try:
event_id = int(event_id)
except (TypeError, ValueError):
return Response(
{"event_id": ["Укажите ID события."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
event = Event.objects.select_related(
"calendar",
"calendar__binding",
"calendar__binding__context_content_type",
"rule",
"creator",
).get(id=event_id)
except Event.DoesNotExist:
return Response(
{"event_id": ["Событие не найдено."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_calendar_event(request.user, event):
return Response(
{"detail": "Нет доступа к этому событию."},
status=status.HTTP_403_FORBIDDEN,
)
link, created = TaskLinkedObject.objects.get_or_create(
task=task,
kind=TaskLinkedObjectKind.CALENDAR_EVENT,
content_type=get_calendar_event_content_type(),
object_id=event.id,
defaults={"created_by": request.user},
)
serializer = TaskLinkedCalendarEventSerializer(
link,
context={"request": request},
)
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskLinkedObjectKind.CALENDAR_EVENT,
object_id=event.id,
metadata={
"object_label": event.title,
"object_type": "Календарное событие",
},
)
send_task_board_update(
task.board,
"linked",
"calendar_event",
event.id,
extra={"task_id": task.id, "link_id": link.id},
)
return Response(
serializer.data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
@action(
detail=True,
methods=["delete"],
url_path=r"linked-events/(?P<link_id>\d+)",
)
def unlink_event(self, request, pk=None, link_id=None):
task = self.get_object()
try:
link = self._event_links_queryset(task).get(id=link_id)
except TaskLinkedObject.DoesNotExist:
return Response(
{"detail": "Связь не найдена."},
status=status.HTTP_404_NOT_FOUND,
)
event_id = link.object_id
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskLinkedObjectKind.CALENDAR_EVENT,
object_id=event_id,
metadata={"object_type": "Календарное событие"},
)
send_task_board_update(
task.board,
"unlinked",
"calendar_event",
event_id,
extra={"task_id": task.id, "link_id": int(link_id)},
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -179,6 +179,8 @@ urlpatterns = [
path("notifications/", include("notifications.api.urls")),
# Procurement API
path("procurement/", include("api.v1.procurement.urls")),
# Tasks API
path("tasks/", include("api.v1.tasks.urls")),
# Guests API
path("guests/", include("api.v1.guests.urls")),
# Search API

View File

@ -749,7 +749,14 @@ class ChatViewSet(viewsets.ModelViewSet):
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages': [
serialize_message(
m,
user=request.user,
include_linked_tasks=True,
)
for m in messages
],
'has_more': has_more
})
@ -831,7 +838,14 @@ class ChatViewSet(viewsets.ModelViewSet):
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages': [
serialize_message(
m,
user=request.user,
include_linked_tasks=True,
)
for m in messages
],
'messages_count': len(messages),
'anchor_id': None,
'anchor_index': 0,
@ -893,7 +907,14 @@ class ChatViewSet(viewsets.ModelViewSet):
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages': [
serialize_message(
m,
user=request.user,
include_linked_tasks=True,
)
for m in messages
],
'messages_count': len(messages),
'anchor_id': None,
'anchor_index': 0,
@ -921,7 +942,14 @@ class ChatViewSet(viewsets.ModelViewSet):
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages': [
serialize_message(
m,
user=request.user,
include_linked_tasks=True,
)
for m in messages
],
'messages_count': len(messages),
'anchor_id': anchor_msg.id,
'anchor_index': anchor_index,
@ -1253,6 +1281,11 @@ class MessageViewSet(viewsets.ModelViewSet):
# Отправляем через WebSocket
channel_layer = get_channel_layer()
payload = serialize_message(message)
response_payload = serialize_message(
message,
user=request.user,
include_linked_tasks=True,
)
async_to_sync(channel_layer.group_send)(
f'chat_{chat.id}',
@ -1265,7 +1298,7 @@ class MessageViewSet(viewsets.ModelViewSet):
return Response({
'ok': True,
'message': payload
'message': response_payload
}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['post'], url_path='upload-temp')
@ -1417,6 +1450,11 @@ class MessageViewSet(viewsets.ModelViewSet):
# WebSocket уведомление
channel_layer = get_channel_layer()
payload = serialize_message(instance)
response_payload = serialize_message(
instance,
user=request.user,
include_linked_tasks=True,
)
group_name = f'chat_{instance.chat_id}'
@ -1429,7 +1467,7 @@ class MessageViewSet(viewsets.ModelViewSet):
}
)
return Response(payload)
return Response(response_payload)
def destroy(self, request, *args, **kwargs):
"""Мягкое удаление сообщения"""

View File

@ -142,7 +142,55 @@ def _get_message_read_by(m) -> list[dict]:
return readers
def serialize_message(m) -> dict:
def _get_message_linked_tasks(m, user) -> list[dict]:
if not user or not getattr(user, "is_authenticated", False):
return []
try:
from django.contrib.contenttypes.models import ContentType
from tasks.access import task_board_access_q
from tasks.models import (
TaskBoard,
TaskLinkedObject,
TaskLinkedObjectKind,
)
except Exception:
return []
content_type = ContentType.objects.get_for_model(m.__class__)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.MESSAGE,
content_type=content_type,
object_id=m.id,
task__board__in=accessible_boards,
)
.select_related("task", "task__board", "task__column")
.order_by("task__title", "task_id")
)
return [
{
"link_id": link.id,
"id": link.task_id,
"title": link.task.title,
"board_id": link.task.board_id,
"board_name": link.task.board.name,
"column_id": link.task.column_id,
"column_name": link.task.column.name,
"column_color": link.task.column.color,
"priority": link.task.priority,
"priority_display": link.task.get_priority_display(),
}
for link in links
]
def serialize_message(m, user=None, include_linked_tasks=False) -> dict:
"""
Сериализация сообщения с поддержкой всех полей.
@ -181,6 +229,8 @@ def serialize_message(m) -> dict:
"has_attachments": m.has_attachments,
}
data["read_count"] = len(data["read_by"])
if include_linked_tasks:
data["linked_tasks"] = _get_message_linked_tasks(m, user)
# Информация о пересылке (используем forward_metadata)
if m.is_forwarded:

View File

@ -68,6 +68,7 @@ INSTALLED_APPS = [
"search.apps.SearchConfig",
"finance.apps.FinanceConfig",
"procurement.apps.ProcurementConfig",
"tasks.apps.TasksConfig",
"guests.apps.GuestsConfig",
"push_notifications", # django-push-notifications для Web Push
]

View File

@ -319,3 +319,16 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
"data": event.get("data", {}),
}
)
async def task_board_update(self, event):
"""
Обновление доски задач.
Вызывается из tasks/realtime.py
"""
await self.send_json(
{
"type": "task_board_update",
"event": event.get("event", "updated"),
"data": event.get("data", {}),
}
)

View File

@ -0,0 +1 @@

65
backend/tasks/access.py Normal file
View File

@ -0,0 +1,65 @@
from django.db.models import Q
from employees.models import Department, EmployeeDepartment, RoleAssignment
from schedule.models import Event
from scheduling.services import user_can_view_calendar
from .models import TaskBoard
def get_user_department_ids(user) -> list[int]:
if not user or not user.is_authenticated:
return []
department_ids = set(
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
).values_list("department_id", flat=True)
)
department_ids.update(
Department.objects.filter(head_id=user.id).values_list("id", flat=True)
)
department_ids.update(
RoleAssignment.objects.filter(
employee_id=user.id,
is_active=True,
).values_list("role__department_id", flat=True)
)
return sorted(department_ids)
def task_board_access_q(user):
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return Q()
department_ids = get_user_department_ids(user)
q = Q(created_by=user)
q |= Q(members=user)
if department_ids:
q |= Q(departments__in=department_ids)
q |= Q(members__isnull=True, departments__isnull=True)
return q
def user_can_access_task_board(user, board: TaskBoard | None) -> bool:
if not user or not user.is_authenticated or board is None:
return False
if user.is_staff or user.is_superuser:
return True
if board.created_by_id == user.id:
return True
if board.members.filter(id=user.id).exists():
return True
department_ids = get_user_department_ids(user)
if department_ids and board.departments.filter(id__in=department_ids).exists():
return True
return (
not board.members.exists()
and not board.departments.exists()
)
def user_can_access_calendar_event(user, event: Event | None) -> bool:
if not user or not user.is_authenticated or event is None:
return False
return user_can_view_calendar(user, event.calendar)

75
backend/tasks/admin.py Normal file
View File

@ -0,0 +1,75 @@
from django.contrib import admin
from .models import (
Task,
TaskActivity,
TaskBoard,
TaskColumn,
TaskLabel,
TaskLinkedObject,
)
class TaskColumnInline(admin.TabularInline):
model = TaskColumn
extra = 0
fields = ["name", "position", "color", "is_done", "is_archived"]
class TaskLabelInline(admin.TabularInline):
model = TaskLabel
extra = 0
fields = ["name", "color"]
@admin.register(TaskBoard)
class TaskBoardAdmin(admin.ModelAdmin):
list_display = ["name", "created_by", "is_archived", "created_at"]
list_filter = ["is_archived", "created_at"]
search_fields = ["name", "description"]
filter_horizontal = ["members", "departments"]
inlines = [TaskColumnInline, TaskLabelInline]
@admin.register(TaskColumn)
class TaskColumnAdmin(admin.ModelAdmin):
list_display = ["name", "board", "position", "is_done", "is_archived"]
list_filter = ["is_done", "is_archived", "board"]
search_fields = ["name", "board__name"]
@admin.register(TaskLabel)
class TaskLabelAdmin(admin.ModelAdmin):
list_display = ["name", "board", "color"]
list_filter = ["board"]
search_fields = ["name", "board__name"]
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
list_display = [
"title",
"board",
"column",
"assignee",
"priority",
"due_date",
"completed_at",
]
list_filter = ["board", "column", "priority", "due_date"]
search_fields = ["title", "description", "assignee__email"]
filter_horizontal = ["labels"]
@admin.register(TaskLinkedObject)
class TaskLinkedObjectAdmin(admin.ModelAdmin):
list_display = ["task", "kind", "content_type", "object_id", "created_by", "created_at"]
list_filter = ["kind", "content_type", "created_at"]
search_fields = ["task__title", "created_by__email", "object_id"]
@admin.register(TaskActivity)
class TaskActivityAdmin(admin.ModelAdmin):
list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"]
list_filter = ["action", "object_kind", "created_at"]
search_fields = ["task__title", "actor__email", "object_id"]

7
backend/tasks/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class TasksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "tasks"
verbose_name = "Задачи"

View File

@ -0,0 +1,110 @@
# Generated by Django 5.2.4 on 2026-07-06 07:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('employees', '0054_employee_id_guest_range_guard'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TaskBoard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('is_archived', models.BooleanField(default=False, verbose_name='Архивная')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_boards', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('departments', models.ManyToManyField(blank=True, related_name='task_boards', to='employees.department', verbose_name='Отделы')),
('members', models.ManyToManyField(blank=True, related_name='task_boards', to=settings.AUTH_USER_MODEL, verbose_name='Участники')),
],
options={
'verbose_name': 'Доска задач',
'verbose_name_plural': 'Доски задач',
'ordering': ['name', 'id'],
},
),
migrations.CreateModel(
name='TaskColumn',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, verbose_name='Название')),
('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')),
('color', models.CharField(blank=True, max_length=32, verbose_name='Цвет')),
('is_done', models.BooleanField(default=False, verbose_name='Финальная колонка')),
('is_archived', models.BooleanField(default=False, verbose_name='Архивная')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='columns', to='tasks.taskboard', verbose_name='Доска')),
],
options={
'verbose_name': 'Колонка задач',
'verbose_name_plural': 'Колонки задач',
'ordering': ['position', 'id'],
},
),
migrations.CreateModel(
name='TaskLabel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=80, verbose_name='Название')),
('color', models.CharField(default='#38bdf8', max_length=32, verbose_name='Цвет')),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='tasks.taskboard', verbose_name='Доска')),
],
options={
'verbose_name': 'Метка задачи',
'verbose_name_plural': 'Метки задач',
'ordering': ['name', 'id'],
},
),
migrations.CreateModel(
name='Task',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий'), ('critical', 'Критический')], default='medium', max_length=20, verbose_name='Приоритет')),
('due_date', models.DateField(blank=True, null=True, verbose_name='Срок')),
('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tasks', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_tasks', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='tasks.taskboard', verbose_name='Доска')),
('column', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tasks', to='tasks.taskcolumn', verbose_name='Колонка')),
('labels', models.ManyToManyField(blank=True, related_name='tasks', to='tasks.tasklabel', verbose_name='Метки')),
],
options={
'verbose_name': 'Задача',
'verbose_name_plural': 'Задачи',
'ordering': ['position', '-created_at', 'id'],
},
),
migrations.AddConstraint(
model_name='taskcolumn',
constraint=models.UniqueConstraint(fields=('board', 'name'), name='uniq_task_column_board_name'),
),
migrations.AddConstraint(
model_name='tasklabel',
constraint=models.UniqueConstraint(fields=('board', 'name'), name='uniq_task_label_board_name'),
),
migrations.AddIndex(
model_name='task',
index=models.Index(fields=['board', 'column', 'position'], name='tasks_task_board_i_6021b0_idx'),
),
migrations.AddIndex(
model_name='task',
index=models.Index(fields=['assignee', 'due_date'], name='tasks_task_assigne_5c11c7_idx'),
),
]

View File

@ -0,0 +1,107 @@
# Generated by Codex on 2026-07-06
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("tasks", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TaskLinkedObject",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kind",
models.CharField(
choices=[("message", "Сообщение")],
max_length=32,
verbose_name="Тип связи",
),
),
("object_id", models.PositiveBigIntegerField(verbose_name="ID объекта")),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
verbose_name="Создано",
),
),
(
"metadata",
models.JSONField(
blank=True,
default=dict,
verbose_name="Метаданные",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
verbose_name="Тип объекта",
),
),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="created_task_links",
to=settings.AUTH_USER_MODEL,
verbose_name="Связал",
),
),
(
"task",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="linked_objects",
to="tasks.task",
verbose_name="Задача",
),
),
],
options={
"verbose_name": "Связанный объект задачи",
"verbose_name_plural": "Связанные объекты задач",
"ordering": ["-created_at", "-id"],
},
),
migrations.AddConstraint(
model_name="tasklinkedobject",
constraint=models.UniqueConstraint(
fields=("task", "content_type", "object_id"),
name="uniq_task_linked_object",
),
),
migrations.AddIndex(
model_name="tasklinkedobject",
index=models.Index(
fields=["task", "kind"],
name="tasks_taskl_task_id_21eadf_idx",
),
),
migrations.AddIndex(
model_name="tasklinkedobject",
index=models.Index(
fields=["content_type", "object_id"],
name="tasks_taskl_content_362991_idx",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-07-06 10:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0002_tasklinkedobject'),
]
operations = [
migrations.AlterField(
model_name='tasklinkedobject',
name='kind',
field=models.CharField(choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие')], max_length=32, verbose_name='Тип связи'),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2026-07-06 10:41
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0003_alter_tasklinkedobject_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TaskActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('created', 'Создал задачу'), ('updated', 'Обновил задачу'), ('moved', 'Переместил задачу'), ('linked', 'Связал объект'), ('unlinked', 'Убрал связь')], max_length=32, verbose_name='Действие')),
('object_kind', models.CharField(blank=True, choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие')], max_length=32, verbose_name='Тип объекта')),
('object_id', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='ID объекта')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='Метаданные')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_activities', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='tasks.task', verbose_name='Задача')),
],
options={
'verbose_name': 'История задачи',
'verbose_name_plural': 'История задач',
'ordering': ['-created_at', '-id'],
'indexes': [models.Index(fields=['task', '-created_at'], name='tasks_taska_task_id_db425c_idx')],
},
),
]

View File

@ -0,0 +1 @@

275
backend/tasks/models.py Normal file
View File

@ -0,0 +1,275 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from employees.models import Department
class TaskPriority(models.TextChoices):
LOW = "low", "Низкий"
MEDIUM = "medium", "Средний"
HIGH = "high", "Высокий"
CRITICAL = "critical", "Критический"
class TaskBoard(models.Model):
name = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_task_boards",
verbose_name="Создал",
)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="task_boards",
verbose_name="Участники",
)
departments = models.ManyToManyField(
Department,
blank=True,
related_name="task_boards",
verbose_name="Отделы",
)
is_archived = models.BooleanField("Архивная", default=False)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Доска задач"
verbose_name_plural = "Доски задач"
ordering = ["name", "id"]
def __str__(self):
return self.name
class TaskColumn(models.Model):
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
related_name="columns",
verbose_name="Доска",
)
name = models.CharField("Название", max_length=120)
position = models.PositiveIntegerField("Позиция", default=0)
color = models.CharField("Цвет", max_length=32, blank=True)
is_done = models.BooleanField("Финальная колонка", default=False)
is_archived = models.BooleanField("Архивная", default=False)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Колонка задач"
verbose_name_plural = "Колонки задач"
ordering = ["position", "id"]
constraints = [
models.UniqueConstraint(
fields=["board", "name"],
name="uniq_task_column_board_name",
)
]
def __str__(self):
return f"{self.board}: {self.name}"
class TaskLabel(models.Model):
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
related_name="labels",
verbose_name="Доска",
)
name = models.CharField("Название", max_length=80)
color = models.CharField("Цвет", max_length=32, default="#38bdf8")
class Meta:
verbose_name = "Метка задачи"
verbose_name_plural = "Метки задач"
ordering = ["name", "id"]
constraints = [
models.UniqueConstraint(
fields=["board", "name"],
name="uniq_task_label_board_name",
)
]
def __str__(self):
return self.name
class Task(models.Model):
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
related_name="tasks",
verbose_name="Доска",
)
column = models.ForeignKey(
TaskColumn,
on_delete=models.PROTECT,
related_name="tasks",
verbose_name="Колонка",
)
title = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_tasks",
verbose_name="Создал",
)
assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assigned_tasks",
verbose_name="Исполнитель",
)
labels = models.ManyToManyField(
TaskLabel,
blank=True,
related_name="tasks",
verbose_name="Метки",
)
priority = models.CharField(
"Приоритет",
max_length=20,
choices=TaskPriority.choices,
default=TaskPriority.MEDIUM,
)
due_date = models.DateField("Срок", null=True, blank=True)
position = models.PositiveIntegerField("Позиция", default=0)
completed_at = models.DateTimeField("Завершено", null=True, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Задача"
verbose_name_plural = "Задачи"
ordering = ["position", "-created_at", "id"]
indexes = [
models.Index(fields=["board", "column", "position"]),
models.Index(fields=["assignee", "due_date"]),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if self.column_id and self.board_id != self.column.board_id:
self.board_id = self.column.board_id
if self.column_id and self.column.is_done and not self.completed_at:
self.completed_at = timezone.now()
elif self.column_id and not self.column.is_done:
self.completed_at = None
super().save(*args, **kwargs)
class TaskLinkedObjectKind(models.TextChoices):
MESSAGE = "message", "Сообщение"
CALENDAR_EVENT = "calendar_event", "Календарное событие"
class TaskActivityAction(models.TextChoices):
CREATED = "created", "Создал задачу"
UPDATED = "updated", "Обновил задачу"
MOVED = "moved", "Переместил задачу"
LINKED = "linked", "Связал объект"
UNLINKED = "unlinked", "Убрал связь"
class TaskActivity(models.Model):
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name="activities",
verbose_name="Задача",
)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="task_activities",
verbose_name="Исполнитель",
)
action = models.CharField(
"Действие",
max_length=32,
choices=TaskActivityAction.choices,
)
object_kind = models.CharField(
"Тип объекта",
max_length=32,
choices=TaskLinkedObjectKind.choices,
blank=True,
)
object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True)
metadata = models.JSONField("Метаданные", default=dict, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "История задачи"
verbose_name_plural = "История задач"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["task", "-created_at"]),
]
def __str__(self):
return f"{self.task} - {self.action}"
class TaskLinkedObject(models.Model):
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name="linked_objects",
verbose_name="Задача",
)
kind = models.CharField(
"Тип связи",
max_length=32,
choices=TaskLinkedObjectKind.choices,
)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name="Тип объекта",
)
object_id = models.PositiveBigIntegerField("ID объекта")
content_object = GenericForeignKey("content_type", "object_id")
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_task_links",
verbose_name="Связал",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
metadata = models.JSONField("Метаданные", default=dict, blank=True)
class Meta:
verbose_name = "Связанный объект задачи"
verbose_name_plural = "Связанные объекты задач"
ordering = ["-created_at", "-id"]
constraints = [
models.UniqueConstraint(
fields=["task", "content_type", "object_id"],
name="uniq_task_linked_object",
)
]
indexes = [
models.Index(fields=["task", "kind"]),
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self):
return f"{self.task} -> {self.kind}:{self.object_id}"

126
backend/tasks/realtime.py Normal file
View File

@ -0,0 +1,126 @@
import logging
from collections.abc import Iterable
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from employees.models import Department, EmployeeDepartment, RoleAssignment
from .models import TaskBoard
logger = logging.getLogger(__name__)
def get_task_board_recipient_ids(board: TaskBoard) -> set[int]:
if not board or not board.pk:
return set()
User = get_user_model()
admin_q = Q(is_staff=True) | Q(is_superuser=True)
if not board.members.exists() and not board.departments.exists():
return set(
User.objects.filter(is_active=True)
.values_list("id", flat=True)
)
department_ids = list(board.departments.values_list("id", flat=True))
recipient_ids = {board.created_by_id}
recipient_ids.update(board.members.values_list("id", flat=True))
if department_ids:
recipient_ids.update(
EmployeeDepartment.objects.filter(
department_id__in=department_ids,
is_active=True,
employee__is_active=True,
).values_list("employee_id", flat=True)
)
recipient_ids.update(
Department.objects.filter(
id__in=department_ids,
head__is_active=True,
).values_list("head_id", flat=True)
)
recipient_ids.update(
RoleAssignment.objects.filter(
role__department_id__in=department_ids,
is_active=True,
employee__is_active=True,
).values_list("employee_id", flat=True)
)
recipient_ids.update(
User.objects.filter(is_active=True)
.filter(admin_q)
.values_list("id", flat=True)
)
return set(
User.objects.filter(
id__in={user_id for user_id in recipient_ids if user_id},
is_active=True,
).values_list("id", flat=True)
)
def send_task_board_update(
board: TaskBoard | None,
event: str,
model: str,
object_id: int | None = None,
*,
recipient_ids: Iterable[int] | None = None,
board_id: int | None = None,
extra: dict | None = None,
) -> None:
resolved_board_id = board_id or getattr(board, "id", None)
if not resolved_board_id:
return
recipients = set(
recipient_ids
or (
get_task_board_recipient_ids(board)
if board and board.pk
else set()
)
)
if not recipients:
return
payload = {
"board_id": resolved_board_id,
"event": event,
"model": model,
"object_id": object_id,
}
if extra:
payload.update(extra)
def _send() -> None:
channel_layer = get_channel_layer()
if channel_layer is None:
return
message = {
"type": "task_board_update",
"event": event,
"data": payload,
}
for user_id in recipients:
try:
async_to_sync(channel_layer.group_send)(
f"user_{user_id}",
message,
)
except Exception:
logger.exception(
"Failed to send task board update to user %s",
user_id,
)
transaction.on_commit(_send)

View File

@ -0,0 +1,521 @@
from unittest.mock import patch
from datetime import timedelta
import pytest
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APIClient
from communications.models import Chat, ChatMembership, Message
from employees.models import Department, EmployeeDepartment, RoleAssignment
from schedule.models import Calendar, CalendarRelation, Event
from tasks.models import Task, TaskBoard, TaskColumn
from tasks.realtime import get_task_board_recipient_ids
pytestmark = pytest.mark.django_db
User = get_user_model()
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user():
return User.objects.create_user(
email="task-user@example.com",
password="testpass123",
phone_number="+79994440001",
first_name="Иван",
last_name="Задачев",
is_active=True,
email_verified=True,
send_activation_email=False,
)
def make_user(email, phone_number, **extra):
defaults = {
"password": "testpass123",
"phone_number": phone_number,
"first_name": "Тест",
"last_name": "Сотрудник",
"is_active": True,
"email_verified": True,
"send_activation_email": False,
}
defaults.update(extra)
return User.objects.create_user(email=email, **defaults)
def test_default_board_creates_work_columns(api_client, user):
api_client.force_authenticate(user=user)
url = reverse("api:v1:tasks:task-board-default")
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == "Рабочая доска"
assert [column["name"] for column in response.data["columns"]] == [
"Новые",
"В работе",
"На проверке",
"Готово",
]
assert TaskBoard.objects.count() == 1
assert TaskColumn.objects.count() == 4
def test_create_task_and_move_to_done_column(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(
reverse("api:v1:tasks:task-board-default")
)
board_id = board_response.data["id"]
columns = {column["name"]: column for column in board_response.data["columns"]}
create_response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board_id,
"column": columns["Новые"]["id"],
"title": "Подготовить прототип доски",
"description": "Собрать первый рабочий workflow",
"assignee_id": user.id,
"priority": "high",
},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
task_id = create_response.data["id"]
assert create_response.data["created_by"]["id"] == user.id
assert create_response.data["assignee"]["id"] == user.id
with patch("api.v1.tasks.views.send_task_board_update") as send_update:
move_response = api_client.post(
reverse("api:v1:tasks:task-move", kwargs={"pk": task_id}),
{"column": columns["Готово"]["id"]},
format="json",
)
assert move_response.status_code == status.HTTP_200_OK
assert move_response.data["column"] == columns["Готово"]["id"]
assert move_response.data["completed_at"] is not None
send_update.assert_called_once()
send_args, send_kwargs = send_update.call_args
assert send_args[0].id == board_id
assert send_args[1:4] == ("moved", "task", task_id)
assert send_kwargs["extra"] == {"column_id": columns["Готово"]["id"]}
task = Task.objects.get(id=task_id)
assert task.column_id == columns["Готово"]["id"]
assert task.completed_at is not None
def test_task_rejects_column_from_another_board(api_client, user):
api_client.force_authenticate(user=user)
first = api_client.get(reverse("api:v1:tasks:task-board-default")).data
second_board = TaskBoard.objects.create(
name="Вторая доска",
created_by=user,
)
other_column = TaskColumn.objects.create(
board=second_board,
name="Чужая колонка",
position=1000,
)
response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": first["id"],
"column": other_column.id,
"title": "Невалидная задача",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "column" in response.data
def test_create_board_accepts_members_and_departments(api_client, user):
member = make_user("task-member@example.com", "+79994440002")
department = Department.objects.create(name="Проектный отдел")
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:tasks:task-board-list"),
{
"name": "Приватная доска",
"description": "Только для проектной группы",
"members": [member.id],
"departments": [department.id],
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["members"] == [member.id]
assert response.data["member_details"][0]["id"] == member.id
assert response.data["departments"] == [department.id]
assert response.data["department_details"][0]["name"] == department.name
assert TaskColumn.objects.filter(board_id=response.data["id"]).count() == 4
def test_board_member_can_access_private_board(api_client):
owner = make_user("task-owner@example.com", "+79994440003")
member = make_user("task-member-2@example.com", "+79994440004")
outsider = make_user("task-outsider@example.com", "+79994440005")
board = TaskBoard.objects.create(name="Закрытая доска", created_by=owner)
board.members.add(member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
api_client.force_authenticate(user=member)
member_list = api_client.get(reverse("api:v1:tasks:task-board-list"))
assert member_list.status_code == status.HTTP_200_OK
assert [item["id"] for item in member_list.data["results"]] == [board.id]
create_response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board.id,
"column": column.id,
"title": "Задача участника",
},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
api_client.force_authenticate(user=outsider)
outsider_list = api_client.get(reverse("api:v1:tasks:task-board-list"))
assert outsider_list.status_code == status.HTTP_200_OK
assert [item["id"] for item in outsider_list.data["results"]] == []
detail_response = api_client.get(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
)
assert detail_response.status_code == status.HTTP_404_NOT_FOUND
hidden_create_response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board.id,
"column": column.id,
"title": "Чужая задача",
},
format="json",
)
assert hidden_create_response.status_code == status.HTTP_400_BAD_REQUEST
assert "board" in hidden_create_response.data
def test_department_member_can_access_department_board(api_client):
owner = make_user("task-board-owner@example.com", "+79994440006")
department_user = make_user("task-department-user@example.com", "+79994440007")
outsider = make_user("task-department-outsider@example.com", "+79994440008")
department = Department.objects.create(name="Отдел внедрения")
EmployeeDepartment.objects.create(
employee=department_user,
department=department,
is_active=True,
)
board = TaskBoard.objects.create(name="Доска отдела", created_by=owner)
board.departments.add(department)
api_client.force_authenticate(user=department_user)
department_response = api_client.get(reverse("api:v1:tasks:task-board-list"))
assert department_response.status_code == status.HTTP_200_OK
assert [item["id"] for item in department_response.data["results"]] == [board.id]
api_client.force_authenticate(user=outsider)
outsider_response = api_client.get(reverse("api:v1:tasks:task-board-list"))
assert outsider_response.status_code == status.HTTP_200_OK
assert [item["id"] for item in outsider_response.data["results"]] == []
def test_task_board_realtime_recipients_match_board_access():
owner = make_user("task-rt-owner@example.com", "+79994440009")
member = make_user("task-rt-member@example.com", "+79994440010")
department_user = make_user("task-rt-dept@example.com", "+79994440011")
role_user = make_user("task-rt-role@example.com", "+79994440012")
outsider = make_user("task-rt-outsider@example.com", "+79994440013")
admin = make_user(
"task-rt-admin@example.com",
"+79994440014",
is_staff=True,
)
inactive_member = make_user(
"task-rt-inactive@example.com",
"+79994440015",
is_active=False,
)
department = Department.objects.create(name="Realtime отдел")
role = department.roles.create(name="Внешний участник")
EmployeeDepartment.objects.create(
employee=department_user,
department=department,
is_active=True,
)
RoleAssignment.objects.create(
employee=role_user,
role=role,
is_active=True,
)
board = TaskBoard.objects.create(name="Закрытая realtime доска", created_by=owner)
board.members.add(member, inactive_member)
board.departments.add(department)
recipients = get_task_board_recipient_ids(board)
assert owner.id in recipients
assert member.id in recipients
assert department_user.id in recipients
assert role_user.id in recipients
assert admin.id in recipients
assert outsider.id not in recipients
assert inactive_member.id not in recipients
def test_public_task_board_realtime_recipients_include_active_users():
owner = make_user("task-rt-public-owner@example.com", "+79994440016")
active_user = make_user("task-rt-public-user@example.com", "+79994440017")
inactive_user = make_user(
"task-rt-public-inactive@example.com",
"+79994440018",
is_active=False,
)
board = TaskBoard.objects.create(name="Публичная realtime доска", created_by=owner)
recipients = get_task_board_recipient_ids(board)
assert owner.id in recipients
assert active_user.id in recipients
assert inactive_user.id not in recipients
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")
board = TaskBoard.objects.create(name="Доска со связями", created_by=owner)
board.members.add(board_member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Разобрать сообщение",
created_by=owner,
)
chat = Chat.objects.create(name="Закрытый чат", type="group", created_by=owner)
ChatMembership.objects.create(chat=chat, user=owner, role="admin")
message = Message.objects.create(
chat=chat,
author=owner,
content="Важный контекст для задачи",
)
api_client.force_authenticate(user=board_member)
denied_response = api_client.post(
reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}),
{"message_id": message.id},
format="json",
)
assert denied_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=owner)
link_response = api_client.post(
reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}),
{"message_id": message.id},
format="json",
)
assert link_response.status_code == status.HTTP_201_CREATED
assert link_response.data["message"]["content"] == "Важный контекст для задачи"
assert link_response.data["can_open"] is True
assert link_response.data["object_url"] == (
f"/messages/{chat.id}?message={message.id}"
)
api_client.force_authenticate(user=board_member)
linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id})
)
assert linked_response.status_code == status.HTTP_200_OK
assert linked_response.data[0]["message_id"] == message.id
assert linked_response.data[0]["message"]["content"] == "Важный контекст для задачи"
assert linked_response.data[0]["can_open"] is False
assert linked_response.data[0]["object_url"] is None
def test_task_linked_calendar_event_respects_event_access(api_client):
owner = make_user("task-event-owner@example.com", "+79994440024")
board_member = make_user("task-event-member@example.com", "+79994440025")
board = TaskBoard.objects.create(name="Доска с событиями", created_by=owner)
board.members.add(board_member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Подготовить встречу",
created_by=owner,
)
calendar = Calendar.objects.create(name="Закрытый календарь", slug="closed")
CalendarRelation.objects.create(
calendar=calendar,
content_type=ContentType.objects.get_for_model(User),
object_id=owner.id,
distinction="owner",
inheritable=True,
)
now = timezone.now()
event = Event.objects.create(
calendar=calendar,
title="Встреча по задаче",
description="Обсудить детали",
start=now,
end=now + timedelta(hours=1),
creator=owner,
color_event="#38bdf8",
)
api_client.force_authenticate(user=board_member)
denied_response = api_client.post(
reverse("api:v1:tasks:task-linked-events", kwargs={"pk": task.id}),
{"event_id": event.id},
format="json",
)
assert denied_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=owner)
link_response = api_client.post(
reverse("api:v1:tasks:task-linked-events", kwargs={"pk": task.id}),
{"event_id": event.id},
format="json",
)
assert link_response.status_code == status.HTTP_201_CREATED
assert link_response.data["event"]["title"] == "Встреча по задаче"
assert link_response.data["can_open"] is True
assert link_response.data["object_url"] == (
f"/calendar?calendar={calendar.id}&event={event.id}"
)
api_client.force_authenticate(user=board_member)
linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-events", kwargs={"pk": task.id})
)
assert linked_response.status_code == status.HTTP_200_OK
assert linked_response.data[0]["event_id"] == event.id
assert linked_response.data[0]["event"]["title"] == "Встреча по задаче"
assert linked_response.data[0]["can_open"] is False
assert linked_response.data[0]["object_url"] is None
def test_task_activity_records_core_actions(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(
reverse("api:v1:tasks:task-board-default")
)
board_id = board_response.data["id"]
columns = board_response.data["columns"]
source_column = columns[0]
target_column = columns[1]
create_response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board_id,
"column": source_column["id"],
"title": "История задачи",
"description": "",
"priority": "medium",
},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
task_id = create_response.data["id"]
update_response = api_client.patch(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task_id}),
{"title": "История задачи обновлена"},
format="json",
)
assert update_response.status_code == status.HTTP_200_OK
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
activity_response = api_client.get(
reverse("api:v1:tasks:task-activity", kwargs={"pk": task_id})
)
assert activity_response.status_code == status.HTTP_200_OK
actions = [item["action"] for item in activity_response.data]
assert actions == ["moved", "updated", "created"]
assert activity_response.data[0]["metadata"]["from_column"] == source_column["name"]
assert activity_response.data[0]["metadata"]["to_column"] == target_column["name"]
assert "title" in activity_response.data[1]["metadata"]["fields"]
def test_chat_message_serialization_shows_only_accessible_linked_tasks(api_client):
owner = make_user("task-link-chat-owner@example.com", "+79994440021")
visible_user = make_user("task-link-chat-visible@example.com", "+79994440022")
hidden_user = make_user("task-link-chat-hidden@example.com", "+79994440023")
board = TaskBoard.objects.create(name="Видимая доска", created_by=owner)
board.members.add(visible_user)
column = TaskColumn.objects.create(
board=board,
name="Новые",
position=1000,
color="#16a34a",
)
task = Task.objects.create(
board=board,
column=column,
title="Задача из сообщения",
created_by=owner,
priority="critical",
)
chat = Chat.objects.create(name="Общий чат", type="group", created_by=owner)
ChatMembership.objects.create(chat=chat, user=owner, role="admin")
ChatMembership.objects.create(chat=chat, user=visible_user, role="member")
ChatMembership.objects.create(chat=chat, user=hidden_user, role="member")
message = Message.objects.create(
chat=chat,
author=owner,
content="Сообщение с задачей",
)
api_client.force_authenticate(user=owner)
api_client.post(
reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}),
{"message_id": message.id},
format="json",
)
api_client.force_authenticate(user=visible_user)
visible_response = api_client.get(
reverse("api:v1:chats-messages", kwargs={"pk": chat.id})
)
assert visible_response.status_code == status.HTTP_200_OK
linked_task = visible_response.data["messages"][0]["linked_tasks"][0]
assert linked_task["id"] == task.id
assert linked_task["column_id"] == column.id
assert linked_task["column_name"] == "Новые"
assert linked_task["column_color"] == "#16a34a"
assert linked_task["priority"] == "critical"
assert linked_task["priority_display"] == "Критический"
api_client.force_authenticate(user=hidden_user)
hidden_response = api_client.get(
reverse("api:v1:chats-messages", kwargs={"pk": chat.id})
)
assert hidden_response.status_code == status.HTTP_200_OK
assert hidden_response.data["messages"][0]["linked_tasks"] == []

View File

@ -10,7 +10,7 @@
- Открытие/закрытие активного чата
"""
import pytest
from channels.testing import WebsocketCommunicator
from channels.layers import get_channel_layer
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
@ -76,6 +76,44 @@ class TestUserConsumerConnection:
# После отключения не должно быть ошибок
@pytest.mark.asyncio
class TestUserConsumerTaskBoardEvents:
"""Тесты realtime-событий доски задач."""
async def test_task_board_update_event(self, ws_communicator, user):
"""Пользователь получает событие обновления доски через личный канал."""
communicator = await ws_communicator(user=user)
connected, _ = await communicator.connect()
assert connected
channel_layer = get_channel_layer()
await channel_layer.group_send(
f"user_{user.id}",
{
"type": "task_board_update",
"event": "moved",
"data": {
"board_id": 15,
"model": "task",
"object_id": 42,
"column_id": 7,
},
},
)
response = await communicator.receive_json_from(timeout=5)
assert response["type"] == "task_board_update"
assert response["event"] == "moved"
assert response["data"]["board_id"] == 15
assert response["data"]["model"] == "task"
assert response["data"]["object_id"] == 42
assert response["data"]["column_id"] == 7
await communicator.disconnect()
@pytest.mark.asyncio
class TestUserConsumerChatManagement:
"""Тесты управления активным чатом."""

View File

@ -892,3 +892,39 @@ html[data-bs-theme="dark"] .shadow-xl {
.app-tabs-scroll::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}
.tasks-board-scroll,
.tasks-column-scroll {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--accent-primary) 42%, var(--surface-tertiary)) transparent;
-ms-overflow-style: auto;
}
.tasks-board-scroll::-webkit-scrollbar,
.tasks-column-scroll::-webkit-scrollbar {
display: block;
}
.tasks-board-scroll::-webkit-scrollbar {
height: 8px;
}
.tasks-column-scroll::-webkit-scrollbar {
width: 6px;
}
.tasks-board-scroll::-webkit-scrollbar-track,
.tasks-column-scroll::-webkit-scrollbar-track {
background: transparent;
}
.tasks-board-scroll::-webkit-scrollbar-thumb,
.tasks-column-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: color-mix(in srgb, var(--accent-primary) 38%, var(--surface-tertiary));
}
.tasks-board-scroll::-webkit-scrollbar-thumb:hover,
.tasks-column-scroll::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}

View File

@ -2,8 +2,16 @@
import React, { Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { MessageCircle, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import {
MessageCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
Link2,
Loader2,
} from "lucide-react";
import { AppShell } from "../../../components/AppShell";
import { Modal } from "@/components/ui";
import ChatDialogHeader from "@/components/messages/ChatDialogHeader";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem";
@ -12,7 +20,15 @@ import MessageReadersModal from "@/components/messages/MessageReadersModal";
import MessageComposer from "@/components/messages/MessageComposer";
import ReactionPickerModal from "@/components/messages/ReactionPickerModal";
import { apiClient } from "@/lib/api";
import type { ChatMessageSearchResponse, ChatMessageSearchResult, Message } from "@/types/api";
import type {
ChatMessageSearchResponse,
ChatMessageSearchResult,
Message,
TaskBoard,
TaskCard,
TaskPriority,
User,
} from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { useChatFallbackSync } from "@/hooks/useChatFallbackSync";
import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard";
@ -22,6 +38,7 @@ import { useMarkRead } from "@/hooks/useMarkRead";
import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll";
import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils";
import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils";
import { displayUserName } from "@/lib/shared";
import wsManager from "@/lib/websocketManager";
import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList";
@ -29,6 +46,14 @@ import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/
type ReplyTarget = { id: number; author: string; preview: string };
type TypingUser = { id: number; name: string };
type TaskLinkMode = "existing" | "create";
const taskPriorityOptions: { value: TaskPriority; label: string }[] = [
{ value: "low", label: "Низкая" },
{ value: "medium", label: "Средняя" },
{ value: "high", label: "Высокая" },
{ value: "critical", label: "Критическая" },
];
/* ─── constants ─── */
const RECENT_REACTIONS_KEY = "eusrr_recent_reactions";
@ -66,6 +91,14 @@ function formatTypingIndicatorText(users: TypingUser[]): string {
return `${users[0].name} и ещё ${additionalCount} ${additionalLabel} печатают...`;
}
function getDefaultTaskTitleFromMessage(message: Message): string {
const content = (message.content || "").trim().replace(/\s+/g, " ");
if (content) {
return content.length > 80 ? `${content.slice(0, 77)}...` : content;
}
return `Задача по сообщению #${message.id}`;
}
/* ─── component ─── */
export default function MessageDialogPage() {
@ -103,6 +136,7 @@ function MessageDialogPageContent() {
const searchInputRef = useRef<HTMLInputElement | null>(null);
const highlightTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingRetryFilesRef = useRef<Map<string, File[]>>(new Map());
const displayMessageIdsRef = useRef<Set<number>>(new Set());
/* ──────────────────────────────────────────────────────
* HOOK: Chat messages (state, pagination, pending)
@ -206,8 +240,28 @@ function MessageDialogPageContent() {
const [searchError, setSearchError] = useState<string | null>(null);
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [taskLinkMessage, setTaskLinkMessage] = useState<Message | null>(null);
const [taskLinkBoards, setTaskLinkBoards] = useState<TaskBoard[]>([]);
const [taskLinkEmployees, setTaskLinkEmployees] = useState<User[]>([]);
const [taskLinkBoardId, setTaskLinkBoardId] = useState<number | "">("");
const [taskLinkTaskId, setTaskLinkTaskId] = useState<number | "">("");
const [taskLinkMode, setTaskLinkMode] = useState<TaskLinkMode>("existing");
const [taskLinkNewTitle, setTaskLinkNewTitle] = useState("");
const [taskLinkNewDescription, setTaskLinkNewDescription] = useState("");
const [taskLinkDetailsOpen, setTaskLinkDetailsOpen] = useState(false);
const [taskLinkColumnId, setTaskLinkColumnId] = useState<number | "">("");
const [taskLinkAssigneeId, setTaskLinkAssigneeId] = useState<number | "">("");
const [taskLinkPriority, setTaskLinkPriority] = useState<TaskPriority>("medium");
const [taskLinkDueDate, setTaskLinkDueDate] = useState("");
const [taskLinkLabelIds, setTaskLinkLabelIds] = useState<number[]>([]);
const [taskLinkLoading, setTaskLinkLoading] = useState(false);
const [taskLinkError, setTaskLinkError] = useState<string | null>(null);
const linkedMessageId = Number(searchParams.get("message") || "");
useEffect(() => {
displayMessageIdsRef.current = new Set(displayMessages.map((message) => message.id));
}, [displayMessages]);
// Sync settings from chat
useEffect(() => {
if (cm.chat) {
@ -311,6 +365,31 @@ function MessageDialogPageContent() {
return () => { wsManager.clearActiveChat(chatId); };
}, [chatId]);
useEffect(() => {
if (!chatId || Number.isNaN(chatId)) return undefined;
const unsubscribe = wsManager.subscribe((event) => {
if (event.type !== "task_board_update") return;
const payload = event.data as { model?: string; object_id?: number } | undefined;
if (payload?.model !== "message") return;
const messageId = Number(payload.object_id);
if (!Number.isFinite(messageId) || !displayMessageIdsRef.current.has(messageId)) return;
void apiClient.getChatMessagesAround(chatId, {
limit: 1,
around_id: messageId,
}).then((response) => {
const updated = response.messages?.find((message: Message) => message.id === messageId);
if (!updated) return;
cm.setMessages(prev => prev.map((message) => (
message.id === messageId ? { ...message, ...updated } : message
)));
}).catch(() => {});
});
return unsubscribe;
}, [chatId, cm]);
useEffect(() => { if (isConnected) resetReloadGuard(); }, [isConnected, resetReloadGuard]);
// WS event handler
@ -729,6 +808,181 @@ function MessageDialogPageContent() {
});
}, []);
const selectedTaskLinkBoard = useMemo(
() => taskLinkBoards.find((item) => item.id === taskLinkBoardId) || null,
[taskLinkBoardId, taskLinkBoards],
);
const selectedTaskLinkTask = useMemo(
() => (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null,
[selectedTaskLinkBoard?.tasks, taskLinkTaskId],
);
const selectedTaskLinkColumn = useMemo(
() => (
(selectedTaskLinkBoard?.columns || []).find((column) => column.id === taskLinkColumnId) ||
(selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) ||
null
),
[selectedTaskLinkBoard?.columns, taskLinkColumnId],
);
const handleOpenTaskLinkModal = useCallback(async (message: Message) => {
setTaskLinkMessage(message);
setExpandedReplyActionForId(null);
setTaskLinkError(null);
setTaskLinkNewTitle(getDefaultTaskTitleFromMessage(message));
setTaskLinkNewDescription(message.content || "");
setTaskLinkLoading(true);
try {
const [response, employeesResponse] = await Promise.all([
apiClient.getTaskBoards(),
apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }),
]);
const boards = (response.results || response || []) as TaskBoard[];
const employees = (employeesResponse.results || employeesResponse || []) as User[];
setTaskLinkBoards(boards);
setTaskLinkEmployees(employees);
const firstBoardWithTasks = boards.find((item) => (item.tasks || []).length > 0) || boards[0] || null;
const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null;
setTaskLinkBoardId(firstBoardWithTasks?.id || "");
setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || "");
setTaskLinkColumnId(firstColumn?.id || "");
setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create");
setTaskLinkDetailsOpen(false);
setTaskLinkAssigneeId("");
setTaskLinkPriority("medium");
setTaskLinkDueDate("");
setTaskLinkLabelIds([]);
} catch (error) {
const messageText = error instanceof Error ? error.message : "Не удалось загрузить задачи";
setTaskLinkError(messageText);
} finally {
setTaskLinkLoading(false);
}
}, []);
const handleTaskLinkBoardChange = useCallback((boardId: number | "") => {
setTaskLinkBoardId(boardId);
const nextBoard = taskLinkBoards.find((item) => item.id === boardId) || null;
setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || "");
setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
setTaskLinkLabelIds([]);
if (!nextBoard?.tasks?.length) {
setTaskLinkMode("create");
}
}, [taskLinkBoards]);
const handleCloseTaskLinkModal = useCallback(() => {
if (taskLinkLoading) return;
setTaskLinkMessage(null);
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
setTaskLinkMode("existing");
setTaskLinkNewTitle("");
setTaskLinkNewDescription("");
setTaskLinkDetailsOpen(false);
setTaskLinkColumnId("");
setTaskLinkAssigneeId("");
setTaskLinkPriority("medium");
setTaskLinkDueDate("");
setTaskLinkLabelIds([]);
}, [taskLinkLoading]);
const toggleTaskLinkLabel = useCallback((labelId: number) => {
setTaskLinkLabelIds((current) => (
current.includes(labelId)
? current.filter((id) => id !== labelId)
: [...current, labelId]
));
}, []);
const handleSaveTaskLink = useCallback(async () => {
if (!taskLinkMessage || !selectedTaskLinkBoard) return;
if (taskLinkMode === "existing" && !selectedTaskLinkTask) return;
if (taskLinkMode === "create" && !taskLinkNewTitle.trim()) return;
setTaskLinkLoading(true);
setTaskLinkError(null);
try {
let taskToLink = selectedTaskLinkTask;
if (taskLinkMode === "create") {
if (!selectedTaskLinkColumn) {
setTaskLinkError("На выбранной доске нет колонки для новой задачи");
return;
}
taskToLink = await apiClient.createTask({
board: selectedTaskLinkBoard.id,
column: selectedTaskLinkColumn.id,
title: taskLinkNewTitle.trim(),
description: taskLinkNewDescription.trim(),
assignee_id: taskLinkAssigneeId || null,
priority: taskLinkPriority,
due_date: taskLinkDueDate || null,
label_ids: taskLinkLabelIds,
});
}
if (!taskToLink) return;
const link = await apiClient.linkTaskMessage(taskToLink.id, taskLinkMessage.id);
const taskColumn = selectedTaskLinkBoard.columns.find((column) => column.id === taskToLink.column) || null;
cm.setMessages(prev => prev.map((message) => {
if (message.id !== taskLinkMessage.id) return message;
const existing = message.linked_tasks || [];
if (existing.some((task) => task.id === taskToLink.id)) return message;
return {
...message,
linked_tasks: [
...existing,
{
link_id: link.id,
id: taskToLink.id,
title: taskToLink.title,
board_id: selectedTaskLinkBoard.id,
board_name: selectedTaskLinkBoard.name,
column_id: taskToLink.column,
column_name: taskColumn?.name || taskToLink.column_name || "",
column_color: taskColumn?.color || "",
priority: taskToLink.priority,
priority_display: taskToLink.priority_display,
},
],
};
}));
setTaskLinkMessage(null);
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
setTaskLinkMode("existing");
setTaskLinkNewTitle("");
setTaskLinkNewDescription("");
setTaskLinkDetailsOpen(false);
setTaskLinkColumnId("");
setTaskLinkAssigneeId("");
setTaskLinkPriority("medium");
setTaskLinkDueDate("");
setTaskLinkLabelIds([]);
} catch (error) {
const messageText = error instanceof Error ? error.message : "Не удалось связать сообщение с задачей";
setTaskLinkError(messageText);
} finally {
setTaskLinkLoading(false);
}
}, [
cm,
selectedTaskLinkColumn,
selectedTaskLinkBoard,
selectedTaskLinkTask,
taskLinkAssigneeId,
taskLinkDueDate,
taskLinkLabelIds,
taskLinkMode,
taskLinkMessage,
taskLinkNewDescription,
taskLinkNewTitle,
taskLinkPriority,
]);
const handleAttachmentLoad = useCallback((id: number) => {
setBrokenMedia(prev => ({ ...prev, [id]: false }));
messagesViewportRef.current?.updateScrollPosition();
@ -1208,6 +1462,7 @@ function MessageDialogPageContent() {
onQuickReact={handleReact}
onOpenReactionPicker={(msg) => setReactionPickerForMessageId(msg.id)}
onShowAllReaders={() => setIsReadersModalOpen(true)}
onLinkToTask={handleOpenTaskLinkModal}
onRetry={handleRetryMessage}
isRetrying={Boolean(message.local_id && retryingLocalIds.has(message.local_id))}
retryDisabled={!canRetryMessage(message)}
@ -1305,6 +1560,261 @@ function MessageDialogPageContent() {
<MessageReadersModal isOpen={isReadersModalOpen} onClose={() => setIsReadersModalOpen(false)} readers={selectedActionReaders} />
) : null}
<Modal
isOpen={Boolean(taskLinkMessage)}
onClose={handleCloseTaskLinkModal}
title="Связать с задачей"
size="md"
closeOnClickOutside
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleCloseTaskLinkModal}
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
disabled={taskLinkLoading}
>
Отмена
</button>
<button
type="button"
onClick={() => void handleSaveTaskLink()}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-60"
disabled={
taskLinkLoading ||
(taskLinkMode === "existing"
? !selectedTaskLinkTask
: !taskLinkNewTitle.trim() || !selectedTaskLinkColumn)
}
>
{taskLinkLoading ? <Loader2 size={16} className="animate-spin" /> : <Link2 size={16} />}
{taskLinkMode === "create" ? "Создать и связать" : "Связать"}
</button>
</div>
)}
>
<div className="space-y-4">
{taskLinkMessage ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<p className="app-card-caption">Сообщение</p>
<p className="app-text-wrap mt-1 line-clamp-3 text-sm text-[var(--foreground)]">
{taskLinkMessage.content || "Сообщение без текста"}
</p>
</div>
) : null}
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Доска</span>
<select
value={taskLinkBoardId}
onChange={(event) => handleTaskLinkBoardChange(Number(event.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
>
<option value="">Выберите доску</option>
{taskLinkBoards.map((board) => (
<option key={board.id} value={board.id}>
{board.name}
</option>
))}
</select>
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setTaskLinkMode("existing")}
className={`rounded-xl border px-3 py-2 text-sm font-medium transition ${
taskLinkMode === "existing"
? "app-selected border-[var(--accent-primary)]"
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
}`}
disabled={taskLinkLoading || !selectedTaskLinkBoard || (selectedTaskLinkBoard.tasks || []).length === 0}
>
Выбрать задачу
</button>
<button
type="button"
onClick={() => setTaskLinkMode("create")}
className={`rounded-xl border px-3 py-2 text-sm font-medium transition ${
taskLinkMode === "create"
? "app-selected border-[var(--accent-primary)]"
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
}`}
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
Создать задачу
</button>
</div>
{taskLinkMode === "existing" ? (
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
<select
value={taskLinkTaskId}
onChange={(event) => setTaskLinkTaskId(Number(event.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
<option value="">Выберите задачу</option>
{(selectedTaskLinkBoard?.tasks || []).map((task: TaskCard) => (
<option key={task.id} value={task.id}>
{task.title}
</option>
))}
</select>
</label>
) : (
<div className="space-y-3">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название задачи</span>
<input
value={taskLinkNewTitle}
onChange={(event) => setTaskLinkNewTitle(event.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
placeholder="Название новой задачи"
/>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Описание</span>
<textarea
value={taskLinkNewDescription}
onChange={(event) => setTaskLinkNewDescription(event.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
rows={3}
/>
</label>
<div className="rounded-xl border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => setTaskLinkDetailsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left"
disabled={taskLinkLoading}
aria-expanded={taskLinkDetailsOpen}
>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Дополнительная информация
</span>
<span className="app-text-muted mt-0.5 block text-xs">
Исполнитель, срочность, срок, колонка и метки
</span>
</span>
<ChevronRight
size={16}
className={`app-text-muted shrink-0 transition-transform ${taskLinkDetailsOpen ? "rotate-90" : ""}`}
/>
</button>
{taskLinkDetailsOpen ? (
<div className="space-y-3 border-t border-[var(--border-subtle)] px-3 py-3">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Колонка</span>
<select
value={taskLinkColumnId}
onChange={(event) => setTaskLinkColumnId(Number(event.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
<option value="">Выберите колонку</option>
{(selectedTaskLinkBoard?.columns || [])
.filter((column) => !column.is_archived)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Исполнитель</span>
<select
value={taskLinkAssigneeId}
onChange={(event) => setTaskLinkAssigneeId(Number(event.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
>
<option value="">Не назначен</option>
{taskLinkEmployees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
<select
value={taskLinkPriority}
onChange={(event) => setTaskLinkPriority(event.target.value as TaskPriority)}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
>
{taskPriorityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Срок</span>
<input
type="date"
value={taskLinkDueDate}
onChange={(event) => setTaskLinkDueDate(event.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
/>
</label>
</div>
<div>
<span className="app-text-muted mb-2 block text-xs font-medium">Метки</span>
{(selectedTaskLinkBoard?.labels || []).length > 0 ? (
<div className="flex flex-wrap gap-2">
{(selectedTaskLinkBoard?.labels || []).map((label) => {
const selected = taskLinkLabelIds.includes(label.id);
return (
<button
key={label.id}
type="button"
onClick={() => toggleTaskLinkLabel(label.id)}
className={`inline-flex max-w-full items-center rounded-full border px-2.5 py-1 text-xs font-medium transition ${
selected ? "border-transparent text-white" : "border-[var(--border-subtle)] text-[var(--muted-foreground)]"
}`}
style={selected ? { backgroundColor: label.color || "#38bdf8" } : undefined}
disabled={taskLinkLoading}
>
<span className="truncate">{label.name}</span>
</button>
);
})}
</div>
) : (
<p className="app-text-muted text-xs">На этой доске меток нет</p>
)}
</div>
</div>
) : null}
</div>
</div>
)}
{taskLinkError ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
{taskLinkError}
</div>
) : null}
</div>
</Modal>
{reactionPickerForMessageId ? (
<ReactionPickerModal
onClose={() => setReactionPickerForMessageId(null)}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users } from "lucide-react";
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -56,6 +56,7 @@ const navItems = [
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: "Гости" },
// { href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки", autoReadOnNavigate: false },
{ href: "/tasks", label: "Доска", icon: Kanban, category: "Доска" },
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
// { href: "/finances", label: "Финансы", icon: Wallet },
];

View File

@ -1,17 +1,47 @@
"use client";
import { Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
import { Edit2, Trash2, Clock, Calendar, FileText, Users, Link2, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui";
import { resolveEventColor } from "@/lib/calendar-event-colors";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api";
import type { TaskBoard, TaskCard } from "@/types/api";
type EventRuleData = {
frequency?: string;
params?: {
byweekday?: unknown;
count?: number | string;
};
};
type EventDetails = Record<string, unknown> & {
id?: number;
title?: string;
description?: string;
start?: string | Date | null;
end?: string | Date | null;
calendar?: number | null;
color_event?: string | null;
can_edit?: boolean;
can_delete?: boolean;
rule?: number | null;
rule_data?: EventRuleData | null;
end_recurring_period?: string | Date | null;
};
type EventParticipant = {
id: number | string;
user_name?: string;
distinction?: string;
};
interface ViewEventDetailsModalProps {
isOpen: boolean;
onClose: () => void;
event: any | null;
event: EventDetails | null;
onEdit: () => void;
onDelete: () => void;
showParticipants?: boolean;
@ -40,9 +70,13 @@ const WEEKDAY_LABELS: Record<number, string> = {
};
// Нормализация byweekday в массив чисел
function normalizeByweekday(byweekday: any): number[] {
function normalizeByweekday(byweekday: unknown): number[] {
if (!byweekday) return [];
if (Array.isArray(byweekday)) return byweekday;
if (Array.isArray(byweekday)) {
return byweekday
.map((day) => Number(day))
.filter((day) => Number.isFinite(day));
}
if (typeof byweekday === 'string') {
return byweekday.split(',').map(d => parseInt(d.trim(), 10)).filter(n => !isNaN(n));
}
@ -58,78 +92,186 @@ export function ViewEventDetailsModal({
onDelete,
showParticipants = false,
}: ViewEventDetailsModalProps) {
const [participants, setParticipants] = useState<any[]>([]);
const [participants, setParticipants] = useState<EventParticipant[]>([]);
const [loadingParticipants, setLoadingParticipants] = useState(false);
const [taskLinkOpen, setTaskLinkOpen] = useState(false);
const [taskLinkBoards, setTaskLinkBoards] = useState<TaskBoard[]>([]);
const [taskLinkBoardId, setTaskLinkBoardId] = useState<number | "">("");
const [taskLinkTaskId, setTaskLinkTaskId] = useState<number | "">("");
const [taskLinkMode, setTaskLinkMode] = useState<"existing" | "create">("existing");
const [taskLinkTitle, setTaskLinkTitle] = useState("");
const [taskLinkDescription, setTaskLinkDescription] = useState("");
const [taskLinkLoading, setTaskLinkLoading] = useState(false);
const [taskLinkError, setTaskLinkError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && event?.id && showParticipants) {
loadParticipants();
} else {
setParticipants([]);
}
}, [isOpen, event?.id, showParticipants]);
const loadParticipants = async () => {
const loadParticipants = useCallback(async () => {
if (!event?.id) return;
try {
setLoadingParticipants(true);
const result = await apiClient.getEventParticipants(event.id);
const participantsList = Array.isArray(result) ? result : (result?.results || []);
const participantsList = (Array.isArray(result) ? result : (result?.results || [])) as EventParticipant[];
setParticipants(participantsList);
} catch (error) {
console.error("Failed to load participants:", error);
} finally {
setLoadingParticipants(false);
}
};
}, [event?.id]);
useEffect(() => {
if (isOpen && event?.id && showParticipants) {
void loadParticipants();
} else {
setParticipants([]);
}
}, [isOpen, event?.id, showParticipants, loadParticipants]);
if (!isOpen || !event) return null;
const formatDateTime = (dateStr: string) => {
const formatDateTime = (dateStr?: string | Date | null) => {
if (!dateStr) return "";
const date = new Date(dateStr);
return format(date, "d MMMM yyyy, HH:mm", { locale: ru });
};
const formatDate = (dateStr: string) => {
const formatDate = (dateStr?: string | Date | null) => {
if (!dateStr) return "";
const date = new Date(dateStr);
return format(date, "d MMMM yyyy", { locale: ru });
};
const capitalizedStart = formatDateTime(event.start).charAt(0).toUpperCase() + formatDateTime(event.start).slice(1);
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
const formattedStart = formatDateTime(event.start);
const formattedEnd = formatDateTime(event.end);
const capitalizedStart = formattedStart.charAt(0).toUpperCase() + formattedStart.slice(1);
const capitalizedEnd = formattedEnd.charAt(0).toUpperCase() + formattedEnd.slice(1);
const canEdit = event.can_edit !== false;
const canDelete = event.can_delete !== false;
const ruleFrequency = typeof event.rule_data?.frequency === "string"
? event.rule_data.frequency
: "";
const ruleFrequencyLabel = ruleFrequency
? FREQUENCY_LABELS[ruleFrequency] || ruleFrequency
: "";
const ruleParams = event.rule_data?.params;
const selectedTaskLinkBoard = taskLinkBoards.find((board) => board.id === taskLinkBoardId) || null;
const selectedTaskLinkTask = (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null;
const selectedTaskLinkColumn = (selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) || null;
const openTaskLinkModal = async () => {
if (!event?.id) return;
setTaskLinkOpen(true);
setTaskLinkError(null);
setTaskLinkLoading(true);
setTaskLinkTitle(event.title || "Задача по событию");
setTaskLinkDescription(event.description || "");
try {
const response = await apiClient.getTaskBoards();
const boards = (response.results || response || []) as TaskBoard[];
setTaskLinkBoards(boards);
const firstBoardWithTasks = boards.find((board) => (board.tasks || []).length > 0) || boards[0] || null;
setTaskLinkBoardId(firstBoardWithTasks?.id || "");
setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || "");
setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create");
} catch (error) {
setTaskLinkError(error instanceof Error ? error.message : "Не удалось загрузить задачи");
} finally {
setTaskLinkLoading(false);
}
};
const closeTaskLinkModal = () => {
if (taskLinkLoading) return;
setTaskLinkOpen(false);
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
setTaskLinkMode("existing");
setTaskLinkTitle("");
setTaskLinkDescription("");
};
const handleTaskLinkBoardChange = (boardId: number | "") => {
setTaskLinkBoardId(boardId);
const nextBoard = taskLinkBoards.find((board) => board.id === boardId) || null;
setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || "");
if (!nextBoard?.tasks?.length) {
setTaskLinkMode("create");
}
};
const saveTaskLink = async () => {
if (!event?.id || !selectedTaskLinkBoard) return;
if (taskLinkMode === "existing" && !selectedTaskLinkTask) return;
if (taskLinkMode === "create" && !taskLinkTitle.trim()) return;
setTaskLinkLoading(true);
setTaskLinkError(null);
try {
let taskToLink = selectedTaskLinkTask;
if (taskLinkMode === "create") {
if (!selectedTaskLinkColumn) {
setTaskLinkError("На выбранной доске нет колонки для новой задачи");
return;
}
taskToLink = await apiClient.createTask({
board: selectedTaskLinkBoard.id,
column: selectedTaskLinkColumn.id,
title: taskLinkTitle.trim(),
description: taskLinkDescription.trim(),
priority: "medium",
});
}
if (!taskToLink) return;
await apiClient.linkTaskCalendarEvent(taskToLink.id, event.id);
closeTaskLinkModal();
} catch (error) {
setTaskLinkError(error instanceof Error ? error.message : "Не удалось связать событие с задачей");
} finally {
setTaskLinkLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={event.title}
size="sm"
footer={canEdit || canDelete ? (
<div className="flex gap-2">
{canEdit ? (
<button
onClick={onEdit}
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Edit2 size={16} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
onClick={onDelete}
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
) : null}
</div>
) : undefined}
>
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={event.title}
size="sm"
footer={canEdit || canDelete || event.id ? (
<div className="flex flex-wrap gap-2">
{event.id ? (
<button
onClick={() => void openTaskLinkModal()}
className="app-action-secondary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Link2 size={16} />
Связать с задачей
</button>
) : null}
{canEdit ? (
<button
onClick={onEdit}
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Edit2 size={16} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
onClick={onDelete}
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
) : null}
</div>
) : undefined}
>
<div className="space-y-4">
{/* Event meta */}
<div className="flex items-center gap-2">
@ -169,18 +311,18 @@ export function ViewEventDetailsModal({
</div>
<div className="app-accent-text space-y-1 text-sm">
<div>
Частота: <span className="font-medium">{FREQUENCY_LABELS[event.rule_data.frequency] || event.rule_data.frequency}</span>
Частота: <span className="font-medium">{ruleFrequencyLabel}</span>
</div>
{event.rule_data.params?.byweekday && (
{Boolean(ruleParams?.byweekday) && (
<div>
Дни недели: <span className="font-medium">
{normalizeByweekday(event.rule_data.params.byweekday).map((day: number) => WEEKDAY_LABELS[day]).join(", ")}
{normalizeByweekday(ruleParams?.byweekday).map((day: number) => WEEKDAY_LABELS[day]).join(", ")}
</span>
</div>
)}
{event.rule_data.params?.count && (
{Boolean(ruleParams?.count) && (
<div>
Повторений: <span className="font-medium">{event.rule_data.params.count}</span>
Повторений: <span className="font-medium">{String(ruleParams?.count)}</span>
</div>
)}
{event.end_recurring_period && (
@ -218,7 +360,7 @@ export function ViewEventDetailsModal({
<p className="app-text-muted text-xs">Загрузка...</p>
) : participants.length > 0 ? (
<div className="space-y-1.5">
{participants.map((participant: any) => (
{participants.map((participant) => (
<div
key={participant.id}
className="app-surface-muted flex items-center gap-2 rounded-lg px-2.5 py-2"
@ -247,6 +389,142 @@ export function ViewEventDetailsModal({
)}
</div>
</Modal>
</Modal>
<Modal
isOpen={taskLinkOpen}
onClose={closeTaskLinkModal}
title="Связать с задачей"
size="md"
closeOnClickOutside
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeTaskLinkModal}
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
disabled={taskLinkLoading}
>
Отмена
</button>
<button
type="button"
onClick={() => void saveTaskLink()}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-60"
disabled={
taskLinkLoading ||
(taskLinkMode === "existing"
? !selectedTaskLinkTask
: !taskLinkTitle.trim() || !selectedTaskLinkColumn)
}
>
{taskLinkLoading ? <Loader2 size={16} className="animate-spin" /> : <Link2 size={16} />}
{taskLinkMode === "create" ? "Создать и связать" : "Связать"}
</button>
</div>
)}
>
<div className="space-y-4">
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<p className="app-card-caption">Событие</p>
<p className="app-text-wrap mt-1 line-clamp-3 text-sm text-[var(--foreground)]">
{event.title}
</p>
</div>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Доска</span>
<select
value={taskLinkBoardId}
onChange={(selectEvent) => handleTaskLinkBoardChange(Number(selectEvent.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
>
<option value="">Выберите доску</option>
{taskLinkBoards.map((board) => (
<option key={board.id} value={board.id}>
{board.name}
</option>
))}
</select>
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setTaskLinkMode("existing")}
className={`rounded-xl border px-3 py-2 text-sm font-medium transition ${
taskLinkMode === "existing"
? "app-selected border-[var(--accent-primary)]"
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
}`}
disabled={taskLinkLoading || !selectedTaskLinkBoard || (selectedTaskLinkBoard.tasks || []).length === 0}
>
Выбрать задачу
</button>
<button
type="button"
onClick={() => setTaskLinkMode("create")}
className={`rounded-xl border px-3 py-2 text-sm font-medium transition ${
taskLinkMode === "create"
? "app-selected border-[var(--accent-primary)]"
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
}`}
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
Создать задачу
</button>
</div>
{taskLinkMode === "existing" ? (
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
<select
value={taskLinkTaskId}
onChange={(selectEvent) => setTaskLinkTaskId(Number(selectEvent.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
<option value="">Выберите задачу</option>
{(selectedTaskLinkBoard?.tasks || []).map((task: TaskCard) => (
<option key={task.id} value={task.id}>
{task.title}
</option>
))}
</select>
</label>
) : (
<div className="space-y-3">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название задачи</span>
<input
value={taskLinkTitle}
onChange={(inputEvent) => setTaskLinkTitle(inputEvent.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
placeholder="Название новой задачи"
/>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Описание</span>
<textarea
value={taskLinkDescription}
onChange={(inputEvent) => setTaskLinkDescription(inputEvent.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
rows={3}
/>
</label>
</div>
)}
{taskLinkError ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
{taskLinkError}
</div>
) : null}
</div>
</Modal>
</>
);
}

View File

@ -17,6 +17,7 @@ import {
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import MessageActionsMenu from "@/components/messages/MessageActionsMenu";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { resolveMediaUrl } from "@/lib/url";
import {
formatFileSize,
@ -74,6 +75,7 @@ type ChatMessageItemProps = {
onQuickReact: (message: Message, emoji: string) => void;
onOpenReactionPicker: (message: Message) => void;
onShowAllReaders?: (message: Message) => void;
onLinkToTask?: (message: Message) => void;
onRetry?: (message: Message) => void;
isRetrying?: boolean;
retryDisabled?: boolean;
@ -153,6 +155,7 @@ export default function ChatMessageItem({
onQuickReact,
onOpenReactionPicker,
onShowAllReaders,
onLinkToTask,
onRetry,
isRetrying = false,
retryDisabled = false,
@ -173,6 +176,7 @@ export default function ChatMessageItem({
const hasActions = canReply || canManage;
const isRead = Boolean(message.is_read);
const sendState = message.send_state;
const linkedTasks = message.linked_tasks || [];
const reactionUsers = useMemo(() => {
const summary = message.reactions_summary || {};
return Object.fromEntries(
@ -454,6 +458,27 @@ export default function ChatMessageItem({
</div>
) : null}
{linkedTasks.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{linkedTasks.slice(0, 2).map((task) => (
<TaskLinkPill
key={task.link_id}
task={task}
tone={isMine ? "onAccent" : "default"}
/>
))}
{linkedTasks.length > 2 ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
isMine ? "bg-white/18 text-white" : "app-pill"
}`}
>
+{linkedTasks.length - 2}
</span>
) : null}
</div>
) : null}
<div className={`mt-1 flex items-center ${isMine ? "justify-end gap-1.5" : "justify-end"}`}>
{isMine && sendState === "failed" && onRetry ? (
<button
@ -530,6 +555,7 @@ export default function ChatMessageItem({
onQuickReact={(emoji) => onQuickReact(message, emoji)}
onOpenReactionPicker={() => onOpenReactionPicker(message)}
onShowAllReaders={onShowAllReaders ? () => onShowAllReaders(message) : undefined}
onLinkToTask={onLinkToTask ? () => onLinkToTask(message) : undefined}
onReply={() => onReply(message)}
onEdit={() => onEdit(message)}
onDelete={() => onDelete(message)}

View File

@ -1,6 +1,6 @@
"use client";
import { CheckCheck, Pencil, Reply, Smile, Trash2 } from "lucide-react";
import { CheckCheck, Link2, Pencil, Reply, Smile, Trash2 } from "lucide-react";
import type { Message } from "@/types/api";
@ -14,6 +14,7 @@ type MessageActionsMenuProps = {
onQuickReact: (emoji: string) => void;
onOpenReactionPicker: () => void;
onShowAllReaders?: () => void;
onLinkToTask?: () => void;
onReply: () => void;
onEdit: () => void;
onDelete: () => void;
@ -29,6 +30,7 @@ export default function MessageActionsMenu({
onQuickReact,
onOpenReactionPicker,
onShowAllReaders,
onLinkToTask,
onReply,
onEdit,
onDelete,
@ -102,6 +104,17 @@ export default function MessageActionsMenu({
</button>
) : null}
{canReply && onLinkToTask ? (
<button
type="button"
onClick={onLinkToTask}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Link2 size={13} className="app-text-muted" />
Связать с задачей
</button>
) : null}
{canManage ? (
<button
type="button"

View File

@ -0,0 +1,104 @@
import { Kanban } from "lucide-react";
import Link from "next/link";
import type { CSSProperties } from "react";
import type { MessageLinkedTask, TaskPriority } from "@/types/api";
type TaskLinkPillTask = Pick<
MessageLinkedTask,
| "id"
| "title"
| "board_id"
| "board_name"
| "column_color"
| "priority"
| "priority_display"
>;
type TaskLinkPillProps = {
task: TaskLinkPillTask;
tone?: "default" | "onAccent";
maxTitleClassName?: string;
className?: string;
};
const fallbackColumnColor = "#38bdf8";
const priorityColors: Record<TaskPriority, string> = {
low: "#22c55e",
medium: "#38bdf8",
high: "#f59e0b",
critical: "#ef4444",
};
function normalizeHexColor(value?: string | null): string {
const color = String(value || "").trim();
if (/^#[0-9a-f]{6}$/i.test(color)) return color;
if (/^#[0-9a-f]{3}$/i.test(color)) {
const [, r, g, b] = color;
return `#${r}${r}${g}${g}${b}${b}`;
}
return fallbackColumnColor;
}
function hexToRgb(color: string): { red: number; green: number; blue: number } {
const normalized = normalizeHexColor(color).slice(1);
return {
red: parseInt(normalized.slice(0, 2), 16),
green: parseInt(normalized.slice(2, 4), 16),
blue: parseInt(normalized.slice(4, 6), 16),
};
}
function getReadableTextColor(color: string): string {
const { red, green, blue } = hexToRgb(color);
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
return luminance > 0.62 ? "#07111f" : "#ffffff";
}
export default function TaskLinkPill({
task,
tone = "default",
maxTitleClassName = "max-w-44",
className = "",
}: TaskLinkPillProps) {
const columnColor = normalizeHexColor(task.column_color);
const priorityColor = task.priority ? priorityColors[task.priority] : priorityColors.medium;
const textColor = getReadableTextColor(columnColor);
const isLightColumn = textColor !== "#ffffff";
const isOnAccent = tone === "onAccent";
const title = `${task.board_name ? `${task.board_name}: ` : ""}${task.title}${
task.priority_display ? `, ${task.priority_display}` : ""
}`;
const style: CSSProperties = {
backgroundColor: columnColor,
borderColor: priorityColor,
boxShadow: isOnAccent ? "inset 0 1px 0 rgba(255, 255, 255, 0.18)" : undefined,
color: textColor,
};
const iconWrapStyle: CSSProperties = {
backgroundColor: isLightColumn ? "rgba(255, 255, 255, 0.72)" : "rgba(255, 255, 255, 0.18)",
};
const href = `/tasks?board=${task.board_id}&task=${task.id}`;
return (
<Link
href={href}
className={`inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium shadow-sm transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45 ${className}`}
style={style}
title={title}
>
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full"
style={iconWrapStyle}
>
<Kanban
size={10}
style={{ color: textColor }}
aria-hidden="true"
/>
</span>
<span className={`${maxTitleClassName} truncate`}>{task.title}</span>
</Link>
);
}

View File

@ -15,6 +15,7 @@ import { createEquipmentApi } from './equipment';
import { createProcurementApi } from './procurement';
import { createAttendanceApi } from './attendance';
import { createGuestsApi } from './guests';
import { createTasksApi } from './tasks';
const base = new ApiClientBase();
const req = base.request.bind(base);
@ -34,6 +35,7 @@ export const apiClient = Object.assign(base, {
...createProcurementApi(req),
...createAttendanceApi(req, tok),
...createGuestsApi(req),
...createTasksApi(req),
});
export default apiClient;

View File

@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
TaskBoard,
TaskCard,
TaskColumn,
TaskActivity,
TaskLabel,
TaskLinkedCalendarEvent,
TaskLinkedMessage,
} from "@/types/api";
import { buildQuery, type RequestFn } from "./utils";
export function createTasksApi(request: RequestFn) {
return {
getTaskBoards: (params?: Record<string, string | number>) =>
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
getDefaultTaskBoard: (): Promise<TaskBoard> =>
request("/api/v1/tasks/boards/default/"),
getTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/`),
createTaskBoard: (data: Record<string, any>): Promise<TaskBoard> =>
request("/api/v1/tasks/boards/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskBoard: (id: number, data: Record<string, any>): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
}),
deleteTaskBoard: (id: number): Promise<void> =>
request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }),
createTaskColumn: (data: Record<string, any>): Promise<TaskColumn> =>
request("/api/v1/tasks/columns/", {
method: "POST",
body: JSON.stringify(data),
}),
createTaskLabel: (data: Record<string, any>): Promise<TaskLabel> =>
request("/api/v1/tasks/labels/", {
method: "POST",
body: JSON.stringify(data),
}),
createTask: (data: Record<string, any>): Promise<TaskCard> =>
request("/api/v1/tasks/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTask: (id: number, data: Record<string, any>): Promise<TaskCard> =>
request(`/api/v1/tasks/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
}),
moveTask: (
id: number,
data: { column: number; position?: number },
): Promise<TaskCard> =>
request(`/api/v1/tasks/${id}/move/`, {
method: "POST",
body: JSON.stringify(data),
}),
getTaskActivity: (id: number): Promise<TaskActivity[]> =>
request(`/api/v1/tasks/${id}/activity/`),
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
request(`/api/v1/tasks/${id}/linked-messages/`),
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
request(`/api/v1/tasks/${id}/linked-messages/`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
}),
unlinkTaskMessage: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/linked-messages/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedEvents: (id: number): Promise<TaskLinkedCalendarEvent[]> =>
request(`/api/v1/tasks/${id}/linked-events/`),
linkTaskCalendarEvent: (id: number, eventId: number): Promise<TaskLinkedCalendarEvent> =>
request(`/api/v1/tasks/${id}/linked-events/`, {
method: "POST",
body: JSON.stringify({ event_id: eventId }),
}),
unlinkTaskCalendarEvent: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/linked-events/${linkId}/`, {
method: "DELETE",
}),
deleteTask: (id: number): Promise<void> =>
request(`/api/v1/tasks/${id}/`, { method: "DELETE" }),
};
}

View File

@ -140,6 +140,114 @@ export interface LoginResponse {
refresh: string;
}
export type TaskPriority = "low" | "medium" | "high" | "critical";
export interface TaskColumn {
id: number;
board: number;
name: string;
position: number;
color?: string;
is_done: boolean;
is_archived: boolean;
tasks_count?: number;
created_at: string;
updated_at: string;
}
export interface TaskLabel {
id: number;
board: number;
name: string;
color: string;
}
export interface TaskCard {
id: number;
board: number;
column: number;
column_name?: string;
title: string;
description?: string;
created_by?: User;
assignee?: User | null;
labels?: TaskLabel[];
priority: TaskPriority;
priority_display?: string;
due_date?: string | null;
position: number;
completed_at?: string | null;
linked_messages_count?: number;
linked_events_count?: number;
linked_objects_count?: number;
created_at: string;
updated_at: string;
}
export interface TaskBoard {
id: number;
name: string;
description?: string;
created_by?: User;
members?: number[];
member_details?: User[];
departments?: number[];
department_details?: Department[];
is_archived: boolean;
columns: TaskColumn[];
labels: TaskLabel[];
tasks: TaskCard[];
tasks_count?: number;
created_at: string;
updated_at: string;
}
export interface TaskLinkedMessage {
id: number;
kind: "message";
message_id: number;
message: Message | null;
can_open?: boolean;
object_url?: string | null;
created_by?: User;
created_at: string;
}
export interface TaskLinkedCalendarEvent {
id: number;
kind: "calendar_event";
event_id: number;
event: CalendarEvent | null;
can_open?: boolean;
object_url?: string | null;
created_by?: User;
created_at: string;
}
export interface TaskActivity {
id: number;
action: "created" | "updated" | "moved" | "linked" | "unlinked";
action_display?: string;
actor?: User | null;
object_kind?: "message" | "calendar_event" | "";
object_id?: number | null;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface MessageLinkedTask {
link_id: number;
id: number;
title: string;
board_id: number;
board_name: string;
column_id?: number;
column_name?: string;
column_color?: string;
priority?: TaskPriority;
priority_display?: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
@ -916,6 +1024,7 @@ export interface Message {
avatar?: string | null;
}>;
}>;
linked_tasks?: MessageLinkedTask[];
}
export interface MessageReplyPreview {