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.log
|
||||
*.log
|
||||
|
||||
# Auth database
|
||||
data/
|
||||
|
||||
@ -6,4 +6,6 @@ RUN pip install --no-cache-dir --timeout 300 \
|
||||
lightrag-hku>=1.4.0 \
|
||||
openai>=1.0.0 \
|
||||
python-dotenv>=1.0.0 \
|
||||
sentence-transformers>=3.0.0
|
||||
sentence-transformers>=3.0.0 \
|
||||
bcrypt>=4.0.0 \
|
||||
"python-jose[cryptography]"
|
||||
|
||||
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 для сервиса транскрибации."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from backend.auth.deps import get_current_user, get_user_from_token, require_admin
|
||||
from backend.auth.models import UserContext
|
||||
from backend.auth.routes import admin_router, router as auth_router
|
||||
from backend.auth import database as auth_db
|
||||
from backend.auth.service import ensure_project_access, list_accessible_projects
|
||||
from backend.paths import org_meetings_dir, org_rag_index_dir, resolve_meeting_path
|
||||
from backend.queue import (
|
||||
UPLOAD_DIR,
|
||||
PROCESSED_DIR,
|
||||
save_upload,
|
||||
delete_folder,
|
||||
get_all_tasks,
|
||||
get_download_path,
|
||||
get_processed_tree,
|
||||
get_queue_info,
|
||||
get_task_status,
|
||||
get_processed_tree,
|
||||
read_file_content,
|
||||
save_upload,
|
||||
set_progress_callback,
|
||||
start_workers,
|
||||
stop_workers,
|
||||
)
|
||||
|
||||
# Добавляем корень проекта в путь, чтобы импортировать src.rag
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from src.config import load_config, resolve_opencode_credentials
|
||||
from src.rag.indexer import get_project_names
|
||||
from src.rag.parser import parse_project_from_filename
|
||||
from src.rag.query import rag_chat, retrieve_context
|
||||
from src.rag.formatter import format_global_document
|
||||
from src.rag.indexer import get_project_names, index_meeting
|
||||
from src.rag.query import rag_chat
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
# WebSocket менеджер
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
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()
|
||||
self.active_connections.append(websocket)
|
||||
self.active_connections.append((websocket, user))
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
self.active_connections = [(ws, u) for ws, u in self.active_connections if ws is not websocket]
|
||||
|
||||
def set_user(self, websocket: WebSocket, user: UserContext):
|
||||
self.active_connections = [(ws, user if ws is websocket else u) for ws, u in self.active_connections]
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for conn in self.active_connections:
|
||||
for conn, _user in self.active_connections:
|
||||
try:
|
||||
await conn.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
# Устанавливаем callback для отправки прогресса через WebSocket
|
||||
manager = ConnectionManager()
|
||||
set_progress_callback(manager.broadcast)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Управление жизненным циклом приложения."""
|
||||
config = load_config()
|
||||
auth_db.init_db(config)
|
||||
auth_db.bootstrap_from_config(config)
|
||||
org_slug = config.get("auth", {}).get("bootstrap", {}).get("org_slug", "merakom")
|
||||
auth_db.migrate_legacy_data(org_slug)
|
||||
|
||||
queue_cfg = config.get("queue", {})
|
||||
transcribe_workers = int(queue_cfg.get("transcribe_workers", 2))
|
||||
postprocess_workers = int(queue_cfg.get("postprocess_workers", 1))
|
||||
print("🚀 Запуск рабочих процессов...")
|
||||
start_workers(
|
||||
transcribe_workers=transcribe_workers,
|
||||
postprocess_workers=postprocess_workers,
|
||||
)
|
||||
start_workers(transcribe_workers=transcribe_workers, postprocess_workers=postprocess_workers)
|
||||
yield
|
||||
print("🛑 Остановка рабочих процессов...")
|
||||
stop_workers()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Transcription Service",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app = FastAPI(title="Transcription Service", version="2.0.0", lifespan=lifespan)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@ -91,84 +94,132 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
def _org_index_dir(user: UserContext) -> Path:
|
||||
return org_rag_index_dir(user.org_slug)
|
||||
|
||||
|
||||
async def _list_rag_project_slugs(user: UserContext) -> List[str]:
|
||||
projects = await get_project_names(_org_index_dir(user))
|
||||
return user.filter_projects(projects)
|
||||
|
||||
|
||||
async def _rag_chat_for_user(user: UserContext, question: str, history: list, project_name: Optional[str], mode: str):
|
||||
if project_name:
|
||||
ensure_project_access(user, project_name)
|
||||
elif not user.can_global_search():
|
||||
raise HTTPException(status_code=403, detail="Глобальный поиск доступен только администратору")
|
||||
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
return await rag_chat(
|
||||
question=question,
|
||||
working_dir_base=_org_index_dir(user),
|
||||
history=history,
|
||||
api_key=api_key,
|
||||
project_name=project_name,
|
||||
base_url=base_url,
|
||||
chat_model=rag_cfg.get("chat_model", "deepseek-v4-flash-free"),
|
||||
mode=mode,
|
||||
index_model=rag_cfg.get("index_model", "mimo-v2.5-free"),
|
||||
)
|
||||
|
||||
# === API Endpoints ===
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
"""Главная страница."""
|
||||
index_path = Path(__file__).parent / "static" / "index.html"
|
||||
index_path = STATIC_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return index_path.read_text(encoding="utf-8")
|
||||
return "<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")
|
||||
async def upload_file(file: UploadFile = File(...)):
|
||||
"""Загружает файл и добавляет в очередь обработки."""
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
project: str = Form(...),
|
||||
user: UserContext = Depends(get_current_user),
|
||||
):
|
||||
content = await file.read()
|
||||
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
||||
try:
|
||||
task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"file": file.filename,
|
||||
"project": project,
|
||||
"status": "queued",
|
||||
"message": "Файл добавлен в очередь обработки",
|
||||
"queue": get_queue_info(),
|
||||
"queue": get_queue_info(user),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/upload-batch")
|
||||
async def upload_batch(files: List[UploadFile] = File(...)):
|
||||
"""Загружает несколько файлов пакетно — все ставятся в очередь."""
|
||||
async def upload_batch(
|
||||
files: List[UploadFile] = File(...),
|
||||
project: str = Form(...),
|
||||
user: UserContext = Depends(get_current_user),
|
||||
):
|
||||
if not files:
|
||||
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
|
||||
|
||||
results = []
|
||||
for file in files:
|
||||
content = await file.read()
|
||||
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
||||
results.append({
|
||||
"task_id": task_id,
|
||||
"file": file.filename,
|
||||
"status": "queued",
|
||||
})
|
||||
try:
|
||||
task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
results.append({"task_id": task_id, "file": file.filename, "project": project, "status": "queued"})
|
||||
|
||||
return {
|
||||
"uploaded": len(results),
|
||||
"tasks": results,
|
||||
"queue": get_queue_info(),
|
||||
"queue": get_queue_info(user),
|
||||
"message": f"{len(results)} файл(ов) добавлено в очередь",
|
||||
}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket для получения прогресса обработки."""
|
||||
await manager.connect(websocket)
|
||||
token = websocket.query_params.get("token")
|
||||
user = get_user_from_token(token)
|
||||
if not user:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
await manager.connect(websocket, user)
|
||||
try:
|
||||
while True:
|
||||
# Ждём сообщения от клиента (ping/keepalive)
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
|
||||
if msg.get("action") == "get_tasks":
|
||||
tasks = get_all_tasks()
|
||||
await websocket.send_json({
|
||||
"type": "tasks_list",
|
||||
"tasks": tasks,
|
||||
"queue": get_queue_info(),
|
||||
"tasks": get_all_tasks(user),
|
||||
"queue": get_queue_info(user),
|
||||
})
|
||||
elif msg.get("action") == "get_tree":
|
||||
tree = get_processed_tree()
|
||||
await websocket.send_json({
|
||||
"type": "file_tree",
|
||||
"tree": tree,
|
||||
"tree": get_processed_tree(user),
|
||||
})
|
||||
elif msg.get("action") == "rag_query":
|
||||
await _handle_rag_query(websocket, msg)
|
||||
elif msg.get("action") == "rag_query_global":
|
||||
await _handle_rag_query(websocket, msg)
|
||||
elif msg.get("action") in ("rag_query", "rag_query_global"):
|
||||
await _handle_rag_query_ws(websocket, msg, user)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
except Exception:
|
||||
@ -176,202 +227,126 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
async def api_tasks():
|
||||
"""Возвращает список всех задач."""
|
||||
return {"tasks": get_all_tasks(), "queue": get_queue_info()}
|
||||
async def api_tasks(user: UserContext = Depends(get_current_user)):
|
||||
return {"tasks": get_all_tasks(user), "queue": get_queue_info(user)}
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}")
|
||||
async def api_task(task_id: str):
|
||||
"""Возвращает статус конкретной задачи."""
|
||||
status = get_task_status(task_id)
|
||||
async def api_task(task_id: str, user: UserContext = Depends(get_current_user)):
|
||||
status = get_task_status(task_id, user)
|
||||
if not status:
|
||||
return {"error": "Task not found"}
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/files")
|
||||
async def api_files():
|
||||
"""Возвращает дерево обработанных файлов."""
|
||||
return {"tree": get_processed_tree()}
|
||||
async def api_files(user: UserContext = Depends(get_current_user)):
|
||||
return {"tree": get_processed_tree(user)}
|
||||
|
||||
|
||||
@app.get("/api/files/content")
|
||||
async def api_file_content(path: str):
|
||||
"""Возвращает содержимое файла."""
|
||||
async def api_file_content(path: str, user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
content = read_file_content(path)
|
||||
content = read_file_content(user, path)
|
||||
return {"content": content, "path": path}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/files/download")
|
||||
async def api_download(path: str):
|
||||
"""Скачивает файл."""
|
||||
file_path = PROCESSED_DIR / path
|
||||
if not file_path.exists():
|
||||
return {"error": "File not found"}
|
||||
return FileResponse(file_path, filename=file_path.name)
|
||||
|
||||
|
||||
import shutil
|
||||
|
||||
@app.delete("/api/folders/{folder_name}")
|
||||
async def api_delete_folder(folder_name: str):
|
||||
"""Удаляет папку с обработанными файлами."""
|
||||
folder_path = PROCESSED_DIR / folder_name
|
||||
if not folder_path.exists():
|
||||
return {"error": "Folder not found"}
|
||||
async def api_download(path: str, user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
shutil.rmtree(folder_path)
|
||||
return {"deleted": folder_name}
|
||||
file_path = get_download_path(user, path)
|
||||
return FileResponse(file_path, filename=file_path.name)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# === RAG / Chat API ===
|
||||
@app.delete("/api/folders/{folder_name:path}")
|
||||
async def api_delete_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
delete_folder(user, folder_name)
|
||||
return {"deleted": folder_name}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/rag/projects")
|
||||
async def api_rag_projects():
|
||||
"""Возвращает список проектов с RAG-индексами."""
|
||||
async def api_rag_projects(user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
projects = await get_project_names(index_dir)
|
||||
return {"projects": projects}
|
||||
db_projects = {p["slug"] for p in list_accessible_projects(user)}
|
||||
indexed = await _list_rag_project_slugs(user)
|
||||
merged = sorted(db_projects | set(indexed))
|
||||
if not user.is_admin:
|
||||
merged = user.filter_projects(merged)
|
||||
return {"projects": merged}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "projects": []}
|
||||
|
||||
|
||||
@app.post("/api/rag/query")
|
||||
async def api_rag_query(payload: dict):
|
||||
"""Запрос к чат-боту по конкретному проекту."""
|
||||
async def api_rag_query(payload: dict, user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
mode = payload.get("mode", "hybrid")
|
||||
|
||||
result = await rag_chat(
|
||||
question=payload.get("question", ""),
|
||||
working_dir_base=index_dir,
|
||||
history=payload.get("history", []),
|
||||
api_key=api_key,
|
||||
project_name=payload.get("project"),
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode=mode,
|
||||
index_model=index_model,
|
||||
result = await _rag_chat_for_user(
|
||||
user,
|
||||
payload.get("question", ""),
|
||||
payload.get("history", []),
|
||||
payload.get("project"),
|
||||
payload.get("mode", "hybrid"),
|
||||
)
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"context": result["context"],
|
||||
"project": result["project"],
|
||||
}
|
||||
return {"answer": result["answer"], "context": result["context"], "project": result["project"]}
|
||||
except HTTPException:
|
||||
raise
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/rag/query-global")
|
||||
async def api_rag_query_global(payload: dict):
|
||||
"""Глобальный запрос ко всем проектам."""
|
||||
async def api_rag_query_global(payload: dict, user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
mode = payload.get("mode", "hybrid")
|
||||
|
||||
result = await rag_chat(
|
||||
question=payload.get("question", ""),
|
||||
working_dir_base=index_dir,
|
||||
history=payload.get("history", []),
|
||||
api_key=api_key,
|
||||
project_name=None,
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode=mode,
|
||||
index_model=index_model,
|
||||
result = await _rag_chat_for_user(
|
||||
user,
|
||||
payload.get("question", ""),
|
||||
payload.get("history", []),
|
||||
None,
|
||||
payload.get("mode", "hybrid"),
|
||||
)
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"context": result["context"],
|
||||
"project": None,
|
||||
}
|
||||
return {"answer": result["answer"], "context": result["context"], "project": None}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/rag/tasks")
|
||||
async def api_rag_tasks(project: Optional[str] = None):
|
||||
"""Возвращает action items из RAG."""
|
||||
@app.post("/api/rag/index/{folder_name:path}")
|
||||
async def api_rag_index_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
|
||||
question = "Перечисли все action items, задачи и ответственных из протоколов."
|
||||
if project:
|
||||
question = f"Перечисли все action items, задачи и ответственных по проекту {project}."
|
||||
|
||||
result = await rag_chat(
|
||||
question=question,
|
||||
working_dir_base=index_dir,
|
||||
history=[],
|
||||
api_key=api_key,
|
||||
project_name=project,
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode="hybrid",
|
||||
index_model=index_model,
|
||||
)
|
||||
return {
|
||||
"tasks": result["answer"],
|
||||
"project": project,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/rag/index/{folder_name}")
|
||||
async def api_rag_index_folder(folder_name: str):
|
||||
"""Принудительная переиндексация папки с обработанным совещанием."""
|
||||
try:
|
||||
folder_path = PROCESSED_DIR / folder_name
|
||||
folder_path = resolve_meeting_path(user.org_slug, folder_name)
|
||||
if not folder_path.exists():
|
||||
return {"error": "Folder not found"}
|
||||
|
||||
# Ищем .txt файл с протоколом
|
||||
from backend.queue import _folder_project_slug
|
||||
project = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug))
|
||||
if not project:
|
||||
return {"error": "Project metadata not found"}
|
||||
ensure_project_access(user, project)
|
||||
|
||||
txt_files = list(folder_path.glob("*.txt"))
|
||||
if not txt_files:
|
||||
return {"error": "No .txt protocol found in folder"}
|
||||
|
||||
txt_path = txt_files[0]
|
||||
doc_text = txt_path.read_text(encoding="utf-8")
|
||||
|
||||
# Определяем проект из имени папки
|
||||
project = parse_project_from_filename(folder_name)
|
||||
|
||||
doc_text = txt_files[0].read_text(encoding="utf-8")
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
|
||||
from src.rag.indexer import index_meeting
|
||||
from src.rag.formatter import format_global_document
|
||||
|
||||
# Для переиндексации используем простую заглушку метаданных
|
||||
metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"}
|
||||
global_doc_text = format_global_document(doc_text, metadata)
|
||||
|
||||
@ -379,54 +354,47 @@ async def api_rag_index_folder(folder_name: str):
|
||||
doc_text=doc_text,
|
||||
global_doc_text=global_doc_text,
|
||||
project_name=project,
|
||||
working_dir_base=index_dir,
|
||||
model=index_model,
|
||||
working_dir_base=_org_index_dir(user),
|
||||
model=rag_cfg.get("index_model", "mimo-v2.5-free"),
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
return {"indexed": folder_name, "project": project}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# === WebSocket Chat Actions ===
|
||||
|
||||
async def _handle_rag_query(websocket: WebSocket, msg: dict):
|
||||
"""Обрабатывает rag_query через WebSocket."""
|
||||
async def _handle_rag_query_ws(websocket: WebSocket, msg: dict, user: UserContext):
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
mode = msg.get("mode", "hybrid")
|
||||
|
||||
result = await rag_chat(
|
||||
question=msg.get("question", ""),
|
||||
working_dir_base=index_dir,
|
||||
history=msg.get("history", []),
|
||||
api_key=api_key,
|
||||
project_name=msg.get("project"),
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode=mode,
|
||||
index_model=index_model,
|
||||
project = msg.get("project")
|
||||
if msg.get("action") == "rag_query_global":
|
||||
project = None
|
||||
result = await _rag_chat_for_user(
|
||||
user,
|
||||
msg.get("question", ""),
|
||||
msg.get("history", []),
|
||||
project,
|
||||
msg.get("mode", "hybrid"),
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "rag_response",
|
||||
"answer": result["answer"],
|
||||
"context": result["context"],
|
||||
"project": result["project"],
|
||||
})
|
||||
except HTTPException as e:
|
||||
await websocket.send_json({"type": "rag_error", "error": e.detail})
|
||||
except PermissionError as e:
|
||||
await websocket.send_json({"type": "rag_error", "error": str(e)})
|
||||
except Exception as e:
|
||||
await websocket.send_json({
|
||||
"type": "rag_error",
|
||||
"error": str(e),
|
||||
})
|
||||
await websocket.send_json({"type": "rag_error", "error": str(e)})
|
||||
|
||||
|
||||
# Статические файлы
|
||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
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 json
|
||||
@ -11,6 +11,9 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.auth.models import UserContext
|
||||
from backend.auth.service import ensure_project_access
|
||||
from backend.paths import org_meetings_dir, org_rag_index_dir, org_upload_dir, resolve_meeting_path, write_folder_project_meta
|
||||
from src.audio_utils import prepare_audio_input
|
||||
from src.config import load_config, resolve_opencode_credentials
|
||||
from src.document import build_document
|
||||
@ -22,13 +25,7 @@ from src.rag.formatter import (
|
||||
format_summary_markdown,
|
||||
)
|
||||
from src.rag.indexer import index_meeting
|
||||
from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename
|
||||
|
||||
|
||||
UPLOAD_DIR = Path("uploads")
|
||||
PROCESSED_DIR = Path("processed")
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
PROCESSED_DIR.mkdir(exist_ok=True)
|
||||
from src.rag.parser import classify_meeting, generate_meeting_brief
|
||||
|
||||
tasks: Dict[str, Dict[str, Any]] = {}
|
||||
_progress_callback: Optional[Callable] = None
|
||||
@ -42,6 +39,33 @@ def set_progress_callback(callback: Callable):
|
||||
_progress_callback = callback
|
||||
|
||||
|
||||
def _task_visible(task: Dict[str, Any], user: UserContext) -> bool:
|
||||
if task.get("org_slug") != user.org_slug:
|
||||
return False
|
||||
if user.has_all_projects_access:
|
||||
return True
|
||||
return task.get("user_id") == user.user_id
|
||||
|
||||
|
||||
def _filter_tasks_for_user(user: UserContext) -> List[Dict[str, Any]]:
|
||||
return [t for t in tasks.values() if _task_visible(t, user)]
|
||||
|
||||
|
||||
def _filter_queue_info(user: UserContext) -> Dict[str, Any]:
|
||||
visible = _filter_tasks_for_user(user)
|
||||
queued = sum(1 for t in visible if t.get("status") == "queued")
|
||||
processing = sum(1 for t in visible if t.get("status") == "processing")
|
||||
postprocessing = sum(1 for t in visible if t.get("status") == "postprocessing")
|
||||
return {
|
||||
"queued": queued,
|
||||
"processing": processing,
|
||||
"postprocessing": postprocessing,
|
||||
"pending_transcribe": _transcribe_queue.qsize(),
|
||||
"pending_postprocess": _postprocess_queue.qsize(),
|
||||
"pending_in_queue": _transcribe_queue.qsize(),
|
||||
}
|
||||
|
||||
|
||||
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
|
||||
if _progress_callback:
|
||||
try:
|
||||
@ -52,6 +76,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
|
||||
"message": message,
|
||||
"status": status,
|
||||
"file": task_info.get("file", ""),
|
||||
"project": task_info.get("project_slug", ""),
|
||||
"queue_position": task_info.get("queue_position"),
|
||||
"result": result,
|
||||
"error": error,
|
||||
@ -64,7 +89,7 @@ def _cleanup_upload(file_path: Path):
|
||||
if not file_path.exists():
|
||||
return
|
||||
parent = file_path.parent
|
||||
if parent != UPLOAD_DIR and parent.name.startswith("task_"):
|
||||
if parent.name.startswith("task_"):
|
||||
shutil.rmtree(parent, ignore_errors=True)
|
||||
else:
|
||||
file_path.unlink(missing_ok=True)
|
||||
@ -85,8 +110,12 @@ def _default_metadata(project: str, segments: List[Dict[str, Any]]) -> Dict[str,
|
||||
|
||||
|
||||
async def process_transcription(file_path: Path, task_id: str):
|
||||
"""Этап 1: WhisperX + docx/md. После — в очередь summary/RAG."""
|
||||
display_name = tasks.get(task_id, {}).get("file", file_path.name)
|
||||
task = tasks.get(task_id, {})
|
||||
display_name = task.get("file", file_path.name)
|
||||
org_slug = task["org_slug"]
|
||||
project_slug = task["project_slug"]
|
||||
meetings_dir = org_meetings_dir(org_slug)
|
||||
|
||||
tasks[task_id].update({
|
||||
"status": "processing",
|
||||
"progress": 0,
|
||||
@ -113,8 +142,9 @@ async def process_transcription(file_path: Path, task_id: str):
|
||||
stem = Path(display_name).stem
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
folder_name = f"{stem}_{timestamp}"
|
||||
output_dir = PROCESSED_DIR / folder_name
|
||||
output_dir = meetings_dir / folder_name
|
||||
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(write_folder_project_meta, output_dir, project_slug)
|
||||
|
||||
docx_path = output_dir / f"{stem}.docx"
|
||||
md_path = output_dir / f"{stem}.md"
|
||||
@ -130,22 +160,26 @@ async def process_transcription(file_path: Path, task_id: str):
|
||||
|
||||
await asyncio.to_thread(_cleanup_upload, file_path)
|
||||
|
||||
rel_dir = str(output_dir.relative_to(meetings_dir))
|
||||
post_position = _postprocess_queue.qsize() + 1
|
||||
result_data = {
|
||||
"docx": str(docx_path),
|
||||
"md": str(md_path),
|
||||
"dir": str(output_dir),
|
||||
"rel_dir": rel_dir,
|
||||
"project": project_slug,
|
||||
}
|
||||
tasks[task_id].update({
|
||||
"status": "postprocessing",
|
||||
"progress": 70,
|
||||
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
||||
"result": {
|
||||
"docx": str(docx_path),
|
||||
"md": str(md_path),
|
||||
"dir": str(output_dir),
|
||||
},
|
||||
"result": result_data,
|
||||
})
|
||||
await _send_progress(
|
||||
task_id, 70,
|
||||
f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
||||
"postprocessing",
|
||||
result=tasks[task_id]["result"],
|
||||
result=result_data,
|
||||
)
|
||||
|
||||
await _postprocess_queue.put({
|
||||
@ -154,10 +188,10 @@ async def process_transcription(file_path: Path, task_id: str):
|
||||
"output_dir": str(output_dir),
|
||||
"stem": stem,
|
||||
"segments_path": str(segments_path),
|
||||
"docx_path": str(docx_path),
|
||||
"md_path": str(md_path),
|
||||
"org_slug": org_slug,
|
||||
"project_slug": project_slug,
|
||||
})
|
||||
print(f"[Transcribe] Задача {task_id} передана в post-processing")
|
||||
print(f"[Transcribe] {task_id} -> post-processing (project={project_slug})")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
@ -172,11 +206,12 @@ async def process_transcription(file_path: Path, task_id: str):
|
||||
|
||||
|
||||
async def process_postprocessing(job: Dict[str, Any]):
|
||||
"""Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию."""
|
||||
task_id = job["task_id"]
|
||||
display_name = job["display_name"]
|
||||
stem = job["stem"]
|
||||
output_dir = Path(job["output_dir"])
|
||||
org_slug = job["org_slug"]
|
||||
project = job["project_slug"]
|
||||
|
||||
summary_path = output_dir / f"{stem}_summary.md"
|
||||
txt_path = output_dir / f"{stem}.txt"
|
||||
@ -188,13 +223,13 @@ async def process_postprocessing(job: Dict[str, Any]):
|
||||
await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing")
|
||||
|
||||
result_data = dict(tasks[task_id].get("result") or {})
|
||||
meetings_dir = org_meetings_dir(org_slug)
|
||||
|
||||
try:
|
||||
segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8"))
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
project = parse_project_from_filename(display_name)
|
||||
meeting_text = build_meeting_text_only(segments)
|
||||
metadata = _default_metadata(project, segments)
|
||||
|
||||
@ -229,11 +264,7 @@ async def process_postprocessing(job: Dict[str, Any]):
|
||||
chunk_size=summary_chunk_size,
|
||||
)
|
||||
summary_md = format_summary_markdown(metadata, brief, display_name)
|
||||
await asyncio.to_thread(
|
||||
summary_path.write_text,
|
||||
summary_md,
|
||||
encoding="utf-8",
|
||||
)
|
||||
await asyncio.to_thread(summary_path.write_text, summary_md, encoding="utf-8")
|
||||
result_data["summary"] = str(summary_path)
|
||||
|
||||
doc_text = format_meeting_document(segments, metadata, display_name)
|
||||
@ -241,10 +272,11 @@ async def process_postprocessing(job: Dict[str, Any]):
|
||||
result_data["txt"] = str(txt_path)
|
||||
result_data["metadata"] = metadata
|
||||
result_data["project"] = project
|
||||
result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir))
|
||||
|
||||
if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True):
|
||||
await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing")
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
index_dir = org_rag_index_dir(org_slug)
|
||||
global_doc_text = format_global_document(doc_text, metadata)
|
||||
await index_meeting(
|
||||
doc_text=doc_text,
|
||||
@ -265,6 +297,7 @@ async def process_postprocessing(job: Dict[str, Any]):
|
||||
doc_text = format_meeting_document(segments, metadata, display_name)
|
||||
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
|
||||
result_data["txt"] = str(txt_path)
|
||||
result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir))
|
||||
|
||||
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
|
||||
tasks[task_id].update({
|
||||
@ -291,7 +324,6 @@ async def _transcribe_worker_loop(worker_id: int):
|
||||
while True:
|
||||
try:
|
||||
task_id, file_path = await _transcribe_queue.get()
|
||||
print(f"[Transcribe Worker {worker_id}] задача {task_id}")
|
||||
await process_transcription(file_path, task_id)
|
||||
_transcribe_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
@ -305,7 +337,6 @@ async def _postprocess_worker_loop(worker_id: int):
|
||||
while True:
|
||||
try:
|
||||
job = await _postprocess_queue.get()
|
||||
print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}")
|
||||
await process_postprocessing(job)
|
||||
_postprocess_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
@ -315,7 +346,6 @@ async def _postprocess_worker_loop(worker_id: int):
|
||||
|
||||
|
||||
def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1):
|
||||
"""Запускает пулы воркеров транскрибации и post-processing."""
|
||||
global _workers
|
||||
_workers.clear()
|
||||
for i in range(transcribe_workers):
|
||||
@ -330,10 +360,17 @@ def stop_workers():
|
||||
w.cancel()
|
||||
|
||||
|
||||
async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
|
||||
async def save_upload(
|
||||
content: bytes,
|
||||
filename: str,
|
||||
user: UserContext,
|
||||
project_slug: str,
|
||||
) -> tuple[str, Path]:
|
||||
ensure_project_access(user, project_slug)
|
||||
slug = project_slug.strip().lower()
|
||||
safe_name = Path(filename).name or "upload.bin"
|
||||
task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
task_dir = UPLOAD_DIR / task_id
|
||||
task_dir = org_upload_dir(user.org_slug, user.user_id) / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = task_dir / safe_name
|
||||
|
||||
@ -346,6 +383,10 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
|
||||
"progress": 0,
|
||||
"message": f"В очереди транскрибации (№{queue_position})",
|
||||
"file": safe_name,
|
||||
"project_slug": slug,
|
||||
"org_slug": user.org_slug,
|
||||
"user_id": user.user_id,
|
||||
"username": user.username,
|
||||
"queue_position": queue_position,
|
||||
"result": None,
|
||||
"error": None,
|
||||
@ -357,7 +398,9 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
|
||||
return task_id, file_path
|
||||
|
||||
|
||||
def get_queue_info() -> Dict[str, Any]:
|
||||
def get_queue_info(user: Optional[UserContext] = None) -> Dict[str, Any]:
|
||||
if user:
|
||||
return _filter_queue_info(user)
|
||||
queued = sum(1 for t in tasks.values() if t.get("status") == "queued")
|
||||
processing = sum(1 for t in tasks.values() if t.get("status") == "processing")
|
||||
postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing")
|
||||
@ -371,11 +414,18 @@ def get_queue_info() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def get_task_status(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
return tasks.get(task_id)
|
||||
def get_task_status(task_id: str, user: Optional[UserContext] = None) -> Optional[Dict[str, Any]]:
|
||||
task = tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
if user and not _task_visible(task, user):
|
||||
return None
|
||||
return task
|
||||
|
||||
|
||||
def get_all_tasks() -> List[Dict[str, Any]]:
|
||||
def get_all_tasks(user: Optional[UserContext] = None) -> List[Dict[str, Any]]:
|
||||
if user:
|
||||
return _filter_tasks_for_user(user)
|
||||
return list(tasks.values())
|
||||
|
||||
|
||||
@ -392,39 +442,84 @@ def _file_sort_key(file_info: Dict[str, Any]) -> tuple:
|
||||
return (4, name)
|
||||
|
||||
|
||||
def get_processed_tree() -> List[Dict[str, Any]]:
|
||||
def _folder_project_slug(folder_name: str, meetings_dir: Path) -> Optional[str]:
|
||||
meta_path = meetings_dir / folder_name / ".project.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
return data.get("project_slug")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_processed_tree(user: UserContext) -> List[Dict[str, Any]]:
|
||||
tree = []
|
||||
if not PROCESSED_DIR.exists():
|
||||
meetings_dir = org_meetings_dir(user.org_slug)
|
||||
if not meetings_dir.exists():
|
||||
return tree
|
||||
|
||||
skip_names = {"lightrag_caches", "lightrag_caches_test"}
|
||||
skip_suffixes = ("_segments.json",)
|
||||
|
||||
for item in sorted(PROCESSED_DIR.iterdir()):
|
||||
if not item.is_dir() or item.name in skip_names:
|
||||
for item in sorted(meetings_dir.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
project_slug = _folder_project_slug(item.name, meetings_dir)
|
||||
if project_slug and not user.can_access_project(project_slug):
|
||||
continue
|
||||
|
||||
files = []
|
||||
for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})):
|
||||
if f.is_file() and not f.name.endswith(skip_suffixes):
|
||||
if f.is_file() and not f.name.endswith(skip_suffixes) and f.name != ".project.json":
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"path": str(f.relative_to(PROCESSED_DIR)),
|
||||
"path": str(f.relative_to(meetings_dir)),
|
||||
"size": f.stat().st_size,
|
||||
"ext": f.suffix.lower(),
|
||||
"kind": "summary" if "_summary" in f.name else "protocol",
|
||||
})
|
||||
tree.append({
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(PROCESSED_DIR)),
|
||||
"path": str(item.relative_to(meetings_dir)),
|
||||
"project": project_slug,
|
||||
"files": files,
|
||||
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
|
||||
})
|
||||
return tree
|
||||
|
||||
|
||||
def read_file_content(rel_path: str) -> str:
|
||||
full_path = PROCESSED_DIR / rel_path
|
||||
def read_file_content(user: UserContext, rel_path: str) -> str:
|
||||
full_path = resolve_meeting_path(user.org_slug, rel_path)
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise FileNotFoundError(f"Файл не найден: {rel_path}")
|
||||
|
||||
folder = full_path.parent.name
|
||||
meetings_dir = org_meetings_dir(user.org_slug)
|
||||
project_slug = _folder_project_slug(folder, meetings_dir)
|
||||
if project_slug and not user.can_access_project(project_slug):
|
||||
raise PermissionError("Нет доступа к этому файлу")
|
||||
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def get_download_path(user: UserContext, rel_path: str) -> Path:
|
||||
full_path = resolve_meeting_path(user.org_slug, rel_path)
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise FileNotFoundError(f"Файл не найден: {rel_path}")
|
||||
folder = full_path.parent.name
|
||||
meetings_dir = org_meetings_dir(user.org_slug)
|
||||
project_slug = _folder_project_slug(folder, meetings_dir)
|
||||
if project_slug and not user.can_access_project(project_slug):
|
||||
raise PermissionError("Нет доступа к этому файлу")
|
||||
return full_path
|
||||
|
||||
|
||||
def delete_folder(user: UserContext, folder_rel: str) -> None:
|
||||
folder_path = resolve_meeting_path(user.org_slug, folder_rel)
|
||||
if not folder_path.is_dir():
|
||||
raise FileNotFoundError("Folder not found")
|
||||
project_slug = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug))
|
||||
if project_slug and not user.can_access_project(project_slug):
|
||||
raise PermissionError("Нет доступа к этой папке")
|
||||
shutil.rmtree(folder_path)
|
||||
|
||||
@ -10,21 +10,158 @@ class TranscriptionApp {
|
||||
this.renderTimeout = null;
|
||||
this.chatHistory = [];
|
||||
this.chatProjects = [];
|
||||
this.uploadProjects = [];
|
||||
this.isChatThinking = false;
|
||||
this.user = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.connectWebSocket();
|
||||
this.setupUpload();
|
||||
this.setupChat();
|
||||
this.loadChatHistory();
|
||||
if (!Auth.requireAuth()) return;
|
||||
this.user = Auth.getUser();
|
||||
this.setupUserBar();
|
||||
this.loadProjects().then(() => {
|
||||
this.connectWebSocket();
|
||||
this.setupProjectsWorkspace();
|
||||
this.setupUpload();
|
||||
this.setupChat();
|
||||
this.setupAdmin();
|
||||
this.loadChatHistory();
|
||||
});
|
||||
}
|
||||
|
||||
setupUserBar() {
|
||||
const info = document.getElementById('userInfo');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (info && this.user) {
|
||||
const roleLabel = {
|
||||
admin: 'администратор',
|
||||
director: 'директор',
|
||||
user: 'пользователь',
|
||||
}[this.user.role] || this.user.role;
|
||||
info.textContent = `${this.user.username} · ${this.user.org_name} · ${roleLabel}`;
|
||||
}
|
||||
logoutBtn?.addEventListener('click', () => Auth.logout());
|
||||
}
|
||||
|
||||
downloadUrl(path) {
|
||||
const token = encodeURIComponent(Auth.getToken() || '');
|
||||
return `/api/files/download?path=${encodeURIComponent(path)}&token=${token}`;
|
||||
}
|
||||
|
||||
async loadProjects() {
|
||||
try {
|
||||
const response = await Auth.apiFetch('/api/auth/projects');
|
||||
const result = await response.json();
|
||||
this.uploadProjects = result.projects || [];
|
||||
this.renderMyProjectsList();
|
||||
this.populateProjectSelect('uploadProjectSelect', this.uploadProjects, false);
|
||||
await this.loadChatProjects();
|
||||
} catch (e) {
|
||||
console.error('Failed to load projects:', e);
|
||||
this.showToast('Не удалось загрузить проекты', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
populateProjectSelect(selectId, projects, includeGlobal) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
if (includeGlobal && this.user?.all_projects_access) {
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = 'Все проекты';
|
||||
select.appendChild(allOpt);
|
||||
}
|
||||
projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.slug || p;
|
||||
const scope = p.scope === 'personal' ? ' · мой' : (p.scope === 'org' ? ' · общий' : '');
|
||||
opt.textContent = `${p.name || p.slug || p}${scope}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (!select.options.length) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'Нет доступных проектов';
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
renderMyProjectsList() {
|
||||
const container = document.getElementById('myProjectsList');
|
||||
if (!container) return;
|
||||
|
||||
const projects = this.uploadProjects || [];
|
||||
if (!projects.length) {
|
||||
container.innerHTML = '<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 =====
|
||||
connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
this.ws = new WebSocket(Auth.wsUrl('/ws'));
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
@ -38,7 +175,12 @@ class TranscriptionApp {
|
||||
this.handleWebSocketMessage(data);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws.onclose = (event) => {
|
||||
if (event.code === 4401) {
|
||||
Auth.clearSession();
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||
setTimeout(() => this.connectWebSocket(), 3000);
|
||||
};
|
||||
@ -126,7 +268,14 @@ class TranscriptionApp {
|
||||
async handleFiles(files) {
|
||||
if (!files.length) return;
|
||||
|
||||
const project = document.getElementById('uploadProjectSelect')?.value;
|
||||
if (!project) {
|
||||
this.showToast('Выберите проект перед загрузкой', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('project', project);
|
||||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
@ -134,7 +283,7 @@ class TranscriptionApp {
|
||||
try {
|
||||
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
|
||||
|
||||
const response = await fetch('/upload-batch', {
|
||||
const response = await Auth.apiFetch('/upload-batch', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@ -273,7 +422,7 @@ class TranscriptionApp {
|
||||
const isDocx = file.ext === '.docx';
|
||||
const isTxt = file.ext === '.txt';
|
||||
const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎';
|
||||
const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`;
|
||||
const downloadUrl = this.downloadUrl(file.path);
|
||||
const cssClass = isSummary ? 'file-item file-summary' : 'file-item';
|
||||
|
||||
if (isDocx) {
|
||||
@ -363,7 +512,7 @@ class TranscriptionApp {
|
||||
|
||||
async deleteFolder(folderName, folderElement) {
|
||||
try {
|
||||
const response = await fetch(`/api/folders/${encodeURIComponent(folderName)}`, {
|
||||
const response = await Auth.apiFetch(`/api/folders/${encodeURIComponent(folderName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const result = await response.json();
|
||||
@ -390,7 +539,7 @@ class TranscriptionApp {
|
||||
|
||||
if (ext === '.md' || ext === '.txt') {
|
||||
try {
|
||||
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||
const response = await Auth.apiFetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
@ -406,7 +555,7 @@ class TranscriptionApp {
|
||||
<div class="md-content">
|
||||
<div class="md-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="md-body">${bodyHtml}</div>
|
||||
@ -493,20 +642,20 @@ class TranscriptionApp {
|
||||
|
||||
async loadChatProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/rag/projects');
|
||||
const response = await Auth.apiFetch('/api/rag/projects');
|
||||
const result = await response.json();
|
||||
const select = document.getElementById('chatProjectSelect');
|
||||
select.innerHTML = '<option value="">Все проекты</option>';
|
||||
if (result.projects) {
|
||||
result.projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
const projects = (result.projects || []).map(p => (
|
||||
typeof p === 'string' ? { slug: p, name: p } : p
|
||||
));
|
||||
this.populateProjectSelect('chatProjectSelect', projects, true);
|
||||
const hint = document.getElementById('chatHint');
|
||||
if (hint) {
|
||||
hint.textContent = this.user?.all_projects_access
|
||||
? 'Выберите проект или «Все проекты» для поиска по организации.'
|
||||
: 'Выберите проект, к которому у вас есть доступ.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load projects:', e);
|
||||
console.error('Failed to load chat projects:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,6 +669,11 @@ class TranscriptionApp {
|
||||
const select = document.getElementById('chatProjectSelect');
|
||||
const project = select.value;
|
||||
|
||||
if (!project && !this.user?.all_projects_access) {
|
||||
this.showToast('Выберите проект для поиска', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
this.addChatBubble('user', question);
|
||||
this.setChatThinking(true);
|
||||
@ -629,6 +783,131 @@ class TranscriptionApp {
|
||||
this.chatHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
setupAdmin() {
|
||||
if (!this.user?.is_admin) return;
|
||||
const section = document.getElementById('adminSection');
|
||||
if (section) section.hidden = false;
|
||||
this.refreshAdminData();
|
||||
|
||||
document.getElementById('createUserForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const role = document.getElementById('newUserRole').value;
|
||||
const projects = role === 'user'
|
||||
? document.getElementById('newUserProjects').value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
try {
|
||||
const response = await Auth.apiFetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('newUsername').value.trim(),
|
||||
password: document.getElementById('newPassword').value,
|
||||
role: document.getElementById('newUserRole').value,
|
||||
projects,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Ошибка создания');
|
||||
this.showToast(`Пользователь ${data.user.username} создан`, 'success');
|
||||
e.target.reset();
|
||||
this.toggleProjectsField();
|
||||
this.refreshAdminData();
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('newUserRole')?.addEventListener('change', () => this.toggleProjectsField());
|
||||
this.toggleProjectsField();
|
||||
|
||||
document.getElementById('createProjectForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await Auth.apiFetch('/api/admin/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: document.getElementById('newProjectSlug').value.trim(),
|
||||
name: document.getElementById('newProjectName').value.trim(),
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Ошибка');
|
||||
this.showToast(`Проект ${data.project.slug} добавлен`, 'success');
|
||||
e.target.reset();
|
||||
await this.loadProjects();
|
||||
this.refreshAdminData();
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleProjectsField() {
|
||||
const role = document.getElementById('newUserRole')?.value;
|
||||
const projectsInput = document.getElementById('newUserProjects');
|
||||
const hint = document.getElementById('newUserProjectsHint');
|
||||
const isUser = role === 'user';
|
||||
if (projectsInput) {
|
||||
projectsInput.disabled = !isUser;
|
||||
projectsInput.placeholder = isUser
|
||||
? 'Проекты через запятую (2026, gp-merakom)'
|
||||
: 'Не требуется — доступ ко всем проектам организации';
|
||||
}
|
||||
if (hint) {
|
||||
hint.textContent = role === 'director'
|
||||
? 'Директор: все проекты, глобальный поиск, без панели администрирования.'
|
||||
: role === 'admin'
|
||||
? 'Администратор: полный доступ + управление пользователями.'
|
||||
: 'Укажите slug общих проектов org, назначенных администратором.';
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAdminData() {
|
||||
if (!this.user?.is_admin) return;
|
||||
try {
|
||||
const [usersRes, projectsRes] = await Promise.all([
|
||||
Auth.apiFetch('/api/admin/users'),
|
||||
Auth.apiFetch('/api/admin/projects'),
|
||||
]);
|
||||
const usersData = await usersRes.json();
|
||||
const projectsData = await projectsRes.json();
|
||||
|
||||
const usersEl = document.getElementById('adminUsersList');
|
||||
if (usersEl) {
|
||||
usersEl.innerHTML = (usersData.users || []).map(u => {
|
||||
const shared = (u.shared_projects || u.projects || []).join(', ');
|
||||
const owned = (u.owned_projects || []).join(', ');
|
||||
const parts = [];
|
||||
if (owned) parts.push(`личные: ${owned}`);
|
||||
if (shared) parts.push(`общие: ${shared}`);
|
||||
if (u.all_projects_access) parts.push('все проекты');
|
||||
const projectsLabel = parts.join(' · ') || '—';
|
||||
const roleLabel = { admin: 'admin', director: 'director', user: 'user' }[u.role] || u.role;
|
||||
return `
|
||||
<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
|
||||
|
||||
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>
|
||||
<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">
|
||||
<!-- Markdown renderer -->
|
||||
<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="/static/auth.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎙️ Транскрибация совещаний</h1>
|
||||
<p class="subtitle">Загрузите аудио или видео файл для получения протокола</p>
|
||||
<div class="header-top">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div class="upload-controls">
|
||||
<label>
|
||||
Проект
|
||||
<select id="uploadProjectSelect" required></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<div class="drop-zone-content">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Queue Status -->
|
||||
<div class="queue-status" id="queueStatus">
|
||||
<div class="queue-status-header">
|
||||
<h3>Очередь обработки</h3>
|
||||
@ -45,7 +72,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Results Section -->
|
||||
<section class="results-section">
|
||||
<div class="panel-left">
|
||||
<h2>📁 Файлы</h2>
|
||||
@ -64,21 +90,18 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chat Section -->
|
||||
<section class="chat-section" id="chatSection">
|
||||
<div class="chat-header">
|
||||
<h2>🤖 Чат с базой знаний</h2>
|
||||
<div class="chat-controls">
|
||||
<select id="chatProjectSelect">
|
||||
<option value="">Все проекты</option>
|
||||
</select>
|
||||
<select id="chatProjectSelect"></select>
|
||||
<button id="chatClearBtn" title="Очистить историю">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<div class="chat-welcome">
|
||||
<p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p>
|
||||
<p class="chat-hint">Выберите проект или оставьте «Все проекты» для глобального поиска.</p>
|
||||
<p class="chat-hint" id="chatHint">Выберите проект для поиска.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
@ -86,12 +109,41 @@
|
||||
<button id="chatSendBtn">Отправить</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 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
|
||||
postprocess_workers: 1
|
||||
|
||||
# Авторизация и multi-tenant (org + projects)
|
||||
auth:
|
||||
jwt_secret: "" # Обязательно задайте JWT_SECRET в .env для production
|
||||
jwt_expire_hours: 168
|
||||
database_path: ./data/transcriba.db
|
||||
bootstrap:
|
||||
org_slug: merakom
|
||||
org_name: "МЕРАКОМ"
|
||||
admin_username: admin
|
||||
admin_password: "admin123" # Смените после первого входа; или AUTH_ADMIN_PASSWORD в .env
|
||||
default_projects:
|
||||
- slug: "2026"
|
||||
name: "2026"
|
||||
- slug: "gp-merakom"
|
||||
name: "ГП МЕРАКОМ"
|
||||
|
||||
# Пути
|
||||
paths:
|
||||
output_dir: ./output
|
||||
|
||||
@ -16,7 +16,8 @@ services:
|
||||
- OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1}
|
||||
- HF_HOME=/root/.cache/huggingface
|
||||
- NLTK_DATA=/root/nltk_data
|
||||
- TRANSFORMERS_OFFLINE=0
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- AUTH_ADMIN_PASSWORD=${AUTH_ADMIN_PASSWORD:-admin123}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- processed:/app/processed
|
||||
@ -27,11 +28,12 @@ services:
|
||||
- ./scripts:/app/scripts:ro
|
||||
- ./models/huggingface:/root/.cache/huggingface
|
||||
- ./models/nltk_data:/root/nltk_data:ro
|
||||
- data:/app/data
|
||||
restart: unless-stopped
|
||||
entrypoint: ["uvicorn"]
|
||||
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/files"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@ -41,3 +43,4 @@ volumes:
|
||||
uploads:
|
||||
processed:
|
||||
tmp:
|
||||
data:
|
||||
|
||||
@ -6,6 +6,10 @@ python-docx
|
||||
pyyaml
|
||||
whisperx
|
||||
|
||||
# Auth
|
||||
bcrypt>=4.0.0
|
||||
python-jose[cryptography]
|
||||
|
||||
# RAG / LightRAG
|
||||
lightrag-hku>=1.4.0
|
||||
openai>=1.0.0
|
||||
|
||||
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