- session: set_tenant use bound param (SQL injection fix)
- health: text('SELECT 1'), REDIS_URL from config
- deps: re-export get_db from session, use settings.ENABLE_DEV_AUTH (default False)
- routes: all get_db from app.api.deps; conftest overrides deps.get_db
- main: register exception handlers from app.api.exceptions
- next.config: enable ESLint and TypeScript checks
- .eslintrc: drop @typescript-eslint/recommended; fix no-console (logger, ws-client, regulations)
- backend/.env.example added
- frontend: export apiFetch; dashboard, profile, settings, risks use api-client
- docs/ANALYSIS_AND_RECOMMENDATIONS.md
Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
6.4 KiB
Python
191 lines
6.4 KiB
Python
"""
|
|
COD-004: Inbox API — интеграция из Express inbox-server в FastAPI.
|
|
Использует тот же формат данных (SQLite + файлы) для совместимости.
|
|
"""
|
|
|
|
import os
|
|
import hashlib
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
import sqlite3
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.core.config import settings
|
|
from app.api.deps import get_current_user
|
|
from app.api.helpers import audit as audit_log
|
|
from app.api.deps import get_db as get_pg_db
|
|
|
|
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
|
|
|
DATA_DIR = Path(settings.INBOX_DATA_DIR).resolve()
|
|
INBOX_DIR = DATA_DIR / "ai-inbox"
|
|
DB_PATH = DATA_DIR / "db" / "inbox.db"
|
|
|
|
|
|
def _ensure_dirs():
|
|
INBOX_DIR.mkdir(parents=True, exist_ok=True)
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _get_db():
|
|
_ensure_dirs()
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS file_registry (
|
|
id TEXT PRIMARY KEY,
|
|
original_name TEXT NOT NULL,
|
|
stored_path TEXT NOT NULL,
|
|
mime TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
status TEXT DEFAULT 'pending'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ai_extraction_run (
|
|
id TEXT PRIMARY KEY,
|
|
file_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
status TEXT DEFAULT 'running',
|
|
error TEXT,
|
|
FOREIGN KEY (file_id) REFERENCES file_registry(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ai_extraction_field (
|
|
id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
field_code TEXT NOT NULL,
|
|
value TEXT,
|
|
confidence REAL,
|
|
provenance TEXT,
|
|
FOREIGN KEY (run_id) REFERENCES ai_extraction_run(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tmc_request_draft (
|
|
id TEXT PRIMARY KEY,
|
|
file_id TEXT NOT NULL,
|
|
extraction_run_id TEXT,
|
|
status TEXT DEFAULT 'draft',
|
|
data TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
FOREIGN KEY (file_id) REFERENCES file_registry(id),
|
|
FOREIGN KEY (extraction_run_id) REFERENCES ai_extraction_run(id)
|
|
);
|
|
""")
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
@router.get("/files")
|
|
def list_files(user=Depends(get_current_user)):
|
|
"""Список файлов в inbox"""
|
|
conn = _get_db()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, original_name, stored_path, mime, size, sha256, created_at, status FROM file_registry ORDER BY created_at DESC"
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/upload")
|
|
def upload_file(file: UploadFile = File(...), user=Depends(get_current_user)):
|
|
"""Загрузка файла в inbox"""
|
|
allowed = [
|
|
"application/pdf",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/msword",
|
|
]
|
|
if file.content_type not in allowed:
|
|
raise HTTPException(400, "Разрешены только PDF и DOCX")
|
|
max_size = settings.INBOX_UPLOAD_MAX_MB * 1024 * 1024
|
|
|
|
_ensure_dirs()
|
|
file_id = str(uuid.uuid4())
|
|
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in (file.filename or "file"))
|
|
stored_name = f"{file_id}_{safe_name}"
|
|
stored_path = INBOX_DIR / stored_name
|
|
|
|
content = file.file.read()
|
|
if len(content) > max_size:
|
|
raise HTTPException(400, f"Файл превышает {settings.INBOX_UPLOAD_MAX_MB} МБ")
|
|
sha256 = hashlib.sha256(content).hexdigest()
|
|
stored_path.write_bytes(content)
|
|
|
|
db_path_rel = f"ai-inbox/{stored_name}"
|
|
created_at = __import__("datetime").datetime.utcnow().isoformat() + "Z"
|
|
|
|
conn = _get_db()
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO file_registry (id, original_name, stored_path, mime, size, sha256, created_at, status) VALUES (?,?,?,?,?,?,?,?)",
|
|
(file_id, file.filename or "file", db_path_rel, file.content_type or "application/octet-stream", len(content), sha256, created_at, "pending"),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
# Audit log
|
|
try:
|
|
pg_db = next(get_pg_db())
|
|
audit_log(pg_db, user, "create", "inbox_file", file_id, description=f"Uploaded {file.filename}")
|
|
pg_db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"id": file_id,
|
|
"originalName": file.filename or "file",
|
|
"storedPath": db_path_rel,
|
|
"mime": file.content_type or "application/octet-stream",
|
|
"size": len(content),
|
|
"sha256": sha256,
|
|
"createdAt": created_at,
|
|
"status": "pending",
|
|
}
|
|
|
|
|
|
@router.get("/files/{file_id}/download")
|
|
def download_file(file_id: str, user=Depends(get_current_user)):
|
|
"""Скачать файл"""
|
|
conn = _get_db()
|
|
try:
|
|
row = conn.execute("SELECT * FROM file_registry WHERE id = ?", (file_id,)).fetchone()
|
|
finally:
|
|
conn.close()
|
|
if not row:
|
|
raise HTTPException(404, "File not found")
|
|
path = DATA_DIR / row["stored_path"] if not str(row["stored_path"]).startswith("/") else Path(row["stored_path"])
|
|
if not path.exists():
|
|
raise HTTPException(404, "File not found on disk")
|
|
return FileResponse(path, filename=row["original_name"])
|
|
|
|
|
|
@router.delete("/files/{file_id}")
|
|
def delete_file(file_id: str, user=Depends(get_current_user)):
|
|
"""Удалить файл"""
|
|
conn = _get_db()
|
|
try:
|
|
row = conn.execute("SELECT * FROM file_registry WHERE id = ?", (file_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "File not found")
|
|
path = DATA_DIR / row["stored_path"] if not str(row["stored_path"]).startswith("/") else Path(row["stored_path"])
|
|
if path.exists():
|
|
path.unlink()
|
|
conn.execute("DELETE FROM file_registry WHERE id = ?", (file_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
# Audit log
|
|
try:
|
|
pg_db = next(get_pg_db())
|
|
audit_log(pg_db, user, "delete", "inbox_file", file_id, description=f"Deleted file")
|
|
pg_db.commit()
|
|
except Exception:
|
|
pass
|
|
return {"success": True}
|