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