diff --git a/docker-compose_db.yml b/docker-compose_db.yml index 6f1379a..8050497 100644 --- a/docker-compose_db.yml +++ b/docker-compose_db.yml @@ -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: diff --git a/main.py b/main.py index c3b4d3e..6aa5aea 100644 --- a/main.py +++ b/main.py @@ -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) # --------------------------------------------------------------------------- diff --git a/redis_db/__init__.py b/redis_db/__init__.py index e515b55..8eb5b5a 100644 --- a/redis_db/__init__.py +++ b/redis_db/__init__.py @@ -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", +] diff --git a/route/auth.py b/route/auth.py index c136929..747172b 100644 --- a/route/auth.py +++ b/route/auth.py @@ -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"} diff --git a/route/auth_utils.py b/route/auth_utils.py index 9863896..0e7b1d0 100644 --- a/route/auth_utils.py +++ b/route/auth_utils.py @@ -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 diff --git a/route/stats_crud.py b/route/stats_crud.py index 122c9c9..ee25764 100644 --- a/route/stats_crud.py +++ b/route/stats_crud.py @@ -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 diff --git a/route/transfer_crud.py b/route/transfer_crud.py new file mode 100644 index 0000000..37c13db --- /dev/null +++ b/route/transfer_crud.py @@ -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, + } diff --git a/stats/api_client.py b/stats/api_client.py index da79fd4..06bd07c 100644 --- a/stats/api_client.py +++ b/stats/api_client.py @@ -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 [] diff --git a/stats/assets/favicon.png b/stats/assets/favicon.png new file mode 100644 index 0000000..8ecde27 Binary files /dev/null and b/stats/assets/favicon.png differ diff --git a/stats/assets/icons/IMG_4533.png b/stats/assets/icons/IMG_4533.png new file mode 100644 index 0000000..7c9703b Binary files /dev/null and b/stats/assets/icons/IMG_4533.png differ diff --git a/stats/assets/icons/README b/stats/assets/icons/README new file mode 100644 index 0000000..709aa22 --- /dev/null +++ b/stats/assets/icons/README @@ -0,0 +1,3 @@ +# Положи сюда своё изображение с именем loading-animation.png +# Это заменит мигающий логотип Flet при загрузке страницы. +# Рекомендуемый размер: PNG, минимум 200x200 px. diff --git a/stats/assets/icons/clipboard-question_16542596.svg b/stats/assets/icons/clipboard-question_16542596.svg new file mode 100644 index 0000000..803761e --- /dev/null +++ b/stats/assets/icons/clipboard-question_16542596.svg @@ -0,0 +1,4 @@ + + + + diff --git a/stats/assets/icons/leaderboard-trophy_14227576.svg b/stats/assets/icons/leaderboard-trophy_14227576.svg new file mode 100644 index 0000000..011f4e3 --- /dev/null +++ b/stats/assets/icons/leaderboard-trophy_14227576.svg @@ -0,0 +1,4 @@ + + + + diff --git a/stats/assets/icons/loading-animation.png b/stats/assets/icons/loading-animation.png new file mode 100644 index 0000000..91c0a0c Binary files /dev/null and b/stats/assets/icons/loading-animation.png differ diff --git a/stats/dashboard.py b/stats/dashboard.py index 4cec70c..ed6fd84 100644 --- a/stats/dashboard.py +++ b/stats/dashboard.py @@ -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() diff --git a/stats/designer.py b/stats/designer.py index 2fb4ec7..e749205 100644 --- a/stats/designer.py +++ b/stats/designer.py @@ -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): diff --git a/stats/main.py b/stats/main.py index cf91b80..33b3a11 100644 --- a/stats/main.py +++ b/stats/main.py @@ -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)]) diff --git a/web/api_client.py b/web/api_client.py index 9fd661d..e7cbb70 100644 --- a/web/api_client.py +++ b/web/api_client.py @@ -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: diff --git a/web/info.py b/web/info.py index 4c90bd3..55db674 100644 --- a/web/info.py +++ b/web/info.py @@ -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")