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

@@ -3,13 +3,20 @@ services:
redis:
image: redis:alpine
container_name: redis
command:
- redis-server --appendonly yes
- redis-server --requirepass CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
command: >
redis-server
--requirepass CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy volatile-lru
--tcp-keepalive 60
--timeout 300
--loglevel notice
ports:
- 6379:6379
volumes:
- ./data:/redis
- ./data/redis:/data
restart: always
postgres:

View File

@@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from route import base, holland_crud, klimov_crud, glomshtok_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, scale_crud, radar_crud, stats_crud
from route import base, holland_crud, klimov_crud, glomshtok_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, scale_crud, radar_crud, stats_crud, transfer_crud
from route import auth
from bd import wait_for_db
import uvicorn
@@ -75,6 +75,8 @@ app.include_router(glomshtok_crud.router)
app.include_router(scale_crud.router)
app.include_router(radar_crud.router)
app.include_router(stats_crud.router)
app.include_router(transfer_crud.router)
app.include_router(transfer_crud.backup_router)
# ---------------------------------------------------------------------------

View File

@@ -17,26 +17,10 @@ class RedisSettings(GenericModel):
else:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@property
def connection_kwargs(self) -> dict:
"""Получить параметры подключения для redis.Redis()"""
return {
"host": self.REDIS_HOST,
"port": self.REDIS_PORT,
"db": self.REDIS_DB,
"password": self.REDIS_PASSWORD,
"decode_responses": True,
}
def get_client(self) -> redis.Redis:
"""Создать и вернуть Redis клиент"""
return redis.Redis(**self.connection_kwargs)
def test_connection(self) -> bool:
"""Протестировать подключение к Redis"""
try:
client = self.get_client()
client.ping()
get_redis_client().ping()
print(f"✓ Успешно подключено к Redis на {self.REDIS_HOST}:{self.REDIS_PORT}")
return True
except redis.ConnectionError as e:
@@ -47,6 +31,19 @@ class RedisSettings(GenericModel):
# Глобальная конфигурация
_redis_settings = RedisSettings()
# Единый connection pool на весь процесс (max_connections=20 — для FastAPI с несколькими воркерами)
_pool = redis.ConnectionPool(
host=_redis_settings.REDIS_HOST,
port=_redis_settings.REDIS_PORT,
db=_redis_settings.REDIS_DB,
password=_redis_settings.REDIS_PASSWORD,
decode_responses=True,
max_connections=20,
socket_connect_timeout=3,
socket_timeout=3,
retry_on_timeout=True,
)
def get_redis_settings() -> RedisSettings:
"""Получить конфигурацию Redis"""
@@ -54,9 +51,72 @@ def get_redis_settings() -> RedisSettings:
def get_redis_client() -> redis.Redis:
"""Получить готовый клиент Redis"""
return _redis_settings.get_client()
"""Получить клиент из общего connection pool (не создаёт новое TCP-соединение)."""
return redis.Redis(connection_pool=_pool)
__all__ = ["RedisSettings", "get_redis_settings", "get_redis_client"]
# ---------------------------------------------------------------------------
# Блэклист токенов
# ---------------------------------------------------------------------------
_BLACKLIST_PREFIX = "token_blacklist:"
def blacklist_token(jti: str, ttl_seconds: int) -> None:
"""Добавить jti токена в блэклист на ttl_seconds секунд."""
try:
client = get_redis_client()
client.setex(f"{_BLACKLIST_PREFIX}{jti}", ttl_seconds, "1")
except redis.RedisError:
pass # Redis недоступен — не блокируем работу
def is_token_blacklisted(jti: str) -> bool:
"""Проверить, отозван ли токен."""
try:
client = get_redis_client()
return client.exists(f"{_BLACKLIST_PREFIX}{jti}") > 0
except redis.RedisError:
return False # при ошибке Redis не блокируем пользователей
# ---------------------------------------------------------------------------
# Кэш
# ---------------------------------------------------------------------------
_CACHE_PREFIX = "cache:"
def cache_get(key: str) -> str | None:
"""Получить значение из кэша по ключу. Возвращает None если нет или Redis недоступен."""
try:
client = get_redis_client()
return client.get(f"{_CACHE_PREFIX}{key}")
except redis.RedisError:
return None
def cache_set(key: str, value: str, ttl_seconds: int = 300) -> None:
"""Сохранить строку в кэш с TTL (по умолчанию 5 минут)."""
try:
client = get_redis_client()
client.setex(f"{_CACHE_PREFIX}{key}", ttl_seconds, value)
except redis.RedisError:
pass
def cache_delete(key: str) -> None:
"""Удалить ключ из кэша."""
try:
client = get_redis_client()
client.delete(f"{_CACHE_PREFIX}{key}")
except redis.RedisError:
pass
__all__ = [
"RedisSettings", "get_redis_settings", "get_redis_client",
"blacklist_token", "is_token_blacklisted",
"cache_get", "cache_set", "cache_delete",
]

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,
}

View File

@@ -89,3 +89,121 @@ async def get_groups() -> list[dict]:
except Exception:
pass
return []
async def get_dimensions(poll_id: Optional[str] = None) -> list[str]:
params = {}
if poll_id:
params["poll_id"] = poll_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/dimensions", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_avg_by_dimension(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/avg-by-dimension", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_leading_type(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/leading-type", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_compare_groups(
dimension_name: str,
by: str = "organization",
poll_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {"dimension_name": dimension_name, "by": by}
if poll_id: params["poll_id"] = poll_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/compare-groups", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async 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 = 10,
) -> list[dict]:
params = {"dimension_name": dimension_name, "limit": limit}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/top-users", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_dimension_trend(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
) -> list[dict]:
params = {"dimension_name": dimension_name}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/dimension-trend", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []

BIN
stats/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -0,0 +1,3 @@
# Положи сюда своё изображение с именем loading-animation.png
# Это заменит мигающий логотип Flet при загрузке страницы.
# Рекомендуемый размер: PNG, минимум 200x200 px.

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M15.829,2c-.413-1.164-1.525-2-2.829-2h-2c-1.304,0-2.416,.836-2.829,2H3V21c0,1.654,1.346,3,3,3h12c1.654,0,3-1.346,3-3V2h-5.171Zm3.171,19c0,.551-.449,1-1,1H6c-.551,0-1-.449-1-1V4h5v-1c0-.551,.449-1,1-1h2c.551,0,1,.449,1,1v1h5V21Zm-8-4h2v2.013h-2v-2.013Zm1.917-9.911c1.19,.308,2.158,1.276,2.465,2.467,.429,1.66-.376,3.381-1.913,4.092-.281,.13-.47,.472-.47,.852v.5h-2v-.5c0-1.162,.64-2.208,1.629-2.666,.548-.254,1.038-.919,.816-1.778-.126-.49-.54-.904-1.03-1.03-.483-.127-.956-.034-1.333,.258-.371,.287-.583,.72-.583,1.188h-2c0-1.091,.495-2.1,1.358-2.769,.863-.67,1.978-.894,3.059-.614Z"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="m19,0H5C2.243,0,0,2.243,0,5v14c0,2.757,2.243,5,5,5h14c2.757,0,5-2.243,5-5V5c0-2.757-2.243-5-5-5Zm3,19c0,1.654-1.346,3-3,3H5c-1.654,0-3-1.346-3-3V5c0-1.654,1.346-3,3-3h14c1.654,0,3,1.346,3,3v14Zm-3-1c0,.552-.447,1-1,1H6c-.552,0-1-.448-1-1s.448-1,1-1h12c.553,0,1,.448,1,1Zm-8.665-7h.665v.725c0,1.333-1.222,1.275-1.222,1.275,0,0-.778.448-.778,1s.448,1,1,1h4c.553,0,1-.448,1-1s-.778-1-.778-1c0,0-1.222.058-1.222-1.275v-.725h.664c2.548,0,4.503-1.258,5.229-3.366.206-.599.107-1.267-.264-1.787-.378-.53-.991-.847-1.641-.847h-1.008c.067-.526-.35-1-.891-1-1.557,0-4.621,0-6.177,0-.541,0-.959.474-.891,1h-1.008c-.649,0-1.263.317-1.641.847-.371.52-.469,1.188-.263,1.787.726,2.108,2.68,3.366,5.228,3.366Zm6.667-4.018c-.441,1.282-1.658,2.018-3.338,2.018h-.262c.138-.188.297-.364.492-.506.566-.412.999-.939,1.335-1.502l1.773-.01Zm-8.226.018c.335.56.767,1.084,1.33,1.494.195.142.353.317.492.506h-.262c-1.679,0-2.896-.735-3.324-2h1.765Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,11 +3,13 @@ dashboard.py — главный вид статистического дашбо
Графики строятся через matplotlib и отображаются как изображения (ft.Image).
"""
import asyncio
import base64
import io
import math
import flet as ft
import matplotlib
import matplotlib.colors
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
@@ -50,8 +52,8 @@ def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None
counts = [d["count"] for d in data]
n = len(labels)
fig_h = max(3.0, n * 0.45 + 0.8)
fig, ax = plt.subplots(figsize=(7, fig_h))
fig_h = max(4.5, n * 0.55 + 1.2)
fig, ax = plt.subplots(figsize=(10, fig_h))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
@@ -71,26 +73,26 @@ def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None
str(v),
va="center",
ha="left",
fontsize=9,
fontsize=11,
color="#1E2A3A",
)
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=9, color="#5A6779")
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel("Количество ответов", fontsize=9, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=9, colors="#5A6779")
ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_xlim(0, max(counts) * 1.18)
ax.set_xlim(0, max(counts) * 1.18 if max(counts) > 0 else 1)
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=0.8)
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
@@ -105,23 +107,23 @@ def _build_line_chart_image(data: list[dict]) -> bytes | None:
counts = [d["count"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(6, n * 0.7), 3.5))
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
ax.plot(range(n), counts, color="#F85A40", linewidth=2, marker="o",
markersize=5, markerfacecolor="#F85A40", markeredgecolor="white",
ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o",
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5)
ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40")
for i, v in enumerate(counts):
ax.text(i, v + max(counts) * 0.03, str(v), ha="center",
fontsize=8, color="#1E2A3A")
fontsize=11, color="#1E2A3A")
ax.set_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=8, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=8, colors="#5A6779")
ax.set_ylabel("Ответов", fontsize=8, color="#5A6779")
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Ответов", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
@@ -129,22 +131,149 @@ def _build_line_chart_image(data: list[dict]) -> bytes | None:
ax.set_ylim(0, max(counts) * 1.25)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=0.8)
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _image_or_placeholder(img_bytes: bytes | None) -> ft.Control:
def _build_avg_bar(data: list[dict], xlabel: str = "Средний балл") -> bytes | None:
"""Горизонтальный bar chart со средними баллами (поле 'avg')."""
if not data:
return None
labels = [_wrap_label(d["label"]) for d in data]
values = [d["avg"] for d in data]
n = len(labels)
colors = [_PALETTE[i % len(_PALETTE)] for i in range(n)]
fig_h = max(5.5, n * 0.65 + 1.5)
fig, ax = plt.subplots(figsize=(14, fig_h))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
bars = ax.barh(range(n), values, color=colors, height=0.6, edgecolor="white", linewidth=0.5)
for bar, v in zip(bars, values):
ax.text(v + max(values) * 0.01, bar.get_y() + bar.get_height() / 2,
f"{v:.1f}", va="center", ha="left", fontsize=11, color="#1E2A3A")
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel(xlabel, fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_xlim(0, max(values) * 1.18 if max(values) > 0 else 1)
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_pie_chart(data: list[dict]) -> bytes | None:
"""Круговая диаграмма распределения ведущего типа."""
if not data:
return None
labels = [d["label"] for d in data]
values = [d["count"] for d in data]
n_colors = len(labels)
if n_colors <= len(_PALETTE):
colors = _PALETTE[:n_colors]
else:
# Генерируем уникальные цвета через hsv colormap
cmap = matplotlib.colormaps["hsv"]
colors = [matplotlib.colors.to_hex(cmap(i / n_colors)) for i in range(n_colors)]
# Размер фигуры растёт с числом элементов для читаемой легенды
legend_rows = math.ceil(n_colors / 2)
fig_h = max(7, legend_rows * 0.35 + 5)
fig, ax = plt.subplots(figsize=(11, fig_h))
fig.patch.set_facecolor("#FFFFFF")
wedges, texts, autotexts = ax.pie(
values,
labels=None,
autopct="%1.1f%%",
colors=colors,
startangle=140,
wedgeprops=dict(edgecolor="white", linewidth=1.5),
pctdistance=0.78,
)
for t in autotexts:
t.set_fontsize(9 if n_colors > 15 else 10)
t.set_color("#1E2A3A")
ncol = 3 if n_colors > 20 else 2
ax.legend(
wedges,
[f"{l} ({v})" for l, v in zip(labels, values)],
loc="lower center",
bbox_to_anchor=(0.5, -0.05 - legend_rows * 0.04),
ncol=ncol,
fontsize=9,
frameon=False,
)
plt.tight_layout(pad=1.5)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_avg_line(data: list[dict]) -> bytes | None:
"""Линейный тренд средних баллов по месяцам (поле 'avg')."""
if not data:
return None
labels = [d["label"] for d in data]
values = [d["avg"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
ax.plot(range(n), values, color="#F85A40", linewidth=2.5, marker="o",
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5)
ax.fill_between(range(n), values, alpha=0.12, color="#F85A40")
for i, v in enumerate(values):
ax.text(i, v + max(values) * 0.03, f"{v:.1f}", ha="center", fontsize=11, color="#1E2A3A")
ax.set_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Средний балл", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_ylim(0, max(values) * 1.25 if max(values) > 0 else 1)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _image_or_placeholder(img_bytes: bytes | None, width: int = 1100) -> ft.Control:
if img_bytes:
return ft.Image(src=img_bytes, fit=ft.ImageFit.CONTAIN, expand=True)
b64 = base64.b64encode(img_bytes).decode()
return ft.Image(
src=f"data:image/png;base64,{b64}",
fit=ft.BoxFit.FIT_WIDTH,
width=width,
)
return ft.Container(
content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True),
alignment=ft.alignment.center,
alignment=ft.Alignment(0, 0),
height=ds.s(120),
expand=True,
width=width,
)
@@ -152,7 +281,7 @@ class DashboardView(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
# ── Фильтры ─────────────────────────────────────────────────────────
# ── Общие фильтры ────────────────────────────────────────────────────
self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты")
self._poll_dd.on_select = self._on_filter_change
@@ -181,7 +310,7 @@ class DashboardView(ft.Column):
color=ds.colors.text_on_primary,
shape=ft.RoundedRectangleBorder(radius=ds.s(8)),
),
on_click=lambda e: page.run_task(self._refresh),
on_click=lambda e: page.run_task(self._refresh_current_tab),
)
# ── Summary карточки ─────────────────────────────────────────────────
@@ -190,22 +319,149 @@ class DashboardView(ft.Column):
self._card_active = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
self._card_orgs = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
# ── Контейнеры графиков ──────────────────────────────────────────────
self._chart_poll = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
# ── Таб 1: графики по прохождениям ───────────────────────────────────
self._chart_poll = self._make_chart_box()
self._chart_org = self._make_chart_box()
self._chart_group = self._make_chart_box()
self._chart_month = self._make_chart_box()
# ── Таб 2: результаты — фильтр по оси ────────────────────────────────
self._dim_dd = ds.SearchableDropdown(label="Ось/Тип", hint_text="Выберите ось")
self._dim_dd.on_select = self._on_results_filter_change
self._compare_by_dd = ds.SearchableDropdown(label="Сравнение по", hint_text="Организациям")
self._compare_by_dd.options = [
ft.DropdownOption(key="organization", text="Организациям"),
ft.DropdownOption(key="group", text="Группам"),
]
self._compare_by_dd.value = "organization"
self._compare_by_dd.on_select = self._on_results_filter_change
self._chart_avg_dim = self._make_chart_box()
self._chart_leading = self._make_chart_box()
self._chart_compare = self._make_chart_box()
self._chart_trend = self._make_chart_box()
# Таблица топ-пользователей
self._top_table = ft.DataTable(
columns=[
ft.DataColumn(ft.Text("Участник", size=ds.s(12), weight=ft.FontWeight.W_600)),
ft.DataColumn(ft.Text("Тест", size=ds.s(12), weight=ft.FontWeight.W_600)),
ft.DataColumn(ft.Text("Балл", size=ds.s(12), weight=ft.FontWeight.W_600), numeric=True),
],
rows=[],
border=ft.border.all(1, ds.colors.border),
border_radius=ds.s(8),
horizontal_lines=ft.BorderSide(1, ds.colors.border),
column_spacing=ds.s(20),
)
self._chart_org = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
# ── Навигация по табам ────────────────────────────────────────────────
self._current_tab = 0
# ── Содержимое табов ──────────────────────────────────────────────────
self._tab1_content = ft.Column(
spacing=ds.s(16),
controls=[
ft.Container(
content=self._chart_card_wrap("По тестам", self._chart_poll),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("По организациям", self._chart_org),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("По группам", self._chart_group),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Динамика по месяцам", self._chart_month),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
margin=ft.margin.only(bottom=ds.s(24)),
),
],
)
self._chart_group = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
self._tab2_content = ft.Column(
spacing=ds.s(16),
controls=[
# Дополнительный фильтр по оси
ft.Container(
content=ft.Column(
spacing=ds.s(8),
controls=[
ft.Text("Фильтр по оси/типу", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row(
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
controls=[self._dim_dd, self._compare_by_dd],
),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border),
),
ft.Container(
content=self._chart_card_wrap("Средний балл по осям", self._chart_avg_dim),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Распределение ведущего типа", self._chart_leading),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Сравнение по группам/организациям", self._chart_compare),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Динамика среднего балла", self._chart_trend),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Топ участников
ft.Container(
content=ft.Column(
spacing=ds.s(10),
controls=[
ft.Text("Топ-10 участников по выбранной оси",
color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
ft.Container(content=self._top_table, expand=True),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.only(left=ds.s(16), right=ds.s(16), bottom=ds.s(24)),
border=ft.border.all(1, ds.colors.border),
),
],
)
self._chart_month = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
# ── Tabs (TabBar + ручное переключение) ──────────────────────────────
self._tab_body = ft.Container(content=self._tab1_content, expand=True)
self._tabs_ctrl = ft.Tabs(
content=ft.Column(
expand=True,
spacing=0,
controls=[
ft.TabBar(
tabs=[
ft.Tab(label="Прохождения", icon=ft.Icons.BAR_CHART),
ft.Tab(label="Результаты тестов", icon=ft.Icons.INSIGHTS),
],
scrollable=False,
),
self._tab_body,
],
),
length=2,
selected_index=0,
on_change=self._on_tab_change,
expand=True,
)
super().__init__(
@@ -229,13 +485,10 @@ class DashboardView(ft.Column):
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)),
border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)),
),
# Summary карточки
ft.Container(
content=ft.Row(
wrap=True,
spacing=ds.s(12),
run_spacing=ds.s(12),
wrap=True, spacing=ds.s(12), run_spacing=ds.s(12),
controls=[
self._summary_tile("Всего ответов", self._card_total,
ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary),
@@ -249,8 +502,7 @@ class DashboardView(ft.Column):
),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Фильтры
# Общие фильтры
ft.Container(
content=ft.Column(
spacing=ds.s(10),
@@ -258,20 +510,12 @@ class DashboardView(ft.Column):
ft.Text("Фильтры", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row(
wrap=True,
spacing=ds.s(10),
run_spacing=ds.s(10),
controls=[
self._poll_dd,
self._org_dd,
self._group_dd,
self._year_dd,
],
),
ft.Row(
spacing=ds.s(8),
controls=[self._reset_btn, self._refresh_btn],
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
controls=[self._poll_dd, self._org_dd,
self._group_dd, self._year_dd],
),
ft.Row(spacing=ds.s(8),
controls=[self._reset_btn, self._refresh_btn]),
],
),
bgcolor=ds.colors.surface,
@@ -280,49 +524,29 @@ class DashboardView(ft.Column):
margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border),
),
# Графики: строка 1
ft.Container(
content=ft.Row(
spacing=ds.s(16),
controls=[
self._chart_card_wrap("По тестам", self._chart_poll),
self._chart_card_wrap("По организациям", self._chart_org),
],
),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Графики: строка 2
ft.Container(
content=ft.Row(
spacing=ds.s(16),
controls=[
self._chart_card_wrap("По группам", self._chart_group),
self._chart_card_wrap("По месяцам", self._chart_month),
],
),
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(4)),
margin=ft.margin.only(bottom=ds.s(24)),
),
# Вкладки
self._tabs_ctrl,
],
)
# ── helpers ─────────────────────────────────────────────────────────────
def _make_chart_box(self) -> ft.Container:
return ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.Alignment(0, 0),
expand=True,
)
def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(4),
tight=True,
spacing=ds.s(4), tight=True,
controls=[
ft.Row(
spacing=ds.s(6),
controls=[
ft.Icon(icon, color=color, size=ds.s(18)),
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
],
),
ft.Row(spacing=ds.s(6), controls=[
ft.Icon(icon, color=color, size=ds.s(18)),
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
]),
value_text,
],
),
@@ -336,8 +560,7 @@ class DashboardView(ft.Column):
def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(10),
tight=True,
spacing=ds.s(10), tight=True,
controls=[
ft.Text(title, color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
@@ -353,40 +576,66 @@ class DashboardView(ft.Column):
# ── mounting ─────────────────────────────────────────────────────────────
def _chart_width(self) -> int:
"""Ширина графика = ширина страницы минус отступы."""
try:
w = self.page.width or 1200
except Exception:
w = 1200
return max(600, int(w) - 80)
def did_mount(self):
self.page.run_task(self._initial_load)
async def _initial_load(self):
"""Загрузка справочников и первичных данных."""
polls, orgs, groups, years, summary = await asyncio.gather(
polls, orgs, groups, years, summary, dimensions = await asyncio.gather(
ac.get_polls(),
ac.get_organizations(),
ac.get_groups(),
ac.get_years(),
ac.get_summary(),
ac.get_dimensions(),
)
# Наполняем дропдауны
_dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text)
self._poll_dd.options = [_dd_opt(p["id"], p["title"]) for p in (polls or [])]
self._org_dd.options = [_dd_opt(o["id"], o["name_organization"]) for o in (orgs or [])]
self._group_dd.options = [_dd_opt(g["id"], g["name_group"]) for g in (groups or [])]
self._year_dd.options = [_dd_opt(str(y), str(y)) for y in (years or [])]
self._dim_dd.options = [_dd_opt(d, d) for d in (dimensions or [])]
if dimensions:
self._dim_dd.value = dimensions[0]
# Summary
self._card_total.value = str(summary.get("total_responses", ""))
self._card_polls.value = str(summary.get("total_polls", ""))
self._card_active.value = str(summary.get("active_polls", ""))
self._card_orgs.value = str(summary.get("orgs_active", ""))
self.update()
await self._refresh()
await self._refresh_tab1()
await self._refresh_tab2()
# ── filter helpers ───────────────────────────────────────────────────────
# ── tab switching ─────────────────────────────────────────────────────────
def _on_tab_change(self, e):
self._current_tab = int(e.data)
self._tab_body.content = self._tab1_content if self._current_tab == 0 else self._tab2_content
self._tab_body.update()
async def _refresh_current_tab(self):
if self._current_tab == 0:
await self._refresh_tab1()
else:
await self._refresh_tab2()
# ── filters ───────────────────────────────────────────────────────────────
def _on_filter_change(self, e):
self.page.run_task(self._refresh)
self.page.run_task(self._refresh_current_tab)
def _on_results_filter_change(self, e):
self.page.run_task(self._refresh_tab2)
def _reset_filters(self, e):
self._poll_dd.value = None
@@ -394,7 +643,7 @@ class DashboardView(ft.Column):
self._group_dd.value = None
self._year_dd.value = None
self.update()
self.page.run_task(self._refresh)
self.page.run_task(self._refresh_current_tab)
def _get_filters(self) -> dict:
year_str = self._year_dd.value
@@ -405,28 +654,21 @@ class DashboardView(ft.Column):
"year": int(year_str) if year_str else None,
}
# ── data refresh ─────────────────────────────────────────────────────────
# ── TAB 1: прохождения ───────────────────────────────────────────────────
async def _refresh(self):
async def _refresh_tab1(self):
f = self._get_filters()
# Показываем спиннеры
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
self._chart_poll.content = spinner()
self._chart_org.content = spinner()
self._chart_group.content = spinner()
self._chart_month.content = spinner()
for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]:
c.content = spinner()
self.update()
# Параллельно загружаем все 4 набора данных
d_poll, d_org, d_group, d_month = await asyncio.gather(
ac.get_chart_data("poll", **f),
ac.get_chart_data("organization", **f),
ac.get_chart_data("group", **f),
ac.get_chart_data("month", **f),
)
# Строим изображения (CPU работа — в executor чтобы не блокировать event loop)
loop = asyncio.get_event_loop()
img_poll, img_org, img_group, img_month = await asyncio.gather(
loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"),
@@ -434,10 +676,70 @@ class DashboardView(ft.Column):
loop.run_in_executor(None, _build_chart_image, d_group, "По группам", "#85C446"),
loop.run_in_executor(None, _build_line_chart_image, d_month),
)
self._chart_poll.content = _image_or_placeholder(img_poll)
self._chart_org.content = _image_or_placeholder(img_org)
self._chart_group.content = _image_or_placeholder(img_group)
self._chart_month.content = _image_or_placeholder(img_month)
w = self._chart_width()
self._chart_poll.content = _image_or_placeholder(img_poll, w)
self._chart_org.content = _image_or_placeholder(img_org, w)
self._chart_group.content = _image_or_placeholder(img_group, w)
self._chart_month.content = _image_or_placeholder(img_month, w)
self.update()
# ── TAB 2: результаты ────────────────────────────────────────────────────
async def _refresh_tab2(self):
f = self._get_filters()
dim = self._dim_dd.value
by = self._compare_by_dd.value or "organization"
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
for c in [self._chart_avg_dim, self._chart_leading,
self._chart_compare, self._chart_trend]:
c.content = spinner()
self._top_table.rows = []
self.update()
# Параллельная загрузка
d_avg, d_lead, d_top = await asyncio.gather(
ac.get_avg_by_dimension(**f),
ac.get_leading_type(**f),
ac.get_top_users(dim, **f) if dim else asyncio.sleep(0),
)
if not dim:
d_top = []
d_compare = await ac.get_compare_groups(dim or "", by=by,
poll_id=f["poll_id"],
year=f["year"]) if dim else []
d_trend = await ac.get_dimension_trend(dim or "",
poll_id=f["poll_id"],
org_id=f["org_id"],
group_id=f["group_id"]) if dim else []
loop = asyncio.get_event_loop()
img_avg, img_lead, img_compare, img_trend = await asyncio.gather(
loop.run_in_executor(None, _build_avg_bar, d_avg, "Средний балл"),
loop.run_in_executor(None, _build_pie_chart, d_lead),
loop.run_in_executor(None, _build_avg_bar, d_compare,
f"Средний балл по {'организациям' if by == 'organization' else 'группам'}"),
loop.run_in_executor(None, _build_avg_line, d_trend),
)
w = self._chart_width()
self._chart_avg_dim.content = _image_or_placeholder(img_avg, w)
self._chart_leading.content = _image_or_placeholder(img_lead, w)
self._chart_compare.content = _image_or_placeholder(img_compare, w)
self._chart_trend.content = _image_or_placeholder(img_trend, w)
# Таблица топ
self._top_table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(row["name"], size=ds.s(12))),
ft.DataCell(ft.Text(row["poll"], size=ds.s(11),
color=ds.colors.text_secondary,
overflow=ft.TextOverflow.ELLIPSIS,
max_lines=1)),
ft.DataCell(ft.Text(str(row["value"]), size=ds.s(12),
weight=ft.FontWeight.BOLD,
color=ds.colors.primary)),
])
for row in (d_top or [])
]
self.update()

View File

@@ -30,7 +30,7 @@ class colors:
class SearchableDropdown(ft.Container):
"""Выпадающий список с поисковой строкой."""
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None):
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None, width: int | None = None):
self._label_str = label
self._hint_str = hint_text
self._options: list = options or []
@@ -69,7 +69,7 @@ class SearchableDropdown(ft.Container):
padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)),
on_click=self._open_dialog,
ink=True,
expand=True,
width=width or s(220),
)
@property
@@ -90,8 +90,11 @@ class SearchableDropdown(ft.Container):
self._value_text.color = colors.text_primary
self._value_text.italic = False
break
if self.page:
self.update()
try:
if self.page:
self.update()
except RuntimeError:
pass
@property
def options(self):

View File

@@ -15,5 +15,5 @@ async def main(page: ft.Page):
page.add(view)
_flet_app = ft.run(main, export_asgi_app=True)
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
app = Starlette(routes=[Mount("/", _flet_app)])

View File

@@ -50,6 +50,18 @@ async def login(username: str, password: str) -> tuple[str | None, str | None]:
return None, f"Ошибка: {e}"
async def logout(token: str) -> None:
"""POST /auth/logout — инвалидирует токен на сервере (Redis блэклист)."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
await client.post(
"/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
except Exception:
pass # даже если сервер недоступен — локальный logout всё равно произойдёт
async def get_me(token: str) -> dict | None:
"""GET /users/me — данные текущего пользователя."""
try:

View File

@@ -37,7 +37,10 @@ class AuthService:
return await api_client.get_me(token)
async def logout(self):
"""Удаляет токен — пользователь разлогинен."""
"""Инвалидирует токен на сервере и удаляет локальную сессию."""
token = self._page.session.store.get(_TOKEN_KEY)
if token:
await api_client.logout(token)
self._page.session.store.remove(_TOKEN_KEY)
self._page.session.store.remove("user_id")
self._page.session.store.remove("url_key")