big update new dashboard and import export

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

View File

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

View File

@@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials 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 route import auth
from bd import wait_for_db from bd import wait_for_db
import uvicorn import uvicorn
@@ -75,6 +75,8 @@ app.include_router(glomshtok_crud.router)
app.include_router(scale_crud.router) app.include_router(scale_crud.router)
app.include_router(radar_crud.router) app.include_router(radar_crud.router)
app.include_router(stats_crud.router) app.include_router(stats_crud.router)
app.include_router(transfer_crud.router)
app.include_router(transfer_crud.backup_router)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

336
route/transfer_crud.py Normal file
View File

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

View File

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

BIN
stats/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

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

View File

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

After

Width:  |  Height:  |  Size: 737 B

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,11 +3,13 @@ dashboard.py — главный вид статистического дашбо
Графики строятся через matplotlib и отображаются как изображения (ft.Image). Графики строятся через matplotlib и отображаются как изображения (ft.Image).
""" """
import asyncio import asyncio
import base64
import io import io
import math import math
import flet as ft import flet as ft
import matplotlib import matplotlib
import matplotlib.colors
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.font_manager as fm 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] counts = [d["count"] for d in data]
n = len(labels) n = len(labels)
fig_h = max(3.0, n * 0.45 + 0.8) fig_h = max(4.5, n * 0.55 + 1.2)
fig, ax = plt.subplots(figsize=(7, fig_h)) fig, ax = plt.subplots(figsize=(10, fig_h))
fig.patch.set_facecolor("#FFFFFF") fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8") ax.set_facecolor("#F4F6F8")
@@ -71,26 +73,26 @@ def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None
str(v), str(v),
va="center", va="center",
ha="left", ha="left",
fontsize=9, fontsize=11,
color="#1E2A3A", color="#1E2A3A",
) )
ax.set_yticks(range(n)) 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.invert_yaxis()
ax.set_xlabel("Количество ответов", fontsize=9, color="#5A6779") ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=9, colors="#5A6779") ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False) ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False) ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA") ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].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="--") 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() 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) plt.close(fig)
buf.seek(0) buf.seek(0)
return buf.read() return buf.read()
@@ -105,23 +107,23 @@ def _build_line_chart_image(data: list[dict]) -> bytes | None:
counts = [d["count"] for d in data] counts = [d["count"] for d in data]
n = len(labels) 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") fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8") ax.set_facecolor("#F4F6F8")
ax.plot(range(n), counts, color="#F85A40", linewidth=2, marker="o", ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o",
markersize=5, markerfacecolor="#F85A40", markeredgecolor="white", markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5) markeredgewidth=1.5)
ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40") ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40")
for i, v in enumerate(counts): for i, v in enumerate(counts):
ax.text(i, v + max(counts) * 0.03, str(v), ha="center", 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_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=8, color="#5A6779") ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=8, colors="#5A6779") ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Ответов", fontsize=8, color="#5A6779") ax.set_ylabel("Ответов", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False) ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False) ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA") 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.set_ylim(0, max(counts) * 1.25)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--") 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() 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) plt.close(fig)
buf.seek(0) buf.seek(0)
return buf.read() 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: 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( return ft.Container(
content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True), 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), height=ds.s(120),
expand=True, width=width,
) )
@@ -152,7 +281,7 @@ class DashboardView(ft.Column):
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self._page = page self._page = page
# ── Фильтры ───────────────────────────────────────────────────────── # ── Общие фильтры ────────────────────────────────────────────────────
self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты") self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты")
self._poll_dd.on_select = self._on_filter_change self._poll_dd.on_select = self._on_filter_change
@@ -181,7 +310,7 @@ class DashboardView(ft.Column):
color=ds.colors.text_on_primary, color=ds.colors.text_on_primary,
shape=ft.RoundedRectangleBorder(radius=ds.s(8)), 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 карточки ───────────────────────────────────────────────── # ── 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_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._card_orgs = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
# ── Контейнеры графиков ────────────────────────────────────────────── # ── Таб 1: графики по прохождениям ───────────────────────────────────
self._chart_poll = ft.Container( self._chart_poll = self._make_chart_box()
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)), self._chart_org = self._make_chart_box()
alignment=ft.alignment.center, height=ds.s(200), expand=True, 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)), self._tab2_content = ft.Column(
alignment=ft.alignment.center, height=ds.s(200), expand=True, 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)), # ── Tabs (TabBar + ручное переключение) ──────────────────────────────
alignment=ft.alignment.center, height=ds.s(200), expand=True, 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__( super().__init__(
@@ -229,13 +485,10 @@ class DashboardView(ft.Column):
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)), padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)),
border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)), border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)),
), ),
# Summary карточки # Summary карточки
ft.Container( ft.Container(
content=ft.Row( content=ft.Row(
wrap=True, wrap=True, spacing=ds.s(12), run_spacing=ds.s(12),
spacing=ds.s(12),
run_spacing=ds.s(12),
controls=[ controls=[
self._summary_tile("Всего ответов", self._card_total, self._summary_tile("Всего ответов", self._card_total,
ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary), ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary),
@@ -249,8 +502,7 @@ class DashboardView(ft.Column):
), ),
padding=ft.padding.symmetric(horizontal=ds.s(16)), padding=ft.padding.symmetric(horizontal=ds.s(16)),
), ),
# Общие фильтры
# Фильтры
ft.Container( ft.Container(
content=ft.Column( content=ft.Column(
spacing=ds.s(10), spacing=ds.s(10),
@@ -258,20 +510,12 @@ class DashboardView(ft.Column):
ft.Text("Фильтры", color=ds.colors.text_secondary, ft.Text("Фильтры", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600), size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row( ft.Row(
wrap=True, wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
spacing=ds.s(10), controls=[self._poll_dd, self._org_dd,
run_spacing=ds.s(10), self._group_dd, self._year_dd],
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],
), ),
ft.Row(spacing=ds.s(8),
controls=[self._reset_btn, self._refresh_btn]),
], ],
), ),
bgcolor=ds.colors.surface, bgcolor=ds.colors.surface,
@@ -280,49 +524,29 @@ class DashboardView(ft.Column):
margin=ft.margin.symmetric(horizontal=ds.s(16)), margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border), border=ft.border.all(1, ds.colors.border),
), ),
# Вкладки
# Графики: строка 1 self._tabs_ctrl,
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)),
),
], ],
) )
# ── helpers ───────────────────────────────────────────────────────────── # ── 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: def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container:
return ft.Container( return ft.Container(
content=ft.Column( content=ft.Column(
spacing=ds.s(4), spacing=ds.s(4), tight=True,
tight=True,
controls=[ controls=[
ft.Row( ft.Row(spacing=ds.s(6), controls=[
spacing=ds.s(6), ft.Icon(icon, color=color, size=ds.s(18)),
controls=[ ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
ft.Icon(icon, color=color, size=ds.s(18)), ]),
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
],
),
value_text, value_text,
], ],
), ),
@@ -336,8 +560,7 @@ class DashboardView(ft.Column):
def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container: def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container:
return ft.Container( return ft.Container(
content=ft.Column( content=ft.Column(
spacing=ds.s(10), spacing=ds.s(10), tight=True,
tight=True,
controls=[ controls=[
ft.Text(title, color=ds.colors.text_primary, size=ds.s(14), ft.Text(title, color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600), weight=ft.FontWeight.W_600),
@@ -353,40 +576,66 @@ class DashboardView(ft.Column):
# ── mounting ───────────────────────────────────────────────────────────── # ── 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): def did_mount(self):
self.page.run_task(self._initial_load) self.page.run_task(self._initial_load)
async def _initial_load(self): async def _initial_load(self):
"""Загрузка справочников и первичных данных.""" polls, orgs, groups, years, summary, dimensions = await asyncio.gather(
polls, orgs, groups, years, summary = await asyncio.gather(
ac.get_polls(), ac.get_polls(),
ac.get_organizations(), ac.get_organizations(),
ac.get_groups(), ac.get_groups(),
ac.get_years(), ac.get_years(),
ac.get_summary(), ac.get_summary(),
ac.get_dimensions(),
) )
# Наполняем дропдауны
_dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text) _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._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._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._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._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_total.value = str(summary.get("total_responses", ""))
self._card_polls.value = str(summary.get("total_polls", "")) self._card_polls.value = str(summary.get("total_polls", ""))
self._card_active.value = str(summary.get("active_polls", "")) self._card_active.value = str(summary.get("active_polls", ""))
self._card_orgs.value = str(summary.get("orgs_active", "")) self._card_orgs.value = str(summary.get("orgs_active", ""))
self.update() 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): 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): def _reset_filters(self, e):
self._poll_dd.value = None self._poll_dd.value = None
@@ -394,7 +643,7 @@ class DashboardView(ft.Column):
self._group_dd.value = None self._group_dd.value = None
self._year_dd.value = None self._year_dd.value = None
self.update() self.update()
self.page.run_task(self._refresh) self.page.run_task(self._refresh_current_tab)
def _get_filters(self) -> dict: def _get_filters(self) -> dict:
year_str = self._year_dd.value year_str = self._year_dd.value
@@ -405,28 +654,21 @@ class DashboardView(ft.Column):
"year": int(year_str) if year_str else None, "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() f = self._get_filters()
# Показываем спиннеры
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24)) spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
self._chart_poll.content = spinner() for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]:
self._chart_org.content = spinner() c.content = spinner()
self._chart_group.content = spinner()
self._chart_month.content = spinner()
self.update() self.update()
# Параллельно загружаем все 4 набора данных
d_poll, d_org, d_group, d_month = await asyncio.gather( d_poll, d_org, d_group, d_month = await asyncio.gather(
ac.get_chart_data("poll", **f), ac.get_chart_data("poll", **f),
ac.get_chart_data("organization", **f), ac.get_chart_data("organization", **f),
ac.get_chart_data("group", **f), ac.get_chart_data("group", **f),
ac.get_chart_data("month", **f), ac.get_chart_data("month", **f),
) )
# Строим изображения (CPU работа — в executor чтобы не блокировать event loop)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
img_poll, img_org, img_group, img_month = await asyncio.gather( img_poll, img_org, img_group, img_month = await asyncio.gather(
loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"), 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_chart_image, d_group, "По группам", "#85C446"),
loop.run_in_executor(None, _build_line_chart_image, d_month), loop.run_in_executor(None, _build_line_chart_image, d_month),
) )
w = self._chart_width()
self._chart_poll.content = _image_or_placeholder(img_poll) self._chart_poll.content = _image_or_placeholder(img_poll, w)
self._chart_org.content = _image_or_placeholder(img_org) self._chart_org.content = _image_or_placeholder(img_org, w)
self._chart_group.content = _image_or_placeholder(img_group) self._chart_group.content = _image_or_placeholder(img_group, w)
self._chart_month.content = _image_or_placeholder(img_month) 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() self.update()

View File

@@ -30,7 +30,7 @@ class colors:
class SearchableDropdown(ft.Container): 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._label_str = label
self._hint_str = hint_text self._hint_str = hint_text
self._options: list = options or [] self._options: list = options or []
@@ -69,7 +69,7 @@ class SearchableDropdown(ft.Container):
padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)), padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)),
on_click=self._open_dialog, on_click=self._open_dialog,
ink=True, ink=True,
expand=True, width=width or s(220),
) )
@property @property
@@ -90,8 +90,11 @@ class SearchableDropdown(ft.Container):
self._value_text.color = colors.text_primary self._value_text.color = colors.text_primary
self._value_text.italic = False self._value_text.italic = False
break break
if self.page: try:
self.update() if self.page:
self.update()
except RuntimeError:
pass
@property @property
def options(self): def options(self):

View File

@@ -15,5 +15,5 @@ async def main(page: ft.Page):
page.add(view) 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)]) app = Starlette(routes=[Mount("/", _flet_app)])

View File

@@ -50,6 +50,18 @@ async def login(username: str, password: str) -> tuple[str | None, str | None]:
return None, f"Ошибка: {e}" 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: async def get_me(token: str) -> dict | None:
"""GET /users/me — данные текущего пользователя.""" """GET /users/me — данные текущего пользователя."""
try: try:

View File

@@ -37,7 +37,10 @@ class AuthService:
return await api_client.get_me(token) return await api_client.get_me(token)
async def logout(self): 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(_TOKEN_KEY)
self._page.session.store.remove("user_id") self._page.session.store.remove("user_id")
self._page.session.store.remove("url_key") self._page.session.store.remove("url_key")