diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 5d86b51..65bb6c0 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -28,6 +28,14 @@ return list(db.scalars(select(Project).order_by(Project.id)).all()) +@router.get("/{project_id}", response_model=ProjectOut) +async def get_project(project_id: int, db: DbDep, user: UserDep) -> Project: + project = db.get(Project, project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + return project + + @router.patch("/{project_id}", response_model=ProjectOut) async def update_project( project_id: int, schema: ProjectUpdate, db: DbDep, user: UserDep diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index ef24453..cb9163d 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -22,15 +22,36 @@ return task +def _validate_parent(db: Any, task: Task | None, parent_id: int | None) -> None: + """Родитель должен существовать; циклы в дереве запрещены.""" + if parent_id is None: + return + if task is not None and parent_id == task.id: + raise HTTPException(status_code=400, detail="Task cannot be its own parent") + parent = cast(Task | None, db.get(Task, parent_id)) + if parent is None: + raise HTTPException(status_code=400, detail="Unknown parent task") + if task is not None: + ancestor: Task | None = parent + while ancestor is not None: + if ancestor.id == task.id: + raise HTTPException(status_code=400, detail="Cycle in task tree") + ancestor = ancestor.parent + + @router.post("") async def create_task( schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks ) -> dict[str, int]: """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do). - Сразу в фоне запускается автодетализация (LLM-предложение метаданных). + С parent_task_id — создание подзадачи (M3). Сразу в фоне запускается + автодетализация (LLM-предложение метаданных). """ - task = Task(title=schema.title, description=schema.description) + _validate_parent(db, None, schema.parent_task_id) + task = Task( + title=schema.title, description=schema.description, parent_task_id=schema.parent_task_id + ) db.add(task) db.flush() # Коммитим до планирования фоновой работы: BackgroundTasks выполняются ДО @@ -47,6 +68,8 @@ detail_state: str | None = Query(None), status: str | None = Query(None), project_id: int | None = Query(None), + tag_id: int | None = Query(None), + parent_id: int | None = Query(None), ) -> list[Task]: stmt = select(Task).order_by(Task.created_at.desc()) if detail_state: @@ -55,6 +78,10 @@ stmt = stmt.where(Task.status == status) if project_id: stmt = stmt.where(Task.project_id == project_id) + if parent_id is not None: + stmt = stmt.where(Task.parent_task_id == parent_id) + if tag_id: + stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) return list(db.scalars(stmt).all()) @@ -75,6 +102,8 @@ if "project_id" in data and data["project_id"] is not None: if db.get(Project, data["project_id"]) is None: raise HTTPException(status_code=400, detail="Unknown project") + if "parent_task_id" in data: + _validate_parent(db, task, data["parent_task_id"]) if "tag_ids" in data: tag_ids = data.pop("tag_ids") or [] tags = db.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all() diff --git a/backend/app/models.py b/backend/app/models.py index ae06ffe..68a398b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -36,6 +36,10 @@ parent_task_id: Mapped[int | None] = mapped_column( ForeignKey("tasks.id"), nullable=True, default=None ) + parent: Mapped["Task | None"] = relationship( + "Task", back_populates="children", remote_side="Task.id" + ) + children: Mapped[list["Task"]] = relationship("Task", back_populates="parent") project_id: Mapped[int | None] = mapped_column( ForeignKey("projects.id"), nullable=True, default=None ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ac930e1..c96f533 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -28,6 +28,7 @@ class TaskCreate(BaseModel): title: str = Field(min_length=1, max_length=500) description: str = "" + parent_task_id: int | None = None # подзадача (M3) class TaskUpdate(BaseModel): @@ -36,6 +37,7 @@ title: str | None = Field(None, max_length=500) description: str | None = None project_id: int | None = None + parent_task_id: int | None = None tag_ids: list[int] | None = None priority: int | None = None status: str | None = None diff --git a/backend/tests/test_tree.py b/backend/tests/test_tree.py new file mode 100644 index 0000000..e68c275 --- /dev/null +++ b/backend/tests/test_tree.py @@ -0,0 +1,69 @@ +"""Тесты M3: дерево подзадач (родитель, циклы) и карточка проекта.""" + +from fastapi.testclient import TestClient + + +def test_create_subtask(client: TestClient) -> None: + parent = client.post("/api/tasks", json={"title": "Ремонт балкона"}).json()["id"] + child = client.post( + "/api/tasks", json={"title": "Купить краску", "parent_task_id": parent} + ).json()["id"] + + task = client.get(f"/api/tasks/{child}").json() + assert task["parent_task_id"] == parent + + root = client.get(f"/api/tasks/{parent}").json() + assert root["parent_task_id"] is None + + +def test_subtask_of_unknown_parent_rejected(client: TestClient) -> None: + resp = client.post("/api/tasks", json={"title": "x", "parent_task_id": 999}) + assert resp.status_code == 400 + + +def test_task_cannot_be_own_parent(client: TestClient) -> None: + task_id = client.post("/api/tasks", json={"title": "x"}).json()["id"] + resp = client.patch(f"/api/tasks/{task_id}", json={"parent_task_id": task_id}) + assert resp.status_code == 400 + + +def test_cycle_rejected_on_reparent(client: TestClient) -> None: + a = client.post("/api/tasks", json={"title": "A"}).json()["id"] + b = client.post("/api/tasks", json={"title": "B", "parent_task_id": a}).json()["id"] + c = client.post("/api/tasks", json={"title": "C", "parent_task_id": b}).json()["id"] + + # c → a создало бы цикл a → b → c → a + assert client.patch(f"/api/tasks/{a}", json={"parent_task_id": c}).status_code == 400 + + # валидный перенос: c → a + moved = client.patch(f"/api/tasks/{c}", json={"parent_task_id": a}).json() + assert moved["parent_task_id"] == a + + +def test_detach_from_parent(client: TestClient) -> None: + parent = client.post("/api/tasks", json={"title": "P"}).json()["id"] + child_id = client.post( + "/api/tasks", json={"title": "C", "parent_task_id": parent} + ).json()["id"] + detached = client.patch(f"/api/tasks/{child_id}", json={"parent_task_id": None}).json() + assert detached["parent_task_id"] is None + + +def test_get_single_project(client: TestClient) -> None: + pid = client.post( + "/api/projects", json={"name": "Дача", "note": "Ссылки: [форум](https://example.com)"} + ).json()["id"] + project = client.get(f"/api/projects/{pid}").json() + assert project["name"] == "Дача" + assert "форум" in project["note"] + assert client.get("/api/projects/999").status_code == 404 + + +def test_project_note_update(client: TestClient) -> None: + pid = client.post("/api/projects", json={"name": "Проект"}).json()["id"] + updated = client.patch( + f"/api/projects/{pid}", + json={"note": "- задача 1\n- задача 2", "relevance_status": "paused"}, + ).json() + assert updated["note"].startswith("- задача 1") + assert updated["relevance_status"] == "paused" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9713063..719ec08 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,8 @@ const navItems = [ { id: 'stack', label: 'Стек входящих', icon: 'ph-inbox', to: '/stack' }, { id: 'tree', label: 'Дерево задач', icon: 'ph-tree-structure', to: '/tree' }, + { id: 'list', label: 'Список задач', icon: 'ph-list-bullets', to: '/list' }, + { id: 'projects', label: 'Проекты', icon: 'ph-folders', to: '/projects' }, ] const currentLabel = computed(() => { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 32b8a0b..8f24502 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -51,6 +51,7 @@ title?: string description?: string project_id?: number | null + parent_task_id?: number | null tag_ids?: number[] priority?: number | null status?: string @@ -82,15 +83,22 @@ export const api = { // tasks - listTasks: (params: Record = {}) => - request('/api/tasks?' + new URLSearchParams( - Object.entries(params).map(([k, v]) => [k, String(v)]), - )), + listTasks: (params: Record = {}) => { + const qs = new URLSearchParams() + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)) + }) + return request('/api/tasks?' + qs) + }, getTask: (id: number) => request(`/api/tasks/${id}`), - createTask: (title: string, description = '') => + createTask: (title: string, description = '', parentTaskId?: number) => request<{ id: number }>('/api/tasks', { method: 'POST', - body: JSON.stringify({ title, description }), + body: JSON.stringify({ + title, + description, + ...(parentTaskId !== undefined ? { parent_task_id: parentTaskId } : {}), + }), }), updateTask: (id: number, patch: TaskUpdateInput) => request(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), @@ -119,8 +127,13 @@ attachmentUrl: (id: number) => `/api/attachments/${id}/file`, // projects listProjects: () => request('/api/projects'), + getProject: (id: number) => request(`/api/projects/${id}`), createProject: (name: string) => request('/api/projects', { method: 'POST', body: JSON.stringify({ name }) }), + updateProject: (id: number, patch: Partial>) => + request(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), + deleteProject: (id: number) => + request<{ ok: boolean }>(`/api/projects/${id}`, { method: 'DELETE' }), // tags listTags: () => request('/api/tags'), createTag: (name: string) => diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2b1d4e2..9a7b4bd 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,6 +6,8 @@ import { GnexusUiVue } from 'gnexus-ui-kit/vue' import App from './App.vue' +import ListView from './views/ListView.vue' +import ProjectsView from './views/ProjectsView.vue' import StackView from './views/StackView.vue' import TreeView from './views/TreeView.vue' @@ -15,6 +17,8 @@ { path: '/', redirect: '/stack' }, { path: '/stack', component: StackView }, { path: '/tree', component: TreeView }, + { path: '/list', component: ListView }, + { path: '/projects', component: ProjectsView }, ], }) diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts new file mode 100644 index 0000000..62f1c7d --- /dev/null +++ b/frontend/src/taskui.ts @@ -0,0 +1,42 @@ +// Общие помощники UI задач: подписи статусов, приоритеты, Markdown. + +import DOMPurify from 'dompurify' +import { marked } from 'marked' + +export const STATUS_LABELS: Record = { + to_do: 'К выполнению', + in_progress: 'В работе', + done: 'Завершено', + cancelled: 'Отменено', + deferred: 'Отложено', +} + +export const STATUS_VARIANTS: Record = { + to_do: 'info', + in_progress: 'accent', + done: 'success', + cancelled: 'error', + deferred: 'warning', +} + +export const RELEVANCE_LABELS: Record = { + active: 'Активен', + paused: 'Приостановлен', + archived: 'Закрыт', +} + +export function statusLabel(status: string): string { + return STATUS_LABELS[status] ?? status +} + +export function statusVariant(status: string): string { + return STATUS_VARIANTS[status] ?? 'neutral' +} + +export function relevanceLabel(status: string): string { + return RELEVANCE_LABELS[status] ?? status +} + +export function renderMarkdown(text: string): string { + return DOMPurify.sanitize(marked.parse(text, { async: false })) +} \ No newline at end of file diff --git a/frontend/src/views/ListView.vue b/frontend/src/views/ListView.vue new file mode 100644 index 0000000..f187fe1 --- /dev/null +++ b/frontend/src/views/ListView.vue @@ -0,0 +1,156 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/ProjectsView.vue b/frontend/src/views/ProjectsView.vue new file mode 100644 index 0000000..9c02523 --- /dev/null +++ b/frontend/src/views/ProjectsView.vue @@ -0,0 +1,182 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue index ab6db55..6b6d153 100644 --- a/frontend/src/views/StackView.vue +++ b/frontend/src/views/StackView.vue @@ -230,7 +230,9 @@ + + \ No newline at end of file