big update new dashboard and import export
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
336
route/transfer_crud.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user