diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index b087307..e93966d 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -11,7 +11,7 @@ router = APIRouter(prefix="/api/projects", tags=["projects"]) -# «Закрыт» (archived) больше нет — вместо него архив (ТЗ 3.11) +# «Закрыт» (archived) больше нет — вместо него архив (ТЗ 3.8.1) VALID_RELEVANCE = {"active", "paused"} @@ -77,7 +77,7 @@ @router.post("/{project_id}/archive", response_model=ProjectOut) async def archive_project(project_id: int, db: DbDep, user: UserDep) -> Project: - """Отправить проект в архив — вместе со всеми его задачами (ТЗ 3.11).""" + """Отправить проект в архив — вместе со всеми его задачами (ТЗ 3.8.1).""" project = db.get(Project, project_id) if project is None: raise HTTPException(status_code=404, detail="Project not found") diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 39b2aa0..23a1993 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -114,6 +114,7 @@ status: str | None = Query(None), project_id: int | None = Query(None), tag_id: int | None = Query(None), + priority: str | None = Query(None), parent_id: int | None = Query(None), include_archived: bool = Query(False), ) -> list[Task]: @@ -125,7 +126,7 @@ if project_id: stmt = stmt.where(Task.project_id == project_id) elif not include_archived: - # задачи архивных проектов — только в истории (ТЗ 3.11) + # задачи архивных проектов — только в истории (ТЗ 3.8.1) stmt = stmt.where( or_(Task.project_id.is_(None), Task.project_id.not_in(_archived_project_ids(db))) ) @@ -133,6 +134,23 @@ stmt = stmt.where(Task.parent_task_id == parent_id) if tag_id: stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) + # Фильтр по приоритету (ТЗ 3.6) — по градациям шкалы 0-10 (см. 3.13): + # без приоритета задача считается «very_low» + if priority: + ranges = { + "very_low": (None, 2), + "low": (3, 4), + "medium": (5, 6), + "high": (7, 8), + "urgent": (9, 10), + } + if priority not in ranges: + raise HTTPException(status_code=422, detail=f"Unknown priority grade: {priority}") + low, high = ranges[priority] + if low is None: + stmt = stmt.where(or_(Task.priority.is_(None), Task.priority <= high)) + else: + stmt = stmt.where(Task.priority.between(low, high)) return list(db.scalars(stmt).all()) diff --git a/backend/app/main.py b/backend/app/main.py index 33575cb..44e7cc5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -42,7 +42,7 @@ class McpAuthMiddleware(BaseHTTPMiddleware): - """Bearer-токен для /mcp (до интеграции SSO-токенов, ТЗ 3.9).""" + """Bearer-токен для /mcp (до интеграции SSO-токенов, ТЗ 3.10).""" async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[Response]] diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index a29b0c0..cf01640 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -1,4 +1,4 @@ -"""MCP-сервер (M5, ТЗ 3.9): инструменты для ИИ-агентов. +"""MCP-сервер (M5, ТЗ 3.10): инструменты для ИИ-агентов. Агент действует от имени пользователя; мультитенантности нет. Аутентификация временно — bearer-токен из конфига (MCP_TOKEN), до интеграции SSO-токенов. diff --git a/backend/app/models.py b/backend/app/models.py index eb670cf..e42f17a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -163,7 +163,7 @@ # Статус актуальности проекта («Закрыт» теперь выражается архивом) relevance_status: Mapped[str] = mapped_column(String(20), default="active") - # Архив (ТЗ 3.11): проект уезжает в историю со всеми задачами + # Архив (ТЗ 3.8.1): проект уезжает в историю со всеми задачами is_archived: Mapped[bool] = mapped_column(Boolean, default=False) priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index cf3d4d7..436b39d 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -210,9 +210,15 @@ project_name = proposal.get("project") if project_name: + # только среди открытых проектов: в архивном (3.8.1) задача пропала бы + # из рабочих видов сразу после утверждения project = db.scalars( - select(Project).where(Project.name.ilike(project_name)) + select(Project).where( + Project.name.ilike(project_name), Project.is_archived.is_(False) + ) ).first() + # совпадение с именем архивного — treated as not found: LLM предложит + # проект заново или задача останется без проекта (пользователь поправит) if project is None and proposal.get("new_project"): project = Project(name=project_name.strip()) db.add(project) @@ -245,7 +251,9 @@ else "", } for p in session.scalars( - select(Project).where(Project.relevance_status == "active") + select(Project).where( + Project.relevance_status == "active", Project.is_archived.is_(False) + ) ).all() ] diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index d79ecbb..b51d051 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -68,6 +68,22 @@ assert done.json()["done_at"] is not None +def test_list_filter_by_priority(client: TestClient) -> None: + """Фильтр по градациям приоритета (ТЗ 3.6): без приоритета — very_low.""" + low = create_task(client, "без приоритета") + high = create_task(client, "срочная") + client.post(f"/api/tasks/{high['id']}/approve") + client.patch(f"/api/tasks/{high['id']}", json={"priority": 9}) + + very_low = client.get("/api/tasks", params={"priority": "very_low"}).json() + assert [t["id"] for t in very_low] == [low["id"]] + urgent = client.get("/api/tasks", params={"priority": "urgent"}).json() + assert [t["id"] for t in urgent] == [high["id"]] + + bad = client.get("/api/tasks", params={"priority": "nope"}) + assert bad.status_code == 422 + + def test_delete_task(client: TestClient) -> None: task = create_task(client) assert client.delete(f"/api/tasks/{task['id']}").json() == {"ok": True} diff --git a/docs/TZ.md b/docs/TZ.md index 39bf785..d99dfcb 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.28 | +| Версия ТЗ | 0.29 | | Дата | 2026-09-21 | | Статус | На обсуждении | @@ -117,6 +117,8 @@ ### 3.6. Представления задач - **Классический вид**: список с фильтрами (проект, тег, статус, приоритет). + Фильтр приоритета — по градациям шкалы 0-10 (очень низкий…срочный); задача без + приоритета попадает в «очень низкий». - **Пагинация**: длинные списки (список задач, стек на главной, задачи проекта, проекты, архив) разбиваются на страницы **по 30 элементов**; навигация кнопками (‹ номера ›, с усечением «…» и подписью «Страница N из M»). Смена фильтров diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 9667040..3538d05 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -365,6 +365,7 @@ filterStatus: 'Status', filterProject: 'Project', filterTag: 'Tag', + filterPriority: 'Priority', }, options: { title: 'Task handout', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 9fa3d74..2a30dfe 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -365,6 +365,7 @@ filterStatus: 'Статус', filterProject: 'Проект', filterTag: 'Тег', + filterPriority: 'Приоритет', }, options: { title: 'Выдача задачи', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 0c334bb..f314eef 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -365,6 +365,7 @@ filterStatus: 'Статус', filterProject: 'Проєкт', filterTag: 'Теґ', + filterPriority: 'Пріоритет', }, options: { title: 'Видача завдання', diff --git a/frontend/src/views/GardenView.vue b/frontend/src/views/GardenView.vue index 5a6f86b..cda9347 100644 --- a/frontend/src/views/GardenView.vue +++ b/frontend/src/views/GardenView.vue @@ -113,7 +113,8 @@ function tierLabel(a: Achievement): string { const idx = currentTier(a) const inHours = a.key === 'marathon' - const fmt = (n: number) => (inHours ? `${Math.round(n / 60)} ч` : `${n}`) + const fmt = (n: number) => + inHours ? t('taskui.time.h', { h: Math.round(n / 60) }) : `${n}` // Пройдено: галочка + высшая ступень («✓ III»); в процессе: ступень и прогресс if (idx === -1) return `✓ ${ROMANS[a.tiers.length - 1]}` return `${ROMANS[idx]} · ${fmt(a.progress)}/${fmt(a.tiers[idx])}` diff --git a/frontend/src/views/ListView.vue b/frontend/src/views/ListView.vue index d2fc81c..7cdb866 100644 --- a/frontend/src/views/ListView.vue +++ b/frontend/src/views/ListView.vue @@ -4,11 +4,11 @@ import { api, type Project, type Tag, type Task } from '../api' import { celebrateEarned } from '../gamification' import { useRealtimeRefresh } from '../realtime' -import { statusOptions } from '../taskui' +import { statusOptions, priorityOptions } from '../taskui' import TaskCard from '../components/TaskCard.vue' import Pagination from '../components/Pagination.vue' -// Классический вид (ТЗ 3.6): список с фильтрами — проект, тег, статус. +// Классический вид (ТЗ 3.6): список с фильтрами — проект, тег, статус, приоритет. // Фильтры применяются сразу при смене значения. const { t } = useI18n() @@ -23,6 +23,7 @@ status: 'to_do', project_id: '', tag_id: '', + priority: '', }) // Пагинация: по 30 карточек на страницу, смена фильтра — с первой @@ -53,6 +54,7 @@ status: filters.value.status, project_id: filters.value.project_id, tag_id: filters.value.tag_id, + priority: filters.value.priority, }) if (seq !== loadSeq) return tasks.value = fetched @@ -118,6 +120,11 @@ { value: '', label: t('common.anyStatus') }, ...statusOptions(), ]) +// Градации приоритета (ТЗ 3.6): без приоритета задача считается very_low +const priorityOptionsWithAny = computed(() => [ + { value: '', label: t('common.any') }, + ...priorityOptions(), +])