diff --git a/.env.example b/.env.example index 940b8b1..9c612b2 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,6 @@ OLLAMA_MODEL=qwen3.5:2b-q4_K_M # MCP-сервер (M5): bearer-токен для ИИ-агентов; пусто — /mcp закрыт -MCP_TOKEN=change-me-mcp-token # docker-compose (M7): TLS терминирует внешний reverse-proxy — # он пробрасывает трафик на WEB_PORT (контейнер web слушает :80); @@ -25,4 +24,4 @@ POSTGRES_DB=gntodo WEB_PORT=8135 # GAUTH_REDIRECT_URI в проде — https-адрес callback, зарегистрированный в SSO -# GAUTH_REDIRECT_URI=https://<ваш-домен>/auth/callback \ No newline at end of file +# GAUTH_REDIRECT_URI=https://<ваш-домен>/auth/callback diff --git a/backend/alembic/versions/f9a0b1c2d3e4_mcp_users_multitenancy.py b/backend/alembic/versions/f9a0b1c2d3e4_mcp_users_multitenancy.py new file mode 100644 index 0000000..067e0f6 --- /dev/null +++ b/backend/alembic/versions/f9a0b1c2d3e4_mcp_users_multitenancy.py @@ -0,0 +1,130 @@ +"""Мультиюзерность: users, mcp_tokens, user_id на таблицах, per-user настройки. + +- users: профиль из SSO, upsert при логине; FK-цель для user_id. +- user_id (nullable) на 7 таблицах: существующие строки остаются бесхозными + (NULL) и приписываются пользователю при первом логине (claim, ТЗ 1.2). +- Уникальности имени проекта/тега становятся per-user (user_id, name). +- uq_coin_daily/uq_coin_level становятся per-user. +- app_settings → app_settings_global (источник claim), новая app_settings + с PK (user_id, key). + +Тесты создают схему через Base.metadata.create_all — эта миграция только +для Postgres. + +Revision ID: f9a0b1c2d3e4 +Revises: e7f8a9b0c1d2 +Create Date: 2026-09-22 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "f9a0b1c2d3e4" +down_revision: str | None = "e7f8a9b0c1d2" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + +USER_TABLES = ( + "projects", + "tasks", + "tags", + "documents", + "xp_events", + "coin_events", + "garden_items", +) + + +def upgrade() -> None: + # 1. Пользователи и MCP-токены + op.create_table( + "users", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("email", sa.String(255), nullable=False, server_default=""), + sa.Column("avatar_url", sa.String(500), nullable=True), + sa.Column("locale", sa.String(16), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "mcp_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.String(64), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False, unique=True), + sa.Column("label", sa.String(100), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + + # 2. user_id на таблицы данных (nullable — claim при первом логине) + for table in USER_TABLES: + op.add_column(table, sa.Column("user_id", sa.String(64), nullable=True)) + op.create_index(f"ix_{table}_user_id", table, ["user_id"]) + op.create_foreign_key( + f"fk_{table}_user_id_users", table, "users", ["user_id"], ["id"] + ) + + # 3. Имена проектов/тегов — уникальны в пределах пользователя + op.drop_constraint("projects_name_key", "projects", type_="unique") + op.drop_constraint("tags_name_key", "tags", type_="unique") + op.create_index("uq_projects_user_name", "projects", ["user_id", "name"], unique=True) + op.create_index("uq_tags_user_name", "tags", ["user_id", "name"], unique=True) + + # 4. Дейли/уровень — награды per-user + op.drop_index("uq_coin_daily", table_name="coin_events") + op.drop_index("uq_coin_level", table_name="coin_events") + op.create_index( + "uq_coin_daily", + "coin_events", + ["user_id", "source", "day"], + unique=True, + postgresql_where=sa.text("source = 'daily'"), + ) + op.create_index( + "uq_coin_level", + "coin_events", + ["user_id", "level"], + unique=True, + postgresql_where=sa.text("source = 'level' AND level IS NOT NULL"), + ) + + # 5. Настройки: глобальные → источник claim, новые — per-user + op.rename_table("app_settings", "app_settings_global") + op.create_table( + "app_settings", + sa.Column("user_id", sa.String(64), primary_key=True), + sa.Column("key", sa.String(100), primary_key=True), + sa.Column("value", sa.String(200), nullable=False), + ) + + +def downgrade() -> None: + # Данные per-user настроек теряются (остаются только глобальные) + op.drop_table("app_settings") + op.rename_table("app_settings_global", "app_settings") + op.drop_index("uq_coin_level", table_name="coin_events") + op.drop_index("uq_coin_daily", table_name="coin_events") + op.create_index( + "uq_coin_daily", + "coin_events", + ["source", "day"], + unique=True, + postgresql_where=sa.text("source = 'daily'"), + ) + op.create_index( + "uq_coin_level", + "coin_events", + ["level"], + unique=True, + postgresql_where=sa.text("source = 'level' AND level IS NOT NULL"), + ) + op.create_unique_constraint("projects_name_key", "projects", ["name"]) + op.create_unique_constraint("tags_name_key", "tags", ["name"]) + op.drop_index("uq_tags_user_name", table_name="tags") + op.drop_index("uq_projects_user_name", table_name="projects") + for table in USER_TABLES: + op.drop_constraint(f"fk_{table}_user_id_users", table, type_="foreignkey") + op.drop_index(f"ix_{table}_user_id", table_name=table) + op.drop_column(table, "user_id") + op.drop_table("mcp_tokens") + op.drop_table("users") \ No newline at end of file diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py index 85f5ff4..41efff9 100644 --- a/backend/app/api/attachments.py +++ b/backend/app/api/attachments.py @@ -14,7 +14,7 @@ from sqlalchemy import select from app.config import get_settings -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.models import Attachment, Document from app.schemas import AttachmentOut @@ -35,15 +35,26 @@ return path -def _get_document_or_404(db: Any, document_id: int) -> Document: - doc = cast(Document | None, db.get(Document, document_id)) +def _get_document_or_404(db: Any, document_id: int, user_id: str) -> Document: + doc = cast( + Document | None, + db.scalar(select(Document).where(Document.id == document_id, Document.user_id == user_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: - att = cast(Attachment | None, db.get(Attachment, attachment_id)) +def _get_attachment_or_404(db: Any, attachment_id: int, user_id: str) -> Attachment: + # Вложение авторизуем через его документ (у attachment своего user_id нет) + att = cast( + Attachment | None, + db.scalar( + select(Attachment) + .join(Document, Document.id == Attachment.document_id) + .where(Attachment.id == attachment_id, Document.user_id == user_id) + ), + ) if att is None: raise HTTPException(status_code=404, detail="Attachment not found") return att @@ -51,10 +62,10 @@ @router.post("/documents/{document_id}/attachments", response_model=list[AttachmentOut]) async def upload_attachments( - document_id: int, db: DbDep, user: UserDep, files: list[UploadFile] + document_id: int, db: DbDep, user_id: UserIdDep, files: list[UploadFile] ) -> list[Attachment]: """Загрузка файлов (в т.ч. Ctrl+V из буфера — приходит как обычный файл).""" - document = _get_document_or_404(db, document_id) + document = _get_document_or_404(db, document_id, user_id) saved: list[Attachment] = [] for file in files: mime = file.content_type or "application/octet-stream" @@ -91,8 +102,8 @@ @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) +async def list_attachments(document_id: int, db: DbDep, user_id: UserIdDep) -> list[Attachment]: + document = _get_document_or_404(db, document_id, user_id) return list( db.scalars( select(Attachment).where(Attachment.document_id == document.id).order_by(Attachment.id) @@ -101,8 +112,8 @@ @router.get("/attachments/{attachment_id}/file") -async def get_attachment_file(attachment_id: int, db: DbDep, user: UserDep) -> FileResponse: - att = _get_attachment_or_404(db, attachment_id) +async def get_attachment_file(attachment_id: int, db: DbDep, user_id: UserIdDep) -> FileResponse: + att = _get_attachment_or_404(db, attachment_id, user_id) path = _attachments_dir() / att.filename if not path.is_file(): raise HTTPException(status_code=404, detail="File missing on disk") @@ -111,8 +122,8 @@ @router.delete("/attachments/{attachment_id}") -async def delete_attachment(attachment_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: - att = _get_attachment_or_404(db, attachment_id) +async def delete_attachment(attachment_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: + att = _get_attachment_or_404(db, attachment_id, user_id) path = _attachments_dir() / att.filename if path.is_file(): path.unlink() diff --git a/backend/app/api/garden.py b/backend/app/api/garden.py index 2dd4818..5e9451e 100644 --- a/backend/app/api/garden.py +++ b/backend/app/api/garden.py @@ -1,12 +1,12 @@ """API сада (ТЗ 3.13): сцена, монеты, магазин декораций и расширений.""" -from typing import Any +from typing import Any, cast from fastapi import APIRouter, HTTPException from pydantic import BaseModel from sqlalchemy import select -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.models import GardenItem, Task, XpEvent from app.realtime import publish from app.services import garden @@ -39,18 +39,18 @@ item_key: str -def _state(db: Any) -> dict[str, Any]: +def _state(db: Any, user_id: str) -> dict[str, Any]: """Полное состояние сцены после ленивого обслуживания.""" - ensure_garden(db) - level = level_from_xp(total_xp(db)) - size = grid_size(db) + ensure_garden(db, user_id) + level = level_from_xp(total_xp(db, user_id)) + size = grid_size(db, user_id) # Растения + их события (редкость, задача) одним запросом rows = db.execute( select(GardenItem, XpEvent, Task) .join(XpEvent, GardenItem.ref_id == XpEvent.id, isouter=True) .join(Task, XpEvent.task_id == Task.id, isouter=True) - .where(GardenItem.kind == "plant") + .where(GardenItem.kind == "plant", GardenItem.user_id == user_id) .order_by(GardenItem.id) ).all() items: list[dict[str, Any]] = [] @@ -70,7 +70,9 @@ } ) decorations = db.scalars( - select(GardenItem).where(GardenItem.kind == "decoration").order_by(GardenItem.id) + select(GardenItem) + .where(GardenItem.kind == "decoration", GardenItem.user_id == user_id) + .order_by(GardenItem.id) ).all() items += [ { @@ -88,9 +90,9 @@ for d in decorations ] - expansion = next_expansion(db) + expansion = next_expansion(db, user_id) return { - "balance": balance(db), + "balance": balance(db, user_id), "level": level, "grid": size, "items": items, @@ -101,21 +103,26 @@ "next_expansion": expansion, # весь список расширений — для дерева уровней (что открывается на уровне) "expansions": [{"key": key, **spec} for key, spec in sorted(EXPANSIONS.items())], - "expansions_bought": garden.expansions_bought(db), + "expansions_bought": garden.expansions_bought(db, user_id), "species": species_catalog(level), "upgrade_costs": UPGRADE_COSTS, } @router.get("") -async def get_garden(db: DbDep, user: UserDep) -> dict[str, Any]: +async def get_garden(db: DbDep, user_id: UserIdDep) -> dict[str, Any]: """Состояние сцены: баланс, элементы, каталоги, сетка.""" - return _state(db) + return _state(db, user_id) @router.patch("/items/{item_id}") -async def move_item(item_id: int, move: MoveIn, db: DbDep, user: UserDep) -> dict[str, Any]: - item = db.get(GardenItem, item_id) +async def move_item(item_id: int, move: MoveIn, db: DbDep, user_id: UserIdDep) -> dict[str, Any]: + item = cast( + GardenItem | None, + db.scalar( + select(GardenItem).where(GardenItem.id == item_id, GardenItem.user_id == user_id) + ), + ) if item is None: raise HTTPException(status_code=404, detail="Item not found") # x/y None — декорация возвращается в инвентарь (растениям нельзя: они от задач) @@ -124,22 +131,27 @@ raise HTTPException(status_code=400, detail="Plants cannot be stored") item.x, item.y = None, None db.commit() - publish(str(user["user_id"]), "garden.changed", {"reason": "move"}) + publish(user_id, "garden.changed", {"reason": "move"}) return {"ok": True} - size = grid_size(db) + size = grid_size(db, user_id) if not (0 <= move.x < size["cols"] and 0 <= move.y < size["rows"]): raise HTTPException(status_code=400, detail="Position outside the garden") if item.kind == "plant" and (move.x, move.y) in house_footprint(size["cols"], size["rows"]): raise HTTPException(status_code=400, detail="Cannot place on the house") item.x, item.y = move.x, move.y db.commit() - publish(str(user["user_id"]), "garden.changed", {"reason": "move"}) + publish(user_id, "garden.changed", {"reason": "move"}) return {"ok": True} @router.post("/plants/{item_id}/upgrade") -async def upgrade_plant(item_id: int, db: DbDep, user: UserDep) -> dict[str, Any]: - item = db.get(GardenItem, item_id) +async def upgrade_plant(item_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, Any]: + item = cast( + GardenItem | None, + db.scalar( + select(GardenItem).where(GardenItem.id == item_id, GardenItem.user_id == user_id) + ), + ) if item is None or item.kind != "plant": raise HTTPException(status_code=404, detail="Plant not found") if item.stage >= MAX_STAGE: @@ -147,19 +159,19 @@ event = db.get(XpEvent, item.ref_id) if item.ref_id else None rarity = event.rarity if event else "common" cost = UPGRADE_COSTS[rarity][item.stage] - if not garden.can_afford(db, cost): + if not garden.can_afford(db, user_id, cost): raise HTTPException(status_code=402, detail=f"Not enough coins: need {cost}") - garden.spend(db, cost, f"upgrade:{item.item_key}") + garden.spend(db, user_id, cost, f"upgrade:{item.item_key}") item.stage += 1 db.commit() - publish(str(user["user_id"]), "garden.changed", {"reason": "upgrade"}) - return {"ok": True, "stage": item.stage, "cost": cost, "balance": balance(db)} + publish(user_id, "garden.changed", {"reason": "upgrade"}) + return {"ok": True, "stage": item.stage, "cost": cost, "balance": balance(db, user_id)} @router.post("/shop/buy") -async def shop_buy(buy: BuyIn, db: DbDep, user: UserDep) -> dict[str, Any]: +async def shop_buy(buy: BuyIn, db: DbDep, user_id: UserIdDep) -> dict[str, Any]: item_key = buy.item_key - level = level_from_xp(total_xp(db)) + level = level_from_xp(total_xp(db, user_id)) # Семя растения: вид из каталога, открытие по уровню, посадка сразу if item_key.startswith("seed:"): @@ -169,59 +181,61 @@ raise HTTPException(status_code=400, detail=f"Unknown species: {species}") if level < spec["level"]: raise HTTPException(status_code=403, detail=f"Unlocks at level {spec['level']}") - if not garden.can_afford(db, spec["seed_cost"]): + if not garden.can_afford(db, user_id, spec["seed_cost"]): raise HTTPException( status_code=402, detail=f"Not enough coins: need {spec['seed_cost']}" ) - garden.spend(db, spec["seed_cost"], item_key) - item = make_seed_plant(db, species) + garden.spend(db, user_id, spec["seed_cost"], item_key) + item = make_seed_plant(db, species, user_id) db.flush() db.commit() - publish(str(user["user_id"]), "garden.changed", {"reason": "seed"}) + publish(user_id, "garden.changed", {"reason": "seed"}) return { "ok": True, "item": {"id": item.id, "item_key": item.item_key, "x": item.x, "y": item.y}, - "balance": balance(db), + "balance": balance(db, user_id), } if item_key in EXPANSIONS: - expansion = next_expansion(db) + expansion = next_expansion(db, user_id) if expansion is None or expansion["key"] != item_key: raise HTTPException(status_code=409, detail="Expansion already bought") spec = EXPANSIONS[item_key] if level < spec["level"]: raise HTTPException(status_code=403, detail=f"Unlocks at level {spec['level']}") - if not garden.can_afford(db, spec["cost"]): + if not garden.can_afford(db, user_id, spec["cost"]): raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}") - garden.spend(db, spec["cost"], item_key) + garden.spend(db, user_id, spec["cost"], item_key) db.flush() db.commit() - publish(str(user["user_id"]), "garden.changed", {"reason": "expansion"}) - return {"ok": True, "grid": grid_size(db), "balance": balance(db)} + publish(user_id, "garden.changed", {"reason": "expansion"}) + return {"ok": True, "grid": grid_size(db, user_id), "balance": balance(db, user_id)} if item_key in DECORATIONS: spec = DECORATIONS[item_key] owned = db.scalar( select(GardenItem).where( - GardenItem.kind == "decoration", GardenItem.item_key == item_key + GardenItem.user_id == user_id, + GardenItem.kind == "decoration", + GardenItem.item_key == item_key, ) ) if spec["unique"] and owned is not None: raise HTTPException(status_code=409, detail="Already bought") - if not garden.can_afford(db, spec["cost"]): + if not garden.can_afford(db, user_id, spec["cost"]): raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}") # Покупка кладёт декорацию в инвентарь (x/y NULL) — пользователь сам # перетаскивает её на карту, место покупки всегда видно - item = GardenItem(kind="decoration", item_key=item_key, x=None, y=None) + item = GardenItem(user_id=user_id, kind="decoration", item_key=item_key, x=None, y=None) db.add(item) - garden.spend(db, spec["cost"], item_key) + garden.spend(db, user_id, spec["cost"], item_key) db.flush() db.commit() - publish(str(user["user_id"]), "garden.changed", {"reason": "shop"}) + publish(user_id, "garden.changed", {"reason": "shop"}) return { "ok": True, "item": {"id": item.id, "item_key": item.item_key, "x": item.x, "y": item.y}, - "balance": balance(db), + "balance": balance(db, user_id), } raise HTTPException(status_code=400, detail=f"Unknown item: {item_key}") diff --git a/backend/app/api/mcp_tokens.py b/backend/app/api/mcp_tokens.py new file mode 100644 index 0000000..b4812db --- /dev/null +++ b/backend/app/api/mcp_tokens.py @@ -0,0 +1,91 @@ +"""API per-user MCP-токенов (ТЗ 3.10): генерация и отзыв токенов для агентов. + +Токен хранится только как sha256-хэш; plaintext показывается один раз при +генерации. Агент с токеном действует от имени владельца (см. McpAuthMiddleware +в main.py и _mcp_user в mcp_server.py). +""" + +import hashlib +import secrets +from datetime import UTC, datetime +from typing import Any, cast + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from sqlalchemy import select + +from app.dependencies import DbDep, UserIdDep +from app.models import McpToken + +router = APIRouter(prefix="/api/mcp-tokens", tags=["mcp-tokens"]) + +TOKEN_PREFIX = "gnt_" + + +class McpTokenCreate(BaseModel): + label: str = "" + + +class McpTokenOut(BaseModel): + id: int + label: str + created_at: datetime + + +class McpTokenCreated(McpTokenOut): + token: str # plaintext — возвращается только один раз + + +def _now() -> datetime: + return datetime.now(UTC) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _list_out(db: Any, user_id: str) -> list[McpTokenOut]: + rows = db.scalars( + select(McpToken).where(McpToken.user_id == user_id).order_by(McpToken.id) + ).all() + return [ + McpTokenOut(id=t.id, label=t.label, created_at=t.created_at.astimezone(UTC)) for t in rows + ] + + +@router.get("", response_model=list[McpTokenOut]) +async def list_tokens(db: DbDep, user_id: UserIdDep) -> list[McpTokenOut]: + """Список токенов пользователя (без секретов).""" + return _list_out(db, user_id) + + +@router.post("", response_model=McpTokenCreated) +async def create_token(schema: McpTokenCreate, db: DbDep, user_id: UserIdDep) -> Any: + """Сгенерировать токен: plaintext в ответе ровно один раз, в БД — только хэш.""" + plaintext = TOKEN_PREFIX + secrets.token_urlsafe(32) + row = McpToken( + user_id=user_id, + token_hash=hash_token(plaintext), + label=schema.label[:100], + ) + db.add(row) + db.flush() + return McpTokenCreated( + id=row.id, + label=row.label, + created_at=row.created_at.astimezone(UTC), + token=plaintext, + ) + + +@router.delete("/{token_id}") +async def delete_token(token_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: + row = cast( + McpToken | None, + db.scalar(select(McpToken).where(McpToken.id == token_id, McpToken.user_id == user_id)), + ) + if row is None: + raise HTTPException(status_code=404, detail="Token not found") + db.delete(row) + db.commit() + return {"ok": True} diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index eb2e9d3..2291285 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -1,9 +1,11 @@ """API проектов M1.""" +from typing import Any, cast + from fastapi import APIRouter, HTTPException, Query, Response from sqlalchemy import select -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.models import CoinEvent, Project from app.realtime import publish from app.schemas import ProjectCreate, ProjectOut, ProjectUpdate @@ -15,94 +17,109 @@ VALID_RELEVANCE = {"active", "paused"} +def _get_project_or_404(db: Any, project_id: int, user_id: str) -> Project: + project = cast( + Project | None, + db.scalar(select(Project).where(Project.id == project_id, Project.user_id == user_id)), + ) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + return project + + @router.post("", response_model=ProjectOut) async def create_project( - schema: ProjectCreate, db: DbDep, user: UserDep, response: Response + schema: ProjectCreate, db: DbDep, user_id: UserIdDep, response: Response ) -> Project: - exists = db.scalars(select(Project).where(Project.name == schema.name)).first() + exists = db.scalars( + select(Project).where(Project.name == schema.name, Project.user_id == user_id) + ).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) + project = Project(user_id=user_id, name=schema.name, priority=schema.priority, note=schema.note) db.add(project) db.flush() # Микронаграда за создание (ТЗ 3.13): чуть больше XP, чем за задачу - grant_create_xp(db, "create_project") - db.add(CoinEvent(source="create_project", amount=coins_for_create("create_project"))) + grant_create_xp(db, "create_project", user_id) + db.add( + CoinEvent( + user_id=user_id, source="create_project", amount=coins_for_create("create_project") + ) + ) response.headers["X-Created-XP"] = "5" response.headers["X-Created-Coins"] = "2" db.commit() - publish(str(user["user_id"]), "project.changed", {"id": project.id}) - publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) + publish(user_id, "project.changed", {"id": project.id}) + publish(user_id, "xp.changed", {"celebrate": False}) return project @router.get("", response_model=list[ProjectOut]) -async def list_projects(db: DbDep, user: UserDep, archived: bool = Query(False)) -> list[Project]: +async def list_projects( + db: DbDep, user_id: UserIdDep, archived: bool = Query(False) +) -> list[Project]: """Активные проекты; archived=true — список архива (история).""" return list( db.scalars( - select(Project).where(Project.is_archived == archived).order_by(Project.id) + select(Project) + .where(Project.is_archived == archived, Project.user_id == user_id) + .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 +async def get_project(project_id: int, db: DbDep, user_id: UserIdDep) -> Project: + return _get_project_or_404(db, project_id, user_id) @router.patch("/{project_id}", response_model=ProjectOut) async def update_project( - project_id: int, schema: ProjectUpdate, db: DbDep, user: UserDep + project_id: int, schema: ProjectUpdate, db: DbDep, user_id: UserIdDep ) -> Project: - project = db.get(Project, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") + project = _get_project_or_404(db, project_id, user_id) 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']}" ) + if "name" in data and data["name"] != project.name: + exists = db.scalars( + select(Project).where(Project.name == data["name"], Project.user_id == user_id) + ).first() + if exists is not None: + raise HTTPException(status_code=409, detail="Project name already exists") for field, value in data.items(): setattr(project, field, value) db.commit() - publish(str(user["user_id"]), "project.changed", {"id": project.id}) + publish(user_id, "project.changed", {"id": project.id}) return project @router.post("/{project_id}/archive", response_model=ProjectOut) -async def archive_project(project_id: int, db: DbDep, user: UserDep) -> Project: +async def archive_project(project_id: int, db: DbDep, user_id: UserIdDep) -> Project: """Отправить проект в архив — вместе со всеми его задачами (ТЗ 3.8.1).""" - project = db.get(Project, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") + project = _get_project_or_404(db, project_id, user_id) project.is_archived = True db.commit() - publish(str(user["user_id"]), "project.changed", {"id": project.id}) + publish(user_id, "project.changed", {"id": project.id}) return project @router.post("/{project_id}/restore", response_model=ProjectOut) -async def restore_project(project_id: int, db: DbDep, user: UserDep) -> Project: +async def restore_project(project_id: int, db: DbDep, user_id: UserIdDep) -> Project: """Вернуть проект из архива — со всеми задачами и историей.""" - project = db.get(Project, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") + project = _get_project_or_404(db, project_id, user_id) project.is_archived = False db.commit() - publish(str(user["user_id"]), "project.changed", {"id": project.id}) + publish(user_id, "project.changed", {"id": project.id}) return project @router.delete("/{project_id}") -async def delete_project(project_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: - project = db.get(Project, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") +async def delete_project(project_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: + project = _get_project_or_404(db, project_id, user_id) db.delete(project) db.commit() - publish(str(user["user_id"]), "project.deleted", {"id": project_id}) + publish(user_id, "project.deleted", {"id": project_id}) return {"ok": True} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 5324941..f17f754 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -1,10 +1,15 @@ -"""API глобальных настроек (ТЗ: валюта бюджетов и язык UI — общие, выбираются один раз).""" +"""API настроек пользователя (валюта бюджетов и язык UI). + +Per-user (ТЗ 1.2): у каждого пользователя свои настройки; строка с PK +(user_id, key). Настройки прежней эпохи (app_settings_global) копируются +в per-user при первом логине (claim) — см. services/users.py. +""" from typing import cast from fastapi import APIRouter -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.models import AppSetting from app.realtime import publish from app.schemas import CURRENCIES, LANGUAGES, AppSettingsOut, AppSettingsUpdate @@ -17,41 +22,43 @@ DEFAULT_LANGUAGE = "" # '' = нет переопределения, следуем SSO locale -def _read_value(db: DbDep, key: str, valid: tuple[str, ...], default: str) -> str: - row = cast(AppSetting | None, db.get(AppSetting, key)) +def _read_value(db: DbDep, user_id: str, key: str, valid: tuple[str, ...], default: str) -> str: + row = cast(AppSetting | None, db.get(AppSetting, (user_id, key))) if row is not None and row.value in valid: return row.value return default -def _upsert(db: DbDep, key: str, value: str) -> None: - row = cast(AppSetting | None, db.get(AppSetting, key)) +def _upsert(db: DbDep, user_id: str, key: str, value: str) -> None: + row = cast(AppSetting | None, db.get(AppSetting, (user_id, key))) if row is None: - db.add(AppSetting(key=key, value=value)) + db.add(AppSetting(user_id=user_id, key=key, value=value)) else: row.value = value db.flush() @router.get("", response_model=AppSettingsOut) -async def get_settings(db: DbDep, user: UserDep) -> AppSettingsOut: +async def get_settings(db: DbDep, user_id: UserIdDep) -> AppSettingsOut: return AppSettingsOut( - currency=_read_value(db, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), - language=_read_value(db, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), + currency=_read_value(db, user_id, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), + language=_read_value(db, user_id, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), ) @router.put("", response_model=AppSettingsOut) -async def update_settings(schema: AppSettingsUpdate, db: DbDep, user: UserDep) -> AppSettingsOut: +async def update_settings( + schema: AppSettingsUpdate, db: DbDep, user_id: UserIdDep +) -> AppSettingsOut: # Частичный PUT: обновляем только переданные поля (model_fields_set) if schema.currency is not None: - _upsert(db, CURRENCY_KEY, schema.currency) + _upsert(db, user_id, CURRENCY_KEY, schema.currency) if schema.language is not None: - _upsert(db, LANGUAGE_KEY, schema.language) + _upsert(db, user_id, LANGUAGE_KEY, schema.language) db.flush() db.commit() - publish(str(user["user_id"]), "settings.changed") + publish(user_id, "settings.changed") return AppSettingsOut( - currency=_read_value(db, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), - language=_read_value(db, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), + currency=_read_value(db, user_id, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), + language=_read_value(db, user_id, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), ) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index f641ad4..47d0a9e 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -1,9 +1,11 @@ """API тегов M1 (справочник).""" +from typing import cast + from fastapi import APIRouter, HTTPException from sqlalchemy import select -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.models import Tag from app.schemas import TagCreate, TagOut @@ -11,24 +13,29 @@ @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() +async def create_tag(schema: TagCreate, db: DbDep, user_id: UserIdDep) -> Tag: + existing = db.scalars( + select(Tag).where(Tag.name == schema.name, Tag.user_id == user_id) + ).first() if existing is not None: raise HTTPException(status_code=409, detail="Tag already exists") - tag = Tag(name=schema.name) + tag = Tag(user_id=user_id, 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()) +async def list_tags(db: DbDep, user_id: UserIdDep) -> list[Tag]: + return list(db.scalars(select(Tag).where(Tag.user_id == user_id).order_by(Tag.name)).all()) @router.delete("/{tag_id}") -async def delete_tag(tag_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: - tag = db.get(Tag, tag_id) +async def delete_tag(tag_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: + tag = cast( + Tag | None, + db.scalar(select(Tag).where(Tag.id == tag_id, Tag.user_id == user_id)), + ) if tag is None: raise HTTPException(status_code=404, detail="Tag not found") db.delete(tag) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 23a1993..136ae9c 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from sqlalchemy import Select, or_, select -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.models import CoinEvent, Project, Tag, Task, utcnow from app.realtime import publish from app.schemas import ( @@ -30,14 +30,17 @@ 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)) +def _get_task_or_404(db: Any, task_id: int, user_id: str) -> Task: + task = cast( + Task | None, + db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id)), + ) if task is None: raise HTTPException(status_code=404, detail="Task not found") return task -def _get_task_for_update(db: Any, task_id: int) -> Task: +def _get_task_for_update(db: Any, task_id: int, user_id: str) -> Task: """Задача с блокировкой строки (Postgres FOR UPDATE; в SQLite игнорируется). Параллельные PATCH одной задачи сериализуются: двойное закрытие не гонит @@ -47,25 +50,30 @@ Task | None, # of=Task: запрос тянет project joined-ом — Postgres запрещает FOR UPDATE # на nullable-стороне outer join, блокируем только строку задачи - db.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)), + db.scalar( + select(Task).where(Task.id == task_id, Task.user_id == user_id).with_for_update(of=Task) + ), ) if task is None: raise HTTPException(status_code=404, detail="Task not found") return task -def _archived_project_ids(db: Any) -> Select[tuple[int]]: +def _archived_project_ids(db: Any, user_id: str) -> Select[tuple[int]]: """id проектов в архиве — их задачи в рабочих видах не показываются.""" - return select(Project.id).where(Project.is_archived.is_(True)) + return select(Project.id).where(Project.is_archived.is_(True), Project.user_id == user_id) -def _validate_parent(db: Any, task: Task | None, parent_id: int | None) -> None: - """Родитель должен существовать; циклы в дереве запрещены.""" +def _validate_parent(db: Any, task: Task | None, parent_id: int | None, user_id: str) -> 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)) + parent = cast( + Task | None, + db.scalar(select(Task).where(Task.id == parent_id, Task.user_id == user_id)), + ) if parent is None: raise HTTPException(status_code=400, detail="Unknown parent task") if task is not None: @@ -78,30 +86,37 @@ @router.post("") async def create_task( - schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks, response: Response + schema: TaskCreate, + db: DbDep, + user_id: UserIdDep, + background: BackgroundTasks, + response: Response, ) -> dict[str, int]: """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do). С parent_task_id — создание подзадачи (M3). Сразу в фоне запускается автодетализация (LLM-предложение метаданных). """ - _validate_parent(db, None, schema.parent_task_id) + _validate_parent(db, None, schema.parent_task_id, user_id) task = Task( - title=schema.title, description=schema.description, parent_task_id=schema.parent_task_id + user_id=user_id, + title=schema.title, + description=schema.description, + parent_task_id=schema.parent_task_id, ) db.add(task) db.flush() # Микронаграда за создание (ТЗ 3.13): без конфетти, просто тост — # заголовки X-Created-* фронт буферизует отдельно от награды за закрытие - grant_create_xp(db, "create_task") - db.add(CoinEvent(source="create_task", amount=coins_for_create("create_task"))) + grant_create_xp(db, "create_task", user_id) + db.add(CoinEvent(user_id=user_id, source="create_task", amount=coins_for_create("create_task"))) response.headers["X-Created-XP"] = "2" response.headers["X-Created-Coins"] = "1" # Коммитим до планирования фоновой работы: BackgroundTasks выполняются ДО # teardown-коммита зависимости get_db, а detail_task открывает свою сессию. db.commit() - publish(str(user["user_id"]), "task.changed", {"id": task.id}) - publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) + publish(user_id, "task.changed", {"id": task.id}) + publish(user_id, "xp.changed", {"celebrate": False}) background.add_task(detail_task, task.id) return {"id": task.id} @@ -109,7 +124,7 @@ @router.get("", response_model=list[TaskOut]) async def list_tasks( db: DbDep, - user: UserDep, + user_id: UserIdDep, detail_state: str | None = Query(None), status: str | None = Query(None), project_id: int | None = Query(None), @@ -118,7 +133,7 @@ parent_id: int | None = Query(None), include_archived: bool = Query(False), ) -> list[Task]: - stmt = select(Task).order_by(Task.created_at.desc()) + stmt = select(Task).where(Task.user_id == user_id).order_by(Task.created_at.desc()) if detail_state: stmt = stmt.where(Task.detail_state == detail_state) if status: @@ -128,7 +143,10 @@ elif not include_archived: # задачи архивных проектов — только в истории (ТЗ 3.8.1) stmt = stmt.where( - or_(Task.project_id.is_(None), Task.project_id.not_in(_archived_project_ids(db))) + or_( + Task.project_id.is_(None), + Task.project_id.not_in(_archived_project_ids(db, user_id)), + ) ) if parent_id is not None: stmt = stmt.where(Task.parent_task_id == parent_id) @@ -155,15 +173,15 @@ @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) +async def get_task(task_id: int, db: DbDep, user_id: UserIdDep) -> Task: + return _get_task_or_404(db, task_id, user_id) @router.patch("/{task_id}", response_model=TaskOut) async def update_task( - task_id: int, schema: TaskUpdate, db: DbDep, user: UserDep, response: Response + task_id: int, schema: TaskUpdate, db: DbDep, user_id: UserIdDep, response: Response ) -> Task: - task = _get_task_for_update(db, task_id) + task = _get_task_for_update(db, task_id, user_id) data = schema.model_dump(exclude_unset=True) if "title" in data and not str(data["title"]).strip(): @@ -181,13 +199,18 @@ if "task_type" in data and data["task_type"] not in ("one_time", "recurring"): raise HTTPException(status_code=422, detail=f"Unknown task type: {data['task_type']}") if "project_id" in data and data["project_id"] is not None: - if db.get(Project, data["project_id"]) is None: + if ( + db.scalar( + select(Project).where(Project.id == data["project_id"], Project.user_id == user_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"]) + _validate_parent(db, task, data["parent_task_id"], user_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() + tags = db.scalars(select(Tag).where(Tag.id.in_(tag_ids), Tag.user_id == user_id)).all() if len(tags) != len(set(tag_ids)): raise HTTPException(status_code=400, detail="Unknown tag id in tag_ids") task.tags = list(tags) @@ -223,20 +246,20 @@ db.refresh(task) # перечитать связи (project/tags) после обновления # События после коммита: подписчик SSE может сразу перечитать данные db.commit() - publish(str(user["user_id"]), "task.changed", {"id": task.id}) + publish(user_id, "task.changed", {"id": task.id}) if earned_event is not None: publish( - str(user["user_id"]), + user_id, "xp.changed", {"amount": earned_event.amount, "rarity": earned_event.rarity, "celebrate": True}, ) - publish(str(user["user_id"]), "garden.changed", {"reason": "task"}) + publish(user_id, "garden.changed", {"reason": "task"}) return task @router.post("/{task_id}/approve", response_model=TaskOut) async def approve_task( - task_id: int, db: DbDep, user: UserDep, schema: ApproveIn | None = None + task_id: int, db: DbDep, user_id: UserIdDep, schema: ApproveIn | None = None ) -> Task: """Утверждение детализации: raw → approved. @@ -244,24 +267,24 @@ утверждением (кнопка «Да, всё верно»); иначе предложенные метаданные игнорируются (после ручной правки их уже применил PATCH). """ - task = _get_task_or_404(db, task_id) + task = _get_task_or_404(db, task_id, user_id) if schema and schema.apply_proposal and task.ai_proposal: - apply_proposal(db, task, task.ai_proposal) + apply_proposal(db, task, task.ai_proposal, user_id) task.detail_state = "approved" task.approved_at = utcnow() db.flush() db.refresh(task) db.commit() - publish(str(user["user_id"]), "task.changed", {"id": task.id}) + publish(user_id, "task.changed", {"id": task.id}) return task @router.post("/{task_id}/redetail", response_model=TaskOut) async def redetail_task( - task_id: int, db: DbDep, user: UserDep, background: BackgroundTasks + task_id: int, db: DbDep, user_id: UserIdDep, background: BackgroundTasks ) -> Task: """Перезапустить автодетализацию (например, после появления новых тегов/проектов).""" - task = _get_task_or_404(db, task_id) + task = _get_task_or_404(db, task_id, user_id) if task.detail_state != "raw": # Утверждённая задача уже детализирована: повтор — no-op, предложение # не сбрасываем, фоновую LLM не будим (worker всё равно работает @@ -271,20 +294,20 @@ # Коммит до фоновой работы, чтобы worker не прочитал старое состояние # и его запись не перезаписалась teardown-коммитом (см. create_task). db.commit() - publish(str(user["user_id"]), "task.changed", {"id": task.id}) + publish(user_id, "task.changed", {"id": task.id}) background.add_task(detail_task, task.id) return task @router.post("/suggest", response_model=list[TaskOut]) -async def suggest_tasks(schema: SuggestIn, db: DbDep, user: UserDep) -> list[Task]: +async def suggest_tasks(schema: SuggestIn, db: DbDep, user_id: UserIdDep) -> list[Task]: """Режим «3 варианта»: до трёх задач под доступное время.""" - return pick_options(db, schema.available_minutes) + return pick_options(db, schema.available_minutes, user_id) @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)) +async def delete_task(task_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: + db.delete(_get_task_or_404(db, task_id, user_id)) db.commit() - publish(str(user["user_id"]), "task.deleted", {"id": task_id}) + publish(user_id, "task.deleted", {"id": task_id}) return {"ok": True} diff --git a/backend/app/api/xp.py b/backend/app/api/xp.py index 00fce24..93f725c 100644 --- a/backend/app/api/xp.py +++ b/backend/app/api/xp.py @@ -5,7 +5,7 @@ from fastapi import APIRouter from sqlalchemy.exc import IntegrityError -from app.dependencies import DbDep, UserDep +from app.dependencies import DbDep, UserIdDep from app.realtime import publish from app.services.xp import claim_daily, xp_events, xp_summary @@ -13,30 +13,30 @@ @router.get("") -async def get_xp(db: DbDep, user: UserDep) -> dict[str, Any]: +async def get_xp(db: DbDep, user_id: UserIdDep) -> dict[str, Any]: """Сводка: суммарный XP, уровень, прогресс до следующего.""" - return xp_summary(db) + return xp_summary(db, user_id) @router.get("/events") -async def get_xp_events(db: DbDep, user: UserDep) -> list[dict[str, Any]]: +async def get_xp_events(db: DbDep, user_id: UserIdDep) -> list[dict[str, Any]]: """Все события начисления: редкость растения, признак «из 3 вариантов», даты.""" - return xp_events(db) + return xp_events(db, user_id) @router.post("/daily") -async def post_daily(db: DbDep, user: UserDep) -> dict[str, Any]: +async def post_daily(db: DbDep, user_id: UserIdDep) -> dict[str, Any]: """Ежедневная награда за визит: 50 монет раз в сутки (идемпотентно). Гонка двух параллельных POST — оба прошли check-then-insert — ловится уникальным индексом на коммите: молча отдаём granted=False вместо 500. """ - result = claim_daily(db) + result = claim_daily(db, user_id) if result.get("granted"): try: db.commit() except IntegrityError: db.rollback() return {"granted": False, "coins": 0} - publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) + publish(user_id, "xp.changed", {"celebrate": False}) return result diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 2bd02a0..1d6ce26 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -15,6 +15,8 @@ from app.auth.client import get_gauth_client from app.config import get_settings +from app.dependencies import DbDep +from app.services.users import claim_global_settings, claim_orphan_data, upsert_user router = APIRouter(prefix="/auth", tags=["auth"]) @@ -39,7 +41,7 @@ @router.get("/callback") -async def callback(request: Request, code: str, state: str) -> RedirectResponse: +async def callback(request: Request, code: str, state: str, db: DbDep) -> RedirectResponse: client = get_gauth_client() try: @@ -49,15 +51,22 @@ user = client.fetch_user(token_set.access_token) - request.session["access_token"] = token_set.access_token - request.session["refresh_token"] = token_set.refresh_token - request.session["user"] = { + session_user = { "user_id": user.user_id, "email": user.email, "avatar_url": user.avatar_url, # Язык UI: SSO отдаёт profile.locale (default 'ru'); '' в поле — нет значения "locale": user.profile.get("locale") or "", } + request.session["access_token"] = token_set.access_token + request.session["refresh_token"] = token_set.refresh_token + request.session["user"] = session_user + + # Профиль в БД + claim: бесхозные данные (user_id NULL) забирает вошедший + # (ТЗ 1.2) — идемпотентно, при повторных логинах уже ничего не находит + upsert_user(db, user.user_id, user.email, user.avatar_url, session_user["locale"]) + claim_orphan_data(db, user.user_id) + claim_global_settings(db, user.user_id) return_to = _safe_return_to(request.session.pop("return_to", "/")) return RedirectResponse(return_to) diff --git a/backend/app/config.py b/backend/app/config.py index b0b305e..f552327 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,9 +34,6 @@ # Хранилище вложений (M2) attachments_path: str = "data/attachments" - # MCP-сервер (M5): bearer-токен для агентов; пусто — /mcp закрыт (401) - mcp_token: str = "" - @model_validator(mode="after") def _session_secret_must_be_set(self) -> "Settings": if self.session_secret == DEFAULT_SESSION_SECRET: diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 0c857c6..dc450d1 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -19,3 +19,14 @@ UserDep = Annotated[dict[str, str], Depends(require_user)] + + +def require_user_id(user: UserDep) -> str: + """user_id текущего пользователя — ключ изоляции всех данных (ТЗ 1.2). + + Один слой чтения сессии: require_user уже проверил 401. + """ + return str(user["user_id"]) + + +UserIdDep = Annotated[str, Depends(require_user_id)] diff --git a/backend/app/main.py b/backend/app/main.py index 44e7cc5..5919b20 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,12 @@ """Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённое /api + /mcp.""" -import hmac +import hashlib from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import select from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.requests import Request @@ -13,9 +14,12 @@ from app import mcp_server from app.api import attachments, events, garden, projects, tags, tasks, xp +from app.api.mcp_tokens import router as mcp_tokens_router from app.api.settings import router as settings_router from app.auth.routes import router as auth_router from app.config import get_settings +from app.db import get_session_factory +from app.models import McpToken @asynccontextmanager @@ -42,17 +46,28 @@ class McpAuthMiddleware(BaseHTTPMiddleware): - """Bearer-токен для /mcp (до интеграции SSO-токенов, ТЗ 3.10).""" + """Bearer-токен /mcp → user_id владельца (per-user токены, ТЗ 3.10). + + Токен хранится в БД только как sha256-хэш (mcp_tokens); найденный user_id + кладётся в request.state — тулы достают его через Context. + """ async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: if request.url.path.startswith("/mcp"): - token = get_settings().mcp_token auth = request.headers.get("authorization", "") - # сравнение constant-time: обычный == позволяет timing-атаку на токен - if not token or not hmac.compare_digest(auth, f"Bearer {token}"): + token = auth.removeprefix("Bearer ").strip() + user_id: str | None = None + if token: + token_hash = hashlib.sha256(token.encode()).hexdigest() + with get_session_factory()() as db: + user_id = db.scalar( + select(McpToken.user_id).where(McpToken.token_hash == token_hash) + ) + if not user_id: return JSONResponse({"detail": "Not authenticated"}, status_code=401) + request.state.gntodo_user_id = user_id return await call_next(request) @@ -64,6 +79,7 @@ app.include_router(tags.router) app.include_router(attachments.router) app.include_router(settings_router) +app.include_router(mcp_tokens_router) app.include_router(xp.router) app.include_router(garden.router) app.include_router(events.router) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index c331953..e495dd4 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -2,8 +2,9 @@ Заточен под небольшие модели: подробные описания тулов и параметров, адресация проекта по имени, ошибки с подсказкой следующего шага. Агент -действует от имени пользователя; мультитенантности нет. Аутентификация -временно — bearer-токен из конфига (MCP_TOKEN), до интеграции SSO-токенов. +действует от имени пользователя: bearer-токен (per-user, ТЗ 3.10) проверяет +McpAuthMiddleware в main.py и кладёт user_id в request.state.gntodo_user_id; +тул получает его через Context (параметр ctx исключён из схемы для агента). Инструменты создают свои сессии к БД (вне HTTP-зависимостей FastAPI). """ @@ -13,7 +14,7 @@ from typing import Annotated, Any, cast from urllib.parse import urlparse -from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp import Context, FastMCP from mcp.server.transport_security import TransportSecuritySettings from pydantic import Field from sqlalchemy import and_, func, select @@ -29,6 +30,18 @@ VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} + +def _mcp_user(ctx: Context[Any, Any, Any] | None) -> str: + """user_id владельца токена (кладёт McpAuthMiddleware).""" + if ctx is None or ctx.request_context is None: + raise ValueError("MCP context is unavailable — call tools through the MCP server.") + request = ctx.request_context.request + user_id = getattr(request.state, "gntodo_user_id", None) if request is not None else None + if not user_id: + raise ValueError("MCP authentication failed: no user bound to this request.") + return str(user_id) + + # Серверные инструкции — агент видит их до вызова любого тула (ТЗ 3.10). # Дисциплина проектного контекста: задачи не должны отрываться от проекта, # а контекст разговора восстанавливается через get_project. @@ -141,8 +154,10 @@ return data -def _get_task(db: Any, task_id: int) -> Task: - task = cast(Task | None, db.get(Task, task_id)) +def _get_task(db: Any, task_id: int, user_id: str) -> Task: + task = cast( + Task | None, db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id)) + ) if task is None: raise ValueError( f"Task {task_id} not found — find the right id with list_tasks(query=...)." @@ -150,14 +165,19 @@ return task -def _resolve_project(db: Any, project_id: int | None, project_name: str | None) -> Project | None: - """Проект по id или имени (регистронезависимо); ничего не задано — None. +def _resolve_project( + db: Any, user_id: str, project_id: int | None, project_name: str | None +) -> Project | None: + """Проект пользователя по id или имени (регистронезависимо); ничего не задано — None. project_id приоритетнее. Ошибка всегда подсказывает, как найти верный проект, — маленькая модель должна понимать следующий шаг (ТЗ 3.10). """ if project_id is not None: - project = cast(Project | None, db.get(Project, project_id)) + project = cast( + Project | None, + db.scalar(select(Project).where(Project.id == project_id, Project.user_id == user_id)), + ) if project is None: raise ValueError( f"Project {project_id} not found — call list_projects() to get valid ids." @@ -170,7 +190,11 @@ project = cast( Project | None, next( - (p for p in db.scalars(select(Project)).all() if p.name.lower() == wanted), + ( + p + for p in db.scalars(select(Project).where(Project.user_id == user_id)).all() + if p.name.lower() == wanted + ), None, ), ) @@ -226,6 +250,7 @@ priority: Annotated[ int | None, Field(description="Приоритет 0-10: 0-2 низкий, 5-6 средний, 9-10 срочный") ] = None, + ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Создать задачу; достаточно title — остальное дописывается через update_task. @@ -233,33 +258,41 @@ задача без проекта теряется. Автодетализация подставит метаданные в фоне. Ответ содержит поле project — проверьте привязку. """ + user_id = _mcp_user(ctx) session = get_session_factory()() try: if parent_task_id is not None: - _get_task(session, parent_task_id) + _get_task(session, parent_task_id, user_id) task = Task( + user_id=user_id, title=title, description=description, parent_task_id=parent_task_id, priority=priority, ) - project = _resolve_project(session, project_id, project_name) + project = _resolve_project(session, user_id, project_id, project_name) if project is not None: task.project_id = project.id if tag_ids: - tags = list(session.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all()) + tags = list( + session.scalars( + select(Tag).where(Tag.id.in_(tag_ids), Tag.user_id == user_id) + ).all() + ) if len(tags) != len(set(tag_ids)): raise ValueError("Unknown tag id in tag_ids — get ids from list_tags()") task.tags = tags session.add(task) session.flush() # Микронаграда за создание (ТЗ 3.13) — как в HTTP-API - grant_create_xp(session, "create_task") - session.add(CoinEvent(source="create_task", amount=coins_for_create("create_task"))) + grant_create_xp(session, "create_task", user_id) + session.add( + CoinEvent(user_id=user_id, source="create_task", amount=coins_for_create("create_task")) + ) session.commit() - # Агент меняет данные вне UI — уведомляем все открытые SSE-соединения - publish(None, "task.changed", {"id": task.id, "source": "mcp"}) - publish(None, "xp.changed", {"celebrate": False}) + # Агент меняет данные владельца токена — уведомляем его вкладки (ТЗ 3.14) + publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) + publish(user_id, "xp.changed", {"celebrate": False}) # Автодетализация — в фоновом потоке (LLM работает десятки секунд) threading.Thread(target=detail_task, args=(task.id,), daemon=True).start() result: dict[str, Any] = { @@ -339,6 +372,7 @@ tag_ids: Annotated[ list[int] | None, Field(description="Полный новый список id тегов (из list_tags)") ] = None, + ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Частично обновить задачу — передавайте только нужные поля. @@ -348,6 +382,7 @@ следующий экземпляр регулярной задачи автоматически. Ответ содержит поле project — проверяйте, что задача осталась в нужном проекте. """ + user_id = _mcp_user(ctx) session = get_session_factory()() try: # Блокировка строки: параллельные закрытия агентами сериализуются, @@ -356,7 +391,11 @@ Task | None, # of=Task — как в PATCH HTTP-API: FOR UPDATE не применим к nullable- # стороне outer join (project joined-ом), блокируем только задачу - session.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)), + session.scalar( + select(Task) + .where(Task.id == task_id, Task.user_id == user_id) + .with_for_update(of=Task) + ), ) if task is None: raise ValueError( @@ -399,11 +438,11 @@ if recur_day_of_month is not None: task.recur_day_of_month = recur_day_of_month if project_id is not None or project_name is not None: - project = _resolve_project(session, project_id, project_name) + project = _resolve_project(session, user_id, project_id, project_name) assert project is not None # ветка входит только при заданном проекте task.project_id = project.id if parent_task_id is not None: - _get_task(session, parent_task_id) + _get_task(session, parent_task_id, user_id) task.parent_task_id = parent_task_id if priority is not None: task.priority = priority @@ -416,7 +455,11 @@ if cost_estimate_money is not None: task.cost_estimate_money = cost_estimate_money if tag_ids is not None: - tags = list(session.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all()) + tags = list( + session.scalars( + select(Tag).where(Tag.id.in_(tag_ids), Tag.user_id == user_id) + ).all() + ) if len(tags) != len(set(tag_ids)): raise ValueError("Unknown tag id in tag_ids") task.tags = tags @@ -430,11 +473,11 @@ elif task.status != "done" and task.done_at is not None: task.done_at = None session.commit() - publish(None, "task.changed", {"id": task.id, "source": "mcp"}) + publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) if outcome is not None and outcome.event is not None: # Скромный тост в открытых вкладках — закрытие тоже праздник (ТЗ 3.14) publish( - None, + user_id, "xp.changed", { "amount": outcome.event.amount, @@ -455,13 +498,14 @@ actual_minutes: Annotated[ int | None, Field(description="Фактически потраченное время в минутах") ] = None, + ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Завершить задачу (псевдоним update_task со status="done"). Награды и спавн следующего экземпляра регулярной задачи выполняются автоматически; повторный вызов дублей не создаёт. """ - return update_task(task_id, status="done", actual_minutes=actual_minutes) + return update_task(task_id, status="done", actual_minutes=actual_minutes, ctx=ctx) def list_tasks( @@ -484,25 +528,32 @@ limit: Annotated[ int, Field(description="Максимум задач в ответе (по умолчанию 50, до 200)") ] = 50, + ctx: Context[Any, Any, Any] | None = None, ) -> list[dict[str, Any]]: """Найти задачи: фильтры по статусу, проекту, тегу и подстроке. Каждая задача в ответе: id, title, status, project, tags и сроки. Работайте с найденной задачей по её id (update_task / get_task / complete_task). """ + user_id = _mcp_user(ctx) session = get_session_factory()() try: - stmt = select(Task).order_by(Task.created_at.desc()).limit(min(limit, 200)) + stmt = ( + select(Task) + .where(Task.user_id == user_id) + .order_by(Task.created_at.desc()) + .limit(min(limit, 200)) + ) if status: stmt = stmt.where(Task.status == status) if project_id or project_name: - project = _resolve_project(session, project_id, project_name) + project = _resolve_project(session, user_id, project_id, project_name) assert project is not None # задан хотя бы один фильтр проекта stmt = stmt.where(Task.project_id == project.id) if detail_state: stmt = stmt.where(Task.detail_state == detail_state) if tag_id: - stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) + stmt = stmt.where(Task.tags.any(and_(Tag.id == tag_id, Tag.user_id == user_id))) if query: pattern = f"%{query}%" # Описание живёт в документе — ищем join'ом по полиморфной привязке @@ -518,21 +569,24 @@ def get_task( task_id: Annotated[int, Field(description="id задачи (из list_tasks)")], + ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Полное описание задачи: описание, время, бюджет, вложения. Ошибка «not found» означает неверный id — найдите верный через list_tasks(query=...). """ + user_id = _mcp_user(ctx) session = get_session_factory()() try: - return _full(_get_task(session, task_id), session) + return _full(_get_task(session, task_id, user_id), session) finally: session.close() def list_projects( include_archived: Annotated[bool, Field(description="Включая проекты в архиве")] = False, + ctx: Context[Any, Any, Any] | None = None, ) -> list[dict[str, Any]]: """Список проектов: id, имя, заметка (сокращённая), число открытых задач. @@ -540,9 +594,10 @@ его project_id (или project_name) в create_task/update_task — так задачи остаются в контексте своего проекта. """ + user_id = _mcp_user(ctx) session = get_session_factory()() try: - stmt = select(Project).order_by(Project.name) + stmt = select(Project).where(Project.user_id == user_id).order_by(Project.name) if not include_archived: stmt = stmt.where(Project.is_archived.is_(False)) return [_project_compact(p, session) for p in session.scalars(stmt).all()] @@ -553,21 +608,24 @@ def get_project( project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None, name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None, + ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Контекст проекта: заметка (цели, договорённости) и открытые задачи. Вызывайте перед обсуждением задач проекта и после перерыва в разговоре — заметка и открытые задачи восстановят контекст. """ + user_id = _mcp_user(ctx) session = get_session_factory()() try: - project = _resolve_project(session, project_id, name) + project = _resolve_project(session, user_id, project_id, name) if project is None: raise ValueError("Pass project_id or name — see list_projects().") tasks = ( session.scalars( select(Task) .where( + Task.user_id == user_id, Task.project_id == project.id, Task.status.in_(("to_do", "in_progress")), ) @@ -585,13 +643,16 @@ session.close() -def list_tags() -> list[dict[str, Any]]: +def list_tags(ctx: Context[Any, Any, Any] | None = None) -> list[dict[str, Any]]: """Справочник тегов: id и имя — для параметра tag_ids.""" + user_id = _mcp_user(ctx) session = get_session_factory()() try: return [ {"id": t.id, "name": t.name} - for t in session.scalars(select(Tag).order_by(Tag.name)).all() + for t in session.scalars( + select(Tag).where(Tag.user_id == user_id).order_by(Tag.name) + ).all() ] finally: session.close() diff --git a/backend/app/models.py b/backend/app/models.py index e42f17a..55c1315 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -13,7 +13,9 @@ Date, DateTime, ForeignKey, + Index, Integer, + PrimaryKeyConstraint, String, Text, UniqueConstraint, @@ -27,10 +29,45 @@ return datetime.now(UTC) +class User(Base): + """Пользователь из SSO (gnexus-gauth): профиль синхронизируется при логине. + + id — user_id из SSO (строка); локальной аутентификации нет. Все данные + приложения привязаны к user_id; строки с NULL user_id — «бесхозные», + приписываются пользователю при первом логине (claim, ТЗ 1.2). + """ + + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + email: Mapped[str] = mapped_column(String(255), default="") + avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None) + locale: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class McpToken(Base): + """Per-user bearer-токен MCP (ТЗ 3.10): агент действует от имени пользователя. + + Хранится только sha256-хэш; plaintext показывается один раз при генерации. + """ + + __tablename__ = "mcp_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + label: Mapped[str] = mapped_column(String(100), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + class Task(Base): __tablename__ = "tasks" id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) title: Mapped[str] = mapped_column(String(500)) # Описание живёт в документе (полиморфная привязка: task/project) @@ -117,7 +154,9 @@ @description.setter def description(self, value: str) -> None: if self.document is None: - self.document = Document(owner_type="task", body=value) + self.document = Document( + owner_type="task", body=value, user_id=getattr(self, "user_id", None) + ) else: self.document.body = value self.document.updated_at = utcnow() @@ -129,9 +168,13 @@ class Project(Base): __tablename__ = "projects" + __table_args__ = (Index("uq_projects_user_name", "user_id", "name", unique=True),) id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(200), unique=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) + name: Mapped[str] = mapped_column(String(200)) # Заметка — тоже документ (markdown: ссылки, контекст, картинки) document: Mapped["Document | None"] = relationship( @@ -152,7 +195,9 @@ @note.setter def note(self, value: str) -> None: if self.document is None: - self.document = Document(owner_type="project", body=value) + self.document = Document( + owner_type="project", body=value, user_id=getattr(self, "user_id", None) + ) else: self.document.body = value self.document.updated_at = utcnow() @@ -174,9 +219,13 @@ class Tag(Base): __tablename__ = "tags" + __table_args__ = (Index("uq_tags_user_name", "user_id", "name", unique=True),) id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(100), unique=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) + name: Mapped[str] = mapped_column(String(100)) tasks: Mapped[list[Task]] = relationship( secondary="task_tags", back_populates="tags", lazy="selectin" @@ -203,6 +252,11 @@ __table_args__ = (UniqueConstraint("owner_type", "owner_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) + # Денормализованный владелец (task/project): авторизация вложений одним + # запросом без полиморфного join'а + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=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="") @@ -228,14 +282,30 @@ document: Mapped[Document] = relationship(back_populates="attachments") -class AppSetting(Base): - """Глобальные настройки (единственный пользователь — мультитенантности нет). +class AppSettingGlobal(Base): + """Глобальные настройки прежней однопользовательской эпохи. - Валюта для бюджетов, например: выбирается один раз и применяется всюду. + Больше не читается приложением — осталась только источником claim: при + первом логине копируется в per-user app_settings (ТЗ 1.2, миграция + mcp_users_multitenancy). Можно дропнуть отдельной миграцией позже. + """ + + __tablename__ = "app_settings_global" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[str] = mapped_column(String(200), nullable=False) + + +class AppSetting(Base): + """Настройки пользователя: валюта бюджетов, язык интерфейса. + + Язык по умолчанию берётся из SSO-локали — настройка переопределяет. """ __tablename__ = "app_settings" + __table_args__ = (PrimaryKeyConstraint("user_id", "key"),) + user_id: Mapped[str] = mapped_column(String(64), primary_key=True) key: Mapped[str] = mapped_column(String(100), primary_key=True) value: Mapped[str] = mapped_column(String(200), nullable=False) @@ -252,6 +322,9 @@ __table_args__ = (UniqueConstraint("task_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) task_id: Mapped[int | None] = mapped_column( ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None ) @@ -278,6 +351,9 @@ __tablename__ = "coin_events" id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) task_id: Mapped[int | None] = mapped_column( ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None ) @@ -305,6 +381,9 @@ __table_args__ = (UniqueConstraint("kind", "ref_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) kind: Mapped[str] = mapped_column(String(16)) # plant | decoration ref_id: Mapped[int | None] = mapped_column( ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True, default=None diff --git a/backend/app/services/closing.py b/backend/app/services/closing.py index 7ab5a47..73c9f2c 100644 --- a/backend/app/services/closing.py +++ b/backend/app/services/closing.py @@ -41,13 +41,21 @@ if event is not None: event.via_options = via_options outcome.event = event + user_id = task.user_id or "" # Монеты — вторая награда за закрытие (валюта сада, рядом с XP) - old_level = xp_service.level_from_xp(xp_service.total_xp(db) - event.amount) + old_level = xp_service.level_from_xp(xp_service.total_xp(db, user_id) - event.amount) coins = coins_for_xp(event.amount) - db.add(CoinEvent(task_id=task.id, source="task", amount=coins)) - new_level = xp_service.level_from_xp(xp_service.total_xp(db)) + db.add(CoinEvent(user_id=user_id, task_id=task.id, source="task", amount=coins)) + new_level = xp_service.level_from_xp(xp_service.total_xp(db, user_id)) if new_level > old_level: coins += level_bonus_coins(new_level) - db.add(CoinEvent(source="level", level=new_level, amount=level_bonus_coins(new_level))) + db.add( + CoinEvent( + user_id=user_id, + source="level", + level=new_level, + amount=level_bonus_coins(new_level), + ) + ) outcome.coins = coins return outcome diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index 65f681f..0239bb2 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -188,7 +188,7 @@ } -def apply_proposal(db: Any, task: Task, proposal: dict[str, Any]) -> None: +def apply_proposal(db: Any, task: Task, proposal: dict[str, Any], user_id: str) -> None: """Применить предложение: заголовок/описание, теги (недостающие создаются), приоритет.""" if proposal.get("title"): task.title = proposal["title"].strip() @@ -197,12 +197,14 @@ tag_names = proposal.get("tags") or [] if tag_names: - existing = {t.name.lower(): t for t in db.scalars(select(Tag)).all()} + existing = { + t.name.lower(): t for t in db.scalars(select(Tag).where(Tag.user_id == user_id)).all() + } tags = [] for name in tag_names: tag = existing.get(name.lower()) if tag is None: - tag = Tag(name=name) + tag = Tag(user_id=user_id, name=name) db.add(tag) db.flush() tags.append(tag) @@ -213,12 +215,16 @@ # только среди открытых проектов: в архивном (3.8.1) задача пропала бы # из рабочих видов сразу после утверждения project = db.scalars( - select(Project).where(Project.name.ilike(project_name), Project.is_archived.is_(False)) + select(Project).where( + Project.user_id == user_id, + 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()) + project = Project(user_id=user_id, name=project_name.strip()) db.add(project) db.flush() if project is not None: @@ -238,6 +244,7 @@ task = session.get(Task, task_id) if task is None or task.detail_state != "raw": return + user_id = task.user_id or "" # Открытые проекты с описаниями: без них LLM назначает задачу первому # попавшемуся или выдумывает новый (ТЗ 3.2) @@ -250,7 +257,9 @@ } for p in session.scalars( select(Project).where( - Project.relevance_status == "active", Project.is_archived.is_(False) + Project.user_id == user_id, + Project.relevance_status == "active", + Project.is_archived.is_(False), ) ).all() ] @@ -259,13 +268,13 @@ proposal = service.propose( task.title, task.description, - [t.name for t in session.scalars(select(Tag)).all()], + [t.name for t in session.scalars(select(Tag).where(Tag.user_id == user_id)).all()], projects, ) if proposal is not None: task.ai_proposal = proposal session.commit() - # LLM работает вне запроса — уведомляем все вкладки (ТЗ 3.14) - publish(None, "detail.changed", {"id": task_id}) + # LLM работает вне запроса — уведомляем вкладки владельца (ТЗ 3.14) + publish(user_id, "detail.changed", {"id": task_id}) finally: session.close() diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py index 9507b66..b228eaa 100644 --- a/backend/app/services/garden.py +++ b/backend/app/services/garden.py @@ -115,30 +115,36 @@ SPIRAL_START_RADIUS = 3 -def balance(db: Session) -> int: - total = db.scalar(select(func.coalesce(func.sum(CoinEvent.amount), 0))) +def balance(db: Session, user_id: str) -> int: + total = db.scalar( + select(func.coalesce(func.sum(CoinEvent.amount), 0)).where(CoinEvent.user_id == user_id) + ) return int(total or 0) -def expansions_bought(db: Session) -> int: +def expansions_bought(db: Session, user_id: str) -> int: return int( db.scalar( select(func.count()) .select_from(CoinEvent) - .where(CoinEvent.source == "shop", CoinEvent.item_key.like("expansion_%")) + .where( + CoinEvent.user_id == user_id, + CoinEvent.source == "shop", + CoinEvent.item_key.like("expansion_%"), + ) ) or 0 ) -def grid_size(db: Session) -> dict[str, int]: - extra = expansions_bought(db) * EXPANSION_PERIMETER +def grid_size(db: Session, user_id: str) -> dict[str, int]: + extra = expansions_bought(db, user_id) * EXPANSION_PERIMETER return {"cols": BASE_COLS + extra, "rows": BASE_ROWS + extra} -def next_expansion(db: Session) -> dict[str, Any] | None: +def next_expansion(db: Session, user_id: str) -> dict[str, Any] | None: """Следующее доступное расширение (ещё не купленное).""" - bought = expansions_bought(db) + bought = expansions_bought(db, user_id) keys = sorted(EXPANSIONS) if bought >= len(keys): return None @@ -153,10 +159,12 @@ ] -def make_seed_plant(db: Session, species: str) -> GardenItem: +def make_seed_plant(db: Session, species: str, user_id: str) -> GardenItem: """Купленное семя: растение-росток в инвентаре (x/y NULL), пользователь сам перетаскивает его на карту. Размещённое семя обратно убрать нельзя.""" - item = GardenItem(kind="plant", ref_id=None, item_key=species, x=None, y=None, stage=0) + item = GardenItem( + user_id=user_id, kind="plant", ref_id=None, item_key=species, x=None, y=None, stage=0 + ) db.add(item) return item @@ -201,15 +209,17 @@ return (0, 0) -def _level_at_event(db: Session, event: XpEvent) -> int: - """Уровень садовника на момент события XP — по сумме XP событий до него.""" +def _level_at_event(db: Session, user_id: str, event: XpEvent) -> int: + """Уровень садовника на момент события XP — по сумме его XP событий до него.""" xp_up_to = db.scalar( - select(func.coalesce(func.sum(XpEvent.amount), 0)).where(XpEvent.id <= event.id) + select(func.coalesce(func.sum(XpEvent.amount), 0)).where( + XpEvent.user_id == user_id, XpEvent.id <= event.id + ) ) return level_from_xp(int(xp_up_to or 0)) -def ensure_garden(db: Session) -> None: +def ensure_garden(db: Session, user_id: str) -> None: """Ленивое обслуживание сада: бонусы уровней + авторазмещение новых растений. Гонка параллельных GET /api/garden: оба читают «ничего не выдано» и пишут @@ -218,63 +228,83 @@ выходим: сад уже обслужил параллельный запрос, его данные закоммичены. """ try: - _ensure_garden_locked(db) + _ensure_garden_locked(db, user_id) except IntegrityError: db.rollback() -def _ensure_garden_locked(db: Session) -> None: +def _ensure_garden_locked(db: Session, user_id: str) -> None: # 1) Бонусы за достигнутые уровни (задним числом — для старых уровней) - reached = level_from_xp(total_xp(db)) + reached = level_from_xp(total_xp(db, user_id)) granted = set( db.scalars( - select(CoinEvent.level).where(CoinEvent.source == "level", CoinEvent.level.is_not(None)) + select(CoinEvent.level).where( + CoinEvent.user_id == user_id, + CoinEvent.source == "level", + CoinEvent.level.is_not(None), + ) ).all() ) for level in range(2, reached + 1): if level not in granted: - db.add(CoinEvent(source="level", level=level, amount=level_bonus_coins(level))) + db.add( + CoinEvent( + user_id=user_id, source="level", level=level, amount=level_bonus_coins(level) + ) + ) # 2) Растения для закрытых задач без размещённого элемента (события # создания kind='create_*' растения не дают — только закрытия) placed_refs = set( db.scalars( select(GardenItem.ref_id).where( - GardenItem.kind == "plant", GardenItem.ref_id.is_not(None) + GardenItem.user_id == user_id, + GardenItem.kind == "plant", + GardenItem.ref_id.is_not(None), ) ).all() ) pending = [ e for e in db.scalars( - select(XpEvent).where(XpEvent.kind == "task").order_by(XpEvent.id) + select(XpEvent) + .where(XpEvent.user_id == user_id, XpEvent.kind == "task") + .order_by(XpEvent.id) ).all() if e.id not in placed_refs ] if pending: - size = grid_size(db) + size = grid_size(db, user_id) # Неразмещенные декорации (в инвентаре) ячейки не занимают occupied = { (int(x), int(y)) for x, y in db.execute( select(GardenItem.x, GardenItem.y).where( - GardenItem.x.is_not(None), GardenItem.y.is_not(None) + GardenItem.user_id == user_id, + GardenItem.x.is_not(None), + GardenItem.y.is_not(None), ) ).all() } | house_footprint(size["cols"], size["rows"]) for event in pending: # вид и уровень — на момент события, чтобы старые закрытия # получили вид из пула своего времени - species = species_for(_level_at_event(db, event), event.task_id or 0, event.rarity) + species = species_for( + _level_at_event(db, user_id, event), event.task_id or 0, event.rarity + ) x, y = spiral_position(occupied, size["cols"], size["rows"]) occupied.add((x, y)) - db.add(GardenItem(kind="plant", ref_id=event.id, item_key=species, x=x, y=y)) + db.add( + GardenItem( + user_id=user_id, kind="plant", ref_id=event.id, item_key=species, x=x, y=y + ) + ) db.flush() -def can_afford(db: Session, cost: int) -> bool: - return balance(db) >= cost +def can_afford(db: Session, user_id: str, cost: int) -> bool: + return balance(db, user_id) >= cost -def spend(db: Session, cost: int, item_key: str) -> None: - db.add(CoinEvent(source="shop", amount=-cost, item_key=item_key)) +def spend(db: Session, user_id: str, cost: int, item_key: str) -> None: + db.add(CoinEvent(user_id=user_id, source="shop", amount=-cost, item_key=item_key)) diff --git a/backend/app/services/options.py b/backend/app/services/options.py index 09bd96c..a33ad5b 100644 --- a/backend/app/services/options.py +++ b/backend/app/services/options.py @@ -44,7 +44,7 @@ return (4, 0) -def pick_options(db: Any, available_minutes: int, limit: int = 3) -> list[Task]: +def pick_options(db: Any, available_minutes: int, user_id: str, limit: int = 3) -> list[Task]: """«3 варианта» (ТЗ 3.8): задачи, подходящие под доступное время. Кандидаты: утверждённые, к выполнению/в работе, проект активен (или без @@ -54,7 +54,11 @@ """ stmt = ( select(Task) - .where(Task.detail_state == "approved", Task.status.in_(("to_do", "in_progress"))) + .where( + Task.user_id == user_id, + Task.detail_state == "approved", + Task.status.in_(("to_do", "in_progress")), + ) .order_by(Task.priority.desc().nullslast(), Task.created_at.asc()) ) tasks = list(db.scalars(stmt).all()) @@ -63,7 +67,7 @@ tasks.sort(key=lambda t: (_urgency(t)[0], _priority_key(t), t.created_at)) active_projects: set[int | None] = {None} - for p in db.scalars(select(Project)).all(): + for p in db.scalars(select(Project).where(Project.user_id == user_id)).all(): # архивный проект в выдачу задачи не попадает, даже «активный» if p.relevance_status == "active" and not p.is_archived: active_projects.add(p.id) diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py index 58bb290..070d329 100644 --- a/backend/app/services/recurrence.py +++ b/backend/app/services/recurrence.py @@ -74,6 +74,7 @@ def _copy(db: Session, source: Task) -> Task: """Новый экземпляр регулярной задачи: те же метаданные, чистое состояние.""" clone = Task( + user_id=source.user_id, title=source.title, description=source.description, task_type="recurring", diff --git a/backend/app/services/users.py b/backend/app/services/users.py new file mode 100644 index 0000000..0f074bf --- /dev/null +++ b/backend/app/services/users.py @@ -0,0 +1,61 @@ +"""Сервис пользователей: upsert профиля из SSO и claim бесхозных данных (ТЗ 1.2).""" + +from typing import Any, cast + +from sqlalchemy import select +from sqlalchemy.engine import CursorResult +from sqlalchemy.orm import Session + +from app.models import AppSetting, AppSettingGlobal, User + +# Таблицы с user_id, подлежащим claim'у (user_id IS NULL → новый пользователь). +# mcp_tokens и app_settings не входят: там user_id NOT NULL, они per-user с рождения. +CLAIM_TABLES = ( + "projects", + "tasks", + "tags", + "documents", + "xp_events", + "coin_events", + "garden_items", +) + + +def upsert_user( + db: Session, user_id: str, email: str, avatar_url: str | None, locale: str | None +) -> User: + """Создать или обновить профиль по данным SSO (email/аватар/локаль могут меняться).""" + user = db.get(User, user_id) + if user is None: + user = User(id=user_id, email=email, avatar_url=avatar_url, locale=locale) + db.add(user) + else: + user.email = email + user.avatar_url = avatar_url + user.locale = locale + return user + + +def claim_orphan_data(db: Session, user_id: str) -> int: + """Приписать все строки без владельца вошедшему пользователю. + + Claim-on-first-login: миграция оставила существующие данные с user_id NULL; + первый вошедший забирает их (идемпотентно — NULL после этого не остаётся). + """ + claimed = 0 + for table in CLAIM_TABLES: + model = User.metadata.tables[table] + result = cast( + CursorResult[Any], + db.execute(model.update().where(model.c.user_id.is_(None)).values(user_id=user_id)), + ) + claimed += result.rowcount or 0 + return claimed + + +def claim_global_settings(db: Session, user_id: str) -> None: + """Скопировать глобальные настройки прежней эпохи пользователю (идемпотентно).""" + existing = {s.key for s in db.scalars(select(AppSetting).where(AppSetting.user_id == user_id))} + for row in db.scalars(select(AppSettingGlobal)): + if row.key not in existing: + db.add(AppSetting(user_id=user_id, key=row.key, value=row.value)) diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py index cc140c9..4448657 100644 --- a/backend/app/services/xp.py +++ b/backend/app/services/xp.py @@ -95,6 +95,7 @@ return None amount = compute_task_xp(task) event = XpEvent( + user_id=task.user_id, task_id=task.id, kind="task", amount=amount, @@ -105,14 +106,14 @@ return event -def grant_create_xp(db: Session, kind: str) -> XpEvent: +def grant_create_xp(db: Session, kind: str, user_id: str) -> XpEvent: """Микронаграда за создание (kind='create_task' | 'create_project'). Растения в саду не даёт (их растят только закрытия — см. ensure_garden); task_id остаётся NULL — удаление задачи/проекта награду не отнимает. """ amount = XP_CREATE_TASK if kind == "create_task" else XP_CREATE_PROJECT - event = XpEvent(task_id=None, kind=kind, amount=amount, rarity="common") + event = XpEvent(user_id=user_id, task_id=None, kind=kind, amount=amount, rarity="common") db.add(event) db.flush() return event @@ -122,26 +123,33 @@ return COINS_CREATE_TASK if kind == "create_task" else COINS_CREATE_PROJECT -def claim_daily(db: Session) -> dict[str, Any]: +def claim_daily(db: Session, user_id: str) -> dict[str, Any]: """Ежедневная награда: 50 монет раз в сутки (граница дня — UTC). Идемпотентно: если за текущий день начисление уже было — granted=False. - Гонку двух параллельных POST ловит уникальный индекс (source, day) — - вызывающий обрабатывает IntegrityError как granted=False. + Гонку двух параллельных POST ловит уникальный индекс (user_id, source, day) + — вызывающий обрабатывает IntegrityError как granted=False. """ today = datetime.now(UTC).date() claimed = db.scalar( - select(CoinEvent).where(CoinEvent.source == "daily", CoinEvent.day == today) + select(CoinEvent).where( + CoinEvent.user_id == user_id, CoinEvent.source == "daily", CoinEvent.day == today + ) ) if claimed is not None: return {"granted": False, "coins": 0} - db.add(CoinEvent(source="daily", amount=DAILY_COINS, day=today)) + db.add(CoinEvent(user_id=user_id, source="daily", amount=DAILY_COINS, day=today)) db.flush() return {"granted": True, "coins": DAILY_COINS} -def total_xp(db: Session) -> int: - return int(db.scalar(select(func.coalesce(func.sum(XpEvent.amount), 0))) or 0) +def total_xp(db: Session, user_id: str) -> int: + return int( + db.scalar( + select(func.coalesce(func.sum(XpEvent.amount), 0)).where(XpEvent.user_id == user_id) + ) + or 0 + ) def level_from_xp(xp: int) -> int: @@ -152,9 +160,9 @@ return level -def xp_summary(db: Session) -> dict[str, Any]: +def xp_summary(db: Session, user_id: str) -> dict[str, Any]: """Сводка для UI: XP, уровень, прогресс до следующего уровня.""" - xp = total_xp(db) + xp = total_xp(db, user_id) level = level_from_xp(xp) current_floor = LEVEL_FACTOR * (level - 1) * level // 2 next_floor = LEVEL_FACTOR * level * (level + 1) // 2 @@ -167,14 +175,21 @@ "level_percent": round((xp - current_floor) / span * 100) if span else 100, # Счётчик закрытых задач: события создания (kind='create_*') не считаются "completed_count": int( - db.scalar(select(func.count(XpEvent.id)).where(XpEvent.kind == "task")) or 0 + db.scalar( + select(func.count(XpEvent.id)).where( + XpEvent.kind == "task", XpEvent.user_id == user_id + ) + ) + or 0 ), } -def xp_events(db: Session) -> list[dict[str, Any]]: +def xp_events(db: Session, user_id: str) -> list[dict[str, Any]]: """Все события начисления (для сада: редкости, счётчик «выбрал и сделал», история).""" - rows = db.scalars(select(XpEvent).order_by(XpEvent.created_at.desc())).all() + rows = db.scalars( + select(XpEvent).where(XpEvent.user_id == user_id).order_by(XpEvent.created_at.desc()) + ).all() return [ { "task_id": r.task_id, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 15a5d14..2210dff 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,14 +3,13 @@ import os import tempfile from collections.abc import Iterator +from types import SimpleNamespace from typing import Any # env-переменные — ДО импорта app.*: get_settings() кэшируется при первом вызове _tmpdir = tempfile.mkdtemp(prefix="gntodo-att-") os.environ.setdefault("ATTACHMENTS_PATH", _tmpdir) -os.environ.setdefault("MCP_TOKEN", "test-mcp-token") os.environ.setdefault("SESSION_SECRET", "test-session-secret") -MCP_TOKEN = os.environ["MCP_TOKEN"] import pytest # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 @@ -61,6 +60,10 @@ import app.mcp_server as mcp_server monkeypatch.setattr(mcp_server, "get_session_factory", lambda: _test_session_factory) + # McpAuthMiddleware (main.py) тоже открывает сессии — направляем в тестовую БД + import app.main as main_module + + monkeypatch.setattr(main_module, "get_session_factory", lambda: _test_session_factory) # Фоновый поток детализации из create_task гоняет транзакции по общему # соединению SQLite (StaticPool) — в тестах не порождаем потоков вовсе. monkeypatch.setattr(mcp_server, "detail_task", lambda task_id: None) @@ -90,3 +93,18 @@ # Без `with`: lifespan (старт MCP session manager) в обычных тестах не нужен, # а менеджер сессий допускает только один запуск на инстанс. yield TestClient(app) + + +def mcp_ctx(user_id: str = "1") -> Any: + """Стаб mcp.server.fastmcp.Context для прямых вызовов тулов в тестах. + + Тулы достают user_id из ctx.request_context.request.state.gntodo_user_id + (его кладёт McpAuthMiddleware); стаб повторяет только этот доступ. + """ + + class _Ctx: + request_context = SimpleNamespace( + request=SimpleNamespace(state=SimpleNamespace(gntodo_user_id=user_id)) + ) + + return _Ctx() diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index ced1410..19a9bb9 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -180,7 +180,7 @@ session = _test_session_factory() db_task = session.get(Task, task["id"]) assert db_task is not None - apply_proposal(session, db_task, proposal) + apply_proposal(session, db_task, proposal, "1") assert db_task.priority == 3 assert [t.name for t in db_task.tags] == ["нет такого"] session.close() diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py index cc1ccb3..0198c39 100644 --- a/backend/tests/test_mcp.py +++ b/backend/tests/test_mcp.py @@ -7,7 +7,30 @@ from app import mcp_server from app.main import app -from tests.conftest import MCP_TOKEN # type: ignore[attr-defined] +from tests.conftest import mcp_ctx # type: ignore[attr-defined] + + +@pytest.fixture(autouse=True) +def _mcp_tools_with_ctx(monkeypatch: Any) -> None: + """Прямые вызовы тулов в тестах — от пользователя AUTH_USER ("1").""" + ctx = mcp_ctx() + for name in ( + "create_task", + "update_task", + "complete_task", + "get_task", + "list_tasks", + "list_projects", + "get_project", + "list_tags", + ): + orig = getattr(mcp_server, name) + + def wrapper(*args: Any, _orig: Any = orig, **kwargs: Any) -> Any: + kwargs.setdefault("ctx", ctx) + return _orig(*args, **kwargs) + + monkeypatch.setattr(mcp_server, name, wrapper) def test_mcp_create_and_get_task(client: TestClient) -> None: @@ -58,6 +81,23 @@ def test_mcp_requires_bearer_token() -> None: + """Токен берётся из БД (per-user, ТЗ 3.10): нет/неверный — 401, свой — проходит.""" + from sqlalchemy import select + + from app.api.mcp_tokens import hash_token + from app.models import McpToken + from tests.conftest import _test_session_factory + + token = "gnt_test-bearer-token" + session = _test_session_factory() + try: + session.add(McpToken(user_id="1", token_hash=hash_token(token), label="test")) + existing = session.scalars(select(McpToken)).all() + assert len(existing) == 1 + session.commit() + finally: + session.close() + # Этот тест — единственный, где запускается lifespan (MCP session manager) with TestClient(app) as client: # без токена @@ -69,7 +109,7 @@ resp = client.post( "/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, - headers={"Authorization": f"Bearer {MCP_TOKEN}"}, + headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code != 401 @@ -161,7 +201,7 @@ from app.models import Project try: - project = Project(name=name, note=note) + project = Project(user_id="1", name=name, note=note) session.add(project) session.commit() return project.id diff --git a/backend/tests/test_mcp_tokens.py b/backend/tests/test_mcp_tokens.py new file mode 100644 index 0000000..05d537f --- /dev/null +++ b/backend/tests/test_mcp_tokens.py @@ -0,0 +1,105 @@ +"""Тесты per-user MCP-токенов (ТЗ 3.10): генерация, отзыв, авторизация /mcp.""" + +from typing import Any + +from fastapi.testclient import TestClient + +from app import mcp_server +from app.api.mcp_tokens import hash_token +from app.main import app +from app.models import Task +from tests.conftest import _test_session_factory # type: ignore[attr-defined] +from tests.test_multiuser import switch_user + + +def _create_token(client: TestClient, label: str = "") -> dict[str, Any]: + resp = client.post("/api/mcp-tokens", json={"label": label}) + assert resp.status_code == 200, resp.text + return resp.json() + + +def test_create_token_returns_plaintext_once(client: TestClient) -> None: + created = _create_token(client, "мой агент") + assert created["token"].startswith("gnt_") + assert created["label"] == "мой агент" + + # в БД — только хэш, plaintext не хранится + session = _test_session_factory() + try: + from sqlalchemy import select + + from app.models import McpToken + + rows = session.scalars(select(McpToken)).all() + assert len(rows) == 1 + assert rows[0].token_hash == hash_token(created["token"]) + assert rows[0].user_id == "1" + assert created["token"] not in str(rows[0].__dict__) + finally: + session.close() + + # список не содержит plaintext + listed = client.get("/api/mcp-tokens").json() + assert len(listed) == 1 + assert "token" not in listed[0] + assert "token_hash" not in listed[0] + + +def test_delete_token(client: TestClient) -> None: + created = _create_token(client) + resp = client.delete(f"/api/mcp-tokens/{created['id']}") + assert resp.status_code == 200 + assert client.get("/api/mcp-tokens").json() == [] + # чужой/несуществующий — 404 + assert client.delete("/api/mcp-tokens/999").status_code == 404 + + +def test_token_grants_mcp_access_and_scopes_to_owner(client: TestClient) -> None: + """Токен из API авторизует /mcp; тул действует от имени владельца токена.""" + created = _create_token(client, "агент") + + # middleware валидирует токен по хэшу из БД: без lifespan дальше mounted-app + # не идёт (307 redirect на /mcp/ — это уже после авторизации), 401 нет + tc = TestClient(app, follow_redirects=False) + resp = tc.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + headers={"Authorization": f"Bearer {created['token']}"}, + ) + assert resp.status_code == 307 # 401 был бы до редиректа + + # тул от имени владельца токена создаёт задачу его пользователя + from tests.conftest import mcp_ctx # type: ignore[attr-defined] + + result = mcp_server.create_task("Через токен", ctx=mcp_ctx("1")) + session = _test_session_factory() + try: + task = session.get(Task, result["id"]) + assert task is not None and task.user_id == "1" + finally: + session.close() + + +def test_revoked_token_gets_401(client: TestClient) -> None: + created = _create_token(client) + headers = {"Authorization": f"Bearer {created['token']}"} + assert client.delete(f"/api/mcp-tokens/{created['id']}").status_code == 200 + + # 401 отдаёт middleware до монтирования MCP — lifespan не нужен + tc = TestClient(app) + assert tc.post("/mcp", json={}, headers=headers).status_code == 401 + + +def test_foreign_token_isolated_per_user(client: TestClient) -> None: + """Токен другого пользователя не виден в списке и не удаляется по id.""" + mine = _create_token(client, "мой") + with switch_user("other-user"): + theirs = _create_token(client, "чужой") + + listed = {t["id"] for t in client.get("/api/mcp-tokens").json()} + assert mine["id"] in listed and theirs["id"] not in listed + assert client.delete(f"/api/mcp-tokens/{theirs['id']}").status_code == 404 + + # неверный токен — 401 (middleware до монтирования, lifespan не нужен) + fake = TestClient(app).post("/mcp", json={}, headers={"Authorization": "Bearer gnt_fake"}) + assert fake.status_code == 401 diff --git a/backend/tests/test_multiuser.py b/backend/tests/test_multiuser.py new file mode 100644 index 0000000..4ed7b6c --- /dev/null +++ b/backend/tests/test_multiuser.py @@ -0,0 +1,224 @@ +"""Тесты мультиюзерности (ТЗ 1.2): изоляция данных двух пользователей. + +Сессия-заглушка conftest мутируема: переключение пользователя — замена +AUTH_USER["user_id"] (см. switch_user). Сервер сам ставит user_id из сессии. +""" + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.models import AppSettingGlobal, Project, Task +from tests.conftest import AUTH_USER, _test_session_factory # type: ignore[attr-defined] + + +@contextmanager +def switch_user(user_id: str) -> Iterator[None]: + """Сменить пользователя сессии-заглушки (вкладка другого человека).""" + old = AUTH_USER["user_id"] + AUTH_USER["user_id"] = user_id + try: + yield + finally: + AUTH_USER["user_id"] = old + + +def test_lists_are_isolated(client: TestClient) -> None: + client.post("/api/tasks", json={"title": "Моя задача"}) + client.post("/api/projects", json={"name": "Мой проект"}) + client.post("/api/tags", json={"name": "мойтег"}) + + with switch_user("other-user"): + assert client.get("/api/tasks").json() == [] + assert client.get("/api/projects").json() == [] + assert client.get("/api/tags").json() == [] + # чужое не отдаётся и по id + assert client.get("/api/tasks/1").status_code == 404 + assert client.get("/api/projects/1").status_code == 404 + + +def test_writes_cannot_touch_foreign_rows(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Чужая для второго"}).json()["id"] + pid = client.post("/api/projects", json={"name": "Проект А"}).json()["id"] + with switch_user("other-user"): + assert client.patch(f"/api/tasks/{tid}", json={"title": "взлом"}).status_code == 404 + assert client.delete(f"/api/tasks/{tid}").status_code == 404 + assert client.patch(f"/api/projects/{pid}", json={"name": "взлом"}).status_code == 404 + assert client.delete(f"/api/projects/{pid}").status_code == 404 + # данные не тронуты + assert client.get(f"/api/tasks/{tid}").json()["title"] == "Чужая для второго" + + +def test_same_names_allowed_for_different_users(client: TestClient) -> None: + assert client.post("/api/projects", json={"name": "Общее имя"}).status_code == 200 + with switch_user("other-user"): + assert client.post("/api/projects", json={"name": "Общее имя"}).status_code == 200 + # дубликат внутри одного пользователя по-прежнему 409 + assert client.post("/api/projects", json={"name": "Общее имя"}).status_code == 409 + + assert client.post("/api/tags", json={"name": "dup"}).status_code == 200 + with switch_user("other-user"): + assert client.post("/api/tags", json={"name": "dup"}).status_code == 200 + + +def test_project_and_tag_refs_scoped(client: TestClient) -> None: + pid = client.post("/api/projects", json={"name": "Проект Б"}).json()["id"] + tid = client.post("/api/tasks", json={"title": "Задача 1"}).json()["id"] + with switch_user("other-user"): + # чужой проект нельзя прицепить к своей задаче + resp = client.patch(f"/api/tasks/{tid}", json={"project_id": pid}) + assert resp.status_code == 404 + + +def test_xp_and_garden_are_per_user(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Закрыть меня"}).json()["id"] + client.patch(f"/api/tasks/{tid}", json={"priority": 8}) + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + my_xp = client.get("/api/xp").json()["total_xp"] + assert my_xp > 0 + + with switch_user("other-user"): + assert client.get("/api/xp").json()["total_xp"] == 0 + garden = client.get("/api/garden").json() + assert garden["items"] == [] + assert garden["balance"] == 0 + assert garden["level"] == 1 + # у первого растение из закрытой задачи есть + assert any(i["kind"] == "plant" for i in client.get("/api/garden").json()["items"]) + + +def test_daily_reward_per_user(client: TestClient) -> None: + assert client.post("/api/xp/daily").json()["granted"] is True + with switch_user("other-user"): + # другому пользователю своя дейли + assert client.post("/api/xp/daily").json()["granted"] is True + # повтор в тот же день внутри своего пользователя — нет + assert client.post("/api/xp/daily").json()["granted"] is False + + +def test_settings_are_per_user(client: TestClient) -> None: + client.put("/api/settings", json={"currency": "USD"}) + with switch_user("other-user"): + # другому — дефолт, свои настройки не перепутались + assert client.get("/api/settings").json()["currency"] == "UAH" + client.put("/api/settings", json={"currency": "EUR"}) + assert client.get("/api/settings").json()["currency"] == "USD" + + +def test_claim_orphan_data_on_first_login(client: TestClient) -> None: + """Бесхозные строки (user_id NULL из прежней эпохи) забирает вошедший.""" + from app.services.users import claim_orphan_data + + session = _test_session_factory() + try: + session.add(Task(title="Старая задача")) + session.add(Project(name="Старый проект")) + session.commit() + + orphans = session.scalars(select(Task).where(Task.user_id.is_(None))).all() + assert len(orphans) == 1 + assert claim_orphan_data(session, "new-user") >= 2 + assert session.scalars(select(Task).where(Task.user_id.is_(None))).all() == [] + mine = session.scalars( + select(Task).where(Task.user_id == "new-user", Task.title == "Старая задача") + ).all() + assert len(mine) == 1 + finally: + session.close() + + +def test_claim_global_settings_on_first_login(client: TestClient) -> None: + from app.models import AppSetting + from app.services.users import claim_global_settings + + session = _test_session_factory() + try: + session.add(AppSettingGlobal(key="currency", value="EUR")) + session.commit() + + claim_global_settings(session, "new-user") + row = session.get(AppSetting, ("new-user", "currency")) + assert row is not None and row.value == "EUR" + # идемпотентно: второй claim не задублирует + claim_global_settings(session, "new-user") + rows = session.scalars( + select(AppSetting).where(AppSetting.user_id == "new-user", AppSetting.key == "currency") + ).all() + assert len(rows) == 1 + finally: + session.close() + + +def test_settings_suggest_scoped_to_user(client: TestClient) -> None: + """Режим «3 вариантов» показывает только свои задачи.""" + from app.services.options import pick_options + + tid = client.post("/api/tasks", json={"title": "Моё дело"}).json()["id"] + client.post(f"/api/tasks/{tid}/approve") + + session = _test_session_factory() + try: + assert pick_options(session, 60, "1")[0].id == tid + assert pick_options(session, 60, "other-user") == [] + finally: + session.close() + + +def test_user_profile_upsert(client: TestClient) -> None: + """upsert_user: создание и обновление профиля из SSO.""" + from app.services.users import upsert_user + + session = _test_session_factory() + try: + user = upsert_user(session, "u1", "a@example.com", None, "ru") + session.commit() + assert user.email == "a@example.com" + + updated = upsert_user(session, "u1", "b@example.com", "http://x/ava.png", "en") + session.commit() + assert updated.email == "b@example.com" + assert updated.avatar_url == "http://x/ava.png" + assert updated.locale == "en" + finally: + session.close() + + +def test_document_owner_follows_task(client: TestClient) -> None: + """Документ (описание задачи) наследует user_id задачи.""" + tid = client.post("/api/tasks", json={"title": "С описанием", "description": "текст"}).json()[ + "id" + ] + session = _test_session_factory() + try: + task = session.get(Task, tid) + assert task is not None + assert task.user_id == AUTH_USER["user_id"] + assert task.document is not None + assert task.document.user_id == AUTH_USER["user_id"] + finally: + session.close() + + +def test_mcp_tool_of_other_user_isolated(client: TestClient) -> None: + """Тул MCP от имени другого пользователя не видит чужие задачи.""" + from app import mcp_server + from tests.conftest import mcp_ctx # type: ignore[attr-defined] + + client.post("/api/tasks", json={"title": "Только моя"}) + + other_ctx: Any = mcp_ctx("agent-user") + tasks = mcp_server.list_tasks(ctx=other_ctx) + assert tasks == [] + created = mcp_server.create_task("От агента", ctx=other_ctx) + assert created["detail_state"] == "raw" + + session = _test_session_factory() + try: + task = session.get(Task, created["id"]) + assert task is not None + assert task.user_id == "agent-user" + finally: + session.close() diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py index 8a19e22..3b433e1 100644 --- a/backend/tests/test_predict.py +++ b/backend/tests/test_predict.py @@ -47,7 +47,7 @@ client.patch(f"/api/projects/{paused}", json={"relevance_status": "paused"}) approve("Из замороженного", 15, paused) - options = pick_options(test_session(), 90) + options = pick_options(test_session(), 90, "1") titles = [t.title for t in options] assert len(options) <= 3 assert "Мелочь 30м" in titles # короче доступного времени — всегда подходит @@ -70,7 +70,7 @@ approve("Не влезает 500м", 500) approve("Без оценки", None) - options = pick_options(test_session(), 60) + options = pick_options(test_session(), 60, "1") titles = [t.title for t in options] assert "Без оценки" in titles # без оценки — учитываем как фолбэк assert "Не влезает 500м" not in titles @@ -81,7 +81,7 @@ client.patch(f"/api/tasks/{tid}", json={"status": "done"}) client.post(f"/api/tasks/{tid}/approve") - options = pick_options(test_session(), 600) + options = pick_options(test_session(), 600, "1") assert all(t.id != tid for t in options) diff --git a/backend/tests/test_recurrence.py b/backend/tests/test_recurrence.py index bc32f30..8f09dd6 100644 --- a/backend/tests/test_recurrence.py +++ b/backend/tests/test_recurrence.py @@ -52,7 +52,7 @@ approve("Мягкий дедлайн", deadline_period="week") approve("Без дедлайна") - options = pick_options(test_session(), 600) + options = pick_options(test_session(), 600, "1") titles = [t.title for t in options] # срочность: близкий строгий → далёкий строгий → нестрогий период; # лимит 3 — задача без дедлайна остаётся за бортом diff --git a/docs/deploy.md b/docs/deploy.md index 7e11b5e..06e621b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -45,7 +45,6 @@ | `GAUTH_CLIENT_ID` / `GAUTH_CLIENT_SECRET` | выдать/взять в панели SSO | | `GAUTH_REDIRECT_URI` | `https://<домен>/auth/callback` — **обязан совпасть** с зарегистрированным в SSO; из него же выводится разрешённый Host для `/mcp` | | `SESSION_SECRET` | вывод `openssl rand -hex 32`; **не** `change-me-*` — приложение не стартует с дефолтом | -| `MCP_TOKEN` | длинная случайная строка (`openssl rand -hex 24`); пусто — `/mcp` закрыт | | `OLLAMA_BASE_URL` | адрес внешнего сервера Ollama, например `http://192.168.1.130:11434` | | `OLLAMA_MODEL` | `qwen3.5:2b-q4_K_M` (дефолт) | | `POSTGRES_PASSWORD` | `openssl rand -hex 16` (не дефолт `gntodo`) | @@ -112,14 +111,9 @@ curl -s -o /dev/null -D - https://<домен>/auth/login | grep -i ^location # → https://auth.gnexus.space/oauth/authorize?... -# 3. MCP: без токена 401, с токеном — 200 и mcp-session-id +# 3. MCP: без токена 401 (токены per-user, создаются в Настройках после логина) curl -s -o /dev/null -w "%{http_code}\n" -X POST https://<домен>/mcp/ \ -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"initialize"}' # 401 -curl -s -D - -o /dev/null -X POST https://<домен>/mcp/ \ - -H "Authorization: Bearer $MCP_TOKEN" -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ - | grep -iE "^(HTTP|mcp-session)" # 200 + mcp-session-id # 4. Логин в браузере: https://<домен> → SSO → возврат в приложение ``` @@ -128,15 +122,20 @@ ## 7. MCP для ИИ-агентов -Адрес сервера — `https://<домен>/mcp/`, токен — `MCP_TOKEN` из `.env`. -Агентам раздаётся не сам токен, а инструкция; страница-инструкция для людей — -`https://<домен>/mcp-help` (как подключить, список тулов). +Адрес сервера — `https://<домен>/mcp/`. Токены per-user (ТЗ 3.10): каждый +пользователь генерирует свой в **Настройках → Токены MCP** (plaintext +показывается один раз, в БД — только sha256-хэш). Агент действует от имени +владельца токена и видит только его данные. Страница-инструкция для людей — +`https://<домен>/mcp-help`. ```bash claude mcp add --transport http gntodo https://<домен>/mcp/ \ - --header "Authorization: Bearer " + --header "Authorization: Bearer <токен из Настроек>" ``` +Важно: переменная `MCP_TOKEN` из `.env` больше не используется — старые +конфиги агентов получат 401, пока владелец не создаст токен в Настройках. + ## 8. Обновление ```bash @@ -162,7 +161,7 @@ | `/mcp` → 421 Misdirected Request | внешний прокси не передаёт Host (нужен `proxy_set_header Host $host`), или Host ≠ домену из `GAUTH_REDIRECT_URI` | | Сад/списки «живут» только после F5; тосты XP не приходят | SSE буферизуется: проверь `proxy_buffering off` на `/api/` у внешнего прокси | | Вложение >1 МБ → 413 | `client_max_body_size` на внешнем прокси (в контейнерном nginx уже 20m) | -| MCP 401 при верном токене | токен не пробрался в контейнер: `docker compose exec api printenv MCP_TOKEN` | +| MCP 401 при верном токене | токен отозван в Настройках, или БД недоступна: `docker compose logs api | grep -i mcp` | | Детализация задач не приходит | `docker compose logs api | grep -i detail`; доступен ли Ollama: `curl $OLLAMA_BASE_URL/api/tags` с хоста api | Логи: `docker compose logs -f api` (uvicorn), `docker compose logs web` (nginx). \ No newline at end of file