Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-06 07:25:47 +03:00
commit fca04d4c78
8 changed files with 308 additions and 47 deletions

View File

@ -170,6 +170,7 @@ class TaskColumnSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"parent": "Нельзя добавить подколонку в архивную колонку."}
)
attrs["is_done"] = parent.is_done
if (
self.instance

View File

@ -633,6 +633,18 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
parent = serializer.validated_data.get("parent")
if parent:
parent = TaskColumn.objects.select_for_update().get(pk=parent.pk)
elif serializer.validated_data.get("is_done"):
board = serializer.validated_data["board"]
TaskColumn.objects.select_for_update().filter(
board=board,
parent__isnull=True,
is_done=True,
).update(is_done=False)
TaskColumn.objects.filter(
board=board,
parent__isnull=False,
is_done=True,
).update(is_done=False)
column = serializer.save(parent=parent)
if parent:
task_updates = {"column": column}
@ -644,7 +656,28 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
send_task_board_update(column.board, "created", "column", column.id)
def perform_update(self, serializer):
column = serializer.save()
with transaction.atomic():
current_column = TaskColumn.objects.select_for_update().get(
pk=serializer.instance.pk
)
requested_is_done = serializer.validated_data.get(
"is_done",
current_column.is_done,
)
if current_column.parent_id is None and requested_is_done:
TaskColumn.objects.filter(
board=current_column.board,
parent__isnull=True,
is_done=True,
).exclude(pk=current_column.pk).update(is_done=False)
TaskColumn.objects.filter(
board=current_column.board,
parent__isnull=False,
is_done=True,
).exclude(parent_id=current_column.pk).update(is_done=False)
column = serializer.save()
if column.parent_id is None:
column.subcolumns.update(is_done=column.is_done)
send_task_board_update(column.board, "updated", "column", column.id)
def destroy(self, request, *args, **kwargs):
@ -1247,27 +1280,27 @@ class TaskViewSet(viewsets.ModelViewSet):
if task.column.is_done:
return Response(self.get_serializer(task).data)
done_column = (
done_parent = (
TaskColumn.objects.filter(
board_id=task.board_id,
is_done=True,
is_archived=False,
parent__isnull=True,
)
.annotate(
active_subcolumns_count=Count(
"subcolumns",
filter=Q(subcolumns__is_archived=False),
)
)
.filter(active_subcolumns_count=0)
.order_by("position", "id")
.first()
)
if done_column is None:
if done_parent is None:
return Response(
{"detail": "На доске не настроена завершающая колонка."},
status=status.HTTP_400_BAD_REQUEST,
)
done_column = (
done_parent.subcolumns.filter(is_archived=False)
.order_by("position", "id")
.first()
or done_parent
)
task = self._move_task(task, done_column, None, None, request.user)
return Response(self.get_serializer(task).data)

View File

@ -0,0 +1,40 @@
from django.db import migrations, models
def normalize_done_columns(apps, schema_editor):
TaskBoard = apps.get_model("tasks", "TaskBoard")
TaskColumn = apps.get_model("tasks", "TaskColumn")
for board_id in TaskBoard.objects.values_list("id", flat=True).iterator():
done_columns = TaskColumn.objects.filter(
board_id=board_id,
parent_id=None,
is_done=True,
).order_by("-updated_at", "-id")
selected_column = done_columns.first()
if selected_column is None:
continue
done_columns.exclude(id=selected_column.id).update(is_done=False)
TaskColumn.objects.filter(
board_id=board_id,
parent_id__isnull=False,
).update(is_done=False)
TaskColumn.objects.filter(parent_id=selected_column.id).update(is_done=True)
class Migration(migrations.Migration):
dependencies = [
("tasks", "0027_taskboardplacement_view_state"),
]
operations = [
migrations.RunPython(normalize_done_columns, migrations.RunPython.noop),
migrations.AddConstraint(
model_name="taskcolumn",
constraint=models.UniqueConstraint(
condition=models.Q(parent__isnull=True, is_done=True),
fields=("board",),
name="uniq_task_done_column_per_board",
),
),
]

View File

@ -214,6 +214,11 @@ class TaskColumn(models.Model):
condition=models.Q(parent__isnull=False),
name="uniq_task_subcolumn_parent_name",
),
models.UniqueConstraint(
fields=["board"],
condition=models.Q(parent__isnull=True, is_done=True),
name="uniq_task_done_column_per_board",
),
]
def __str__(self):

View File

@ -1183,7 +1183,7 @@ def test_member_can_claim_unassigned_task(api_client, user):
assert completed_task.assignee_id is None
def test_only_assignee_can_complete_task_in_first_done_column(api_client, user):
def test_only_assignee_can_complete_task_in_configured_done_column(api_client, user):
assignee = make_user("task-assignee@example.com", "+79994440093")
member = make_user("task-member@example.com", "+79994440094")
board = TaskBoard.objects.create(name="Доска завершения", created_by=user)
@ -1193,17 +1193,16 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user):
name="В работе",
position=1000,
)
first_done_column = TaskColumn.objects.create(
previous_done_column = TaskColumn.objects.create(
board=board,
name="Готово",
position=2000,
is_done=True,
)
TaskColumn.objects.create(
configured_done_column = TaskColumn.objects.create(
board=board,
name="Архив завершенных",
name="Опубликовано",
position=3000,
is_done=True,
)
task = Task.objects.create(
board=board,
@ -1214,6 +1213,26 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user):
)
complete_url = reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id})
api_client.force_authenticate(user=user)
configure_response = api_client.patch(
reverse(
"api:v1:tasks:task-column-detail",
kwargs={"pk": configured_done_column.id},
),
{"is_done": True},
format="json",
)
assert configure_response.status_code == status.HTTP_200_OK
previous_done_column.refresh_from_db()
configured_done_column.refresh_from_db()
assert previous_done_column.is_done is False
assert configured_done_column.is_done is True
assert TaskColumn.objects.filter(
board=board,
parent__isnull=True,
is_done=True,
).count() == 1
api_client.force_authenticate(user=member)
forbidden_response = api_client.post(complete_url)
assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN
@ -1229,18 +1248,18 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user):
complete_response = api_client.post(complete_url)
assert complete_response.status_code == status.HTTP_200_OK
assert complete_response.data["column"] == first_done_column.id
assert complete_response.data["column"] == configured_done_column.id
assert complete_response.data["completed_at"] is not None
task.refresh_from_db()
assert task.column_id == first_done_column.id
assert task.column_id == configured_done_column.id
assert task.completed_at is not None
moved_activity = task.activities.get(action="moved")
assert moved_activity.actor_id == assignee.id
assert moved_activity.metadata == {
"from_column_id": source_column.id,
"from_column": source_column.name,
"to_column_id": first_done_column.id,
"to_column": first_done_column.name,
"to_column_id": configured_done_column.id,
"to_column": configured_done_column.name,
}
notify_moved.assert_called_once()
send_update.assert_called_once()
@ -1257,6 +1276,53 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user):
send_update.assert_not_called()
def test_complete_task_uses_first_subcolumn_of_configured_done_column(
api_client,
user,
):
board = TaskBoard.objects.create(name="Финал с подколонками", created_by=user)
source_column = TaskColumn.objects.create(
board=board,
name="В работе",
position=1000,
)
done_parent = TaskColumn.objects.create(
board=board,
name="Завершено",
position=2000,
is_done=True,
)
api_client.force_authenticate(user=user)
child_response = api_client.post(
reverse("api:v1:tasks:task-column-list"),
{
"board": board.id,
"parent": done_parent.id,
"name": "Принято",
"position": 1000,
},
format="json",
)
assert child_response.status_code == status.HTTP_201_CREATED
done_child = TaskColumn.objects.get(id=child_response.data["id"])
assert done_child.is_done is True
task = Task.objects.create(
board=board,
column=source_column,
title="Переместить в финальную подколонку",
created_by=user,
assignee=user,
)
response = api_client.post(
reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id})
)
assert response.status_code == status.HTTP_200_OK
assert response.data["column"] == done_child.id
assert response.data["completed_at"] is not None
def test_assignee_cannot_complete_task_without_done_column(api_client, user):
board = TaskBoard.objects.create(name="Доска без финала", created_by=user)
column = TaskColumn.objects.create(board=board, name="В работе", position=1000)

View File

@ -7,6 +7,7 @@ import {
DragOverlay,
PointerSensor,
closestCorners,
pointerWithin,
useDraggable,
useSensor,
useSensors,
@ -5434,8 +5435,18 @@ function TasksPageContent() {
}, []);
const boardCollisionDetection = useCallback<CollisionDetection>((args) => {
if (args.active.data.current?.type !== "column") {
const acceptsTasks = (collision: { id: string | number }) => (
args.droppableContainers.find(
(container) => container.id === collision.id,
)?.data.current?.acceptsTasks === true
);
const pointerCollisions = pointerWithin(args).filter(acceptsTasks);
if (pointerCollisions.length > 0) return pointerCollisions;
return closestCorners(args).filter(acceptsTasks);
}
const collisions = closestCorners(args);
if (args.active.data.current?.type !== "column") return collisions;
const activeColumnId = Number(args.active.data.current?.columnId);
const activeColumn = activeColumns.find((column) => column.id === activeColumnId);
@ -5562,8 +5573,8 @@ function TasksPageContent() {
desktopWideMode={desktopWideMode}
onDesktopWideModeChange={changeDesktopWideMode}
>
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "mx-auto w-full max-w-[1600px]"}`}>
<div className={`min-h-0 gap-4 ${desktopWideMode ? "lg:flex lg:flex-1" : ""}`}>
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "mx-auto w-full max-w-[1600px] xl:flex xl:h-[calc(100dvh-5.5rem)] xl:min-h-0 xl:flex-col"}`}>
<div className={`min-h-0 gap-4 ${desktopWideMode ? "lg:flex lg:flex-1" : "xl:flex xl:flex-1"}`}>
<TaskBoardNavigator
boards={boards}
groups={boardGroups}
@ -5587,8 +5598,8 @@ function TasksPageContent() {
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`}
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
<section className="app-surface relative z-50 shrink-0 overflow-visible rounded-2xl p-4">
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : "xl:flex xl:h-full"}`}>
<section className="app-surface relative z-30 shrink-0 overflow-visible rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
@ -5827,11 +5838,7 @@ function TasksPageContent() {
}}
className={`tasks-board-scroll min-w-0 overflow-hidden pb-3 xl:overflow-auto ${
boardPanning ? "cursor-grabbing select-none" : ""
} ${
desktopWideMode
? "lg:min-h-0 lg:flex-1"
: "xl:max-h-[calc(100dvh-15rem)]"
}`}
} ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : "xl:min-h-0 xl:flex-1"}`}
>
<div className="flex w-full min-w-0 gap-3 xl:min-h-full xl:w-auto xl:min-w-max xl:items-stretch">
{topLevelColumns.map((column) => {
@ -6392,15 +6399,17 @@ function TasksPageContent() {
</div>
</label>
<label className="app-surface-muted flex cursor-pointer items-center gap-3 rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-sm">
<input
type="checkbox"
checked={columnForm.is_done}
onChange={(event) => setColumnForm((current) => ({ ...current, is_done: event.target.checked }))}
className="h-4 w-4"
/>
Финальная колонка
</label>
{!columnForm.parent ? (
<label className="app-surface-muted flex cursor-pointer items-center gap-3 rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-sm">
<input
type="checkbox"
checked={columnForm.is_done}
onChange={(event) => setColumnForm((current) => ({ ...current, is_done: event.target.checked }))}
className="h-4 w-4"
/>
Финальная колонка
</label>
) : null}
</div>
</Modal>

View File

@ -35,6 +35,7 @@ type MessagesChatListItemProps = {
currentUserId?: number;
identity: ChatIdentity;
active?: boolean;
compact?: boolean;
onChatUpdated?: (chatId: number) => void | Promise<void>;
};
@ -78,6 +79,7 @@ export default function MessagesChatListItem({
currentUserId,
identity,
active = false,
compact = false,
onChatUpdated,
}: MessagesChatListItemProps) {
const title = getChatTitle(chat, currentUserId, identity);
@ -132,6 +134,45 @@ export default function MessagesChatListItem({
}
};
if (compact) {
return (
<Link
href={`/messages/${chat.id}`}
className={`relative mx-auto flex w-fit rounded-xl p-1.5 transition ${
active ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
title={title}
aria-label={`Открыть чат «${title}»`}
aria-current={active ? "page" : undefined}
>
<span className="relative h-10 w-10 shrink-0 overflow-visible">
<span className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
{avatar ? (
<Image
src={resolveMediaUrl(avatar)}
alt={title}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getChatInitials(chat, currentUserId, identity)
)}
</span>
<span className="app-badge app-badge-accent absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)]">
{getChatTypeIcon(chat)}
</span>
{unread > 0 ? (
<span className="chat-list-unread-badge app-counter-danger absolute right-0 top-0 z-20 flex min-w-[1.35rem] -translate-y-1/4 translate-x-1/4 items-center justify-center px-1.5 text-[10px] font-bold leading-none">
{unread > 99 ? "99+" : unread}
</span>
) : null}
</span>
</Link>
);
}
return (
<>
<div

View File

@ -1,6 +1,7 @@
"use client";
import { Plus } from "lucide-react";
import { useCallback, useSyncExternalStore } from "react";
import { ChevronLeft, MessageSquare, Plus } from "lucide-react";
import MessagesChatListControls from "@/components/messages/MessagesChatListControls";
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
@ -16,6 +17,9 @@ type MessagesFocusSidebarProps = {
onCreateChat: () => void;
};
const SIDEBAR_COLLAPSED_STORAGE_PREFIX = "messages.chat-list-collapsed";
const SIDEBAR_COLLAPSED_CHANGE_EVENT = "messages-chat-list-collapsed-change";
export default function MessagesFocusSidebar({
activeChatId,
mode,
@ -23,12 +27,73 @@ export default function MessagesFocusSidebar({
onCreateChat,
}: MessagesFocusSidebarProps) {
const { user, identity, visibleChats, chatTypeCounts, loading, refreshChat } = useMessagesDirectory();
const sidebarStorageKey = user?.id
? `${SIDEBAR_COLLAPSED_STORAGE_PREFIX}.${user.id}`
: null;
const subscribeToSidebarState = useCallback((notify: () => void) => {
if (!sidebarStorageKey) return () => undefined;
const handleStorage = (event: StorageEvent) => {
if (event.key === sidebarStorageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(SIDEBAR_COLLAPSED_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(SIDEBAR_COLLAPSED_CHANGE_EVENT, notify);
};
}, [sidebarStorageKey]);
const getSidebarState = useCallback(
() => Boolean(
sidebarStorageKey
&& window.localStorage.getItem(sidebarStorageKey) === "1"
),
[sidebarStorageKey],
);
const sidebarCollapsed = useSyncExternalStore(
subscribeToSidebarState,
getSidebarState,
() => false,
);
const toggleSidebarCollapsed = () => {
if (!sidebarStorageKey) return;
window.localStorage.setItem(
sidebarStorageKey,
sidebarCollapsed ? "0" : "1",
);
window.dispatchEvent(new Event(SIDEBAR_COLLAPSED_CHANGE_EVENT));
};
return (
<aside data-testid="messages-focus-sidebar" className="app-surface hidden min-h-0 w-[24rem] shrink-0 flex-col overflow-hidden rounded-2xl lg:flex">
<div className="flex items-center justify-between gap-2 p-4 pb-0">
<p className="app-card-caption min-w-0 truncate">Сообщения</p>
<div className="flex shrink-0 items-center gap-1">
<aside
data-testid="messages-focus-sidebar"
className={`hidden min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 lg:flex ${
sidebarCollapsed
? "w-[4.75rem] bg-transparent"
: "app-surface w-[24rem] rounded-2xl"
}`}
>
<div className={`flex items-center gap-2 pb-0 ${sidebarCollapsed ? "justify-center p-3" : "justify-between p-4"}`}>
<button
type="button"
onClick={toggleSidebarCollapsed}
className={`app-action-ghost flex items-center justify-center rounded-lg ${
sidebarCollapsed ? "h-9 w-9" : "min-w-0 gap-1 px-1 py-1"
}`}
title={sidebarCollapsed ? "Развернуть список чатов" : "Свернуть список чатов"}
aria-label={sidebarCollapsed ? "Развернуть список чатов" : "Свернуть список чатов"}
aria-expanded={!sidebarCollapsed}
>
{sidebarCollapsed ? (
<MessageSquare size={17} />
) : (
<>
<ChevronLeft size={14} className="shrink-0" />
<span className="app-card-caption min-w-0 truncate">Сообщения</span>
</>
)}
</button>
{!sidebarCollapsed ? <div className="flex shrink-0 items-center gap-1">
<MessagesFocusModeButton
enabled={mode !== "standard"}
onChange={(enabled) => onModeChange(enabled ? "wide" : "standard")}
@ -42,14 +107,14 @@ export default function MessagesFocusSidebar({
<Plus size={14} />
Создать чат
</button>
</div>
</div> : null}
</div>
<div className="p-4 pb-0">
{!sidebarCollapsed ? <div className="p-4 pb-0">
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
</div>
</div> : null}
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 pb-4">
<div className={`min-h-0 flex-1 space-y-2 overflow-y-auto pb-4 ${sidebarCollapsed ? "px-2 pt-2" : "px-4"}`}>
{loading ? (
<p className="app-text-muted px-3 py-6 text-center text-xs">Загрузка чатов...</p>
) : visibleChats.length === 0 ? (
@ -61,6 +126,7 @@ export default function MessagesFocusSidebar({
currentUserId={user?.id}
identity={identity}
active={chat.id === activeChatId}
compact={sidebarCollapsed}
onChatUpdated={refreshChat}
/>
))}