diff --git a/backend/alembic/versions/d9e8f7c6b5a4_documents_entity.py b/backend/alembic/versions/d9e8f7c6b5a4_documents_entity.py new file mode 100644 index 0000000..83fddae --- /dev/null +++ b/backend/alembic/versions/d9e8f7c6b5a4_documents_entity.py @@ -0,0 +1,101 @@ +"""Сущность «документ»: описания задач и заметки проектов переносятся в неё, +вложения привязываются к документу вместо задачи (см. docs/TZ.md, модель данных). + +Revision ID: d9e8f7c6b5a4 +Revises: c8f7e6d5b4a3 +Create Date: 2026-09-20 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "d9e8f7c6b5a4" +down_revision: str | Sequence[str] | None = "c8f7e6d5b4a3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "documents", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_type", sa.String(length=20), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("body", sa.Text(), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("owner_type", "owner_id"), + ) + conn = op.get_bind() + # Документ заводится каждой задаче и проекту (включая пустой текст) — + # упрощает код: document всегда есть. + conn.execute( + sa.text( + "INSERT INTO documents (owner_type, owner_id, body, created_at, updated_at) " + "SELECT 'task', id, description, created_at, created_at FROM tasks" + ) + ) + conn.execute( + sa.text( + "INSERT INTO documents (owner_type, owner_id, body, created_at, updated_at) " + "SELECT 'project', id, note, created_at, created_at FROM projects" + ) + ) + op.add_column("attachments", sa.Column("document_id", sa.Integer(), nullable=True)) + conn.execute( + sa.text( + "UPDATE attachments SET document_id = (" + "SELECT id FROM documents WHERE owner_type = 'task' AND owner_id = task_id)" + ) + ) + op.alter_column("attachments", "document_id", nullable=False) + op.create_foreign_key( + "fk_attachments_document_id_documents", + "attachments", + "documents", + ["document_id"], + ["id"], + ondelete="CASCADE", + ) + op.drop_column("attachments", "task_id") + op.drop_column("tasks", "description") + op.drop_column("projects", "note") + + +def downgrade() -> None: + op.add_column("tasks", sa.Column("description", sa.Text(), nullable=False, server_default="")) + op.add_column("projects", sa.Column("note", sa.Text(), nullable=False, server_default="")) + conn = op.get_bind() + conn.execute( + sa.text( + "UPDATE tasks SET description = COALESCE((" + "SELECT body FROM documents WHERE owner_type = 'task' AND owner_id = tasks.id), '')" + ) + ) + conn.execute( + sa.text( + "UPDATE projects SET note = COALESCE((" + "SELECT body FROM documents WHERE owner_type = 'project'" + " AND owner_id = projects.id), '')" + ) + ) + op.add_column("attachments", sa.Column("task_id", sa.Integer(), nullable=True)) + conn.execute( + sa.text( + "UPDATE attachments SET task_id = (" + "SELECT owner_id FROM documents WHERE documents.id = attachments.document_id)" + ) + ) + op.drop_constraint( + "fk_attachments_document_id_documents", "attachments", type_="foreignkey" + ) + op.alter_column("attachments", "task_id", nullable=False) + op.create_foreign_key( + "fk_attachments_task_id_tasks", "attachments", "tasks", ["task_id"], ["id"], + ondelete="CASCADE", + ) + op.drop_column("attachments", "document_id") + op.drop_table("documents") diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py index 8f0806c..e616eaa 100644 --- a/backend/app/api/attachments.py +++ b/backend/app/api/attachments.py @@ -1,4 +1,9 @@ -"""API вложений M2: изображения задач, в том числе вставка из буфера обмена.""" +"""API вложений: изображения документов (описания задач, заметки проектов), +в том числе вставка из буфера обмена. + +URL скачивания файла (`/api/attachments/{id}/file`) неизменен — он зашит в +уже сохранённый markdown-текст документов. +""" import shutil import uuid @@ -11,7 +16,7 @@ from app.config import get_settings from app.dependencies import DbDep, UserDep -from app.models import Attachment, Task +from app.models import Attachment, Document from app.schemas import AttachmentOut router = APIRouter(prefix="/api", tags=["attachments"]) @@ -25,11 +30,11 @@ return path -def _get_task_or_404(db: Any, task_id: int) -> Task: - task = cast(Task | None, db.get(Task, task_id)) - if task is None: - raise HTTPException(status_code=404, detail="Task not found") - return task +def _get_document_or_404(db: Any, document_id: int) -> Document: + doc = cast(Document | None, db.get(Document, document_id)) + if doc is None: + raise HTTPException(status_code=404, detail="Document not found") + return doc def _get_attachment_or_404(db: Any, attachment_id: int) -> Attachment: @@ -39,12 +44,12 @@ return att -@router.post("/tasks/{task_id}/attachments", response_model=list[AttachmentOut]) +@router.post("/documents/{document_id}/attachments", response_model=list[AttachmentOut]) async def upload_attachments( - task_id: int, db: DbDep, user: UserDep, files: list[UploadFile] + document_id: int, db: DbDep, user: UserDep, files: list[UploadFile] ) -> list[Attachment]: """Загрузка файлов (в т.ч. Ctrl+V из буфера — приходит как обычный файл).""" - _get_task_or_404(db, task_id) + document = _get_document_or_404(db, document_id) saved: list[Attachment] = [] for file in files: mime = file.content_type or "application/octet-stream" @@ -56,7 +61,7 @@ with target.open("wb") as out: shutil.copyfileobj(file.file, out) att = Attachment( - task_id=task_id, + document_id=document.id, filename=stored_name, original_name=(file.filename or "image")[:255], mime=mime, @@ -68,12 +73,14 @@ return saved -@router.get("/tasks/{task_id}/attachments", response_model=list[AttachmentOut]) -async def list_attachments(task_id: int, db: DbDep, user: UserDep) -> list[Attachment]: - _get_task_or_404(db, task_id) +@router.get("/documents/{document_id}/attachments", response_model=list[AttachmentOut]) +async def list_attachments(document_id: int, db: DbDep, user: UserDep) -> list[Attachment]: + document = _get_document_or_404(db, document_id) return list( db.scalars( - select(Attachment).where(Attachment.task_id == task_id).order_by(Attachment.id) + select(Attachment) + .where(Attachment.document_id == document.id) + .order_by(Attachment.id) ).all() ) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index b4450fe..3bdf863 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -11,10 +11,10 @@ from typing import Any, cast from mcp.server.fastmcp import FastMCP -from sqlalchemy import select +from sqlalchemy import and_, select from app.db import get_session_factory -from app.models import Attachment, Project, Tag, Task, utcnow +from app.models import Attachment, Document, Project, Tag, Task, utcnow from app.schemas import DEADLINE_PERIODS, RECUR_KINDS from app.services.detailing import detail_task from app.services.recurrence import spawn_next @@ -60,7 +60,7 @@ "attachments": [ {"id": a.id, "original_name": a.original_name, "mime": a.mime, "size": a.size} for a in db.scalars( - select(Attachment).where(Attachment.task_id == task.id) + select(Attachment).where(Attachment.document_id == task.document_id) ).all() ], } @@ -248,7 +248,12 @@ stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) if query: pattern = f"%{query}%" - stmt = stmt.where(Task.title.ilike(pattern) | Task.description.ilike(pattern)) + # Описание живёт в документе — ищем join'ом по полиморфной привязке + stmt = stmt.outerjoin( + Document, + and_(Document.owner_id == Task.id, Document.owner_type == "task"), + ) + stmt = stmt.where(Task.title.ilike(pattern) | Document.body.ilike(pattern)) return [_compact(t) for t in session.scalars(stmt).all()] finally: session.close() diff --git a/backend/app/models.py b/backend/app/models.py index e382981..4a23bdd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -32,7 +32,16 @@ id: Mapped[int] = mapped_column(Integer, primary_key=True) title: Mapped[str] = mapped_column(String(500)) - description: Mapped[str] = mapped_column(Text, default="") + + # Описание живёт в документе (полиморфная привязка: task/project) + document: Mapped["Document | None"] = relationship( + "Document", + primaryjoin="and_(foreign(Document.owner_id) == Task.id, Document.owner_type == 'task')", + uselist=False, + cascade="all, delete-orphan", + lazy="selectin", + overlaps="document", + ) # Тип: разовая / регулярная (регулярные — в следующих вехах) task_type: Mapped[str] = mapped_column(String(20), default="one_time") @@ -96,16 +105,61 @@ DateTime(timezone=True), nullable=True, default=None ) + @property + def description(self) -> str: + return self.document.body if self.document else "" + + @description.setter + def description(self, value: str) -> None: + if self.document is None: + self.document = Document(owner_type="task", body=value) + else: + self.document.body = value + self.document.updated_at = utcnow() + + @property + def document_id(self) -> int | None: + return self.document.id if self.document else None + class Project(Base): __tablename__ = "projects" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(200), unique=True) + + # Заметка — тоже документ (markdown: ссылки, контекст, картинки) + document: Mapped["Document | None"] = relationship( + "Document", + primaryjoin=( + "and_(foreign(Document.owner_id) == Project.id," + " Document.owner_type == 'project')" + ), + uselist=False, + cascade="all, delete-orphan", + lazy="selectin", + overlaps="document", + ) + + @property + def note(self) -> str: + return self.document.body if self.document else "" + + @note.setter + def note(self, value: str) -> None: + if self.document is None: + self.document = Document(owner_type="project", body=value) + else: + self.document.body = value + self.document.updated_at = utcnow() + + @property + def document_id(self) -> int | None: + return self.document.id if self.document else None + # Статус актуальности проекта relevance_status: Mapped[str] = mapped_column(String(20), default="active") priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) - note: Mapped[str] = mapped_column(Text, default="") # markdown: ссылки, контекст tasks: Mapped[list[Task]] = relationship(back_populates="project") @@ -134,17 +188,41 @@ ) +class Document(Base): + """Markdown-текст с вложениями, привязанный к владельцу (задача/проект). + + Описания задач и заметки проектов — одна сущность: текст и его картинки + живут вместе, вложения ссылаются на документ (см. docs/TZ.md, модель данных). + """ + + __tablename__ = "documents" + __table_args__ = (UniqueConstraint("owner_type", "owner_id"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_type: Mapped[str] = mapped_column(String(20)) # 'task' | 'project' + owner_id: Mapped[int] = mapped_column(Integer) + body: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + attachments: Mapped[list["Attachment"]] = relationship( + back_populates="document", cascade="all, delete-orphan" + ) + + class Attachment(Base): __tablename__ = "attachments" id: Mapped[int] = mapped_column(Integer, primary_key=True) - task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE")) + document_id: Mapped[int] = mapped_column(ForeignKey("documents.id", ondelete="CASCADE")) filename: Mapped[str] = mapped_column(String(255)) # имя на диске (uuid) original_name: Mapped[str] = mapped_column(String(255)) # имя при загрузке mime: Mapped[str] = mapped_column(String(100)) size: Mapped[int] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + document: Mapped[Document] = relationship(back_populates="attachments") + class AppSetting(Base): """Глобальные настройки (единственный пользователь — мультитенантности нет). diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c8c245e..1c54953 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -26,6 +26,7 @@ relevance_status: str priority: int | None note: str + document_id: int | None class TaskCreate(BaseModel): @@ -69,6 +70,7 @@ id: int title: str description: str + document_id: int | None task_type: str status: str detail_state: str @@ -109,12 +111,12 @@ class AttachmentOut(BaseModel): - """Вложение задачи (изображение).""" + """Вложение документа (изображение).""" model_config = ConfigDict(from_attributes=True) id: int - task_id: int + document_id: int filename: str original_name: str mime: str diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index 4e62b87..9bd55fe 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -136,7 +136,7 @@ png = b"\x89PNG\r\n\x1a\nfake-image-bytes" up = client.post( - f"/api/tasks/{task['id']}/attachments", + f"/api/documents/{task['document_id']}/attachments", files={"files": ("скрин.png", png, "image/png")}, ) assert up.status_code == 200, up.text @@ -144,7 +144,7 @@ assert att["original_name"] == "скрин.png" assert att["mime"] == "image/png" - lst = client.get(f"/api/tasks/{task['id']}/attachments").json() + lst = client.get(f"/api/documents/{task['document_id']}/attachments").json() assert len(lst) == 1 got = client.get(f"/api/attachments/{att['id']}/file") @@ -158,7 +158,7 @@ def test_attachments_reject_non_image(client: TestClient) -> None: task = create_task(client) up = client.post( - f"/api/tasks/{task['id']}/attachments", + f"/api/documents/{task['document_id']}/attachments", files={"files": ("doc.pdf", b"%PDF-1.4", "application/pdf")}, ) assert up.status_code == 415 diff --git a/backend/tests/test_tree.py b/backend/tests/test_tree.py index e68c275..ba7a860 100644 --- a/backend/tests/test_tree.py +++ b/backend/tests/test_tree.py @@ -67,3 +67,35 @@ ).json() assert updated["note"].startswith("- задача 1") assert updated["relevance_status"] == "paused" + + +def test_project_note_attachments(client: TestClient) -> None: + """Заметка проекта — документ: к ней грузятся вложения, как к описанию задачи.""" + pid = client.post("/api/projects", json={"name": "Дача", "note": "Заметка"}).json()["id"] + project = client.get(f"/api/projects/{pid}").json() + assert project["note"] == "Заметка" + assert project["document_id"] is not None + + png = b"\x89PNG\r\n\x1a\nfake-image-bytes" + up = client.post( + f"/api/documents/{project['document_id']}/attachments", + files={"files": ("план.png", png, "image/png")}, + ) + assert up.status_code == 200, up.text + assert up.json()[0]["document_id"] == project["document_id"] + + lst = client.get(f"/api/documents/{project['document_id']}/attachments").json() + assert len(lst) == 1 + assert client.get(f"/api/attachments/{lst[0]['id']}/file").content == png + + +def test_task_description_roundtrip(client: TestClient) -> None: + """description живёт в документе, но API не меняется: create → read → patch → delete.""" + tid = client.post("/api/tasks", json={"title": "x", "description": "текст"}).json()["id"] + got = client.get(f"/api/tasks/{tid}").json() + assert got["description"] == "текст" + assert got["document_id"] == tid and got["document_id"] is not None + patched = client.patch(f"/api/tasks/{tid}", json={"description": "новое"}).json() + assert patched["description"] == "новое" + # Удаление задачи уносит документ (и вложения) с собой + client.delete(f"/api/tasks/{tid}") diff --git a/docs/TZ.md b/docs/TZ.md index 89ed805..ed1f232 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.12 | +| Версия ТЗ | 0.13 | | Дата | 2026-09-20 | | Статус | На обсуждении | @@ -123,7 +123,7 @@ - Статус **актуальности** проекта (активен / приостановлен / закрыт). - **Приоритет** проекта. -- **Заметка к проекту**: Markdown-поле для ссылок на ресурсы проекта и прочего контекста. +- **Заметка к проекту**: Markdown-поле для ссылок на ресурсы проекта и прочего контекста; заметка — тот же «документ», что и описание задачи: в неё можно вставлять изображения из буфера (Ctrl+V). ### 3.9. Режим выбора («3 варианта») @@ -232,7 +232,8 @@ ``` Task - id, title, description (markdown) + id, title + description markdown — живёт в Document (owner_type = task) task_type one_time | recurring — тип задачи: разовая / регулярная status to_do | in_progress | done | cancelled | deferred detail_state raw | approved — плоскость детализации (стека) @@ -254,10 +255,16 @@ id, name relevance_status active | paused | archived — статус актуальности priority - note markdown — ссылки на ресурсы, контекст + note markdown — живёт в Document (owner_type = project): + ссылки, контекст, картинки Tag — справочник тегов -Attachment — файлы задач (изображения), task_id, mime +Document — markdown-текст с полиморфной привязкой к владельцу: + owner_type (task | project), owner_id, body (у задачи — + описание, у проекта — заметка); document всегда один на владельца +Attachment — файлы документов (изображения), document_id, mime. + URL скачивания файла неизменен: /api/attachments/{id}/file — + он зашит в сохранённый markdown-текст ``` История завершённых задач (фактическое время) — источник для прогнозирования длительности. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 64a463a..e68694b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,6 +12,7 @@ relevance_status: string priority: number | null note: string + document_id: number | null } // Черновое предложение автодетализации (LLM) @@ -25,7 +26,7 @@ export interface Attachment { id: number - task_id: number + document_id: number original_name: string mime: string size: number @@ -36,6 +37,7 @@ id: number title: string description: string + document_id: number | null task_type: string status: string detail_state: string @@ -195,13 +197,13 @@ body: JSON.stringify({ available_minutes: availableMinutes }), }), deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), - // attachments - listAttachments: (taskId: number) => - request(`/api/tasks/${taskId}/attachments`), - uploadAttachments: (taskId: number, files: File[]) => { + // attachments (привязаны к документу — описание задачи или заметка проекта) + listAttachments: (documentId: number) => + request(`/api/documents/${documentId}/attachments`), + uploadAttachments: (documentId: number, files: File[]) => { const form = new FormData() files.forEach((f) => form.append('files', f)) - return request(`/api/tasks/${taskId}/attachments`, { + return request(`/api/documents/${documentId}/attachments`, { method: 'POST', body: form, }) diff --git a/frontend/src/components/TaskForm.vue b/frontend/src/components/TaskForm.vue index 54b0cf9..b552f45 100644 --- a/frontend/src/components/TaskForm.vue +++ b/frontend/src/components/TaskForm.vue @@ -107,12 +107,12 @@ } catch (e) { toast.error({ title: t('common.error'), text: String(e) }) } - if (task) void loadAttachments(task.id) + if (task && task.document_id !== null) void loadAttachments(task.document_id) }) -async function loadAttachments(taskId: number) { +async function loadAttachments(documentId: number) { try { - attachments.value = await api.listAttachments(taskId) + attachments.value = await api.listAttachments(documentId) } catch (e) { toast.error({ title: t('common.error'), text: String(e) }) } @@ -216,7 +216,7 @@ if (!props.task) return [] uploading.value = true try { - const saved = await api.uploadAttachments(props.task.id, files) + const saved = await api.uploadAttachments(props.task.document_id!, files) attachments.value.push(...saved) return saved } catch (e) { diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index b6b325d..e094b1d 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { useToast } from 'gnexus-ui-kit/vue' import { celebrateEarned } from '../gamification' -import { api, ApiError, type Project, type Tag, type Task } from '../api' +import { api, ApiError, type Attachment, type Project, type Tag, type Task } from '../api' import { deadlineOverdue, gradeToPriority, priorityOptions, priorityToGrade, priorityLabel, priorityVariant, relevanceLabel, renderMarkdown, setPageTitle, statusOptions } from '../taskui' import MdEditor from '../components/MdEditor.vue' import MdLightbox from '../components/MdLightbox.vue' @@ -124,6 +124,18 @@ } } +// Картинки в заметке: загрузка из буфера (MdEditor вставит markdown в курсор) +async function uploadImages(files: File[]): Promise { + const project0 = project.value + if (!project0?.document_id) return [] + try { + return await api.uploadAttachments(project0.document_id, files) + } catch (e) { + toast.error({ title: t('common.error'), text: String(e) }) + return [] + } +} + async function load() { loading.value = true notFound.value = false @@ -377,7 +389,7 @@ />
- + - +
{{ t('common.save') }} diff --git a/frontend/src/views/ProjectsView.vue b/frontend/src/views/ProjectsView.vue index 18e9614..8fa07c7 100644 --- a/frontend/src/views/ProjectsView.vue +++ b/frontend/src/views/ProjectsView.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useToast } from 'gnexus-ui-kit/vue' -import { api, type Project, type Task } from '../api' +import { api, type Attachment, type Project, type Task } from '../api' import { gradeToPriority, priorityOptions, @@ -80,6 +80,17 @@ editing.value = null } +// Картинки в заметке: загрузка из буфера (MdEditor вставит markdown в курсор) +async function uploadImages(files: File[]): Promise { + if (!editing.value?.document_id) return [] + try { + return await api.uploadAttachments(editing.value.document_id, files) + } catch (e) { + toast.error({ title: t('common.error'), text: String(e) }) + return [] + } +} + async function saveEdit() { if (!editing.value) return try { @@ -213,7 +224,7 @@ icon="ph-flag" :options="priorityOptionsWithNone" /> - +
{{ t('common.save') }} diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index 30019b2..5599090 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -67,7 +67,9 @@ error.value = '' try { task.value = await api.getTask(taskId.value) - attachments.value = await api.listAttachments(taskId.value) + if (task.value.document_id !== null) { + attachments.value = await api.listAttachments(task.value.document_id) + } // все задачи (не только утверждённые) — подзадача видна сразу после создания tasks.value = await api.listTasks() tagNames.value = task.value.tags.map((t0) => t0.name) @@ -155,7 +157,7 @@ const task0 = task.value if (!task0) return [] try { - const saved = await api.uploadAttachments(task0.id, files) + const saved = await api.uploadAttachments(task0.document_id!, files) attachments.value.push(...saved) return saved } catch (e) {