feat: add task board module
This commit is contained in:
parent
0dbd817c60
commit
cb5b520e01
1
backend/api/v1/tasks/__init__.py
Normal file
1
backend/api/v1/tasks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
423
backend/api/v1/tasks/serializers.py
Normal file
423
backend/api/v1/tasks/serializers.py
Normal 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)
|
||||
21
backend/api/v1/tasks/urls.py
Normal file
21
backend/api/v1/tasks/urls.py
Normal 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)),
|
||||
]
|
||||
632
backend/api/v1/tasks/views.py
Normal file
632
backend/api/v1/tasks/views.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
"""Мягкое удаление сообщения"""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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", {}),
|
||||
}
|
||||
)
|
||||
|
||||
1
backend/tasks/__init__.py
Normal file
1
backend/tasks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
65
backend/tasks/access.py
Normal file
65
backend/tasks/access.py
Normal 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
75
backend/tasks/admin.py
Normal 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
7
backend/tasks/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TasksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "tasks"
|
||||
verbose_name = "Задачи"
|
||||
110
backend/tasks/migrations/0001_initial.py
Normal file
110
backend/tasks/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
107
backend/tasks/migrations/0002_tasklinkedobject.py
Normal file
107
backend/tasks/migrations/0002_tasklinkedobject.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
18
backend/tasks/migrations/0003_alter_tasklinkedobject_kind.py
Normal file
18
backend/tasks/migrations/0003_alter_tasklinkedobject_kind.py
Normal 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='Тип связи'),
|
||||
),
|
||||
]
|
||||
35
backend/tasks/migrations/0004_taskactivity.py
Normal file
35
backend/tasks/migrations/0004_taskactivity.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/tasks/migrations/__init__.py
Normal file
1
backend/tasks/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
275
backend/tasks/models.py
Normal file
275
backend/tasks/models.py
Normal 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
126
backend/tasks/realtime.py
Normal 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)
|
||||
521
backend/tests/api/v1/tasks/test_tasks_api.py
Normal file
521
backend/tests/api/v1/tasks/test_tasks_api.py
Normal 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"] == []
|
||||
@ -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:
|
||||
"""Тесты управления активным чатом."""
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
2443
frontend/src/app/tasks/page.tsx
Normal file
2443
frontend/src/app/tasks/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 },
|
||||
];
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
|
||||
104
frontend/src/components/tasks/TaskLinkPill.tsx
Normal file
104
frontend/src/components/tasks/TaskLinkPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
88
frontend/src/lib/api/tasks.ts
Normal file
88
frontend/src/lib/api/tasks.ts
Normal 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" }),
|
||||
};
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user