From 8df14e3102e8c2aeba09b62fbc354617a9e42732 Mon Sep 17 00:00:00 2001 From: keboss-m <85340750+keboss-m@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:54:25 +0300 Subject: [PATCH] Add multi-tenant auth with org projects, roles, and personal workspaces. JWT login, org-scoped storage and RAG, admin/director/user roles, user-owned projects, login UI, and legacy data migration. Co-authored-by: Cursor --- .gitignore | 3 + Dockerfile.rag | 4 +- backend/auth/__init__.py | 6 + backend/auth/database.py | 407 +++++++++++++++++++++++++++++++++++ backend/auth/deps.py | 70 ++++++ backend/auth/models.py | 53 +++++ backend/auth/routes.py | 146 +++++++++++++ backend/auth/security.py | 71 ++++++ backend/auth/service.py | 180 ++++++++++++++++ backend/main.py | 442 ++++++++++++++++++-------------------- backend/paths.py | 49 +++++ backend/queue.py | 191 +++++++++++----- backend/static/app.js | 325 ++++++++++++++++++++++++++-- backend/static/auth.js | 106 +++++++++ backend/static/index.html | 78 +++++-- backend/static/login.html | 32 +++ backend/static/styles.css | 286 ++++++++++++++++++++++++ config.yaml | 16 ++ docker-compose.yml | 7 +- requirements.txt | 4 + tests/test_auth.py | 170 +++++++++++++++ 21 files changed, 2322 insertions(+), 324 deletions(-) create mode 100644 backend/auth/__init__.py create mode 100644 backend/auth/database.py create mode 100644 backend/auth/deps.py create mode 100644 backend/auth/models.py create mode 100644 backend/auth/routes.py create mode 100644 backend/auth/security.py create mode 100644 backend/auth/service.py create mode 100644 backend/paths.py create mode 100644 backend/static/auth.js create mode 100644 backend/static/login.html create mode 100644 tests/test_auth.py diff --git a/.gitignore b/.gitignore index 08cd31f..087ef9a 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ video/ # Server logs server.log *.log + +# Auth database +data/ diff --git a/Dockerfile.rag b/Dockerfile.rag index afca770..9a5f4af 100644 --- a/Dockerfile.rag +++ b/Dockerfile.rag @@ -6,4 +6,6 @@ RUN pip install --no-cache-dir --timeout 300 \ lightrag-hku>=1.4.0 \ openai>=1.0.0 \ python-dotenv>=1.0.0 \ - sentence-transformers>=3.0.0 + sentence-transformers>=3.0.0 \ + bcrypt>=4.0.0 \ + "python-jose[cryptography]" diff --git a/backend/auth/__init__.py b/backend/auth/__init__.py new file mode 100644 index 0000000..f5ba767 --- /dev/null +++ b/backend/auth/__init__.py @@ -0,0 +1,6 @@ +"""Authentication and multi-tenant access control.""" + +from backend.auth.deps import get_current_user, require_admin +from backend.auth.models import UserContext + +__all__ = ["UserContext", "get_current_user", "require_admin"] diff --git a/backend/auth/database.py b/backend/auth/database.py new file mode 100644 index 0000000..431e1a9 --- /dev/null +++ b/backend/auth/database.py @@ -0,0 +1,407 @@ +"""SQLite persistence for organizations, users, projects.""" + +import os +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from backend.auth.security import hash_password + +DB_PATH = Path("data/transcriba.db") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + UNIQUE(org_id, username), + FOREIGN KEY (org_id) REFERENCES organizations(id) +); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(org_id, slug), + FOREIGN KEY (org_id) REFERENCES organizations(id) +); + +CREATE TABLE IF NOT EXISTS user_projects ( + user_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + PRIMARY KEY (user_id, project_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); +""" + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def get_db_path(config: Optional[dict] = None) -> Path: + env_path = os.getenv("AUTH_DATABASE_PATH") + if env_path: + return Path(env_path) + if config: + custom = config.get("auth", {}).get("database_path") + if custom: + return Path(custom) + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + return DB_PATH + + +@contextmanager +def get_connection(config: Optional[dict] = None): + db_path = get_db_path(config) + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db(config: Optional[dict] = None) -> None: + with get_connection(config) as conn: + conn.executescript(_SCHEMA) + migrate_schema(config) + + +def migrate_schema(config: Optional[dict] = None) -> None: + """Добавляет owner_user_id для личных проектов пользователей.""" + with get_connection(config) as conn: + cols = {row[1] for row in conn.execute("PRAGMA table_info(projects)").fetchall()} + if "owner_user_id" not in cols: + conn.execute( + "ALTER TABLE projects ADD COLUMN owner_user_id INTEGER REFERENCES users(id)" + ) + print("[Auth] Migration: projects.owner_user_id added") + + +def count_users(config: Optional[dict] = None) -> int: + with get_connection(config) as conn: + row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone() + return int(row["c"]) + + +def get_org_by_slug(slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]: + with get_connection(config) as conn: + row = conn.execute( + "SELECT id, slug, name, created_at FROM organizations WHERE slug = ?", + (slug,), + ).fetchone() + return dict(row) if row else None + + +def create_organization(slug: str, name: str, config: Optional[dict] = None) -> Dict[str, Any]: + with get_connection(config) as conn: + cur = conn.execute( + "INSERT INTO organizations (slug, name, created_at) VALUES (?, ?, ?)", + (slug, name, _utcnow()), + ) + org_id = cur.lastrowid + row = conn.execute( + "SELECT id, slug, name, created_at FROM organizations WHERE id = ?", + (org_id,), + ).fetchone() + return dict(row) + + +def create_user( + org_id: int, + username: str, + password: str, + role: str = "user", + config: Optional[dict] = None, +) -> Dict[str, Any]: + with get_connection(config) as conn: + cur = conn.execute( + """ + INSERT INTO users (org_id, username, password_hash, role, is_active, created_at) + VALUES (?, ?, ?, ?, 1, ?) + """, + (org_id, username, hash_password(password), role, _utcnow()), + ) + user_id = cur.lastrowid + row = conn.execute( + "SELECT id, org_id, username, role, is_active, created_at FROM users WHERE id = ?", + (user_id,), + ).fetchone() + return dict(row) + + +def get_user_by_username(org_id: int, username: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]: + with get_connection(config) as conn: + row = conn.execute( + """ + SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active, + o.slug AS org_slug, o.name AS org_name + FROM users u + JOIN organizations o ON o.id = u.org_id + WHERE u.org_id = ? AND u.username = ? + """, + (org_id, username), + ).fetchone() + return dict(row) if row else None + + +def get_user_by_id(user_id: int, config: Optional[dict] = None) -> Optional[Dict[str, Any]]: + with get_connection(config) as conn: + row = conn.execute( + """ + SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active, + o.slug AS org_slug, o.name AS org_name + FROM users u + JOIN organizations o ON o.id = u.org_id + WHERE u.id = ? + """, + (user_id,), + ).fetchone() + return dict(row) if row else None + + +def list_users(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]: + with get_connection(config) as conn: + rows = conn.execute( + """ + SELECT id, org_id, username, role, is_active, created_at + FROM users WHERE org_id = ? ORDER BY username + """, + (org_id,), + ).fetchall() + users = [dict(r) for r in rows] + for user in users: + user["projects"] = get_user_project_slugs(user["id"], config=config, conn=conn) + return users + + +def get_user_project_slugs( + user_id: int, + config: Optional[dict] = None, + conn: Optional[sqlite3.Connection] = None, +) -> List[str]: + """Org-wide projects assigned to user by admin (not personal).""" + query = """ + SELECT p.slug FROM projects p + JOIN user_projects up ON up.project_id = p.id + WHERE up.user_id = ? AND p.owner_user_id IS NULL + ORDER BY p.slug + """ + + def _fetch(connection: sqlite3.Connection) -> List[str]: + rows = connection.execute(query, (user_id,)).fetchall() + return [row["slug"] for row in rows] + + if conn is not None: + return _fetch(conn) + + with get_connection(config) as connection: + return _fetch(connection) + + +def get_owned_project_slugs( + user_id: int, + config: Optional[dict] = None, +) -> List[str]: + with get_connection(config) as conn: + rows = conn.execute( + """ + SELECT slug FROM projects + WHERE owner_user_id = ? + ORDER BY slug + """, + (user_id,), + ).fetchall() + return [row["slug"] for row in rows] + + +def _row_to_project(row: sqlite3.Row) -> Dict[str, Any]: + data = dict(row) + data["scope"] = "personal" if data.get("owner_user_id") else "org" + return data + + +def create_project( + org_id: int, + slug: str, + name: str, + owner_user_id: Optional[int] = None, + config: Optional[dict] = None, +) -> Dict[str, Any]: + slug = slug.strip().lower() + with get_connection(config) as conn: + cur = conn.execute( + """ + INSERT INTO projects (org_id, slug, name, owner_user_id, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (org_id, slug, name, owner_user_id, _utcnow()), + ) + project_id = cur.lastrowid + row = conn.execute( + """ + SELECT id, org_id, slug, name, owner_user_id, created_at + FROM projects WHERE id = ? + """, + (project_id,), + ).fetchone() + return _row_to_project(row) + + +def list_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]: + with get_connection(config) as conn: + rows = conn.execute( + """ + SELECT id, org_id, slug, name, owner_user_id, created_at + FROM projects WHERE org_id = ? ORDER BY slug + """, + (org_id,), + ).fetchall() + return [_row_to_project(r) for r in rows] + + +def list_org_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]: + """Только общие проекты организации (без личных).""" + with get_connection(config) as conn: + rows = conn.execute( + """ + SELECT id, org_id, slug, name, owner_user_id, created_at + FROM projects WHERE org_id = ? AND owner_user_id IS NULL + ORDER BY slug + """, + (org_id,), + ).fetchall() + return [_row_to_project(r) for r in rows] + + +def set_user_projects(user_id: int, project_ids: List[int], config: Optional[dict] = None) -> None: + with get_connection(config) as conn: + conn.execute("DELETE FROM user_projects WHERE user_id = ?", (user_id,)) + for project_id in project_ids: + conn.execute( + "INSERT INTO user_projects (user_id, project_id) VALUES (?, ?)", + (user_id, project_id), + ) + + +def get_project_by_slug(org_id: int, slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]: + with get_connection(config) as conn: + row = conn.execute( + """ + SELECT id, org_id, slug, name, owner_user_id, created_at + FROM projects WHERE org_id = ? AND slug = ? + """, + (org_id, slug.strip().lower()), + ).fetchone() + return _row_to_project(row) if row else None + + +def delete_project(project_id: int, config: Optional[dict] = None) -> None: + with get_connection(config) as conn: + conn.execute("DELETE FROM user_projects WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + + +def bootstrap_from_config(config: dict) -> None: + init_db(config) + if count_users(config) > 0: + return + + auth_cfg = config.get("auth", {}) + bootstrap = auth_cfg.get("bootstrap", {}) + org_slug = bootstrap.get("org_slug", "merakom") + org_name = bootstrap.get("org_name", "МЕРАКОМ") + admin_username = bootstrap.get("admin_username", "admin") + admin_password = ( + os.getenv("AUTH_ADMIN_PASSWORD") + or bootstrap.get("admin_password") + or auth_cfg.get("admin_password", "admin123") + ) + + org = get_org_by_slug(org_slug, config) + if not org: + org = create_organization(org_slug, org_name, config) + + create_user(org["id"], admin_username, admin_password, role="admin", config=config) + + default_projects = bootstrap.get("default_projects") or [ + {"slug": "2026", "name": "2026"}, + {"slug": "gp-merakom", "name": "ГП МЕРАКОМ"}, + ] + for proj in default_projects: + slug = str(proj.get("slug", "")).strip().lower() + name = str(proj.get("name", slug)) + if slug and not get_project_by_slug(org["id"], slug, config): + create_project(org["id"], slug, name, owner_user_id=None, config=config) + + print(f"[Auth] Bootstrap: org={org_slug}, admin={admin_username}") + + +def migrate_legacy_data(org_slug: str) -> None: + """Move flat processed/* into org-scoped layout (one-time).""" + from backend.paths import MEETINGS_DIRNAME, RAG_CACHE_DIRNAME, PROCESSED_ROOT, write_folder_project_meta + from src.rag.parser import parse_project_from_filename + import shutil + + legacy_rag = PROCESSED_ROOT / "lightrag_caches" + legacy_rag_test = PROCESSED_ROOT / "lightrag_caches_test" + org_root = PROCESSED_ROOT / org_slug + target_meetings = org_root / MEETINGS_DIRNAME + target_rag = org_root / RAG_CACHE_DIRNAME + target_meetings.mkdir(parents=True, exist_ok=True) + target_rag.mkdir(parents=True, exist_ok=True) + + skip = {org_slug, "lightrag_caches", "lightrag_caches_test", MEETINGS_DIRNAME, RAG_CACHE_DIRNAME} + if PROCESSED_ROOT.exists(): + for item in PROCESSED_ROOT.iterdir(): + if not item.is_dir() or item.name in skip: + continue + dest = target_meetings / item.name + if not dest.exists(): + shutil.move(str(item), str(dest)) + print(f"[Auth] Migrated meeting folder: {item.name} -> {dest}") + + for folder in target_meetings.iterdir(): + if not folder.is_dir(): + continue + meta_path = folder / ".project.json" + if not meta_path.exists(): + project_slug = parse_project_from_filename(folder.name) + write_folder_project_meta(folder, project_slug) + print(f"[Auth] Assigned project {project_slug} -> {folder.name}") + + if legacy_rag.exists() and legacy_rag != target_rag: + for item in legacy_rag.iterdir(): + dest = target_rag / item.name + if item.is_dir() and not dest.exists(): + shutil.move(str(item), str(dest)) + if not any(legacy_rag.iterdir()): + legacy_rag.rmdir() + print(f"[Auth] Migrated RAG cache -> {target_rag}") + + if legacy_rag_test.exists(): + shutil.rmtree(legacy_rag_test, ignore_errors=True) diff --git a/backend/auth/deps.py b/backend/auth/deps.py new file mode 100644 index 0000000..f700018 --- /dev/null +++ b/backend/auth/deps.py @@ -0,0 +1,70 @@ +"""FastAPI auth dependencies.""" + +from typing import Optional + +from fastapi import Depends, HTTPException, Query, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from backend.auth.models import UserContext +from backend.auth.security import safe_decode_token +from backend.auth.service import get_user_context +from src.config import load_config + +bearer_scheme = HTTPBearer(auto_error=False) + + +def _resolve_token( + credentials: Optional[HTTPAuthorizationCredentials], + token: Optional[str], +) -> Optional[str]: + if credentials and credentials.scheme.lower() == "bearer": + return credentials.credentials + if token: + return token + return None + + +async def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), + token: Optional[str] = Query(None, alias="token"), +) -> UserContext: + raw_token = _resolve_token(credentials, token) + if not raw_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Требуется авторизация", + headers={"WWW-Authenticate": "Bearer"}, + ) + + config = load_config() + payload = safe_decode_token(raw_token, config) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен", + ) + + user_id = int(payload.get("sub", 0)) + ctx = get_user_context(user_id) + if not ctx: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Пользователь не найден или деактивирован", + ) + return ctx + + +async def require_admin(user: UserContext = Depends(get_current_user)) -> UserContext: + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Требуются права администратора") + return user + + +def get_user_from_token(token: Optional[str]) -> Optional[UserContext]: + if not token: + return None + config = load_config() + payload = safe_decode_token(token, config) + if not payload: + return None + return get_user_context(int(payload.get("sub", 0))) diff --git a/backend/auth/models.py b/backend/auth/models.py new file mode 100644 index 0000000..154325b --- /dev/null +++ b/backend/auth/models.py @@ -0,0 +1,53 @@ +"""Auth data models.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +ROLES = ("admin", "director", "user") + + +@dataclass +class UserContext: + user_id: int + username: str + role: str + org_id: int + org_slug: str + org_name: str + project_slugs: List[str] = field(default_factory=list) # назначенные org-проекты + owned_project_slugs: List[str] = field(default_factory=list) # личные проекты + + @property + def accessible_project_slugs(self) -> set[str]: + if self.has_all_projects_access: + return set() + return set(self.project_slugs) | set(self.owned_project_slugs) + + @property + def is_admin(self) -> bool: + return self.role == "admin" + + @property + def is_director(self) -> bool: + return self.role == "director" + + @property + def has_all_projects_access(self) -> bool: + """Admin and director see all org projects and global RAG search.""" + return self.role in ("admin", "director") + + def can_access_project(self, project_slug: Optional[str]) -> bool: + if not project_slug: + return True + if self.has_all_projects_access: + return True + return project_slug in self.accessible_project_slugs + + def filter_projects(self, projects: List[str]) -> List[str]: + if self.has_all_projects_access: + return projects + allowed = self.accessible_project_slugs + return [p for p in projects if p in allowed] + + def can_global_search(self) -> bool: + return self.has_all_projects_access diff --git a/backend/auth/routes.py b/backend/auth/routes.py new file mode 100644 index 0000000..aeca3f7 --- /dev/null +++ b/backend/auth/routes.py @@ -0,0 +1,146 @@ +"""Auth HTTP routes.""" + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from backend.auth.deps import get_current_user, require_admin +from backend.auth.models import ROLES, UserContext +from backend.auth import database as db +from backend.auth.service import ( + authenticate, + create_org_user, + create_personal_project, + delete_personal_project, + get_user_context, + list_accessible_projects, + update_user_projects, + user_to_dict, +) +from src.config import load_config + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +admin_router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +class LoginRequest(BaseModel): + org_slug: str = Field(default="merakom") + username: str + password: str + + +class CreateUserRequest(BaseModel): + username: str + password: str + role: str = "user" + projects: List[str] = Field(default_factory=list) + + +class CreateProjectRequest(BaseModel): + slug: str + name: str + + +class UpdateUserProjectsRequest(BaseModel): + projects: List[str] = Field(default_factory=list) + + +@router.post("/login") +async def login(payload: LoginRequest): + result = authenticate(payload.org_slug, payload.username, payload.password) + if not result: + raise HTTPException(status_code=401, detail="Неверная организация, логин или пароль") + return result + + +@router.get("/me") +async def me(user: UserContext = Depends(get_current_user)): + return user_to_dict(user) + + +@router.get("/projects") +async def my_projects(user: UserContext = Depends(get_current_user)): + return {"projects": list_accessible_projects(user)} + + +@router.post("/projects") +async def create_my_project(payload: CreateProjectRequest, user: UserContext = Depends(get_current_user)): + try: + project = create_personal_project(user, payload.slug, payload.name) + return {"project": project} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + if "UNIQUE" in str(e): + raise HTTPException(status_code=409, detail="Проект уже существует") from e + raise HTTPException(status_code=400, detail=str(e)) from e + + +@router.delete("/projects/{slug}") +async def delete_my_project(slug: str, user: UserContext = Depends(get_current_user)): + try: + delete_personal_project(user, slug) + return {"deleted": slug} + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + +@admin_router.get("/users") +async def admin_list_users(admin: UserContext = Depends(require_admin)): + config = load_config() + rows = db.list_users(admin.org_id, config) + users = [] + for row in rows: + ctx = get_user_context(row["id"]) + if ctx: + users.append(user_to_dict(ctx)) + return {"users": users} + + +@admin_router.post("/users") +async def admin_create_user(payload: CreateUserRequest, admin: UserContext = Depends(require_admin)): + if payload.role not in ROLES: + raise HTTPException(status_code=400, detail=f"role must be one of: {', '.join(ROLES)}") + try: + user = create_org_user(admin, payload.username, payload.password, payload.role, payload.projects) + return {"user": user} + except Exception as e: + if "UNIQUE" in str(e): + raise HTTPException(status_code=409, detail="Пользователь уже существует") from e + raise HTTPException(status_code=400, detail=str(e)) from e + + +@admin_router.put("/users/{user_id}/projects") +async def admin_set_user_projects( + user_id: int, + payload: UpdateUserProjectsRequest, + admin: UserContext = Depends(require_admin), +): + try: + user = update_user_projects(admin, user_id, payload.projects) + return {"user": user} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + +@admin_router.get("/projects") +async def admin_list_projects(admin: UserContext = Depends(require_admin)): + config = load_config() + return {"projects": db.list_projects(admin.org_id, config)} + + +@admin_router.post("/projects") +async def admin_create_project(payload: CreateProjectRequest, admin: UserContext = Depends(require_admin)): + slug = payload.slug.strip().lower() + if not slug: + raise HTTPException(status_code=400, detail="slug обязателен") + try: + project = db.create_project(admin.org_id, slug, payload.name, owner_user_id=None, config=load_config()) + return {"project": project} + except Exception as e: + if "UNIQUE" in str(e): + raise HTTPException(status_code=409, detail="Проект уже существует") from e + raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/backend/auth/security.py b/backend/auth/security.py new file mode 100644 index 0000000..dd6f2dd --- /dev/null +++ b/backend/auth/security.py @@ -0,0 +1,71 @@ +"""Password hashing and JWT tokens.""" + +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +import bcrypt +from jose import JWTError, jwt + +ALGORITHM = "HS256" + + +def get_jwt_secret(config: Optional[dict] = None) -> str: + env_secret = os.getenv("JWT_SECRET") + if env_secret: + return env_secret + if config: + auth_cfg = config.get("auth", {}) + secret = auth_cfg.get("jwt_secret") + if secret: + return secret + return "dev-insecure-change-me" + + +def get_jwt_expire_hours(config: Optional[dict] = None) -> int: + if config: + return int(config.get("auth", {}).get("jwt_expire_hours", 168)) + return 168 + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except ValueError: + return False + + +def create_access_token( + user_id: int, + org_id: int, + role: str, + username: str, + org_slug: str, + config: Optional[dict] = None, +) -> str: + expire_hours = get_jwt_expire_hours(config) + expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours) + payload = { + "sub": str(user_id), + "org_id": org_id, + "org_slug": org_slug, + "role": role, + "username": username, + "exp": expire, + } + return jwt.encode(payload, get_jwt_secret(config), algorithm=ALGORITHM) + + +def decode_access_token(token: str, config: Optional[dict] = None) -> Dict[str, Any]: + return jwt.decode(token, get_jwt_secret(config), algorithms=[ALGORITHM]) + + +def safe_decode_token(token: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]: + try: + return decode_access_token(token, config) + except JWTError: + return None diff --git a/backend/auth/service.py b/backend/auth/service.py new file mode 100644 index 0000000..90899a2 --- /dev/null +++ b/backend/auth/service.py @@ -0,0 +1,180 @@ +"""Auth business logic.""" + +import re +from typing import Any, Dict, List, Optional + +from backend.auth import database as db +from backend.auth.models import UserContext +from backend.auth.security import create_access_token, verify_password +from src.config import load_config + + +def normalize_project_slug(slug: str) -> str: + value = slug.strip().lower() + value = re.sub(r"[^\w\-]", "-", value) + value = re.sub(r"-+", "-", value).strip("-_") + if not value: + raise ValueError("Некорректный slug проекта") + return value + + +def _build_user_context(row: Dict[str, Any], config: Optional[dict] = None) -> UserContext: + user_id = row["id"] + return UserContext( + user_id=user_id, + username=row["username"], + role=row["role"], + org_id=row["org_id"], + org_slug=row["org_slug"], + org_name=row["org_name"], + project_slugs=db.get_user_project_slugs(user_id, config=config), + owned_project_slugs=db.get_owned_project_slugs(user_id, config=config), + ) + + +def _enrich_project(project: Dict[str, Any], ctx: UserContext) -> Dict[str, Any]: + item = dict(project) + item["is_owner"] = project.get("owner_user_id") == ctx.user_id + item["scope"] = project.get("scope") or ("personal" if project.get("owner_user_id") else "org") + return item + + +def authenticate(org_slug: str, username: str, password: str) -> Optional[Dict[str, Any]]: + config = load_config() + org = db.get_org_by_slug(org_slug.strip().lower(), config) + if not org: + return None + + user = db.get_user_by_username(org["id"], username.strip(), config) + if not user or not user.get("is_active"): + return None + if not verify_password(password, user["password_hash"]): + return None + + ctx = _build_user_context(user, config) + token = create_access_token( + user_id=ctx.user_id, + org_id=ctx.org_id, + role=ctx.role, + username=ctx.username, + org_slug=ctx.org_slug, + config=config, + ) + return { + "access_token": token, + "token_type": "bearer", + "user": user_to_dict(ctx), + } + + +def get_user_context(user_id: int) -> Optional[UserContext]: + config = load_config() + row = db.get_user_by_id(user_id, config) + if not row or not row.get("is_active"): + return None + return _build_user_context(row, config) + + +def user_to_dict(ctx: UserContext) -> Dict[str, Any]: + return { + "id": ctx.user_id, + "username": ctx.username, + "role": ctx.role, + "org_id": ctx.org_id, + "org_slug": ctx.org_slug, + "org_name": ctx.org_name, + "shared_projects": ctx.project_slugs, + "owned_projects": ctx.owned_project_slugs, + "projects": list(ctx.accessible_project_slugs) if not ctx.has_all_projects_access else [], + "is_admin": ctx.is_admin, + "is_director": ctx.is_director, + "all_projects_access": ctx.has_all_projects_access, + } + + +def list_accessible_projects(ctx: UserContext) -> List[Dict[str, Any]]: + config = load_config() + all_projects = db.list_projects(ctx.org_id, config) + if ctx.has_all_projects_access: + return [_enrich_project(p, ctx) for p in all_projects] + + result = [] + assigned = set(ctx.project_slugs) + owned = set(ctx.owned_project_slugs) + for project in all_projects: + slug = project["slug"] + if project.get("owner_user_id") == ctx.user_id or slug in owned: + result.append(_enrich_project(project, ctx)) + elif not project.get("owner_user_id") and slug in assigned: + result.append(_enrich_project(project, ctx)) + return sorted(result, key=lambda p: (p["scope"] != "personal", p["slug"])) + + +def ensure_project_access(ctx: UserContext, project_slug: str) -> None: + slug = project_slug.strip().lower() + if not ctx.can_access_project(slug): + raise PermissionError(f"Нет доступа к проекту: {slug}") + + +def create_personal_project(ctx: UserContext, slug: str, name: str) -> Dict[str, Any]: + config = load_config() + normalized = normalize_project_slug(slug) + display_name = name.strip() or normalized + if db.get_project_by_slug(ctx.org_id, normalized, config): + raise ValueError("Проект с таким slug уже существует") + project = db.create_project( + ctx.org_id, + normalized, + display_name, + owner_user_id=ctx.user_id, + config=config, + ) + return _enrich_project(project, ctx) + + +def delete_personal_project(ctx: UserContext, slug: str) -> None: + config = load_config() + normalized = normalize_project_slug(slug) + project = db.get_project_by_slug(ctx.org_id, normalized, config) + if not project: + raise ValueError("Проект не найден") + if ctx.is_admin: + db.delete_project(project["id"], config) + return + if project.get("owner_user_id") != ctx.user_id: + raise PermissionError("Можно удалять только свои личные проекты") + db.delete_project(project["id"], config) + + +def create_org_user( + ctx: UserContext, + username: str, + password: str, + role: str, + project_slugs: List[str], +) -> Dict[str, Any]: + config = load_config() + user = db.create_user(ctx.org_id, username, password, role=role, config=config) + if role == "user" and project_slugs: + projects = db.list_org_projects(ctx.org_id, config) + slug_to_id = {p["slug"]: p["id"] for p in projects} + project_ids = [slug_to_id[s] for s in project_slugs if s in slug_to_id] + db.set_user_projects(user["id"], project_ids, config) + user_row = db.get_user_by_id(user["id"], config) + return user_to_dict(_build_user_context(user_row, config)) + + +def update_user_projects(ctx: UserContext, user_id: int, project_slugs: List[str]) -> Dict[str, Any]: + config = load_config() + target = db.get_user_by_id(user_id, config) + if not target or target["org_id"] != ctx.org_id: + raise ValueError("Пользователь не найден") + if target["role"] in ("admin", "director"): + return user_to_dict(_build_user_context(target, config)) + + projects = db.list_org_projects(ctx.org_id, config) + slug_to_id = {p["slug"]: p["id"] for p in projects} + project_ids = [slug_to_id[s] for s in project_slugs if s in slug_to_id] + db.set_user_projects(user_id, project_ids, config) + updated = db.get_user_by_id(user_id, config) + return user_to_dict(_build_user_context(updated, config)) diff --git a/backend/main.py b/backend/main.py index bd30eb7..477d67e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,88 +1,91 @@ """FastAPI backend для сервиса транскрибации.""" import json -import os import sys from contextlib import asynccontextmanager from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import List, Optional -from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect +from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles +from backend.auth.deps import get_current_user, get_user_from_token, require_admin +from backend.auth.models import UserContext +from backend.auth.routes import admin_router, router as auth_router +from backend.auth import database as auth_db +from backend.auth.service import ensure_project_access, list_accessible_projects +from backend.paths import org_meetings_dir, org_rag_index_dir, resolve_meeting_path from backend.queue import ( - UPLOAD_DIR, - PROCESSED_DIR, - save_upload, + delete_folder, get_all_tasks, + get_download_path, + get_processed_tree, get_queue_info, get_task_status, - get_processed_tree, read_file_content, + save_upload, set_progress_callback, start_workers, stop_workers, ) -# Добавляем корень проекта в путь, чтобы импортировать src.rag sys.path.insert(0, str(Path(__file__).parent.parent)) from src.config import load_config, resolve_opencode_credentials -from src.rag.indexer import get_project_names -from src.rag.parser import parse_project_from_filename -from src.rag.query import rag_chat, retrieve_context +from src.rag.formatter import format_global_document +from src.rag.indexer import get_project_names, index_meeting +from src.rag.query import rag_chat + +STATIC_DIR = Path(__file__).parent / "static" + -# WebSocket менеджер class ConnectionManager: def __init__(self): - self.active_connections: List[WebSocket] = [] - - async def connect(self, websocket: WebSocket): + self.active_connections: list[tuple[WebSocket, Optional[UserContext]]] = [] + + async def connect(self, websocket: WebSocket, user: Optional[UserContext] = None): await websocket.accept() - self.active_connections.append(websocket) - + self.active_connections.append((websocket, user)) + def disconnect(self, websocket: WebSocket): - if websocket in self.active_connections: - self.active_connections.remove(websocket) - + self.active_connections = [(ws, u) for ws, u in self.active_connections if ws is not websocket] + + def set_user(self, websocket: WebSocket, user: UserContext): + self.active_connections = [(ws, user if ws is websocket else u) for ws, u in self.active_connections] + async def broadcast(self, message: dict): - for conn in self.active_connections: + for conn, _user in self.active_connections: try: await conn.send_json(message) except Exception: pass -manager = ConnectionManager() -# Устанавливаем callback для отправки прогресса через WebSocket +manager = ConnectionManager() set_progress_callback(manager.broadcast) @asynccontextmanager async def lifespan(app: FastAPI): - """Управление жизненным циклом приложения.""" config = load_config() + auth_db.init_db(config) + auth_db.bootstrap_from_config(config) + org_slug = config.get("auth", {}).get("bootstrap", {}).get("org_slug", "merakom") + auth_db.migrate_legacy_data(org_slug) + queue_cfg = config.get("queue", {}) transcribe_workers = int(queue_cfg.get("transcribe_workers", 2)) postprocess_workers = int(queue_cfg.get("postprocess_workers", 1)) print("🚀 Запуск рабочих процессов...") - start_workers( - transcribe_workers=transcribe_workers, - postprocess_workers=postprocess_workers, - ) + start_workers(transcribe_workers=transcribe_workers, postprocess_workers=postprocess_workers) yield print("🛑 Остановка рабочих процессов...") stop_workers() -app = FastAPI( - title="Transcription Service", - version="1.0.0", - lifespan=lifespan, -) +app = FastAPI(title="Transcription Service", version="2.0.0", lifespan=lifespan) -# CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -91,84 +94,132 @@ app.add_middleware( allow_headers=["*"], ) +app.include_router(auth_router) +app.include_router(admin_router) + + +def _org_index_dir(user: UserContext) -> Path: + return org_rag_index_dir(user.org_slug) + + +async def _list_rag_project_slugs(user: UserContext) -> List[str]: + projects = await get_project_names(_org_index_dir(user)) + return user.filter_projects(projects) + + +async def _rag_chat_for_user(user: UserContext, question: str, history: list, project_name: Optional[str], mode: str): + if project_name: + ensure_project_access(user, project_name) + elif not user.can_global_search(): + raise HTTPException(status_code=403, detail="Глобальный поиск доступен только администратору") + + config = load_config() + rag_cfg = config.get("rag", {}) + api_key, base_url = resolve_opencode_credentials(config) + return await rag_chat( + question=question, + working_dir_base=_org_index_dir(user), + history=history, + api_key=api_key, + project_name=project_name, + base_url=base_url, + chat_model=rag_cfg.get("chat_model", "deepseek-v4-flash-free"), + mode=mode, + index_model=rag_cfg.get("index_model", "mimo-v2.5-free"), + ) -# === API Endpoints === @app.get("/", response_class=HTMLResponse) async def root(): - """Главная страница.""" - index_path = Path(__file__).parent / "static" / "index.html" + index_path = STATIC_DIR / "index.html" if index_path.exists(): return index_path.read_text(encoding="utf-8") - return "

Transcription Service

Frontend not built

" + return "

Transcription Service

" + + +@app.get("/login", response_class=HTMLResponse) +async def login_page(): + login_path = STATIC_DIR / "login.html" + if login_path.exists(): + return login_path.read_text(encoding="utf-8") + return "

Login page missing

" @app.post("/upload") -async def upload_file(file: UploadFile = File(...)): - """Загружает файл и добавляет в очередь обработки.""" +async def upload_file( + file: UploadFile = File(...), + project: str = Form(...), + user: UserContext = Depends(get_current_user), +): content = await file.read() - task_id, _ = await save_upload(content, file.filename or "upload.bin") + try: + task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e return { "task_id": task_id, "file": file.filename, + "project": project, "status": "queued", "message": "Файл добавлен в очередь обработки", - "queue": get_queue_info(), + "queue": get_queue_info(user), } @app.post("/upload-batch") -async def upload_batch(files: List[UploadFile] = File(...)): - """Загружает несколько файлов пакетно — все ставятся в очередь.""" +async def upload_batch( + files: List[UploadFile] = File(...), + project: str = Form(...), + user: UserContext = Depends(get_current_user), +): if not files: return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []} results = [] for file in files: content = await file.read() - task_id, _ = await save_upload(content, file.filename or "upload.bin") - results.append({ - "task_id": task_id, - "file": file.filename, - "status": "queued", - }) + try: + task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + results.append({"task_id": task_id, "file": file.filename, "project": project, "status": "queued"}) return { "uploaded": len(results), "tasks": results, - "queue": get_queue_info(), + "queue": get_queue_info(user), "message": f"{len(results)} файл(ов) добавлено в очередь", } @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): - """WebSocket для получения прогресса обработки.""" - await manager.connect(websocket) + token = websocket.query_params.get("token") + user = get_user_from_token(token) + if not user: + await websocket.close(code=4401) + return + + await manager.connect(websocket, user) try: while True: - # Ждём сообщения от клиента (ping/keepalive) data = await websocket.receive_text() msg = json.loads(data) - + if msg.get("action") == "get_tasks": - tasks = get_all_tasks() await websocket.send_json({ "type": "tasks_list", - "tasks": tasks, - "queue": get_queue_info(), + "tasks": get_all_tasks(user), + "queue": get_queue_info(user), }) elif msg.get("action") == "get_tree": - tree = get_processed_tree() await websocket.send_json({ "type": "file_tree", - "tree": tree, + "tree": get_processed_tree(user), }) - elif msg.get("action") == "rag_query": - await _handle_rag_query(websocket, msg) - elif msg.get("action") == "rag_query_global": - await _handle_rag_query(websocket, msg) + elif msg.get("action") in ("rag_query", "rag_query_global"): + await _handle_rag_query_ws(websocket, msg, user) except WebSocketDisconnect: manager.disconnect(websocket) except Exception: @@ -176,202 +227,126 @@ async def websocket_endpoint(websocket: WebSocket): @app.get("/api/tasks") -async def api_tasks(): - """Возвращает список всех задач.""" - return {"tasks": get_all_tasks(), "queue": get_queue_info()} +async def api_tasks(user: UserContext = Depends(get_current_user)): + return {"tasks": get_all_tasks(user), "queue": get_queue_info(user)} @app.get("/api/tasks/{task_id}") -async def api_task(task_id: str): - """Возвращает статус конкретной задачи.""" - status = get_task_status(task_id) +async def api_task(task_id: str, user: UserContext = Depends(get_current_user)): + status = get_task_status(task_id, user) if not status: return {"error": "Task not found"} return status @app.get("/api/files") -async def api_files(): - """Возвращает дерево обработанных файлов.""" - return {"tree": get_processed_tree()} +async def api_files(user: UserContext = Depends(get_current_user)): + return {"tree": get_processed_tree(user)} @app.get("/api/files/content") -async def api_file_content(path: str): - """Возвращает содержимое файла.""" +async def api_file_content(path: str, user: UserContext = Depends(get_current_user)): try: - content = read_file_content(path) + content = read_file_content(user, path) return {"content": content, "path": path} + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except Exception as e: return {"error": str(e)} @app.get("/api/files/download") -async def api_download(path: str): - """Скачивает файл.""" - file_path = PROCESSED_DIR / path - if not file_path.exists(): - return {"error": "File not found"} - return FileResponse(file_path, filename=file_path.name) - - -import shutil - -@app.delete("/api/folders/{folder_name}") -async def api_delete_folder(folder_name: str): - """Удаляет папку с обработанными файлами.""" - folder_path = PROCESSED_DIR / folder_name - if not folder_path.exists(): - return {"error": "Folder not found"} +async def api_download(path: str, user: UserContext = Depends(get_current_user)): try: - shutil.rmtree(folder_path) - return {"deleted": folder_name} + file_path = get_download_path(user, path) + return FileResponse(file_path, filename=file_path.name) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except Exception as e: return {"error": str(e)} -# === RAG / Chat API === +@app.delete("/api/folders/{folder_name:path}") +async def api_delete_folder(folder_name: str, user: UserContext = Depends(get_current_user)): + try: + delete_folder(user, folder_name) + return {"deleted": folder_name} + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except Exception as e: + return {"error": str(e)} + @app.get("/api/rag/projects") -async def api_rag_projects(): - """Возвращает список проектов с RAG-индексами.""" +async def api_rag_projects(user: UserContext = Depends(get_current_user)): try: - config = load_config() - rag_cfg = config.get("rag", {}) - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) - projects = await get_project_names(index_dir) - return {"projects": projects} + db_projects = {p["slug"] for p in list_accessible_projects(user)} + indexed = await _list_rag_project_slugs(user) + merged = sorted(db_projects | set(indexed)) + if not user.is_admin: + merged = user.filter_projects(merged) + return {"projects": merged} except Exception as e: return {"error": str(e), "projects": []} @app.post("/api/rag/query") -async def api_rag_query(payload: dict): - """Запрос к чат-боту по конкретному проекту.""" +async def api_rag_query(payload: dict, user: UserContext = Depends(get_current_user)): try: - config = load_config() - rag_cfg = config.get("rag", {}) - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) - api_key, base_url = resolve_opencode_credentials(config) - chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") - index_model = rag_cfg.get("index_model", "mimo-v2.5-free") - mode = payload.get("mode", "hybrid") - - result = await rag_chat( - question=payload.get("question", ""), - working_dir_base=index_dir, - history=payload.get("history", []), - api_key=api_key, - project_name=payload.get("project"), - base_url=base_url, - chat_model=chat_model, - mode=mode, - index_model=index_model, + result = await _rag_chat_for_user( + user, + payload.get("question", ""), + payload.get("history", []), + payload.get("project"), + payload.get("mode", "hybrid"), ) - return { - "answer": result["answer"], - "context": result["context"], - "project": result["project"], - } + return {"answer": result["answer"], "context": result["context"], "project": result["project"]} + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except Exception as e: return {"error": str(e)} @app.post("/api/rag/query-global") -async def api_rag_query_global(payload: dict): - """Глобальный запрос ко всем проектам.""" +async def api_rag_query_global(payload: dict, user: UserContext = Depends(get_current_user)): try: - config = load_config() - rag_cfg = config.get("rag", {}) - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) - api_key, base_url = resolve_opencode_credentials(config) - chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") - index_model = rag_cfg.get("index_model", "mimo-v2.5-free") - mode = payload.get("mode", "hybrid") - - result = await rag_chat( - question=payload.get("question", ""), - working_dir_base=index_dir, - history=payload.get("history", []), - api_key=api_key, - project_name=None, - base_url=base_url, - chat_model=chat_model, - mode=mode, - index_model=index_model, + result = await _rag_chat_for_user( + user, + payload.get("question", ""), + payload.get("history", []), + None, + payload.get("mode", "hybrid"), ) - return { - "answer": result["answer"], - "context": result["context"], - "project": None, - } + return {"answer": result["answer"], "context": result["context"], "project": None} + except HTTPException: + raise except Exception as e: return {"error": str(e)} -@app.get("/api/rag/tasks") -async def api_rag_tasks(project: Optional[str] = None): - """Возвращает action items из RAG.""" +@app.post("/api/rag/index/{folder_name:path}") +async def api_rag_index_folder(folder_name: str, user: UserContext = Depends(get_current_user)): try: - config = load_config() - rag_cfg = config.get("rag", {}) - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) - api_key, base_url = resolve_opencode_credentials(config) - chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") - index_model = rag_cfg.get("index_model", "mimo-v2.5-free") - - question = "Перечисли все action items, задачи и ответственных из протоколов." - if project: - question = f"Перечисли все action items, задачи и ответственных по проекту {project}." - - result = await rag_chat( - question=question, - working_dir_base=index_dir, - history=[], - api_key=api_key, - project_name=project, - base_url=base_url, - chat_model=chat_model, - mode="hybrid", - index_model=index_model, - ) - return { - "tasks": result["answer"], - "project": project, - } - except Exception as e: - return {"error": str(e)} - - -@app.post("/api/rag/index/{folder_name}") -async def api_rag_index_folder(folder_name: str): - """Принудительная переиндексация папки с обработанным совещанием.""" - try: - folder_path = PROCESSED_DIR / folder_name + folder_path = resolve_meeting_path(user.org_slug, folder_name) if not folder_path.exists(): return {"error": "Folder not found"} - # Ищем .txt файл с протоколом + from backend.queue import _folder_project_slug + project = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug)) + if not project: + return {"error": "Project metadata not found"} + ensure_project_access(user, project) + txt_files = list(folder_path.glob("*.txt")) if not txt_files: return {"error": "No .txt protocol found in folder"} - txt_path = txt_files[0] - doc_text = txt_path.read_text(encoding="utf-8") - - # Определяем проект из имени папки - project = parse_project_from_filename(folder_name) - + doc_text = txt_files[0].read_text(encoding="utf-8") config = load_config() rag_cfg = config.get("rag", {}) - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) - index_model = rag_cfg.get("index_model", "mimo-v2.5-free") api_key, base_url = resolve_opencode_credentials(config) - - from src.rag.indexer import index_meeting - from src.rag.formatter import format_global_document - - # Для переиндексации используем простую заглушку метаданных metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"} global_doc_text = format_global_document(doc_text, metadata) @@ -379,54 +354,47 @@ async def api_rag_index_folder(folder_name: str): doc_text=doc_text, global_doc_text=global_doc_text, project_name=project, - working_dir_base=index_dir, - model=index_model, + working_dir_base=_org_index_dir(user), + model=rag_cfg.get("index_model", "mimo-v2.5-free"), api_key=api_key, base_url=base_url, ) - return {"indexed": folder_name, "project": project} + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except Exception as e: return {"error": str(e)} -# === WebSocket Chat Actions === - -async def _handle_rag_query(websocket: WebSocket, msg: dict): - """Обрабатывает rag_query через WebSocket.""" +async def _handle_rag_query_ws(websocket: WebSocket, msg: dict, user: UserContext): try: - config = load_config() - rag_cfg = config.get("rag", {}) - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) - api_key, base_url = resolve_opencode_credentials(config) - chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") - index_model = rag_cfg.get("index_model", "mimo-v2.5-free") - mode = msg.get("mode", "hybrid") - - result = await rag_chat( - question=msg.get("question", ""), - working_dir_base=index_dir, - history=msg.get("history", []), - api_key=api_key, - project_name=msg.get("project"), - base_url=base_url, - chat_model=chat_model, - mode=mode, - index_model=index_model, + project = msg.get("project") + if msg.get("action") == "rag_query_global": + project = None + result = await _rag_chat_for_user( + user, + msg.get("question", ""), + msg.get("history", []), + project, + msg.get("mode", "hybrid"), ) - await websocket.send_json({ "type": "rag_response", "answer": result["answer"], "context": result["context"], "project": result["project"], }) + except HTTPException as e: + await websocket.send_json({"type": "rag_error", "error": e.detail}) + except PermissionError as e: + await websocket.send_json({"type": "rag_error", "error": str(e)}) except Exception as e: - await websocket.send_json({ - "type": "rag_error", - "error": str(e), - }) + await websocket.send_json({"type": "rag_error", "error": str(e)}) -# Статические файлы -app.mount("/static", StaticFiles(directory="backend/static"), name="static") +@app.get("/api/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") diff --git a/backend/paths.py b/backend/paths.py new file mode 100644 index 0000000..3b3b04a --- /dev/null +++ b/backend/paths.py @@ -0,0 +1,49 @@ +"""Org-scoped filesystem paths.""" + +import json +from datetime import datetime +from pathlib import Path + +DATA_ROOT = Path("data") +UPLOAD_ROOT = Path("uploads") +PROCESSED_ROOT = Path("processed") +RAG_CACHE_DIRNAME = "lightrag_caches" +MEETINGS_DIRNAME = "meetings" + + +def org_upload_dir(org_slug: str, user_id: int) -> Path: + path = UPLOAD_ROOT / org_slug / str(user_id) + path.mkdir(parents=True, exist_ok=True) + return path + + +def org_meetings_dir(org_slug: str) -> Path: + path = PROCESSED_ROOT / org_slug / MEETINGS_DIRNAME + path.mkdir(parents=True, exist_ok=True) + return path + + +def org_rag_index_dir(org_slug: str) -> Path: + path = PROCESSED_ROOT / org_slug / RAG_CACHE_DIRNAME + path.mkdir(parents=True, exist_ok=True) + return path + + +def resolve_meeting_path(org_slug: str, rel_path: str) -> Path: + """Resolve relative path under org meetings dir; reject traversal.""" + base = org_meetings_dir(org_slug).resolve() + full = (base / rel_path).resolve() + if not str(full).startswith(str(base)): + raise ValueError("Invalid path") + return full + + +def write_folder_project_meta(folder_path: Path, project_slug: str) -> None: + meta = { + "project_slug": project_slug.strip().lower(), + "created_at": datetime.now().isoformat(), + } + (folder_path / ".project.json").write_text( + json.dumps(meta, ensure_ascii=False), + encoding="utf-8", + ) diff --git a/backend/queue.py b/backend/queue.py index 3817890..9c970ca 100644 --- a/backend/queue.py +++ b/backend/queue.py @@ -1,4 +1,4 @@ -"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены.""" +"""Фоновая очередь: транскрибация и post-processing с org/project изоляцией.""" import asyncio import json @@ -11,6 +11,9 @@ from typing import Any, Callable, Dict, List, Optional sys.path.insert(0, str(Path(__file__).parent.parent)) +from backend.auth.models import UserContext +from backend.auth.service import ensure_project_access +from backend.paths import org_meetings_dir, org_rag_index_dir, org_upload_dir, resolve_meeting_path, write_folder_project_meta from src.audio_utils import prepare_audio_input from src.config import load_config, resolve_opencode_credentials from src.document import build_document @@ -22,13 +25,7 @@ from src.rag.formatter import ( format_summary_markdown, ) from src.rag.indexer import index_meeting -from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename - - -UPLOAD_DIR = Path("uploads") -PROCESSED_DIR = Path("processed") -UPLOAD_DIR.mkdir(exist_ok=True) -PROCESSED_DIR.mkdir(exist_ok=True) +from src.rag.parser import classify_meeting, generate_meeting_brief tasks: Dict[str, Dict[str, Any]] = {} _progress_callback: Optional[Callable] = None @@ -42,6 +39,33 @@ def set_progress_callback(callback: Callable): _progress_callback = callback +def _task_visible(task: Dict[str, Any], user: UserContext) -> bool: + if task.get("org_slug") != user.org_slug: + return False + if user.has_all_projects_access: + return True + return task.get("user_id") == user.user_id + + +def _filter_tasks_for_user(user: UserContext) -> List[Dict[str, Any]]: + return [t for t in tasks.values() if _task_visible(t, user)] + + +def _filter_queue_info(user: UserContext) -> Dict[str, Any]: + visible = _filter_tasks_for_user(user) + queued = sum(1 for t in visible if t.get("status") == "queued") + processing = sum(1 for t in visible if t.get("status") == "processing") + postprocessing = sum(1 for t in visible if t.get("status") == "postprocessing") + return { + "queued": queued, + "processing": processing, + "postprocessing": postprocessing, + "pending_transcribe": _transcribe_queue.qsize(), + "pending_postprocess": _postprocess_queue.qsize(), + "pending_in_queue": _transcribe_queue.qsize(), + } + + async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None): if _progress_callback: try: @@ -52,6 +76,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str, "message": message, "status": status, "file": task_info.get("file", ""), + "project": task_info.get("project_slug", ""), "queue_position": task_info.get("queue_position"), "result": result, "error": error, @@ -64,7 +89,7 @@ def _cleanup_upload(file_path: Path): if not file_path.exists(): return parent = file_path.parent - if parent != UPLOAD_DIR and parent.name.startswith("task_"): + if parent.name.startswith("task_"): shutil.rmtree(parent, ignore_errors=True) else: file_path.unlink(missing_ok=True) @@ -85,8 +110,12 @@ def _default_metadata(project: str, segments: List[Dict[str, Any]]) -> Dict[str, async def process_transcription(file_path: Path, task_id: str): - """Этап 1: WhisperX + docx/md. После — в очередь summary/RAG.""" - display_name = tasks.get(task_id, {}).get("file", file_path.name) + task = tasks.get(task_id, {}) + display_name = task.get("file", file_path.name) + org_slug = task["org_slug"] + project_slug = task["project_slug"] + meetings_dir = org_meetings_dir(org_slug) + tasks[task_id].update({ "status": "processing", "progress": 0, @@ -113,8 +142,9 @@ async def process_transcription(file_path: Path, task_id: str): stem = Path(display_name).stem timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = f"{stem}_{timestamp}" - output_dir = PROCESSED_DIR / folder_name + output_dir = meetings_dir / folder_name await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) + await asyncio.to_thread(write_folder_project_meta, output_dir, project_slug) docx_path = output_dir / f"{stem}.docx" md_path = output_dir / f"{stem}.md" @@ -130,22 +160,26 @@ async def process_transcription(file_path: Path, task_id: str): await asyncio.to_thread(_cleanup_upload, file_path) + rel_dir = str(output_dir.relative_to(meetings_dir)) post_position = _postprocess_queue.qsize() + 1 + result_data = { + "docx": str(docx_path), + "md": str(md_path), + "dir": str(output_dir), + "rel_dir": rel_dir, + "project": project_slug, + } tasks[task_id].update({ "status": "postprocessing", "progress": 70, "message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", - "result": { - "docx": str(docx_path), - "md": str(md_path), - "dir": str(output_dir), - }, + "result": result_data, }) await _send_progress( task_id, 70, f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", "postprocessing", - result=tasks[task_id]["result"], + result=result_data, ) await _postprocess_queue.put({ @@ -154,10 +188,10 @@ async def process_transcription(file_path: Path, task_id: str): "output_dir": str(output_dir), "stem": stem, "segments_path": str(segments_path), - "docx_path": str(docx_path), - "md_path": str(md_path), + "org_slug": org_slug, + "project_slug": project_slug, }) - print(f"[Transcribe] Задача {task_id} передана в post-processing") + print(f"[Transcribe] {task_id} -> post-processing (project={project_slug})") except Exception as e: error_msg = str(e) @@ -172,11 +206,12 @@ async def process_transcription(file_path: Path, task_id: str): async def process_postprocessing(job: Dict[str, Any]): - """Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию.""" task_id = job["task_id"] display_name = job["display_name"] stem = job["stem"] output_dir = Path(job["output_dir"]) + org_slug = job["org_slug"] + project = job["project_slug"] summary_path = output_dir / f"{stem}_summary.md" txt_path = output_dir / f"{stem}.txt" @@ -188,13 +223,13 @@ async def process_postprocessing(job: Dict[str, Any]): await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing") result_data = dict(tasks[task_id].get("result") or {}) + meetings_dir = org_meetings_dir(org_slug) try: segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8")) config = load_config() rag_cfg = config.get("rag", {}) api_key, base_url = resolve_opencode_credentials(config) - project = parse_project_from_filename(display_name) meeting_text = build_meeting_text_only(segments) metadata = _default_metadata(project, segments) @@ -229,11 +264,7 @@ async def process_postprocessing(job: Dict[str, Any]): chunk_size=summary_chunk_size, ) summary_md = format_summary_markdown(metadata, brief, display_name) - await asyncio.to_thread( - summary_path.write_text, - summary_md, - encoding="utf-8", - ) + await asyncio.to_thread(summary_path.write_text, summary_md, encoding="utf-8") result_data["summary"] = str(summary_path) doc_text = format_meeting_document(segments, metadata, display_name) @@ -241,10 +272,11 @@ async def process_postprocessing(job: Dict[str, Any]): result_data["txt"] = str(txt_path) result_data["metadata"] = metadata result_data["project"] = project + result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir)) if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True): await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing") - index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + index_dir = org_rag_index_dir(org_slug) global_doc_text = format_global_document(doc_text, metadata) await index_meeting( doc_text=doc_text, @@ -265,6 +297,7 @@ async def process_postprocessing(job: Dict[str, Any]): doc_text = format_meeting_document(segments, metadata, display_name) await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8") result_data["txt"] = str(txt_path) + result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir)) await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data) tasks[task_id].update({ @@ -291,7 +324,6 @@ async def _transcribe_worker_loop(worker_id: int): while True: try: task_id, file_path = await _transcribe_queue.get() - print(f"[Transcribe Worker {worker_id}] задача {task_id}") await process_transcription(file_path, task_id) _transcribe_queue.task_done() except asyncio.CancelledError: @@ -305,7 +337,6 @@ async def _postprocess_worker_loop(worker_id: int): while True: try: job = await _postprocess_queue.get() - print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}") await process_postprocessing(job) _postprocess_queue.task_done() except asyncio.CancelledError: @@ -315,7 +346,6 @@ async def _postprocess_worker_loop(worker_id: int): def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1): - """Запускает пулы воркеров транскрибации и post-processing.""" global _workers _workers.clear() for i in range(transcribe_workers): @@ -330,10 +360,17 @@ def stop_workers(): w.cancel() -async def save_upload(content: bytes, filename: str) -> tuple[str, Path]: +async def save_upload( + content: bytes, + filename: str, + user: UserContext, + project_slug: str, +) -> tuple[str, Path]: + ensure_project_access(user, project_slug) + slug = project_slug.strip().lower() safe_name = Path(filename).name or "upload.bin" task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" - task_dir = UPLOAD_DIR / task_id + task_dir = org_upload_dir(user.org_slug, user.user_id) / task_id task_dir.mkdir(parents=True, exist_ok=True) file_path = task_dir / safe_name @@ -346,6 +383,10 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]: "progress": 0, "message": f"В очереди транскрибации (№{queue_position})", "file": safe_name, + "project_slug": slug, + "org_slug": user.org_slug, + "user_id": user.user_id, + "username": user.username, "queue_position": queue_position, "result": None, "error": None, @@ -357,7 +398,9 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]: return task_id, file_path -def get_queue_info() -> Dict[str, Any]: +def get_queue_info(user: Optional[UserContext] = None) -> Dict[str, Any]: + if user: + return _filter_queue_info(user) queued = sum(1 for t in tasks.values() if t.get("status") == "queued") processing = sum(1 for t in tasks.values() if t.get("status") == "processing") postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing") @@ -371,11 +414,18 @@ def get_queue_info() -> Dict[str, Any]: } -def get_task_status(task_id: str) -> Optional[Dict[str, Any]]: - return tasks.get(task_id) +def get_task_status(task_id: str, user: Optional[UserContext] = None) -> Optional[Dict[str, Any]]: + task = tasks.get(task_id) + if not task: + return None + if user and not _task_visible(task, user): + return None + return task -def get_all_tasks() -> List[Dict[str, Any]]: +def get_all_tasks(user: Optional[UserContext] = None) -> List[Dict[str, Any]]: + if user: + return _filter_tasks_for_user(user) return list(tasks.values()) @@ -392,39 +442,84 @@ def _file_sort_key(file_info: Dict[str, Any]) -> tuple: return (4, name) -def get_processed_tree() -> List[Dict[str, Any]]: +def _folder_project_slug(folder_name: str, meetings_dir: Path) -> Optional[str]: + meta_path = meetings_dir / folder_name / ".project.json" + if meta_path.exists(): + try: + data = json.loads(meta_path.read_text(encoding="utf-8")) + return data.get("project_slug") + except Exception: + pass + return None + + +def get_processed_tree(user: UserContext) -> List[Dict[str, Any]]: tree = [] - if not PROCESSED_DIR.exists(): + meetings_dir = org_meetings_dir(user.org_slug) + if not meetings_dir.exists(): return tree - skip_names = {"lightrag_caches", "lightrag_caches_test"} skip_suffixes = ("_segments.json",) - for item in sorted(PROCESSED_DIR.iterdir()): - if not item.is_dir() or item.name in skip_names: + for item in sorted(meetings_dir.iterdir()): + if not item.is_dir(): continue + project_slug = _folder_project_slug(item.name, meetings_dir) + if project_slug and not user.can_access_project(project_slug): + continue + files = [] for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})): - if f.is_file() and not f.name.endswith(skip_suffixes): + if f.is_file() and not f.name.endswith(skip_suffixes) and f.name != ".project.json": files.append({ "name": f.name, - "path": str(f.relative_to(PROCESSED_DIR)), + "path": str(f.relative_to(meetings_dir)), "size": f.stat().st_size, "ext": f.suffix.lower(), "kind": "summary" if "_summary" in f.name else "protocol", }) tree.append({ "name": item.name, - "path": str(item.relative_to(PROCESSED_DIR)), + "path": str(item.relative_to(meetings_dir)), + "project": project_slug, "files": files, "created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(), }) return tree -def read_file_content(rel_path: str) -> str: - full_path = PROCESSED_DIR / rel_path +def read_file_content(user: UserContext, rel_path: str) -> str: + full_path = resolve_meeting_path(user.org_slug, rel_path) if not full_path.exists() or not full_path.is_file(): raise FileNotFoundError(f"Файл не найден: {rel_path}") + + folder = full_path.parent.name + meetings_dir = org_meetings_dir(user.org_slug) + project_slug = _folder_project_slug(folder, meetings_dir) + if project_slug and not user.can_access_project(project_slug): + raise PermissionError("Нет доступа к этому файлу") + with open(full_path, "r", encoding="utf-8") as f: return f.read() + + +def get_download_path(user: UserContext, rel_path: str) -> Path: + full_path = resolve_meeting_path(user.org_slug, rel_path) + if not full_path.exists() or not full_path.is_file(): + raise FileNotFoundError(f"Файл не найден: {rel_path}") + folder = full_path.parent.name + meetings_dir = org_meetings_dir(user.org_slug) + project_slug = _folder_project_slug(folder, meetings_dir) + if project_slug and not user.can_access_project(project_slug): + raise PermissionError("Нет доступа к этому файлу") + return full_path + + +def delete_folder(user: UserContext, folder_rel: str) -> None: + folder_path = resolve_meeting_path(user.org_slug, folder_rel) + if not folder_path.is_dir(): + raise FileNotFoundError("Folder not found") + project_slug = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug)) + if project_slug and not user.can_access_project(project_slug): + raise PermissionError("Нет доступа к этой папке") + shutil.rmtree(folder_path) diff --git a/backend/static/app.js b/backend/static/app.js index 3a5fe1a..47553ec 100644 --- a/backend/static/app.js +++ b/backend/static/app.js @@ -10,21 +10,158 @@ class TranscriptionApp { this.renderTimeout = null; this.chatHistory = []; this.chatProjects = []; + this.uploadProjects = []; this.isChatThinking = false; + this.user = null; this.init(); } init() { - this.connectWebSocket(); - this.setupUpload(); - this.setupChat(); - this.loadChatHistory(); + if (!Auth.requireAuth()) return; + this.user = Auth.getUser(); + this.setupUserBar(); + this.loadProjects().then(() => { + this.connectWebSocket(); + this.setupProjectsWorkspace(); + this.setupUpload(); + this.setupChat(); + this.setupAdmin(); + this.loadChatHistory(); + }); + } + + setupUserBar() { + const info = document.getElementById('userInfo'); + const logoutBtn = document.getElementById('logoutBtn'); + if (info && this.user) { + const roleLabel = { + admin: 'администратор', + director: 'директор', + user: 'пользователь', + }[this.user.role] || this.user.role; + info.textContent = `${this.user.username} · ${this.user.org_name} · ${roleLabel}`; + } + logoutBtn?.addEventListener('click', () => Auth.logout()); + } + + downloadUrl(path) { + const token = encodeURIComponent(Auth.getToken() || ''); + return `/api/files/download?path=${encodeURIComponent(path)}&token=${token}`; + } + + async loadProjects() { + try { + const response = await Auth.apiFetch('/api/auth/projects'); + const result = await response.json(); + this.uploadProjects = result.projects || []; + this.renderMyProjectsList(); + this.populateProjectSelect('uploadProjectSelect', this.uploadProjects, false); + await this.loadChatProjects(); + } catch (e) { + console.error('Failed to load projects:', e); + this.showToast('Не удалось загрузить проекты', 'error'); + } + } + + populateProjectSelect(selectId, projects, includeGlobal) { + const select = document.getElementById(selectId); + if (!select) return; + select.innerHTML = ''; + if (includeGlobal && this.user?.all_projects_access) { + const allOpt = document.createElement('option'); + allOpt.value = ''; + allOpt.textContent = 'Все проекты'; + select.appendChild(allOpt); + } + projects.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.slug || p; + const scope = p.scope === 'personal' ? ' · мой' : (p.scope === 'org' ? ' · общий' : ''); + opt.textContent = `${p.name || p.slug || p}${scope}`; + select.appendChild(opt); + }); + if (!select.options.length) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'Нет доступных проектов'; + select.appendChild(opt); + } + } + + renderMyProjectsList() { + const container = document.getElementById('myProjectsList'); + if (!container) return; + + const projects = this.uploadProjects || []; + if (!projects.length) { + container.innerHTML = '

Нет проектов. Создайте первый проект ниже.

'; + return; + } + + container.innerHTML = projects.map(p => { + const badge = p.scope === 'personal' + ? 'мой' + : 'общий'; + const deleteBtn = p.is_owner + ? `` + : ''; + return ` +
+
+ ${this.escapeHtml(p.name || p.slug)} + ${badge} +
+ ${this.escapeHtml(p.slug)} + ${deleteBtn} +
+ `; + }).join(''); + + container.querySelectorAll('.project-delete').forEach(btn => { + btn.addEventListener('click', () => this.deleteMyProject(btn.dataset.slug)); + }); + } + + setupProjectsWorkspace() { + document.getElementById('createMyProjectForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const slug = document.getElementById('myProjectSlug').value.trim(); + const name = document.getElementById('myProjectName').value.trim(); + try { + const response = await Auth.apiFetch('/api/auth/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug, name }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || 'Ошибка создания проекта'); + this.showToast(`Проект «${data.project.name}» создан`, 'success'); + e.target.reset(); + await this.loadProjects(); + } catch (err) { + this.showToast(err.message, 'error'); + } + }); + } + + async deleteMyProject(slug) { + if (!confirm(`Удалить проект «${slug}»?\n\nИндекс RAG и файлы не удаляются автоматически.`)) return; + try { + const response = await Auth.apiFetch(`/api/auth/projects/${encodeURIComponent(slug)}`, { + method: 'DELETE', + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || 'Ошибка удаления'); + this.showToast(`Проект «${slug}» удалён`, 'success'); + await this.loadProjects(); + } catch (err) { + this.showToast(err.message, 'error'); + } } // ===== WebSocket ===== connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`); + this.ws = new WebSocket(Auth.wsUrl('/ws')); this.ws.onopen = () => { console.log('WebSocket connected'); @@ -38,7 +175,12 @@ class TranscriptionApp { this.handleWebSocketMessage(data); }; - this.ws.onclose = () => { + this.ws.onclose = (event) => { + if (event.code === 4401) { + Auth.clearSession(); + window.location.href = '/login'; + return; + } console.log('WebSocket disconnected, reconnecting in 3s...'); setTimeout(() => this.connectWebSocket(), 3000); }; @@ -126,7 +268,14 @@ class TranscriptionApp { async handleFiles(files) { if (!files.length) return; + const project = document.getElementById('uploadProjectSelect')?.value; + if (!project) { + this.showToast('Выберите проект перед загрузкой', 'error'); + return; + } + const formData = new FormData(); + formData.append('project', project); for (const file of files) { formData.append('files', file); } @@ -134,7 +283,7 @@ class TranscriptionApp { try { this.showToast(`Загрузка ${files.length} файл(а)...`, 'info'); - const response = await fetch('/upload-batch', { + const response = await Auth.apiFetch('/upload-batch', { method: 'POST', body: formData, }); @@ -273,7 +422,7 @@ class TranscriptionApp { const isDocx = file.ext === '.docx'; const isTxt = file.ext === '.txt'; const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎'; - const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`; + const downloadUrl = this.downloadUrl(file.path); const cssClass = isSummary ? 'file-item file-summary' : 'file-item'; if (isDocx) { @@ -363,7 +512,7 @@ class TranscriptionApp { async deleteFolder(folderName, folderElement) { try { - const response = await fetch(`/api/folders/${encodeURIComponent(folderName)}`, { + const response = await Auth.apiFetch(`/api/folders/${encodeURIComponent(folderName)}`, { method: 'DELETE', }); const result = await response.json(); @@ -390,7 +539,7 @@ class TranscriptionApp { if (ext === '.md' || ext === '.txt') { try { - const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); + const response = await Auth.apiFetch(`/api/files/content?path=${encodeURIComponent(path)}`); const result = await response.json(); if (result.error) { @@ -406,7 +555,7 @@ class TranscriptionApp {
${this.escapeHtml(path)} - ⬇️ Скачать
${bodyHtml}
@@ -493,20 +642,20 @@ class TranscriptionApp { async loadChatProjects() { try { - const response = await fetch('/api/rag/projects'); + const response = await Auth.apiFetch('/api/rag/projects'); const result = await response.json(); - const select = document.getElementById('chatProjectSelect'); - select.innerHTML = ''; - if (result.projects) { - result.projects.forEach(p => { - const opt = document.createElement('option'); - opt.value = p; - opt.textContent = p; - select.appendChild(opt); - }); + const projects = (result.projects || []).map(p => ( + typeof p === 'string' ? { slug: p, name: p } : p + )); + this.populateProjectSelect('chatProjectSelect', projects, true); + const hint = document.getElementById('chatHint'); + if (hint) { + hint.textContent = this.user?.all_projects_access + ? 'Выберите проект или «Все проекты» для поиска по организации.' + : 'Выберите проект, к которому у вас есть доступ.'; } } catch (e) { - console.error('Failed to load projects:', e); + console.error('Failed to load chat projects:', e); } } @@ -520,6 +669,11 @@ class TranscriptionApp { const select = document.getElementById('chatProjectSelect'); const project = select.value; + if (!project && !this.user?.all_projects_access) { + this.showToast('Выберите проект для поиска', 'error'); + return; + } + input.value = ''; this.addChatBubble('user', question); this.setChatThinking(true); @@ -629,6 +783,131 @@ class TranscriptionApp { this.chatHistory = []; } } + + setupAdmin() { + if (!this.user?.is_admin) return; + const section = document.getElementById('adminSection'); + if (section) section.hidden = false; + this.refreshAdminData(); + + document.getElementById('createUserForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const role = document.getElementById('newUserRole').value; + const projects = role === 'user' + ? document.getElementById('newUserProjects').value.split(',').map(s => s.trim()).filter(Boolean) + : []; + try { + const response = await Auth.apiFetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: document.getElementById('newUsername').value.trim(), + password: document.getElementById('newPassword').value, + role: document.getElementById('newUserRole').value, + projects, + }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || 'Ошибка создания'); + this.showToast(`Пользователь ${data.user.username} создан`, 'success'); + e.target.reset(); + this.toggleProjectsField(); + this.refreshAdminData(); + } catch (err) { + this.showToast(err.message, 'error'); + } + }); + + document.getElementById('newUserRole')?.addEventListener('change', () => this.toggleProjectsField()); + this.toggleProjectsField(); + + document.getElementById('createProjectForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const response = await Auth.apiFetch('/api/admin/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug: document.getElementById('newProjectSlug').value.trim(), + name: document.getElementById('newProjectName').value.trim(), + }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || 'Ошибка'); + this.showToast(`Проект ${data.project.slug} добавлен`, 'success'); + e.target.reset(); + await this.loadProjects(); + this.refreshAdminData(); + } catch (err) { + this.showToast(err.message, 'error'); + } + }); + } + + toggleProjectsField() { + const role = document.getElementById('newUserRole')?.value; + const projectsInput = document.getElementById('newUserProjects'); + const hint = document.getElementById('newUserProjectsHint'); + const isUser = role === 'user'; + if (projectsInput) { + projectsInput.disabled = !isUser; + projectsInput.placeholder = isUser + ? 'Проекты через запятую (2026, gp-merakom)' + : 'Не требуется — доступ ко всем проектам организации'; + } + if (hint) { + hint.textContent = role === 'director' + ? 'Директор: все проекты, глобальный поиск, без панели администрирования.' + : role === 'admin' + ? 'Администратор: полный доступ + управление пользователями.' + : 'Укажите slug общих проектов org, назначенных администратором.'; + } + } + + async refreshAdminData() { + if (!this.user?.is_admin) return; + try { + const [usersRes, projectsRes] = await Promise.all([ + Auth.apiFetch('/api/admin/users'), + Auth.apiFetch('/api/admin/projects'), + ]); + const usersData = await usersRes.json(); + const projectsData = await projectsRes.json(); + + const usersEl = document.getElementById('adminUsersList'); + if (usersEl) { + usersEl.innerHTML = (usersData.users || []).map(u => { + const shared = (u.shared_projects || u.projects || []).join(', '); + const owned = (u.owned_projects || []).join(', '); + const parts = []; + if (owned) parts.push(`личные: ${owned}`); + if (shared) parts.push(`общие: ${shared}`); + if (u.all_projects_access) parts.push('все проекты'); + const projectsLabel = parts.join(' · ') || '—'; + const roleLabel = { admin: 'admin', director: 'director', user: 'user' }[u.role] || u.role; + return ` +
+ ${this.escapeHtml(u.username)} + ${roleLabel} + ${this.escapeHtml(projectsLabel)} +
+ `}).join(''); + } + + const projectsEl = document.getElementById('adminProjectsList'); + if (projectsEl) { + projectsEl.innerHTML = (projectsData.projects || []).map(p => ` +
+ ${this.escapeHtml(p.slug)} + ${this.escapeHtml(p.name)} + ${p.scope === 'personal' ? 'личный' : 'org'} +
+ `).join(''); + } + } catch (e) { + console.error('Admin refresh failed:', e); + } + } } // Initialize diff --git a/backend/static/auth.js b/backend/static/auth.js new file mode 100644 index 0000000..536d3f0 --- /dev/null +++ b/backend/static/auth.js @@ -0,0 +1,106 @@ +/** + * Shared auth helpers for Transcriba frontend. + */ + +const Auth = { + TOKEN_KEY: 'transcriba_token', + USER_KEY: 'transcriba_user', + + getToken() { + return localStorage.getItem(this.TOKEN_KEY); + }, + + setSession(token, user) { + localStorage.setItem(this.TOKEN_KEY, token); + localStorage.setItem(this.USER_KEY, JSON.stringify(user)); + }, + + getUser() { + try { + const raw = localStorage.getItem(this.USER_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } + }, + + clearSession() { + localStorage.removeItem(this.TOKEN_KEY); + localStorage.removeItem(this.USER_KEY); + localStorage.removeItem('transcriba_chat_history'); + }, + + requireAuth() { + if (!this.getToken()) { + window.location.href = '/login'; + return false; + } + return true; + }, + + authHeaders(extra = {}) { + const headers = { ...extra }; + const token = this.getToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + return headers; + }, + + async apiFetch(url, options = {}) { + const headers = this.authHeaders(options.headers || {}); + const response = await fetch(url, { ...options, headers }); + if (response.status === 401) { + this.clearSession(); + window.location.href = '/login'; + throw new Error('Требуется авторизация'); + } + return response; + }, + + async login(orgSlug, username, password) { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ org_slug: orgSlug, username, password }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || 'Ошибка входа'); + } + this.setSession(data.access_token, data.user); + return data.user; + }, + + logout() { + this.clearSession(); + window.location.href = '/login'; + }, + + wsUrl(path = '/ws') { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const token = encodeURIComponent(this.getToken() || ''); + return `${protocol}//${window.location.host}${path}?token=${token}`; + }, +}; + +if (document.getElementById('loginForm')) { + document.getElementById('loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('loginBtn'); + const errEl = document.getElementById('loginError'); + errEl.hidden = true; + btn.disabled = true; + try { + await Auth.login( + document.getElementById('orgSlug').value.trim(), + document.getElementById('username').value.trim(), + document.getElementById('password').value + ); + window.location.href = '/'; + } catch (error) { + errEl.textContent = error.message; + errEl.hidden = false; + } finally { + btn.disabled = false; + } + }); +} diff --git a/backend/static/index.html b/backend/static/index.html index 5cbab4c..32a6d0e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -6,20 +6,48 @@ Транскрибация совещаний - +
-

🎙️ Транскрибация совещаний

-

Загрузите аудио или видео файл для получения протокола

+
+
+

🎙️ Транскрибация совещаний

+

Загрузите аудио или видео файл для получения протокола

+
+
+ + +
+
- +
+
+

📂 Мои проекты

+

Создайте личный проект и работайте в нём: загрузка, протоколы, RAG-поиск.

+
+
+
+
+ + + +
+
+
+
+
+ +
@@ -33,7 +61,6 @@
-

Очередь обработки

@@ -45,7 +72,6 @@
-

📁 Файлы

@@ -64,21 +90,18 @@
-

🤖 Чат с базой знаний

- +

Здравствуйте! Я помогу найти информацию в протоколах совещаний.

-

Выберите проект или оставьте «Все проекты» для глобального поиска.

+

Выберите проект для поиска.

@@ -86,12 +109,41 @@
+ +
-
- diff --git a/backend/static/login.html b/backend/static/login.html new file mode 100644 index 0000000..a364c0e --- /dev/null +++ b/backend/static/login.html @@ -0,0 +1,32 @@ + + + + + + Вход — Транскрибация + + + + + + + diff --git a/backend/static/styles.css b/backend/static/styles.css index d960682..d499cc2 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -750,3 +750,289 @@ header h1 { gap: 10px; } } + +/* ===== Auth / Login ===== */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 20px; +} + +.login-card { + width: 100%; + max-width: 420px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + box-shadow: var(--shadow); +} + +.login-card h1 { + margin-bottom: 8px; +} + +.login-subtitle { + color: var(--text-secondary); + margin-bottom: 24px; +} + +.login-card label { + display: block; + margin-bottom: 14px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.login-card input { + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-hover); + color: var(--text-primary); +} + +.login-card button { + width: 100%; + margin-top: 8px; + padding: 12px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + cursor: pointer; +} + +.login-error { + color: var(--error); + margin-top: 12px; +} + +.header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.user-bar { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.btn-secondary { + background: var(--bg-hover); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 14px; + cursor: pointer; +} + +.upload-controls { + margin-bottom: 12px; +} + +.upload-controls label { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-secondary); + max-width: 320px; +} + +.upload-controls select { + padding: 8px 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-hover); + color: var(--text-primary); +} + +.workspace-section { + margin-bottom: 24px; + padding: 20px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.workspace-header h2 { + margin-bottom: 6px; +} + +.workspace-hint { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 16px; +} + +.projects-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.projects-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} + +.project-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + position: relative; +} + +.project-card-main { + display: flex; + align-items: center; + gap: 8px; +} + +.project-slug { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.project-badge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 999px; +} + +.project-badge.personal { + background: rgba(74, 158, 255, 0.15); + color: var(--accent); +} + +.project-badge.org { + background: rgba(74, 222, 128, 0.12); + color: var(--success); +} + +.project-delete { + position: absolute; + top: 8px; + right: 8px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; +} + +.project-create-form { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.project-create-form input { + padding: 8px 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-hover); + color: var(--text-primary); + min-width: 180px; +} + +.project-create-form button { + padding: 8px 16px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + cursor: pointer; +} + +.admin-section { + margin-top: 24px; + padding: 20px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.admin-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 16px; +} + +.admin-card { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} + +.admin-list { + margin: 12px 0; + max-height: 220px; + overflow-y: auto; +} + +.admin-list-item { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.admin-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.admin-form input, +.admin-form select { + padding: 8px 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-hover); + color: var(--text-primary); +} + +.admin-form button { + align-self: flex-start; + padding: 8px 14px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + cursor: pointer; +} + +.admin-hint { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0; +} + +@media (max-width: 768px) { + .admin-grid { + grid-template-columns: 1fr; + } + + .header-top { + flex-direction: column; + } +} diff --git a/config.yaml b/config.yaml index bb3a7a1..a38ae9f 100644 --- a/config.yaml +++ b/config.yaml @@ -71,6 +71,22 @@ queue: transcribe_workers: 2 postprocess_workers: 1 +# Авторизация и multi-tenant (org + projects) +auth: + jwt_secret: "" # Обязательно задайте JWT_SECRET в .env для production + jwt_expire_hours: 168 + database_path: ./data/transcriba.db + bootstrap: + org_slug: merakom + org_name: "МЕРАКОМ" + admin_username: admin + admin_password: "admin123" # Смените после первого входа; или AUTH_ADMIN_PASSWORD в .env + default_projects: + - slug: "2026" + name: "2026" + - slug: "gp-merakom" + name: "ГП МЕРАКОМ" + # Пути paths: output_dir: ./output diff --git a/docker-compose.yml b/docker-compose.yml index 8b5bd17..0fce2dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,8 @@ services: - OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1} - HF_HOME=/root/.cache/huggingface - NLTK_DATA=/root/nltk_data - - TRANSFORMERS_OFFLINE=0 + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - AUTH_ADMIN_PASSWORD=${AUTH_ADMIN_PASSWORD:-admin123} volumes: - uploads:/app/uploads - processed:/app/processed @@ -27,11 +28,12 @@ services: - ./scripts:/app/scripts:ro - ./models/huggingface:/root/.cache/huggingface - ./models/nltk_data:/root/nltk_data:ro + - data:/app/data restart: unless-stopped entrypoint: ["uvicorn"] command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"] healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/api/files"] + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] interval: 30s timeout: 10s retries: 3 @@ -41,3 +43,4 @@ volumes: uploads: processed: tmp: + data: diff --git a/requirements.txt b/requirements.txt index fcc189f..0d04d28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,10 @@ python-docx pyyaml whisperx +# Auth +bcrypt>=4.0.0 +python-jose[cryptography] + # RAG / LightRAG lightrag-hku>=1.4.0 openai>=1.0.0 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6e15608 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,170 @@ +"""Tests for multi-tenant auth (without WhisperX dependency).""" + +import os +import tempfile +import unittest +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from backend.auth.database import bootstrap_from_config, init_db +from backend.auth.routes import admin_router, router as auth_router + + +def _build_test_app() -> FastAPI: + app = FastAPI() + app.include_router(auth_router) + app.include_router(admin_router) + return app + + +class AuthTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._tmpdir = tempfile.TemporaryDirectory() + cls.db_path = Path(cls._tmpdir.name) / "test.db" + os.environ["JWT_SECRET"] = "test-secret-key" + os.environ["AUTH_ADMIN_PASSWORD"] = "admin123" + os.environ["AUTH_DATABASE_PATH"] = str(cls.db_path) + + config = { + "auth": { + "database_path": str(cls.db_path), + "bootstrap": { + "org_slug": "merakom", + "org_name": "Test Org", + "admin_username": "admin", + "default_projects": [ + {"slug": "2026", "name": "2026"}, + {"slug": "gp-merakom", "name": "GP"}, + ], + }, + } + } + init_db(config) + bootstrap_from_config(config) + cls.client = TestClient(_build_test_app()) + + @classmethod + def tearDownClass(cls): + cls._tmpdir.cleanup() + os.environ.pop("AUTH_DATABASE_PATH", None) + + def test_login_admin(self): + response = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "admin", "password": "admin123"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("access_token", data) + self.assertTrue(data["user"]["is_admin"]) + + def test_create_user_and_project_acl(self): + admin_login = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "admin", "password": "admin123"}, + ).json() + headers = {"Authorization": f"Bearer {admin_login['access_token']}"} + + create_user = self.client.post( + "/api/admin/users", + headers=headers, + json={ + "username": "worker", + "password": "worker123", + "role": "user", + "projects": ["2026"], + }, + ) + self.assertEqual(create_user.status_code, 200) + + user_login = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "worker", "password": "worker123"}, + ).json() + user_headers = {"Authorization": f"Bearer {user_login['access_token']}"} + + projects = self.client.get("/api/auth/projects", headers=user_headers).json() + slugs = {p["slug"] for p in projects["projects"]} + self.assertEqual(slugs, {"2026"}) + + def test_director_has_all_projects_and_no_admin(self): + admin_login = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "admin", "password": "admin123"}, + ).json() + headers = {"Authorization": f"Bearer {admin_login['access_token']}"} + + create_director = self.client.post( + "/api/admin/users", + headers=headers, + json={ + "username": "director1", + "password": "dir123", + "role": "director", + "projects": [], + }, + ) + self.assertEqual(create_director.status_code, 200) + self.assertTrue(create_director.json()["user"]["all_projects_access"]) + self.assertFalse(create_director.json()["user"]["is_admin"]) + + director_login = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "director1", "password": "dir123"}, + ).json() + d_headers = {"Authorization": f"Bearer {director_login['access_token']}"} + + projects = self.client.get("/api/auth/projects", headers=d_headers).json() + slugs = {p["slug"] for p in projects["projects"]} + self.assertEqual(slugs, {"2026", "gp-merakom"}) + + global_query = self.client.post( + "/api/rag/query-global", + headers=d_headers, + json={"question": "test"}, + ) + self.assertNotEqual(global_query.status_code, 403) + + def test_user_creates_personal_project(self): + admin_login = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "admin", "password": "admin123"}, + ).json() + headers = {"Authorization": f"Bearer {admin_login['access_token']}"} + + self.client.post( + "/api/admin/users", + headers=headers, + json={ + "username": "builder", + "password": "build123", + "role": "user", + "projects": [], + }, + ) + + user_login = self.client.post( + "/api/auth/login", + json={"org_slug": "merakom", "username": "builder", "password": "build123"}, + ).json() + user_headers = {"Authorization": f"Bearer {user_login['access_token']}"} + + created = self.client.post( + "/api/auth/projects", + headers=user_headers, + json={"slug": "my-gp", "name": "Мой ГП"}, + ) + self.assertEqual(created.status_code, 200) + self.assertEqual(created.json()["project"]["scope"], "personal") + self.assertTrue(created.json()["project"]["is_owner"]) + + projects = self.client.get("/api/auth/projects", headers=user_headers).json() + slugs = {p["slug"] for p in projects["projects"]} + self.assertEqual(slugs, {"my-gp"}) + + +if __name__ == "__main__": + unittest.main()