diff --git a/README.md b/README.md index c95ad1a..25bf2fb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,16 @@ ## Разработка +Dev-БД (docker, порт 15432 — 5432/5433/5434 на машине заняты): + +```bash +docker run -d --name gntodo-postgres -p 15432:5432 \ + -e POSTGRES_USER=gntodo -e POSTGRES_PASSWORD=gntodo -e POSTGRES_DB=gntodo \ + postgres:17-alpine +``` + +Миграции: `cd backend && uv run alembic upgrade head`. + Backend: ```bash diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..c5ddc18 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# url берётся из app settings (см. alembic/env.py) + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..58fe930 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,52 @@ +"""Alembic environment: URL из app settings, метаданные из app.models.""" + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context +from app import models # noqa: F401 — регистрация моделей в Base.metadata +from app.config import get_settings +from app.db import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/9886818da1f0_m1_tasks_projects_tags.py b/backend/alembic/versions/9886818da1f0_m1_tasks_projects_tags.py new file mode 100644 index 0000000..15275f9 --- /dev/null +++ b/backend/alembic/versions/9886818da1f0_m1_tasks_projects_tags.py @@ -0,0 +1,75 @@ +"""M1: tasks, projects, tags + +Revision ID: 9886818da1f0 +Revises: +Create Date: 2026-09-19 20:29:35.401325 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '9886818da1f0' +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('relevance_status', sa.String(length=20), nullable=False), + sa.Column('priority', sa.Integer(), nullable=True), + sa.Column('note', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('tasks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=500), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('task_type', sa.String(length=20), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('detail_state', sa.String(length=20), nullable=False), + sa.Column('done', sa.Boolean(), nullable=False), + sa.Column('parent_task_id', sa.Integer(), nullable=True), + sa.Column('project_id', sa.Integer(), nullable=True), + sa.Column('priority', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('done_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['parent_task_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_tags', + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('task_id', 'tag_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task_tags') + op.drop_table('tasks') + op.drop_table('tags') + op.drop_table('projects') + # ### end Alembic commands ### diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/backend/app/api/__init__.py diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..5d86b51 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,54 @@ +"""API проектов M1.""" + +from fastapi import APIRouter, HTTPException +from sqlalchemy import select + +from app.dependencies import DbDep, UserDep +from app.models import Project +from app.schemas import ProjectCreate, ProjectOut, ProjectUpdate + +router = APIRouter(prefix="/api/projects", tags=["projects"]) + +VALID_RELEVANCE = {"active", "paused", "archived"} + + +@router.post("", response_model=ProjectOut) +async def create_project(schema: ProjectCreate, db: DbDep, user: UserDep) -> Project: + exists = db.scalars(select(Project).where(Project.name == schema.name)).first() + if exists is not None: + raise HTTPException(status_code=409, detail="Project name already exists") + project = Project(name=schema.name, priority=schema.priority, note=schema.note) + db.add(project) + db.flush() + return project + + +@router.get("", response_model=list[ProjectOut]) +async def list_projects(db: DbDep, user: UserDep) -> list[Project]: + return list(db.scalars(select(Project).order_by(Project.id)).all()) + + +@router.patch("/{project_id}", response_model=ProjectOut) +async def update_project( + project_id: int, schema: ProjectUpdate, db: DbDep, user: UserDep +) -> Project: + project = db.get(Project, project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + data = schema.model_dump(exclude_unset=True) + if "relevance_status" in data and data["relevance_status"] not in VALID_RELEVANCE: + raise HTTPException( + status_code=422, detail=f"Unknown relevance_status: {data['relevance_status']}" + ) + for field, value in data.items(): + setattr(project, field, value) + return project + + +@router.delete("/{project_id}") +async def delete_project(project_id: int, db: DbDep) -> dict[str, bool]: + project = db.get(Project, project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + db.delete(project) + return {"ok": True} diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py new file mode 100644 index 0000000..6063f53 --- /dev/null +++ b/backend/app/api/tags.py @@ -0,0 +1,35 @@ +"""API тегов M1 (справочник).""" + +from fastapi import APIRouter, HTTPException +from sqlalchemy import select + +from app.dependencies import DbDep, UserDep +from app.models import Tag +from app.schemas import TagCreate, TagOut + +router = APIRouter(prefix="/api/tags", tags=["tags"]) + + +@router.post("", response_model=TagOut) +async def create_tag(schema: TagCreate, db: DbDep, user: UserDep) -> Tag: + existing = db.scalars(select(Tag).where(Tag.name == schema.name)).first() + if existing is not None: + raise HTTPException(status_code=409, detail="Tag already exists") + tag = Tag(name=schema.name) + db.add(tag) + db.flush() + return tag + + +@router.get("", response_model=list[TagOut]) +async def list_tags(db: DbDep, user: UserDep) -> list[Tag]: + return list(db.scalars(select(Tag).order_by(Tag.name)).all()) + + +@router.delete("/{tag_id}") +async def delete_tag(tag_id: int, db: DbDep) -> dict[str, bool]: + tag = db.get(Tag, tag_id) + if tag is None: + raise HTTPException(status_code=404, detail="Tag not found") + db.delete(tag) + return {"ok": True} diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..e88f3b1 --- /dev/null +++ b/backend/app/api/tasks.py @@ -0,0 +1,97 @@ +"""API задач M1: быстрый захват, стек, ручная детализация, CRUD.""" + +from typing import Any, cast + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import select + +from app.dependencies import DbDep, UserDep +from app.models import Project, Tag, Task, utcnow +from app.schemas import TaskCreate, TaskOut, TaskUpdate + +router = APIRouter(prefix="/api/tasks", tags=["tasks"]) + +VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} + + +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 + + +@router.post("") +async def create_task(schema: TaskCreate, db: DbDep, user: UserDep) -> dict[str, int]: + """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do).""" + task = Task(title=schema.title, description=schema.description) + db.add(task) + db.flush() + return {"id": task.id} + + +@router.get("", response_model=list[TaskOut]) +async def list_tasks( + db: DbDep, + user: UserDep, + detail_state: str | None = Query(None), + status: str | None = Query(None), + project_id: int | None = Query(None), +) -> list[Task]: + stmt = select(Task).order_by(Task.created_at.desc()) + if detail_state: + stmt = stmt.where(Task.detail_state == detail_state) + if status: + stmt = stmt.where(Task.status == status) + if project_id: + stmt = stmt.where(Task.project_id == project_id) + return list(db.scalars(stmt).all()) + + +@router.get("/{task_id}", response_model=TaskOut) +async def get_task(task_id: int, db: DbDep, user: UserDep) -> Task: + return _get_task_or_404(db, task_id) + + +@router.patch("/{task_id}", response_model=TaskOut) +async def update_task(task_id: int, schema: TaskUpdate, db: DbDep, user: UserDep) -> Task: + task = _get_task_or_404(db, task_id) + data = schema.model_dump(exclude_unset=True) + + if "title" in data and not str(data["title"]).strip(): + raise HTTPException(status_code=422, detail="title cannot be empty") + if "status" in data and data["status"] not in VALID_STATUSES: + raise HTTPException(status_code=422, detail=f"Unknown status: {data['status']}") + 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 "tag_ids" in data: + tag_ids = data.pop("tag_ids") or [] + tags = db.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all() + if len(tags) != len(set(tag_ids)): + raise HTTPException(status_code=400, detail="Unknown tag id in tag_ids") + task.tags = list(tags) + + for field, value in data.items(): + setattr(task, field, value) + if data.get("status") == "done" and task.done_at is None: + task.done_at = utcnow() + + db.flush() + db.refresh(task) # перечитать связи (project/tags) после обновления + return task + + +@router.post("/{task_id}/approve", response_model=TaskOut) +async def approve_task(task_id: int, db: DbDep, user: UserDep) -> Task: + """Утверждение детализации: raw → approved.""" + task = _get_task_or_404(db, task_id) + task.detail_state = "approved" + task.approved_at = utcnow() + return task + + +@router.delete("/{task_id}") +async def delete_task(task_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: + db.delete(_get_task_or_404(db, task_id)) + return {"ok": True} diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..8e2b91c --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,42 @@ +"""Подключение к БД и базовый класс моделей (SQLAlchemy 2.0, sync).""" + +from collections.abc import Generator + +from sqlalchemy import Engine, create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class Base(DeclarativeBase): + pass + + +_engine: Engine | None = None +_session_factory: sessionmaker[Session] | None = None + + +def get_engine() -> Engine: + global _engine + if _engine is None: + _engine = create_engine(get_settings().database_url, pool_pre_ping=True) + return _engine + + +def get_session_factory() -> sessionmaker[Session]: + global _session_factory + if _session_factory is None: + _session_factory = sessionmaker(bind=get_engine(), expire_on_commit=False) + return _session_factory + + +def get_db() -> Generator[Session, None, None]: + session = get_session_factory()() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..0c857c6 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,21 @@ +"""Общие зависимости FastAPI: авторизация и сессия БД.""" + +from typing import Annotated, cast + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.db import get_db + +DbDep = Annotated[Session, Depends(get_db)] + + +def require_user(request: Request) -> dict[str, str]: + """Защита API: пользователь должен быть залогинен (сессия SSO).""" + user = request.session.get("user") + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return cast(dict[str, str], user) + + +UserDep = Annotated[dict[str, str], Depends(require_user)] diff --git a/backend/app/main.py b/backend/app/main.py index 8f3db01..aa498a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,10 @@ -"""Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённый /api.""" +"""Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённое /api.""" -from typing import Annotated, cast - -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware +from app.api import projects, tags, tasks from app.auth.routes import router as auth_router from app.config import get_settings @@ -24,23 +23,11 @@ app.add_middleware(SessionMiddleware, secret_key=settings.session_secret) app.include_router(auth_router) - - -def require_user(request: Request) -> dict[str, str]: - """Зависимость-защита API: пользователь должен быть залогинен.""" - user = request.session.get("user") - if not user: - raise HTTPException(status_code=401, detail="Not authenticated") - return cast(dict[str, str], user) +app.include_router(tasks.router) +app.include_router(projects.router) +app.include_router(tags.router) @app.get("/api/health") async def health() -> dict[str, str]: return {"status": "ok"} - - -@app.get("/api/me") -async def api_me( - user: Annotated[dict[str, str], Depends(require_user)], -) -> dict[str, dict[str, str]]: - return {"user": user} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..567e1a8 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,91 @@ +"""Модели данных M1: задачи, проекты, теги. + +Дедлайны/повторения/бюджет/вложения добавляются миграциями в следующих вехах +(см. docs/TZ.md, модель данных). +""" + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(500)) + description: Mapped[str] = mapped_column(Text, default="") + + # Тип: разовая / регулярная (регулярные — в следующих вехах) + task_type: Mapped[str] = mapped_column(String(20), default="one_time") + + # Статус выполнения + status: Mapped[str] = mapped_column(String(20), default="to_do") + # Плоскость детализации стека: raw (в стеке) / approved (утверждена) + detail_state: Mapped[str] = mapped_column(String(20), default="raw") + done: Mapped[bool] = mapped_column(Boolean, default=False) + + parent_task_id: Mapped[int | None] = mapped_column( + ForeignKey("tasks.id"), nullable=True, default=None + ) + project_id: Mapped[int | None] = mapped_column( + ForeignKey("projects.id"), nullable=True, default=None + ) + priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + + tags: Mapped[list["Tag"]] = relationship( + secondary="task_tags", back_populates="tasks", lazy="selectin" + ) + project: Mapped["Project | None"] = relationship(back_populates="tasks", lazy="joined") + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + approved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) + done_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(200), unique=True) + # Статус актуальности проекта + 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") + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class Tag(Base): + __tablename__ = "tags" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True) + + tasks: Mapped[list[Task]] = relationship( + secondary="task_tags", back_populates="tags", lazy="selectin" + ) + + +class TaskTag(Base): + __tablename__ = "task_tags" + + task_id: Mapped[int] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True + ) + tag_id: Mapped[int] = mapped_column( + ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..2287fd1 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,78 @@ +"""Pydantic-схемы API M1.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +# --- Task --- + + +class TagOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + + +class ProjectOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + relevance_status: str + priority: int | None + note: str + + +class TaskCreate(BaseModel): + title: str = Field(min_length=1, max_length=500) + description: str = "" + + +class TaskUpdate(BaseModel): + """Частичное обновление метаданных задачи (ручная детализация).""" + + title: str | None = Field(None, max_length=500) + description: str | None = None + project_id: int | None = None + tag_ids: list[int] | None = None + priority: int | None = None + status: str | None = None + + +class TaskOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + description: str + task_type: str + status: str + detail_state: str + parent_task_id: int | None + project: ProjectOut | None + priority: int | None + tags: list[TagOut] + created_at: datetime + approved_at: datetime | None + done_at: datetime | None + + +# --- Project / Tag --- + + +class ProjectCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + priority: int | None = None + note: str = "" + + +class ProjectUpdate(BaseModel): + name: str | None = Field(None, max_length=200) + relevance_status: str | None = None + priority: int | None = None + note: str | None = None + + +class TagCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 449afdb..c0985a5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,57 @@ -"""Тестовое окружение: фиктивные креды SSO и сессии до импорта приложения.""" +"""Фикстуры тестов: in-memory БД и авторизация-заглушка.""" -import os +from collections.abc import Iterator +from typing import Any -os.environ.setdefault("GAUTH_CLIENT_ID", "test-client") -os.environ.setdefault("GAUTH_CLIENT_SECRET", "test-secret") -os.environ.setdefault("SESSION_SECRET", "test-session-secret") +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db import Base +from app.dependencies import get_db, require_user +from app.main import app + +TEST_DB_URL = "sqlite://" + +_engine = create_engine( + TEST_DB_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +Base.metadata.create_all(_engine) +_test_session_factory = sessionmaker(bind=_engine, expire_on_commit=False) + + +def _override_get_db() -> Iterator[Session]: + session = _test_session_factory() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +app.dependency_overrides[get_db] = _override_get_db + +AUTH_USER: dict[str, str] = {"user_id": "1", "email": "test@example.com"} +app.dependency_overrides[require_user] = lambda: AUTH_USER + + +@pytest.fixture +def client() -> Iterator[TestClient]: + with TestClient(app) as c: + yield c + + +def create_task(client: TestClient, title: str = "тестовая задача") -> dict[str, Any]: + resp = client.post("/api/tasks", json={"title": title}) + assert resp.status_code == 200, resp.text + task_id = resp.json()["id"] + resp = client.get(f"/api/tasks/{task_id}") + assert resp.status_code == 200, resp.text + return resp.json() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e1a3e07..0b94881 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -12,10 +12,6 @@ assert client.get("/api/health").json() == {"status": "ok"} -def test_api_me_requires_auth() -> None: - assert client.get("/api/me").status_code == 401 - - def test_auth_me_requires_auth() -> None: assert client.get("/auth/me").status_code == 401 @@ -27,3 +23,19 @@ location = resp.headers["location"] assert location.startswith(base_url) assert "state=" in location and "code_challenge=" in location + + +def test_api_requires_auth() -> None: + """Регрессия: все /api эндпоинты должны требовать сессию.""" + from app.dependencies import require_user + + saved = app.dependency_overrides.pop(require_user, None) + try: + with TestClient(app) as c: + assert c.get("/api/tasks").status_code == 401 + assert c.get("/api/projects").status_code == 401 + assert c.get("/api/tags").status_code == 401 + assert c.post("/api/tasks", json={"title": "x"}).status_code == 401 + finally: + if saved is not None: + app.dependency_overrides[require_user] = saved diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py new file mode 100644 index 0000000..8d9565b --- /dev/null +++ b/backend/tests/test_tasks_api.py @@ -0,0 +1,95 @@ +"""Тесты API M1: быстрый захват, стек, детализация, проекты, теги.""" + +from typing import Any + +from fastapi.testclient import TestClient + + +def create_task(client: TestClient, title: str = "тестовая задача") -> dict[str, Any]: + resp = client.post("/api/tasks", json={"title": title}) + assert resp.status_code == 200, resp.text + task_id = resp.json()["id"] + resp = client.get(f"/api/tasks/{task_id}") + assert resp.status_code == 200, resp.text + return resp.json() + + +def test_quick_capture_lands_in_stack(client: TestClient) -> None: + task = create_task(client, "позвонить маме") + assert task["detail_state"] == "raw" + assert task["status"] == "to_do" + assert task["title"] == "позвонить маме" + + stack = client.get("/api/tasks", params={"detail_state": "raw"}).json() + assert any(t["id"] == task["id"] for t in stack) + + +def test_approve_moves_out_of_stack(client: TestClient) -> None: + task = create_task(client) + resp = client.post(f"/api/tasks/{task['id']}/approve") + assert resp.status_code == 200 + approved = resp.json() + assert approved["detail_state"] == "approved" + assert approved["approved_at"] is not None + + stack = client.get("/api/tasks", params={"detail_state": "raw"}).json() + assert all(t["id"] != task["id"] for t in stack) + + +def test_update_with_project_and_tags(client: TestClient) -> None: + project_id = client.post("/api/projects", json={"name": "дом"}).json()["id"] + tag_id = client.post("/api/tags", json={"name": "срочное"}).json()["id"] + task = create_task(client) + + resp = client.patch( + f"/api/tasks/{task['id']}", + json={"project_id": project_id, "tag_ids": [tag_id], "priority": 3}, + ) + assert resp.status_code == 200, resp.text + updated = resp.json() + assert updated["project"]["id"] == project_id + assert [t["id"] for t in updated["tags"]] == [tag_id] + assert updated["priority"] == 3 + + +def test_update_unknown_project_rejected(client: TestClient) -> None: + task = create_task(client) + resp = client.patch(f"/api/tasks/{task['id']}", json={"project_id": 99999}) + assert resp.status_code == 400 + + +def test_status_validation_and_done_at(client: TestClient) -> None: + task = create_task(client) + bad = client.patch(f"/api/tasks/{task['id']}", json={"status": "nope"}) + assert bad.status_code == 422 + + done = client.patch(f"/api/tasks/{task['id']}", json={"status": "done"}) + assert done.status_code == 200 + assert done.json()["done_at"] is not None + + +def test_delete_task(client: TestClient) -> None: + task = create_task(client) + assert client.delete(f"/api/tasks/{task['id']}").json() == {"ok": True} + assert client.get(f"/api/tasks/{task['id']}").status_code == 404 + + +def test_project_crud_and_duplicate_name(client: TestClient) -> None: + resp = client.post("/api/projects", json={"name": "gntodo-разработка"}) + assert resp.status_code == 200 + pid = resp.json()["id"] + + dup = client.post("/api/projects", json={"name": "gntodo-разработка"}) + assert dup.status_code == 409 + + resp = client.patch(f"/api/projects/{pid}", json={"relevance_status": "paused"}) + assert resp.status_code == 200 + assert resp.json()["relevance_status"] == "paused" + + bad = client.patch(f"/api/projects/{pid}", json={"relevance_status": "nope"}) + assert bad.status_code == 422 + + +def test_tag_duplicate(client: TestClient) -> None: + assert client.post("/api/tags", json={"name": "быт"}).status_code == 200 + assert client.post("/api/tags", json={"name": "быт"}).status_code == 409 diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..3e50515 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,80 @@ +// Мини-клиент API. Ошибки авторизации редиректят на вход. + +export interface Tag { + id: number + name: string +} + +export interface Project { + id: number + name: string + relevance_status: string + priority: number | null + note: string +} + +export interface Task { + id: number + title: string + description: string + task_type: string + status: string + detail_state: string + parent_task_id: number | null + project: Project | null + priority: number | null + tags: Tag[] + created_at: string + approved_at: string | null + done_at: string | null +} + +export class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + if (res.status === 401) { + window.location.href = '/auth/login' + throw new ApiError(401, 'Not authenticated') + } + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })) + throw new ApiError(res.status, body.detail ?? 'Ошибка запроса') + } + return res.json() as Promise +} + +export const api = { + // tasks + listTasks: (params: Record = {}) => + request('/api/tasks?' + new URLSearchParams( + Object.entries(params).map(([k, v]) => [k, String(v)]), + )), + getTask: (id: number) => request(`/api/tasks/${id}`), + createTask: (title: string, description = '') => + request<{ id: number }>('/api/tasks', { + method: 'POST', + body: JSON.stringify({ title, description }), + }), + updateTask: (id: number, patch: Partial & { tag_ids?: number[] }) => + request(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), + approveTask: (id: number) => request(`/api/tasks/${id}/approve`, { method: 'POST' }), + deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), + // projects + listProjects: () => request('/api/projects'), + createProject: (name: string) => + request('/api/projects', { method: 'POST', body: JSON.stringify({ name }) }), + // tags + listTags: () => request('/api/tags'), + createTag: (name: string) => + request('/api/tags', { method: 'POST', body: JSON.stringify({ name }) }), +} \ No newline at end of file diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue index ac81e2a..3730b77 100644 --- a/frontend/src/views/StackView.vue +++ b/frontend/src/views/StackView.vue @@ -1,10 +1,207 @@