"""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)