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")