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 <cursoragent@cursor.com>
This commit is contained in:
parent
2727d3cd32
commit
8df14e3102
3
.gitignore
vendored
3
.gitignore
vendored
@ -73,3 +73,6 @@ video/
|
|||||||
# Server logs
|
# Server logs
|
||||||
server.log
|
server.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Auth database
|
||||||
|
data/
|
||||||
|
|||||||
@ -6,4 +6,6 @@ RUN pip install --no-cache-dir --timeout 300 \
|
|||||||
lightrag-hku>=1.4.0 \
|
lightrag-hku>=1.4.0 \
|
||||||
openai>=1.0.0 \
|
openai>=1.0.0 \
|
||||||
python-dotenv>=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]"
|
||||||
|
|||||||
6
backend/auth/__init__.py
Normal file
6
backend/auth/__init__.py
Normal file
@ -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"]
|
||||||
407
backend/auth/database.py
Normal file
407
backend/auth/database.py
Normal file
@ -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)
|
||||||
70
backend/auth/deps.py
Normal file
70
backend/auth/deps.py
Normal file
@ -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)))
|
||||||
53
backend/auth/models.py
Normal file
53
backend/auth/models.py
Normal file
@ -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
|
||||||
146
backend/auth/routes.py
Normal file
146
backend/auth/routes.py
Normal file
@ -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
|
||||||
71
backend/auth/security.py
Normal file
71
backend/auth/security.py
Normal file
@ -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
|
||||||
180
backend/auth/service.py
Normal file
180
backend/auth/service.py
Normal file
@ -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))
|
||||||
434
backend/main.py
434
backend/main.py
@ -1,88 +1,91 @@
|
|||||||
"""FastAPI backend для сервиса транскрибации."""
|
"""FastAPI backend для сервиса транскрибации."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 (
|
from backend.queue import (
|
||||||
UPLOAD_DIR,
|
delete_folder,
|
||||||
PROCESSED_DIR,
|
|
||||||
save_upload,
|
|
||||||
get_all_tasks,
|
get_all_tasks,
|
||||||
|
get_download_path,
|
||||||
|
get_processed_tree,
|
||||||
get_queue_info,
|
get_queue_info,
|
||||||
get_task_status,
|
get_task_status,
|
||||||
get_processed_tree,
|
|
||||||
read_file_content,
|
read_file_content,
|
||||||
|
save_upload,
|
||||||
set_progress_callback,
|
set_progress_callback,
|
||||||
start_workers,
|
start_workers,
|
||||||
stop_workers,
|
stop_workers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем корень проекта в путь, чтобы импортировать src.rag
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
from src.config import load_config, resolve_opencode_credentials
|
from src.config import load_config, resolve_opencode_credentials
|
||||||
from src.rag.indexer import get_project_names
|
from src.rag.formatter import format_global_document
|
||||||
from src.rag.parser import parse_project_from_filename
|
from src.rag.indexer import get_project_names, index_meeting
|
||||||
from src.rag.query import rag_chat, retrieve_context
|
from src.rag.query import rag_chat
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
# WebSocket менеджер
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: List[WebSocket] = []
|
self.active_connections: list[tuple[WebSocket, Optional[UserContext]]] = []
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket):
|
async def connect(self, websocket: WebSocket, user: Optional[UserContext] = None):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
self.active_connections.append(websocket)
|
self.active_connections.append((websocket, user))
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket):
|
def disconnect(self, websocket: WebSocket):
|
||||||
if websocket in self.active_connections:
|
self.active_connections = [(ws, u) for ws, u in self.active_connections if ws is not websocket]
|
||||||
self.active_connections.remove(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):
|
async def broadcast(self, message: dict):
|
||||||
for conn in self.active_connections:
|
for conn, _user in self.active_connections:
|
||||||
try:
|
try:
|
||||||
await conn.send_json(message)
|
await conn.send_json(message)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
manager = ConnectionManager()
|
|
||||||
|
|
||||||
# Устанавливаем callback для отправки прогресса через WebSocket
|
manager = ConnectionManager()
|
||||||
set_progress_callback(manager.broadcast)
|
set_progress_callback(manager.broadcast)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Управление жизненным циклом приложения."""
|
|
||||||
config = load_config()
|
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", {})
|
queue_cfg = config.get("queue", {})
|
||||||
transcribe_workers = int(queue_cfg.get("transcribe_workers", 2))
|
transcribe_workers = int(queue_cfg.get("transcribe_workers", 2))
|
||||||
postprocess_workers = int(queue_cfg.get("postprocess_workers", 1))
|
postprocess_workers = int(queue_cfg.get("postprocess_workers", 1))
|
||||||
print("🚀 Запуск рабочих процессов...")
|
print("🚀 Запуск рабочих процессов...")
|
||||||
start_workers(
|
start_workers(transcribe_workers=transcribe_workers, postprocess_workers=postprocess_workers)
|
||||||
transcribe_workers=transcribe_workers,
|
|
||||||
postprocess_workers=postprocess_workers,
|
|
||||||
)
|
|
||||||
yield
|
yield
|
||||||
print("🛑 Остановка рабочих процессов...")
|
print("🛑 Остановка рабочих процессов...")
|
||||||
stop_workers()
|
stop_workers()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(title="Transcription Service", version="2.0.0", lifespan=lifespan)
|
||||||
title="Transcription Service",
|
|
||||||
version="1.0.0",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@ -91,84 +94,132 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root():
|
async def root():
|
||||||
"""Главная страница."""
|
index_path = STATIC_DIR / "index.html"
|
||||||
index_path = Path(__file__).parent / "static" / "index.html"
|
|
||||||
if index_path.exists():
|
if index_path.exists():
|
||||||
return index_path.read_text(encoding="utf-8")
|
return index_path.read_text(encoding="utf-8")
|
||||||
return "<h1>Transcription Service</h1><p>Frontend not built</p>"
|
return "<h1>Transcription Service</h1>"
|
||||||
|
|
||||||
|
|
||||||
|
@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 "<h1>Login page missing</h1>"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload")
|
@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()
|
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 {
|
return {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"file": file.filename,
|
"file": file.filename,
|
||||||
|
"project": project,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"message": "Файл добавлен в очередь обработки",
|
"message": "Файл добавлен в очередь обработки",
|
||||||
"queue": get_queue_info(),
|
"queue": get_queue_info(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload-batch")
|
@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:
|
if not files:
|
||||||
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
|
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for file in files:
|
for file in files:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
try:
|
||||||
results.append({
|
task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
|
||||||
"task_id": task_id,
|
except PermissionError as e:
|
||||||
"file": file.filename,
|
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||||
"status": "queued",
|
results.append({"task_id": task_id, "file": file.filename, "project": project, "status": "queued"})
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uploaded": len(results),
|
"uploaded": len(results),
|
||||||
"tasks": results,
|
"tasks": results,
|
||||||
"queue": get_queue_info(),
|
"queue": get_queue_info(user),
|
||||||
"message": f"{len(results)} файл(ов) добавлено в очередь",
|
"message": f"{len(results)} файл(ов) добавлено в очередь",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
"""WebSocket для получения прогресса обработки."""
|
token = websocket.query_params.get("token")
|
||||||
await manager.connect(websocket)
|
user = get_user_from_token(token)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
await manager.connect(websocket, user)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Ждём сообщения от клиента (ping/keepalive)
|
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
msg = json.loads(data)
|
msg = json.loads(data)
|
||||||
|
|
||||||
if msg.get("action") == "get_tasks":
|
if msg.get("action") == "get_tasks":
|
||||||
tasks = get_all_tasks()
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "tasks_list",
|
"type": "tasks_list",
|
||||||
"tasks": tasks,
|
"tasks": get_all_tasks(user),
|
||||||
"queue": get_queue_info(),
|
"queue": get_queue_info(user),
|
||||||
})
|
})
|
||||||
elif msg.get("action") == "get_tree":
|
elif msg.get("action") == "get_tree":
|
||||||
tree = get_processed_tree()
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "file_tree",
|
"type": "file_tree",
|
||||||
"tree": tree,
|
"tree": get_processed_tree(user),
|
||||||
})
|
})
|
||||||
elif msg.get("action") == "rag_query":
|
elif msg.get("action") in ("rag_query", "rag_query_global"):
|
||||||
await _handle_rag_query(websocket, msg)
|
await _handle_rag_query_ws(websocket, msg, user)
|
||||||
elif msg.get("action") == "rag_query_global":
|
|
||||||
await _handle_rag_query(websocket, msg)
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(websocket)
|
manager.disconnect(websocket)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -176,202 +227,126 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/tasks")
|
@app.get("/api/tasks")
|
||||||
async def api_tasks():
|
async def api_tasks(user: UserContext = Depends(get_current_user)):
|
||||||
"""Возвращает список всех задач."""
|
return {"tasks": get_all_tasks(user), "queue": get_queue_info(user)}
|
||||||
return {"tasks": get_all_tasks(), "queue": get_queue_info()}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tasks/{task_id}")
|
@app.get("/api/tasks/{task_id}")
|
||||||
async def api_task(task_id: str):
|
async def api_task(task_id: str, user: UserContext = Depends(get_current_user)):
|
||||||
"""Возвращает статус конкретной задачи."""
|
status = get_task_status(task_id, user)
|
||||||
status = get_task_status(task_id)
|
|
||||||
if not status:
|
if not status:
|
||||||
return {"error": "Task not found"}
|
return {"error": "Task not found"}
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/files")
|
@app.get("/api/files")
|
||||||
async def api_files():
|
async def api_files(user: UserContext = Depends(get_current_user)):
|
||||||
"""Возвращает дерево обработанных файлов."""
|
return {"tree": get_processed_tree(user)}
|
||||||
return {"tree": get_processed_tree()}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/files/content")
|
@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:
|
try:
|
||||||
content = read_file_content(path)
|
content = read_file_content(user, path)
|
||||||
return {"content": content, "path": path}
|
return {"content": content, "path": path}
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/files/download")
|
@app.get("/api/files/download")
|
||||||
async def api_download(path: str):
|
async def api_download(path: str, user: UserContext = Depends(get_current_user)):
|
||||||
"""Скачивает файл."""
|
|
||||||
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"}
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(folder_path)
|
file_path = get_download_path(user, path)
|
||||||
return {"deleted": folder_name}
|
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:
|
except Exception as e:
|
||||||
return {"error": str(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")
|
@app.get("/api/rag/projects")
|
||||||
async def api_rag_projects():
|
async def api_rag_projects(user: UserContext = Depends(get_current_user)):
|
||||||
"""Возвращает список проектов с RAG-индексами."""
|
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
db_projects = {p["slug"] for p in list_accessible_projects(user)}
|
||||||
rag_cfg = config.get("rag", {})
|
indexed = await _list_rag_project_slugs(user)
|
||||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
merged = sorted(db_projects | set(indexed))
|
||||||
projects = await get_project_names(index_dir)
|
if not user.is_admin:
|
||||||
return {"projects": projects}
|
merged = user.filter_projects(merged)
|
||||||
|
return {"projects": merged}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e), "projects": []}
|
return {"error": str(e), "projects": []}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/rag/query")
|
@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:
|
try:
|
||||||
config = load_config()
|
result = await _rag_chat_for_user(
|
||||||
rag_cfg = config.get("rag", {})
|
user,
|
||||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
payload.get("question", ""),
|
||||||
api_key, base_url = resolve_opencode_credentials(config)
|
payload.get("history", []),
|
||||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
payload.get("project"),
|
||||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
payload.get("mode", "hybrid"),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
return {
|
return {"answer": result["answer"], "context": result["context"], "project": result["project"]}
|
||||||
"answer": result["answer"],
|
except HTTPException:
|
||||||
"context": result["context"],
|
raise
|
||||||
"project": result["project"],
|
except PermissionError as e:
|
||||||
}
|
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/rag/query-global")
|
@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:
|
try:
|
||||||
config = load_config()
|
result = await _rag_chat_for_user(
|
||||||
rag_cfg = config.get("rag", {})
|
user,
|
||||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
payload.get("question", ""),
|
||||||
api_key, base_url = resolve_opencode_credentials(config)
|
payload.get("history", []),
|
||||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
None,
|
||||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
payload.get("mode", "hybrid"),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
return {
|
return {"answer": result["answer"], "context": result["context"], "project": None}
|
||||||
"answer": result["answer"],
|
except HTTPException:
|
||||||
"context": result["context"],
|
raise
|
||||||
"project": None,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rag/tasks")
|
@app.post("/api/rag/index/{folder_name:path}")
|
||||||
async def api_rag_tasks(project: Optional[str] = None):
|
async def api_rag_index_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
|
||||||
"""Возвращает action items из RAG."""
|
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
folder_path = resolve_meeting_path(user.org_slug, folder_name)
|
||||||
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
|
|
||||||
if not folder_path.exists():
|
if not folder_path.exists():
|
||||||
return {"error": "Folder not found"}
|
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"))
|
txt_files = list(folder_path.glob("*.txt"))
|
||||||
if not txt_files:
|
if not txt_files:
|
||||||
return {"error": "No .txt protocol found in folder"}
|
return {"error": "No .txt protocol found in folder"}
|
||||||
|
|
||||||
txt_path = txt_files[0]
|
doc_text = txt_files[0].read_text(encoding="utf-8")
|
||||||
doc_text = txt_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
# Определяем проект из имени папки
|
|
||||||
project = parse_project_from_filename(folder_name)
|
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
rag_cfg = config.get("rag", {})
|
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)
|
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": "Переиндексация"}
|
metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"}
|
||||||
global_doc_text = format_global_document(doc_text, metadata)
|
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,
|
doc_text=doc_text,
|
||||||
global_doc_text=global_doc_text,
|
global_doc_text=global_doc_text,
|
||||||
project_name=project,
|
project_name=project,
|
||||||
working_dir_base=index_dir,
|
working_dir_base=_org_index_dir(user),
|
||||||
model=index_model,
|
model=rag_cfg.get("index_model", "mimo-v2.5-free"),
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"indexed": folder_name, "project": project}
|
return {"indexed": folder_name, "project": project}
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
# === WebSocket Chat Actions ===
|
async def _handle_rag_query_ws(websocket: WebSocket, msg: dict, user: UserContext):
|
||||||
|
|
||||||
async def _handle_rag_query(websocket: WebSocket, msg: dict):
|
|
||||||
"""Обрабатывает rag_query через WebSocket."""
|
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
project = msg.get("project")
|
||||||
rag_cfg = config.get("rag", {})
|
if msg.get("action") == "rag_query_global":
|
||||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
project = None
|
||||||
api_key, base_url = resolve_opencode_credentials(config)
|
result = await _rag_chat_for_user(
|
||||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
user,
|
||||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
msg.get("question", ""),
|
||||||
mode = msg.get("mode", "hybrid")
|
msg.get("history", []),
|
||||||
|
project,
|
||||||
result = await rag_chat(
|
msg.get("mode", "hybrid"),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "rag_response",
|
"type": "rag_response",
|
||||||
"answer": result["answer"],
|
"answer": result["answer"],
|
||||||
"context": result["context"],
|
"context": result["context"],
|
||||||
"project": result["project"],
|
"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:
|
except Exception as e:
|
||||||
await websocket.send_json({
|
await websocket.send_json({"type": "rag_error", "error": str(e)})
|
||||||
"type": "rag_error",
|
|
||||||
"error": str(e),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# Статические файлы
|
@app.get("/api/health")
|
||||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|||||||
49
backend/paths.py
Normal file
49
backend/paths.py
Normal file
@ -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",
|
||||||
|
)
|
||||||
191
backend/queue.py
191
backend/queue.py
@ -1,4 +1,4 @@
|
|||||||
"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены."""
|
"""Фоновая очередь: транскрибация и post-processing с org/project изоляцией."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@ -11,6 +11,9 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
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.audio_utils import prepare_audio_input
|
||||||
from src.config import load_config, resolve_opencode_credentials
|
from src.config import load_config, resolve_opencode_credentials
|
||||||
from src.document import build_document
|
from src.document import build_document
|
||||||
@ -22,13 +25,7 @@ from src.rag.formatter import (
|
|||||||
format_summary_markdown,
|
format_summary_markdown,
|
||||||
)
|
)
|
||||||
from src.rag.indexer import index_meeting
|
from src.rag.indexer import index_meeting
|
||||||
from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename
|
from src.rag.parser import classify_meeting, generate_meeting_brief
|
||||||
|
|
||||||
|
|
||||||
UPLOAD_DIR = Path("uploads")
|
|
||||||
PROCESSED_DIR = Path("processed")
|
|
||||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
|
||||||
PROCESSED_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
tasks: Dict[str, Dict[str, Any]] = {}
|
tasks: Dict[str, Dict[str, Any]] = {}
|
||||||
_progress_callback: Optional[Callable] = None
|
_progress_callback: Optional[Callable] = None
|
||||||
@ -42,6 +39,33 @@ def set_progress_callback(callback: Callable):
|
|||||||
_progress_callback = callback
|
_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):
|
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
|
||||||
if _progress_callback:
|
if _progress_callback:
|
||||||
try:
|
try:
|
||||||
@ -52,6 +76,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
|
|||||||
"message": message,
|
"message": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
"file": task_info.get("file", ""),
|
"file": task_info.get("file", ""),
|
||||||
|
"project": task_info.get("project_slug", ""),
|
||||||
"queue_position": task_info.get("queue_position"),
|
"queue_position": task_info.get("queue_position"),
|
||||||
"result": result,
|
"result": result,
|
||||||
"error": error,
|
"error": error,
|
||||||
@ -64,7 +89,7 @@ def _cleanup_upload(file_path: Path):
|
|||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return
|
return
|
||||||
parent = file_path.parent
|
parent = file_path.parent
|
||||||
if parent != UPLOAD_DIR and parent.name.startswith("task_"):
|
if parent.name.startswith("task_"):
|
||||||
shutil.rmtree(parent, ignore_errors=True)
|
shutil.rmtree(parent, ignore_errors=True)
|
||||||
else:
|
else:
|
||||||
file_path.unlink(missing_ok=True)
|
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):
|
async def process_transcription(file_path: Path, task_id: str):
|
||||||
"""Этап 1: WhisperX + docx/md. После — в очередь summary/RAG."""
|
task = tasks.get(task_id, {})
|
||||||
display_name = tasks.get(task_id, {}).get("file", file_path.name)
|
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({
|
tasks[task_id].update({
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
@ -113,8 +142,9 @@ async def process_transcription(file_path: Path, task_id: str):
|
|||||||
stem = Path(display_name).stem
|
stem = Path(display_name).stem
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
folder_name = f"{stem}_{timestamp}"
|
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(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"
|
docx_path = output_dir / f"{stem}.docx"
|
||||||
md_path = output_dir / f"{stem}.md"
|
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)
|
await asyncio.to_thread(_cleanup_upload, file_path)
|
||||||
|
|
||||||
|
rel_dir = str(output_dir.relative_to(meetings_dir))
|
||||||
post_position = _postprocess_queue.qsize() + 1
|
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({
|
tasks[task_id].update({
|
||||||
"status": "postprocessing",
|
"status": "postprocessing",
|
||||||
"progress": 70,
|
"progress": 70,
|
||||||
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
||||||
"result": {
|
"result": result_data,
|
||||||
"docx": str(docx_path),
|
|
||||||
"md": str(md_path),
|
|
||||||
"dir": str(output_dir),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await _send_progress(
|
await _send_progress(
|
||||||
task_id, 70,
|
task_id, 70,
|
||||||
f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
||||||
"postprocessing",
|
"postprocessing",
|
||||||
result=tasks[task_id]["result"],
|
result=result_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
await _postprocess_queue.put({
|
await _postprocess_queue.put({
|
||||||
@ -154,10 +188,10 @@ async def process_transcription(file_path: Path, task_id: str):
|
|||||||
"output_dir": str(output_dir),
|
"output_dir": str(output_dir),
|
||||||
"stem": stem,
|
"stem": stem,
|
||||||
"segments_path": str(segments_path),
|
"segments_path": str(segments_path),
|
||||||
"docx_path": str(docx_path),
|
"org_slug": org_slug,
|
||||||
"md_path": str(md_path),
|
"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:
|
except Exception as e:
|
||||||
error_msg = str(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]):
|
async def process_postprocessing(job: Dict[str, Any]):
|
||||||
"""Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию."""
|
|
||||||
task_id = job["task_id"]
|
task_id = job["task_id"]
|
||||||
display_name = job["display_name"]
|
display_name = job["display_name"]
|
||||||
stem = job["stem"]
|
stem = job["stem"]
|
||||||
output_dir = Path(job["output_dir"])
|
output_dir = Path(job["output_dir"])
|
||||||
|
org_slug = job["org_slug"]
|
||||||
|
project = job["project_slug"]
|
||||||
|
|
||||||
summary_path = output_dir / f"{stem}_summary.md"
|
summary_path = output_dir / f"{stem}_summary.md"
|
||||||
txt_path = output_dir / f"{stem}.txt"
|
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")
|
await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing")
|
||||||
|
|
||||||
result_data = dict(tasks[task_id].get("result") or {})
|
result_data = dict(tasks[task_id].get("result") or {})
|
||||||
|
meetings_dir = org_meetings_dir(org_slug)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8"))
|
segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8"))
|
||||||
config = load_config()
|
config = load_config()
|
||||||
rag_cfg = config.get("rag", {})
|
rag_cfg = config.get("rag", {})
|
||||||
api_key, base_url = resolve_opencode_credentials(config)
|
api_key, base_url = resolve_opencode_credentials(config)
|
||||||
project = parse_project_from_filename(display_name)
|
|
||||||
meeting_text = build_meeting_text_only(segments)
|
meeting_text = build_meeting_text_only(segments)
|
||||||
metadata = _default_metadata(project, segments)
|
metadata = _default_metadata(project, segments)
|
||||||
|
|
||||||
@ -229,11 +264,7 @@ async def process_postprocessing(job: Dict[str, Any]):
|
|||||||
chunk_size=summary_chunk_size,
|
chunk_size=summary_chunk_size,
|
||||||
)
|
)
|
||||||
summary_md = format_summary_markdown(metadata, brief, display_name)
|
summary_md = format_summary_markdown(metadata, brief, display_name)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(summary_path.write_text, summary_md, encoding="utf-8")
|
||||||
summary_path.write_text,
|
|
||||||
summary_md,
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
result_data["summary"] = str(summary_path)
|
result_data["summary"] = str(summary_path)
|
||||||
|
|
||||||
doc_text = format_meeting_document(segments, metadata, display_name)
|
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["txt"] = str(txt_path)
|
||||||
result_data["metadata"] = metadata
|
result_data["metadata"] = metadata
|
||||||
result_data["project"] = project
|
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):
|
if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True):
|
||||||
await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing")
|
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)
|
global_doc_text = format_global_document(doc_text, metadata)
|
||||||
await index_meeting(
|
await index_meeting(
|
||||||
doc_text=doc_text,
|
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)
|
doc_text = format_meeting_document(segments, metadata, display_name)
|
||||||
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
|
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
|
||||||
result_data["txt"] = str(txt_path)
|
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)
|
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
|
||||||
tasks[task_id].update({
|
tasks[task_id].update({
|
||||||
@ -291,7 +324,6 @@ async def _transcribe_worker_loop(worker_id: int):
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
task_id, file_path = await _transcribe_queue.get()
|
task_id, file_path = await _transcribe_queue.get()
|
||||||
print(f"[Transcribe Worker {worker_id}] задача {task_id}")
|
|
||||||
await process_transcription(file_path, task_id)
|
await process_transcription(file_path, task_id)
|
||||||
_transcribe_queue.task_done()
|
_transcribe_queue.task_done()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@ -305,7 +337,6 @@ async def _postprocess_worker_loop(worker_id: int):
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
job = await _postprocess_queue.get()
|
job = await _postprocess_queue.get()
|
||||||
print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}")
|
|
||||||
await process_postprocessing(job)
|
await process_postprocessing(job)
|
||||||
_postprocess_queue.task_done()
|
_postprocess_queue.task_done()
|
||||||
except asyncio.CancelledError:
|
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):
|
def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1):
|
||||||
"""Запускает пулы воркеров транскрибации и post-processing."""
|
|
||||||
global _workers
|
global _workers
|
||||||
_workers.clear()
|
_workers.clear()
|
||||||
for i in range(transcribe_workers):
|
for i in range(transcribe_workers):
|
||||||
@ -330,10 +360,17 @@ def stop_workers():
|
|||||||
w.cancel()
|
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"
|
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_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)
|
task_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file_path = task_dir / safe_name
|
file_path = task_dir / safe_name
|
||||||
|
|
||||||
@ -346,6 +383,10 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
|
|||||||
"progress": 0,
|
"progress": 0,
|
||||||
"message": f"В очереди транскрибации (№{queue_position})",
|
"message": f"В очереди транскрибации (№{queue_position})",
|
||||||
"file": safe_name,
|
"file": safe_name,
|
||||||
|
"project_slug": slug,
|
||||||
|
"org_slug": user.org_slug,
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"username": user.username,
|
||||||
"queue_position": queue_position,
|
"queue_position": queue_position,
|
||||||
"result": None,
|
"result": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
@ -357,7 +398,9 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
|
|||||||
return task_id, file_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")
|
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")
|
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")
|
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]]:
|
def get_task_status(task_id: str, user: Optional[UserContext] = None) -> Optional[Dict[str, Any]]:
|
||||||
return tasks.get(task_id)
|
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())
|
return list(tasks.values())
|
||||||
|
|
||||||
|
|
||||||
@ -392,39 +442,84 @@ def _file_sort_key(file_info: Dict[str, Any]) -> tuple:
|
|||||||
return (4, name)
|
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 = []
|
tree = []
|
||||||
if not PROCESSED_DIR.exists():
|
meetings_dir = org_meetings_dir(user.org_slug)
|
||||||
|
if not meetings_dir.exists():
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
skip_names = {"lightrag_caches", "lightrag_caches_test"}
|
|
||||||
skip_suffixes = ("_segments.json",)
|
skip_suffixes = ("_segments.json",)
|
||||||
|
|
||||||
for item in sorted(PROCESSED_DIR.iterdir()):
|
for item in sorted(meetings_dir.iterdir()):
|
||||||
if not item.is_dir() or item.name in skip_names:
|
if not item.is_dir():
|
||||||
continue
|
continue
|
||||||
|
project_slug = _folder_project_slug(item.name, meetings_dir)
|
||||||
|
if project_slug and not user.can_access_project(project_slug):
|
||||||
|
continue
|
||||||
|
|
||||||
files = []
|
files = []
|
||||||
for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})):
|
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({
|
files.append({
|
||||||
"name": f.name,
|
"name": f.name,
|
||||||
"path": str(f.relative_to(PROCESSED_DIR)),
|
"path": str(f.relative_to(meetings_dir)),
|
||||||
"size": f.stat().st_size,
|
"size": f.stat().st_size,
|
||||||
"ext": f.suffix.lower(),
|
"ext": f.suffix.lower(),
|
||||||
"kind": "summary" if "_summary" in f.name else "protocol",
|
"kind": "summary" if "_summary" in f.name else "protocol",
|
||||||
})
|
})
|
||||||
tree.append({
|
tree.append({
|
||||||
"name": item.name,
|
"name": item.name,
|
||||||
"path": str(item.relative_to(PROCESSED_DIR)),
|
"path": str(item.relative_to(meetings_dir)),
|
||||||
|
"project": project_slug,
|
||||||
"files": files,
|
"files": files,
|
||||||
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
|
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
|
||||||
})
|
})
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
def read_file_content(rel_path: str) -> str:
|
def read_file_content(user: UserContext, rel_path: str) -> str:
|
||||||
full_path = PROCESSED_DIR / rel_path
|
full_path = resolve_meeting_path(user.org_slug, rel_path)
|
||||||
if not full_path.exists() or not full_path.is_file():
|
if not full_path.exists() or not full_path.is_file():
|
||||||
raise FileNotFoundError(f"Файл не найден: {rel_path}")
|
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:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
return f.read()
|
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)
|
||||||
|
|||||||
@ -10,21 +10,158 @@ class TranscriptionApp {
|
|||||||
this.renderTimeout = null;
|
this.renderTimeout = null;
|
||||||
this.chatHistory = [];
|
this.chatHistory = [];
|
||||||
this.chatProjects = [];
|
this.chatProjects = [];
|
||||||
|
this.uploadProjects = [];
|
||||||
this.isChatThinking = false;
|
this.isChatThinking = false;
|
||||||
|
this.user = null;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.connectWebSocket();
|
if (!Auth.requireAuth()) return;
|
||||||
this.setupUpload();
|
this.user = Auth.getUser();
|
||||||
this.setupChat();
|
this.setupUserBar();
|
||||||
this.loadChatHistory();
|
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 = '<p class="empty-state">Нет проектов. Создайте первый проект ниже.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = projects.map(p => {
|
||||||
|
const badge = p.scope === 'personal'
|
||||||
|
? '<span class="project-badge personal">мой</span>'
|
||||||
|
: '<span class="project-badge org">общий</span>';
|
||||||
|
const deleteBtn = p.is_owner
|
||||||
|
? `<button type="button" class="project-delete" data-slug="${this.escapeHtml(p.slug)}" title="Удалить">✕</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="project-card">
|
||||||
|
<div class="project-card-main">
|
||||||
|
<strong>${this.escapeHtml(p.name || p.slug)}</strong>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
<span class="project-slug">${this.escapeHtml(p.slug)}</span>
|
||||||
|
${deleteBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).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 =====
|
// ===== WebSocket =====
|
||||||
connectWebSocket() {
|
connectWebSocket() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
this.ws = new WebSocket(Auth.wsUrl('/ws'));
|
||||||
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
@ -38,7 +175,12 @@ class TranscriptionApp {
|
|||||||
this.handleWebSocketMessage(data);
|
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...');
|
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||||
setTimeout(() => this.connectWebSocket(), 3000);
|
setTimeout(() => this.connectWebSocket(), 3000);
|
||||||
};
|
};
|
||||||
@ -126,7 +268,14 @@ class TranscriptionApp {
|
|||||||
async handleFiles(files) {
|
async handleFiles(files) {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const project = document.getElementById('uploadProjectSelect')?.value;
|
||||||
|
if (!project) {
|
||||||
|
this.showToast('Выберите проект перед загрузкой', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append('project', project);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
formData.append('files', file);
|
formData.append('files', file);
|
||||||
}
|
}
|
||||||
@ -134,7 +283,7 @@ class TranscriptionApp {
|
|||||||
try {
|
try {
|
||||||
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
|
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
|
||||||
|
|
||||||
const response = await fetch('/upload-batch', {
|
const response = await Auth.apiFetch('/upload-batch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@ -273,7 +422,7 @@ class TranscriptionApp {
|
|||||||
const isDocx = file.ext === '.docx';
|
const isDocx = file.ext === '.docx';
|
||||||
const isTxt = file.ext === '.txt';
|
const isTxt = file.ext === '.txt';
|
||||||
const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎';
|
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';
|
const cssClass = isSummary ? 'file-item file-summary' : 'file-item';
|
||||||
|
|
||||||
if (isDocx) {
|
if (isDocx) {
|
||||||
@ -363,7 +512,7 @@ class TranscriptionApp {
|
|||||||
|
|
||||||
async deleteFolder(folderName, folderElement) {
|
async deleteFolder(folderName, folderElement) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/folders/${encodeURIComponent(folderName)}`, {
|
const response = await Auth.apiFetch(`/api/folders/${encodeURIComponent(folderName)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@ -390,7 +539,7 @@ class TranscriptionApp {
|
|||||||
|
|
||||||
if (ext === '.md' || ext === '.txt') {
|
if (ext === '.md' || ext === '.txt') {
|
||||||
try {
|
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();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@ -406,7 +555,7 @@ class TranscriptionApp {
|
|||||||
<div class="md-content">
|
<div class="md-content">
|
||||||
<div class="md-header">
|
<div class="md-header">
|
||||||
<span class="md-title">${this.escapeHtml(path)}</span>
|
<span class="md-title">${this.escapeHtml(path)}</span>
|
||||||
<a href="/api/files/download?path=${encodeURIComponent(path)}"
|
<a href="${this.downloadUrl(path)}"
|
||||||
class="btn-download" download>⬇️ Скачать</a>
|
class="btn-download" download>⬇️ Скачать</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="md-body">${bodyHtml}</div>
|
<div class="md-body">${bodyHtml}</div>
|
||||||
@ -493,20 +642,20 @@ class TranscriptionApp {
|
|||||||
|
|
||||||
async loadChatProjects() {
|
async loadChatProjects() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/rag/projects');
|
const response = await Auth.apiFetch('/api/rag/projects');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const select = document.getElementById('chatProjectSelect');
|
const projects = (result.projects || []).map(p => (
|
||||||
select.innerHTML = '<option value="">Все проекты</option>';
|
typeof p === 'string' ? { slug: p, name: p } : p
|
||||||
if (result.projects) {
|
));
|
||||||
result.projects.forEach(p => {
|
this.populateProjectSelect('chatProjectSelect', projects, true);
|
||||||
const opt = document.createElement('option');
|
const hint = document.getElementById('chatHint');
|
||||||
opt.value = p;
|
if (hint) {
|
||||||
opt.textContent = p;
|
hint.textContent = this.user?.all_projects_access
|
||||||
select.appendChild(opt);
|
? 'Выберите проект или «Все проекты» для поиска по организации.'
|
||||||
});
|
: 'Выберите проект, к которому у вас есть доступ.';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 select = document.getElementById('chatProjectSelect');
|
||||||
const project = select.value;
|
const project = select.value;
|
||||||
|
|
||||||
|
if (!project && !this.user?.all_projects_access) {
|
||||||
|
this.showToast('Выберите проект для поиска', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
this.addChatBubble('user', question);
|
this.addChatBubble('user', question);
|
||||||
this.setChatThinking(true);
|
this.setChatThinking(true);
|
||||||
@ -629,6 +783,131 @@ class TranscriptionApp {
|
|||||||
this.chatHistory = [];
|
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 `
|
||||||
|
<div class="admin-list-item">
|
||||||
|
<strong>${this.escapeHtml(u.username)}</strong>
|
||||||
|
<span>${roleLabel}</span>
|
||||||
|
<span>${this.escapeHtml(projectsLabel)}</span>
|
||||||
|
</div>
|
||||||
|
`}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsEl = document.getElementById('adminProjectsList');
|
||||||
|
if (projectsEl) {
|
||||||
|
projectsEl.innerHTML = (projectsData.projects || []).map(p => `
|
||||||
|
<div class="admin-list-item">
|
||||||
|
<strong>${this.escapeHtml(p.slug)}</strong>
|
||||||
|
<span>${this.escapeHtml(p.name)}</span>
|
||||||
|
<span>${p.scope === 'personal' ? 'личный' : 'org'}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Admin refresh failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
|
|||||||
106
backend/static/auth.js
Normal file
106
backend/static/auth.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -6,20 +6,48 @@
|
|||||||
<title>Транскрибация совещаний</title>
|
<title>Транскрибация совещаний</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>">
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
<!-- Markdown renderer -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
|
||||||
|
<script src="/static/auth.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>🎙️ Транскрибация совещаний</h1>
|
<div class="header-top">
|
||||||
<p class="subtitle">Загрузите аудио или видео файл для получения протокола</p>
|
<div>
|
||||||
|
<h1>🎙️ Транскрибация совещаний</h1>
|
||||||
|
<p class="subtitle">Загрузите аудио или видео файл для получения протокола</p>
|
||||||
|
</div>
|
||||||
|
<div class="user-bar" id="userBar">
|
||||||
|
<span id="userInfo"></span>
|
||||||
|
<button type="button" id="logoutBtn" class="btn-secondary">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<!-- Upload Section -->
|
<section class="workspace-section" id="workspaceSection">
|
||||||
|
<div class="workspace-header">
|
||||||
|
<h2>📂 Мои проекты</h2>
|
||||||
|
<p class="workspace-hint">Создайте личный проект и работайте в нём: загрузка, протоколы, RAG-поиск.</p>
|
||||||
|
</div>
|
||||||
|
<div class="projects-panel">
|
||||||
|
<div id="myProjectsList" class="projects-list"></div>
|
||||||
|
<form id="createMyProjectForm" class="project-create-form">
|
||||||
|
<input type="text" id="myProjectSlug" placeholder="slug (например: gp-2026)" required>
|
||||||
|
<input type="text" id="myProjectName" placeholder="Название проекта" required>
|
||||||
|
<button type="submit">Создать проект</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="upload-section" id="uploadSection">
|
<section class="upload-section" id="uploadSection">
|
||||||
|
<div class="upload-controls">
|
||||||
|
<label>
|
||||||
|
Проект
|
||||||
|
<select id="uploadProjectSelect" required></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="drop-zone" id="dropZone">
|
<div class="drop-zone" id="dropZone">
|
||||||
<div class="drop-zone-content">
|
<div class="drop-zone-content">
|
||||||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -33,7 +61,6 @@
|
|||||||
<input type="file" id="fileInput" multiple accept=".mp4,.webm,.avi,.mkv,.mov,.wav,.mp3,.m4a,.ogg,.flac" hidden>
|
<input type="file" id="fileInput" multiple accept=".mp4,.webm,.avi,.mkv,.mov,.wav,.mp3,.m4a,.ogg,.flac" hidden>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Queue Status -->
|
|
||||||
<div class="queue-status" id="queueStatus">
|
<div class="queue-status" id="queueStatus">
|
||||||
<div class="queue-status-header">
|
<div class="queue-status-header">
|
||||||
<h3>Очередь обработки</h3>
|
<h3>Очередь обработки</h3>
|
||||||
@ -45,7 +72,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Results Section -->
|
|
||||||
<section class="results-section">
|
<section class="results-section">
|
||||||
<div class="panel-left">
|
<div class="panel-left">
|
||||||
<h2>📁 Файлы</h2>
|
<h2>📁 Файлы</h2>
|
||||||
@ -64,21 +90,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Chat Section -->
|
|
||||||
<section class="chat-section" id="chatSection">
|
<section class="chat-section" id="chatSection">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<h2>🤖 Чат с базой знаний</h2>
|
<h2>🤖 Чат с базой знаний</h2>
|
||||||
<div class="chat-controls">
|
<div class="chat-controls">
|
||||||
<select id="chatProjectSelect">
|
<select id="chatProjectSelect"></select>
|
||||||
<option value="">Все проекты</option>
|
|
||||||
</select>
|
|
||||||
<button id="chatClearBtn" title="Очистить историю">🗑️</button>
|
<button id="chatClearBtn" title="Очистить историю">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-messages" id="chatMessages">
|
<div class="chat-messages" id="chatMessages">
|
||||||
<div class="chat-welcome">
|
<div class="chat-welcome">
|
||||||
<p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p>
|
<p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p>
|
||||||
<p class="chat-hint">Выберите проект или оставьте «Все проекты» для глобального поиска.</p>
|
<p class="chat-hint" id="chatHint">Выберите проект для поиска.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input-row">
|
<div class="chat-input-row">
|
||||||
@ -86,12 +109,41 @@
|
|||||||
<button id="chatSendBtn">Отправить</button>
|
<button id="chatSendBtn">Отправить</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section" id="adminSection" hidden>
|
||||||
|
<h2>⚙️ Администрирование</h2>
|
||||||
|
<div class="admin-grid">
|
||||||
|
<div class="admin-card">
|
||||||
|
<h3>Пользователи</h3>
|
||||||
|
<div id="adminUsersList" class="admin-list"></div>
|
||||||
|
<form id="createUserForm" class="admin-form">
|
||||||
|
<input type="text" id="newUsername" placeholder="Логин" required>
|
||||||
|
<input type="password" id="newPassword" placeholder="Пароль" required>
|
||||||
|
<select id="newUserRole">
|
||||||
|
<option value="user">user — выбранные проекты</option>
|
||||||
|
<option value="director">director — все проекты и поиск</option>
|
||||||
|
<option value="admin">admin — полный доступ</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="newUserProjects" placeholder="Общие org-проекты через запятую">
|
||||||
|
<p class="admin-hint" id="newUserProjectsHint"></p>
|
||||||
|
<button type="submit">Создать</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card">
|
||||||
|
<h3>Проекты организации</h3>
|
||||||
|
<div id="adminProjectsList" class="admin-list"></div>
|
||||||
|
<form id="createProjectForm" class="admin-form">
|
||||||
|
<input type="text" id="newProjectSlug" placeholder="slug (2026)" required>
|
||||||
|
<input type="text" id="newProjectName" placeholder="Название" required>
|
||||||
|
<button type="submit">Добавить проект</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
|
||||||
<div class="toast-container" id="toastContainer"></div>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
32
backend/static/login.html
Normal file
32
backend/static/login.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Вход — Транскрибация</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>🎙️ Transcriba</h1>
|
||||||
|
<p class="login-subtitle">Вход в рабочее пространство организации</p>
|
||||||
|
<form id="loginForm">
|
||||||
|
<label>
|
||||||
|
Организация
|
||||||
|
<input type="text" id="orgSlug" value="merakom" autocomplete="organization" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Логин
|
||||||
|
<input type="text" id="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Пароль
|
||||||
|
<input type="password" id="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" id="loginBtn">Войти</button>
|
||||||
|
<p class="login-error" id="loginError" hidden></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="/static/auth.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -750,3 +750,289 @@ header h1 {
|
|||||||
gap: 10px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
16
config.yaml
16
config.yaml
@ -71,6 +71,22 @@ queue:
|
|||||||
transcribe_workers: 2
|
transcribe_workers: 2
|
||||||
postprocess_workers: 1
|
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:
|
paths:
|
||||||
output_dir: ./output
|
output_dir: ./output
|
||||||
|
|||||||
@ -16,7 +16,8 @@ services:
|
|||||||
- OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1}
|
- OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1}
|
||||||
- HF_HOME=/root/.cache/huggingface
|
- HF_HOME=/root/.cache/huggingface
|
||||||
- NLTK_DATA=/root/nltk_data
|
- NLTK_DATA=/root/nltk_data
|
||||||
- TRANSFORMERS_OFFLINE=0
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
|
- AUTH_ADMIN_PASSWORD=${AUTH_ADMIN_PASSWORD:-admin123}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
- processed:/app/processed
|
- processed:/app/processed
|
||||||
@ -27,11 +28,12 @@ services:
|
|||||||
- ./scripts:/app/scripts:ro
|
- ./scripts:/app/scripts:ro
|
||||||
- ./models/huggingface:/root/.cache/huggingface
|
- ./models/huggingface:/root/.cache/huggingface
|
||||||
- ./models/nltk_data:/root/nltk_data:ro
|
- ./models/nltk_data:/root/nltk_data:ro
|
||||||
|
- data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
entrypoint: ["uvicorn"]
|
entrypoint: ["uvicorn"]
|
||||||
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/files"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@ -41,3 +43,4 @@ volumes:
|
|||||||
uploads:
|
uploads:
|
||||||
processed:
|
processed:
|
||||||
tmp:
|
tmp:
|
||||||
|
data:
|
||||||
|
|||||||
@ -6,6 +6,10 @@ python-docx
|
|||||||
pyyaml
|
pyyaml
|
||||||
whisperx
|
whisperx
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
bcrypt>=4.0.0
|
||||||
|
python-jose[cryptography]
|
||||||
|
|
||||||
# RAG / LightRAG
|
# RAG / LightRAG
|
||||||
lightrag-hku>=1.4.0
|
lightrag-hku>=1.4.0
|
||||||
openai>=1.0.0
|
openai>=1.0.0
|
||||||
|
|||||||
170
tests/test_auth.py
Normal file
170
tests/test_auth.py
Normal file
@ -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()
|
||||||
Loading…
Reference in New Issue
Block a user