big update new dashboard and import export

This commit is contained in:
2026-04-10 00:39:35 +05:00
parent 4013701441
commit e2fb4ddb2a
19 changed files with 1324 additions and 159 deletions

View File

@@ -2,12 +2,15 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime, timezone
import uuid
from bd import make_engine
from bd.tables.users import User
from route.auth_utils import hash_password, verify_password, create_access_token
from route.auth_utils import hash_password, verify_password, create_access_token, SECRET_KEY, ALGORITHM, oauth2_scheme
from jose import jwt, JWTError
from redis_db import blacklist_token
router = APIRouter(tags=["auth"], prefix="/auth")
@@ -75,3 +78,18 @@ def login(form: OAuth2PasswordRequestForm = Depends()):
)
token = create_access_token({"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer"}
@router.post("/logout")
def logout(token: str = Depends(oauth2_scheme)):
"""Инвалидирует текущий Bearer-токен через Redis блэклист."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
jti: str | None = payload.get("jti")
exp: int | None = payload.get("exp")
if jti and exp:
ttl = max(0, exp - int(datetime.now(timezone.utc).timestamp()))
blacklist_token(jti, ttl)
except JWTError:
pass # токен уже невалиден — ничего не делаем
return {"detail": "Successfully logged out"}

View File

@@ -41,6 +41,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode["exp"] = expire
to_encode["jti"] = str(uuid.uuid4()) # уникальный ID токена для блэклиста
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -51,6 +52,7 @@ def get_session():
def get_current_user(token: str = Depends(oauth2_scheme)):
"""FastAPI dependency: валидирует Bearer-токен и возвращает объект User."""
from redis_db import is_token_blacklisted
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
@@ -59,8 +61,15 @@ def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
jti: str = payload.get("jti")
if user_id is None:
raise credentials_exc
if jti and is_token_blacklisted(jti):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise credentials_exc

View File

@@ -1,18 +1,30 @@
from fastapi import APIRouter, Query
from typing import Optional
from sqlalchemy import func
import json
import hashlib
from bd import make_engine
from bd.tables.response import Response
from bd.tables.poll import Poll
from bd.tables.group import Group
from bd.tables.organization import Organization
from bd.tables.radar_result import RadarResult, RadarResultItem
from bd.tables.users import User
from redis_db import cache_get, cache_set
router = APIRouter(tags=["stats"], prefix="/stats")
_MONTH_NAMES = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
_CACHE_TTL = 300 # 5 минут
def _cache_key(*parts) -> str:
raw = ":".join(str(p) for p in parts)
return hashlib.md5(raw.encode()).hexdigest()
def get_session():
from sqlalchemy.orm import sessionmaker
@@ -22,29 +34,42 @@ def get_session():
@router.get("/summary")
def get_summary():
"""Общая сводка: кол-во ответов, тестов, активных тестов, организаций с активностью."""
key = _cache_key("summary")
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
total_responses = session.query(func.count(Response.id)).scalar() or 0
total_polls = session.query(func.count(Poll.id)).scalar() or 0
active_polls = session.query(func.count(Poll.id)).filter(Poll.is_active.is_(True)).scalar() or 0
orgs_active = session.query(func.count(func.distinct(Response.organization_id))).scalar() or 0
return {
result = {
"total_responses": total_responses,
"total_polls": total_polls,
"active_polls": active_polls,
"orgs_active": orgs_active,
}
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/years")
def get_years():
"""Список лет, в которых есть хотя бы один ответ."""
key = _cache_key("years")
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
rows = session.query(
func.extract("year", Response.submitted_at).label("yr")
).distinct().order_by("yr").all()
return [int(r.yr) for r in rows if r.yr is not None]
result = [int(r.yr) for r in rows if r.yr is not None]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/responses")
@@ -65,7 +90,13 @@ def get_responses_grouped(
Все фильтры опциональны и комбинируются.
"""
key = _cache_key("responses", group_by, poll_id, org_id, group_id, year)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
result = []
with Session() as session:
if group_by == "poll":
@@ -80,9 +111,9 @@ def get_responses_grouped(
if poll_id:
q = q.filter(Response.poll_id == poll_id)
rows = q.group_by(Poll.title).order_by(func.count(Response.id).desc()).limit(20).all()
return [{"label": r[0], "count": r[1]} for r in rows]
result = [{"label": r[0], "count": r[1]} for r in rows]
if group_by == "organization":
elif group_by == "organization":
q = session.query(Organization.name_organization, func.count(Response.id).label("cnt"))
q = q.outerjoin(Response, Organization.id == Response.organization_id)
if poll_id:
@@ -94,9 +125,9 @@ def get_responses_grouped(
rows = q.group_by(Organization.name_organization).order_by(
func.count(Response.id).desc()
).limit(20).all()
return [{"label": r[0], "count": r[1] or 0} for r in rows]
result = [{"label": r[0], "count": r[1] or 0} for r in rows]
if group_by == "group":
elif group_by == "group":
q = session.query(Group.name_group, func.count(Response.id).label("cnt"))
q = q.outerjoin(Response, Group.id == Response.group_id)
if poll_id:
@@ -108,9 +139,9 @@ def get_responses_grouped(
rows = q.group_by(Group.name_group).order_by(
func.count(Response.id).desc()
).limit(20).all()
return [{"label": r[0], "count": r[1] or 0} for r in rows]
result = [{"label": r[0], "count": r[1] or 0} for r in rows]
if group_by == "month":
elif group_by == "month":
yr_col = func.extract("year", Response.submitted_at).label("yr")
mo_col = func.extract("month", Response.submitted_at).label("mo")
q = session.query(yr_col, mo_col, func.count(Response.id).label("cnt"))
@@ -123,7 +154,7 @@ def get_responses_grouped(
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by("yr", "mo").order_by("yr", "mo").all()
return [
result = [
{
"label": f"{_MONTH_NAMES[int(r.mo) - 1]} {int(r.yr)}",
"count": r.cnt,
@@ -131,4 +162,257 @@ def get_responses_grouped(
for r in rows
]
return []
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
# ---------------------------------------------------------------------------
# Эндпоинты по результатам (RadarResultItem)
# ---------------------------------------------------------------------------
@router.get("/results/avg-by-dimension")
def get_avg_by_dimension(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
):
"""Средний балл по каждой оси/типу."""
key = _cache_key("avg_dim", poll_id, org_id, group_id, year)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
q = (
session.query(
RadarResultItem.dimension_name,
func.avg(RadarResultItem.value).label("avg"),
func.count(RadarResultItem.id).label("cnt"),
)
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by(RadarResultItem.dimension_name).order_by(func.avg(RadarResultItem.value).desc()).all()
result = [{"label": r[0], "avg": round(float(r[1]), 2), "count": r[2]} for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/leading-type")
def get_leading_type(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
):
"""Распределение ведущего типа: у скольких прохождений данная ось оказалась максимальной."""
key = _cache_key("leading", poll_id, org_id, group_id, year)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
max_subq = (
session.query(
RadarResultItem.result_id,
func.max(RadarResultItem.value).label("max_val"),
)
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
)
if poll_id:
max_subq = max_subq.filter(Response.poll_id == poll_id)
if org_id:
max_subq = max_subq.filter(Response.organization_id == org_id)
if group_id:
max_subq = max_subq.filter(Response.group_id == group_id)
if year:
max_subq = max_subq.filter(func.extract("year", Response.submitted_at) == year)
max_subq = max_subq.group_by(RadarResultItem.result_id).subquery()
rows = (
session.query(
RadarResultItem.dimension_name,
func.count(RadarResultItem.result_id).label("cnt"),
)
.join(max_subq, (RadarResultItem.result_id == max_subq.c.result_id) &
(RadarResultItem.value == max_subq.c.max_val))
.group_by(RadarResultItem.dimension_name)
.order_by(func.count(RadarResultItem.result_id).desc())
.all()
)
result = [{"label": r[0], "count": r[1]} for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/compare-groups")
def get_compare_groups(
dimension_name: str,
poll_id: Optional[str] = None,
year: Optional[int] = None,
by: str = Query("organization", pattern="^(organization|group)$"),
):
"""Средний балл по выбранной оси в разбивке по организациям или группам."""
key = _cache_key("compare_groups", dimension_name, poll_id, year, by)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
if by == "organization":
label_col = Organization.name_organization
q = (
session.query(label_col, func.avg(RadarResultItem.value).label("avg"))
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.join(Organization, Response.organization_id == Organization.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
else:
label_col = Group.name_group
q = (
session.query(label_col, func.avg(RadarResultItem.value).label("avg"))
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.join(Group, Response.group_id == Group.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by(label_col).order_by(func.avg(RadarResultItem.value).desc()).limit(20).all()
result = [{"label": r[0], "avg": round(float(r[1]), 2)} for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/top-users")
def get_top_users(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
limit: int = Query(10, ge=1, le=50),
):
"""Топ-N прохождений с наибольшим баллом по выбранной оси."""
key = _cache_key("top_users", dimension_name, poll_id, org_id, group_id, year, limit)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
q = (
session.query(
User.last_name,
User.first_name,
Poll.title,
RadarResultItem.value,
)
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.join(Poll, Response.poll_id == Poll.id)
.outerjoin(User, Response.user_id == User.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.order_by(RadarResultItem.value.desc()).limit(limit).all()
result = [
{
"name": f"{r[0] or '?'} {r[1] or ''}".strip(),
"poll": r[2],
"value": round(float(r[3]), 2),
}
for r in rows
]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/dimension-trend")
def get_dimension_trend(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
):
"""Динамика среднего балла по оси во времени (по месяцам)."""
key = _cache_key("dim_trend", dimension_name, poll_id, org_id, group_id)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
yr_col = func.extract("year", Response.submitted_at).label("yr")
mo_col = func.extract("month", Response.submitted_at).label("mo")
q = (
session.query(yr_col, mo_col, func.avg(RadarResultItem.value).label("avg"))
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
rows = q.group_by("yr", "mo").order_by("yr", "mo").all()
result = [
{"label": f"{_MONTH_NAMES[int(r.mo) - 1]} {int(r.yr)}", "avg": round(float(r.avg), 2)}
for r in rows
]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/dimensions")
def get_dimensions(poll_id: Optional[str] = None):
"""Список уникальных названий осей (для выпадающего списка)."""
key = _cache_key("dimensions", poll_id)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
q = session.query(RadarResultItem.dimension_name).distinct()
if poll_id:
q = (
q.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.filter(Response.poll_id == poll_id)
)
rows = q.order_by(RadarResultItem.dimension_name).all()
result = [r[0] for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result

336
route/transfer_crud.py Normal file
View File

@@ -0,0 +1,336 @@
"""
transfer_crud.py — полный экспорт и импорт всех данных между инстанциями API.
Экспортирует ВСЁ: справочники, тесты, пользователей, ответы, результаты.
Импорт — upsert (INSERT ON CONFLICT DO NOTHING), UUID сохраняются,
FK-связи не рвутся. Безопасно запускать повторно — дубли пропускаются.
Порядок импорта (FK-зависимости):
Organization → Group → Poll → Question → Choice
→ ScaleDimension → ChoiceScore
→ User → Response → Answer
→ RadarResult → RadarResultItem + SVG-файлы
Оба эндпоинта защищены X-Admin-Key.
"""
import io
import json
import os
import shutil
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import sessionmaker
from bd import make_engine
from bd.tables.organization import Organization
from bd.tables.group import Group
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
from bd.tables.users import User
from bd.tables.response import Response, Answer
from bd.tables.radar_result import RadarResult, RadarResultItem
from route.auth_utils import require_admin_key
router = APIRouter(prefix="/transfer", tags=["transfer"])
EXPORT_FORMAT_VERSION = "2.0"
_APP_PATH = os.getenv("APP_PATH", "/app")
_RADAR_DIR = Path(_APP_PATH) / "data" / "radar"
# Порядок экспорта/импорта — строго по FK-зависимостям
_TABLES: list[tuple[str, object]] = [
("organizations", Organization),
("groups", Group),
("polls", Poll),
("questions", Question),
("choices", Choice),
("scale_dimensions", ScaleDimension),
("choice_scores", ChoiceScore),
("users", User),
("responses", Response),
("answers", Answer),
("radar_results", RadarResult),
("radar_result_items", RadarResultItem),
]
def _get_session():
return sessionmaker(bind=make_engine(), future=True)
def _row_to_dict(row) -> dict:
result = {}
for col in row.__table__.columns:
val = getattr(row, col.name)
if val is None:
result[col.name] = None
elif hasattr(val, "hex"): # UUID
result[col.name] = str(val)
elif hasattr(val, "isoformat"): # datetime
result[col.name] = val.isoformat()
else:
result[col.name] = val
return result
# ── EXPORT ──────────────────────────────────────────────────────────────────
@router.get(
"/export",
summary="Экспортировать все данные в ZIP-архив",
response_class=StreamingResponse,
dependencies=[Depends(require_admin_key)],
)
def export_data():
Session = _get_session()
buf = io.BytesIO()
counts = {}
with Session() as session:
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for filename, model_class in _TABLES:
rows = [_row_to_dict(r) for r in session.query(model_class).all()]
counts[filename] = len(rows)
zf.writestr(f"{filename}.json", json.dumps(rows, ensure_ascii=False))
# SVG-файлы радара
svg_count = 0
if _RADAR_DIR.exists():
for svg_file in _RADAR_DIR.rglob("*.svg"):
arc_name = "radar_svgs/" + svg_file.relative_to(_RADAR_DIR).as_posix()
zf.write(svg_file, arc_name)
svg_count += 1
counts["svg_files"] = svg_count
manifest = {
"format_version": EXPORT_FORMAT_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"counts": counts,
}
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
buf.seek(0)
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="export_{ts}.zip"'},
)
# ── IMPORT ──────────────────────────────────────────────────────────────────
@router.post(
"/import",
summary="Импортировать данные из ZIP-архива (upsert по UUID)",
dependencies=[Depends(require_admin_key)],
)
def import_data(file: UploadFile = File(...)):
content = file.file.read()
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Файл не является валидным ZIP-архивом")
if "manifest.json" not in zf.namelist():
raise HTTPException(status_code=400, detail="Отсутствует manifest.json")
manifest = json.loads(zf.read("manifest.json"))
fmt = manifest.get("format_version", "")
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = _get_session()
result = {"inserted": {}, "skipped": {}}
with Session() as session:
for filename, model_class in _TABLES:
arc_file = f"{filename}.json"
if arc_file not in zf.namelist():
result["inserted"][filename] = 0
result["skipped"][filename] = 0
continue
rows: list[dict] = json.loads(zf.read(arc_file))
if not rows:
result["inserted"][filename] = 0
result["skipped"][filename] = 0
continue
table = model_class.__table__
inserted = 0
for row in rows:
stmt = (
pg_insert(table)
.values(**row)
.on_conflict_do_nothing(index_elements=["id"])
)
res = session.execute(stmt)
inserted += res.rowcount
result["inserted"][filename] = inserted
result["skipped"][filename] = len(rows) - inserted
session.commit()
# SVG-файлы
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/")]
svg_written = 0
for arc_name in svg_names:
rel_path = arc_name[len("radar_svgs/"):]
if not rel_path:
continue
dest = _RADAR_DIR / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(arc_name))
svg_written += 1
result["inserted"]["svg_files"] = svg_written
return {
"status": "ok",
"source_exported_at": manifest.get("exported_at"),
"result": result,
}
# ════════════════════════════════════════════════════════════════════════════
# BACKUP — полное резервное копирование и полное восстановление
# /backup/export — скачать полный снимок БД + SVG
# /backup/restore — ПОЛНАЯ замена БД из архива (все старые данные удаляются)
# ════════════════════════════════════════════════════════════════════════════
backup_router = APIRouter(prefix="/backup", tags=["backup"])
@backup_router.get(
"/export",
summary="Создать резервную копию всей БД (ZIP)",
response_class=StreamingResponse,
dependencies=[Depends(require_admin_key)],
)
def backup_export():
"""Идентично /transfer/export, но в имени файла указан префикс backup_."""
Session = _get_session()
buf = io.BytesIO()
counts = {}
with Session() as session:
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for filename, model_class in _TABLES:
rows = [_row_to_dict(r) for r in session.query(model_class).all()]
counts[filename] = len(rows)
zf.writestr(f"{filename}.json", json.dumps(rows, ensure_ascii=False))
svg_count = 0
if _RADAR_DIR.exists():
for svg_file in _RADAR_DIR.rglob("*.svg"):
arc_name = "radar_svgs/" + svg_file.relative_to(_RADAR_DIR).as_posix()
zf.write(svg_file, arc_name)
svg_count += 1
counts["svg_files"] = svg_count
manifest = {
"format_version": EXPORT_FORMAT_VERSION,
"backup_type": "full",
"exported_at": datetime.now(timezone.utc).isoformat(),
"counts": counts,
}
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
buf.seek(0)
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="backup_{ts}.zip"'},
)
@backup_router.post(
"/restore",
summary="Полное восстановление БД из резервной копии (УДАЛЯЕТ все текущие данные)",
dependencies=[Depends(require_admin_key)],
)
def backup_restore(file: UploadFile = File(...)):
"""
ВНИМАНИЕ: удаляет ВСЕ существующие данные в обратном FK-порядке,
затем вставляет данные из архива в прямом FK-порядке.
SVG-диаграммы тоже полностью заменяются.
"""
content = file.file.read()
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Файл не является валидным ZIP-архивом")
if "manifest.json" not in zf.namelist():
raise HTTPException(status_code=400, detail="Отсутствует manifest.json")
manifest = json.loads(zf.read("manifest.json"))
fmt = manifest.get("format_version", "")
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = _get_session()
deleted_counts: dict[str, int] = {}
inserted_counts: dict[str, int] = {}
with Session() as session:
# 1. Удаляем в ОБРАТНОМ порядке FK-зависимостей
for filename, model_class in reversed(_TABLES):
table = model_class.__table__
res = session.execute(table.delete())
deleted_counts[filename] = res.rowcount
session.commit()
# 2. Вставляем в ПРЯМОМ порядке FK-зависимостей
for filename, model_class in _TABLES:
arc_file = f"{filename}.json"
if arc_file not in zf.namelist():
inserted_counts[filename] = 0
continue
rows: list[dict] = json.loads(zf.read(arc_file))
if not rows:
inserted_counts[filename] = 0
continue
table = model_class.__table__
session.execute(table.insert(), rows)
inserted_counts[filename] = len(rows)
session.commit()
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
if svg_names and _RADAR_DIR.exists():
shutil.rmtree(_RADAR_DIR)
_RADAR_DIR.mkdir(parents=True, exist_ok=True)
svg_written = 0
for arc_name in svg_names:
rel_path = arc_name[len("radar_svgs/"):]
if not rel_path:
continue
dest = _RADAR_DIR / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(arc_name))
svg_written += 1
inserted_counts["svg_files"] = svg_written
return {
"status": "ok",
"source_exported_at": manifest.get("exported_at"),
"deleted": deleted_counts,
"inserted": inserted_counts,
}