feat(tasks): expand task resources and global labels
This commit is contained in:
parent
bfb175088c
commit
45866bb69c
@ -94,11 +94,18 @@ def validate_board_access(serializer, board):
|
||||
class TaskLabelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TaskLabel
|
||||
fields = ["id", "board", "name", "color"]
|
||||
fields = ["id", "name", "color"]
|
||||
read_only_fields = ["id"]
|
||||
|
||||
def validate_board(self, board):
|
||||
return validate_board_access(self, board)
|
||||
def validate_name(self, value):
|
||||
name = value.strip()
|
||||
duplicate = TaskLabel.objects.only("id", "name")
|
||||
if self.instance:
|
||||
duplicate = duplicate.exclude(pk=self.instance.pk)
|
||||
normalized_name = name.casefold()
|
||||
if any(label.name.casefold() == normalized_name for label in duplicate):
|
||||
raise serializers.ValidationError("Метка с таким названием уже существует.")
|
||||
return name
|
||||
|
||||
|
||||
class TaskColumnSerializer(serializers.ModelSerializer):
|
||||
@ -532,7 +539,6 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
if "row" in attrs
|
||||
else getattr(self.instance, "row", None)
|
||||
)
|
||||
labels = attrs.get("labels")
|
||||
participants = attrs.get("participants")
|
||||
|
||||
if board and not user_can_access_task_board(user, board):
|
||||
@ -557,15 +563,6 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
{"row": "Дорожка должна принадлежать основной колонке задачи."}
|
||||
)
|
||||
|
||||
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": "Метки должны принадлежать выбранной доске."}
|
||||
)
|
||||
|
||||
if participants is not None and board:
|
||||
inaccessible_participants = [
|
||||
participant.id
|
||||
@ -658,8 +655,8 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
columns = TaskColumnSerializer(many=True, read_only=True)
|
||||
column_rows = serializers.SerializerMethodField()
|
||||
labels = TaskLabelSerializer(many=True, read_only=True)
|
||||
tasks = TaskSerializer(many=True, read_only=True)
|
||||
labels = serializers.SerializerMethodField()
|
||||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||||
is_default_for_current_user = serializers.SerializerMethodField()
|
||||
group_id = serializers.IntegerField(
|
||||
@ -696,8 +693,8 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
"is_archived",
|
||||
"columns",
|
||||
"column_rows",
|
||||
"labels",
|
||||
"tasks",
|
||||
"labels",
|
||||
"tasks_count",
|
||||
"is_default_for_current_user",
|
||||
"group_id",
|
||||
@ -708,6 +705,14 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@extend_schema_field(TaskLabelSerializer(many=True))
|
||||
def get_labels(self, obj):
|
||||
labels = getattr(self, "_global_labels_cache", None)
|
||||
if labels is None:
|
||||
labels = list(TaskLabel.objects.order_by("name", "id"))
|
||||
self._global_labels_cache = labels
|
||||
return TaskLabelSerializer(labels, many=True).data
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
access_scope = attrs.get("access_scope")
|
||||
|
||||
@ -255,7 +255,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
),
|
||||
).order_by("parent_id", "position", "id"),
|
||||
),
|
||||
"labels",
|
||||
"tasks__created_by",
|
||||
"tasks__assignee",
|
||||
"tasks__participants",
|
||||
@ -877,33 +876,27 @@ class TaskLabelViewSet(viewsets.ModelViewSet):
|
||||
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")
|
||||
return TaskLabel.objects.order_by("name", "id")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
label = serializer.save()
|
||||
send_task_board_update(label.board, "created", "label", label.id)
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
board_ids = list(
|
||||
serializer.instance.tasks.values_list("board_id", flat=True).distinct()
|
||||
)
|
||||
label = serializer.save()
|
||||
send_task_board_update(label.board, "updated", "label", label.id)
|
||||
for board in TaskBoard.objects.filter(id__in=board_ids):
|
||||
send_task_board_update(board, "updated", "label", label.id)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
board = instance.board
|
||||
board_ids = list(
|
||||
instance.tasks.values_list("board_id", flat=True).distinct()
|
||||
)
|
||||
label_id = instance.id
|
||||
instance.delete()
|
||||
send_task_board_update(board, "deleted", "label", label_id)
|
||||
for board in TaskBoard.objects.filter(id__in=board_ids):
|
||||
send_task_board_update(board, "deleted", "label", label_id)
|
||||
|
||||
|
||||
class TaskViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@ -23,12 +23,6 @@ class TaskColumnInline(admin.TabularInline):
|
||||
fields = ["parent", "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 = [
|
||||
@ -41,7 +35,7 @@ class TaskBoardAdmin(admin.ModelAdmin):
|
||||
list_filter = ["access_scope", "is_archived", "created_at"]
|
||||
search_fields = ["name", "description"]
|
||||
filter_horizontal = ["members", "departments"]
|
||||
inlines = [TaskColumnInline, TaskLabelInline]
|
||||
inlines = [TaskColumnInline]
|
||||
|
||||
|
||||
@admin.register(TaskBoardGroup)
|
||||
@ -90,9 +84,8 @@ class TaskColumnRowAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(TaskLabel)
|
||||
class TaskLabelAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "board", "color"]
|
||||
list_filter = ["board"]
|
||||
search_fields = ["name", "board__name"]
|
||||
list_display = ["name", "color"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
@admin.register(Task)
|
||||
|
||||
58
backend/tasks/migrations/0030_global_task_labels.py
Normal file
58
backend/tasks/migrations/0030_global_task_labels.py
Normal file
@ -0,0 +1,58 @@
|
||||
from django.db import migrations, models
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
|
||||
def merge_duplicate_labels(apps, schema_editor):
|
||||
TaskLabel = apps.get_model("tasks", "TaskLabel")
|
||||
Task = apps.get_model("tasks", "Task")
|
||||
through = Task._meta.get_field("labels").remote_field.through
|
||||
keepers = {}
|
||||
|
||||
for label in TaskLabel.objects.order_by("id"):
|
||||
normalized_name = label.name.strip()
|
||||
if normalized_name != label.name:
|
||||
label.name = normalized_name
|
||||
label.save(update_fields=["name"])
|
||||
key = normalized_name.casefold()
|
||||
keeper = keepers.get(key)
|
||||
if keeper is None:
|
||||
keepers[key] = label
|
||||
continue
|
||||
|
||||
task_ids = through.objects.filter(tasklabel_id=label.id).values_list(
|
||||
"task_id",
|
||||
flat=True,
|
||||
)
|
||||
through.objects.bulk_create(
|
||||
[
|
||||
through(task_id=task_id, tasklabel_id=keeper.id)
|
||||
for task_id in task_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
label.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0029_taskattachment_is_cover"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="tasklabel",
|
||||
name="uniq_task_label_board_name",
|
||||
),
|
||||
migrations.RunPython(merge_duplicate_labels, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="tasklabel",
|
||||
name="board",
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="tasklabel",
|
||||
constraint=models.UniqueConstraint(
|
||||
Lower("name"),
|
||||
name="uniq_task_label_name_ci",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -2,6 +2,7 @@ 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.db.models.functions import Lower
|
||||
from django.utils import timezone
|
||||
from employees.models import Department
|
||||
|
||||
@ -258,12 +259,6 @@ class TaskColumnRow(models.Model):
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@ -273,8 +268,8 @@ class TaskLabel(models.Model):
|
||||
ordering = ["name", "id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["board", "name"],
|
||||
name="uniq_task_label_board_name",
|
||||
Lower("name"),
|
||||
name="uniq_task_label_name_ci",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ from tasks.models import (
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskExternalLink,
|
||||
TaskLabel,
|
||||
TaskUserSettings,
|
||||
)
|
||||
from tasks.realtime import get_task_board_recipient_ids
|
||||
@ -491,6 +492,71 @@ def test_board_view_state_is_persisted_per_user(api_client, user):
|
||||
}
|
||||
|
||||
|
||||
def test_task_labels_are_global_and_can_be_used_on_different_boards(
|
||||
api_client,
|
||||
user,
|
||||
):
|
||||
first_board = TaskBoard.objects.create(name="Первая доска", created_by=user)
|
||||
second_board = TaskBoard.objects.create(name="Вторая доска", created_by=user)
|
||||
first_column = TaskColumn.objects.create(
|
||||
board=first_board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
)
|
||||
second_column = TaskColumn.objects.create(
|
||||
board=second_board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
)
|
||||
labels_url = reverse("api:v1:tasks:task-label-list")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
create_response = api_client.post(
|
||||
labels_url,
|
||||
{"name": "Срочно", "color": "#ef4444"},
|
||||
format="json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
assert "board" not in create_response.data
|
||||
label_id = create_response.data["id"]
|
||||
|
||||
duplicate_response = api_client.post(
|
||||
labels_url,
|
||||
{"name": "срочно", "color": "#38bdf8"},
|
||||
format="json",
|
||||
)
|
||||
assert duplicate_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert TaskLabel.objects.count() == 1
|
||||
|
||||
for board, column, title in [
|
||||
(first_board, first_column, "Первая задача"),
|
||||
(second_board, second_column, "Вторая задача"),
|
||||
]:
|
||||
task_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-list"),
|
||||
{
|
||||
"board": board.id,
|
||||
"column": column.id,
|
||||
"title": title,
|
||||
"label_ids": [label_id],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert task_response.status_code == status.HTTP_201_CREATED
|
||||
assert task_response.data["labels"][0]["id"] == label_id
|
||||
|
||||
board_response = api_client.get(
|
||||
reverse(
|
||||
"api:v1:tasks:task-board-detail",
|
||||
kwargs={"pk": first_board.id},
|
||||
)
|
||||
)
|
||||
assert board_response.status_code == status.HTTP_200_OK
|
||||
assert board_response.data["labels"] == [
|
||||
{"id": label_id, "name": "Срочно", "color": "#ef4444"}
|
||||
]
|
||||
|
||||
|
||||
def test_board_view_state_rejects_objects_from_another_board(api_client, user):
|
||||
board = TaskBoard.objects.create(name="Первая доска", created_by=user)
|
||||
other_board = TaskBoard.objects.create(name="Другая доска", created_by=user)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -171,6 +171,8 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getTaskLabels: (): Promise<TaskLabel[] | { results: TaskLabel[] }> =>
|
||||
request("/api/v1/tasks/labels/"),
|
||||
createTask: (data: Record<string, any>): Promise<TaskCard> =>
|
||||
request("/api/v1/tasks/", {
|
||||
method: "POST",
|
||||
|
||||
@ -201,7 +201,6 @@ export interface TaskColumnRow {
|
||||
|
||||
export interface TaskLabel {
|
||||
id: number;
|
||||
board: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
@ -267,8 +266,8 @@ export interface TaskBoard {
|
||||
is_archived: boolean;
|
||||
columns: TaskColumn[];
|
||||
column_rows: TaskColumnRow[];
|
||||
labels: TaskLabel[];
|
||||
tasks: TaskCard[];
|
||||
labels: TaskLabel[];
|
||||
tasks_count?: number;
|
||||
is_default_for_current_user?: boolean;
|
||||
group_id?: number | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user