419 lines
16 KiB
Python
419 lines
16 KiB
Python
from fastapi import APIRouter, Query
|
||
from typing import Optional
|
||
from sqlalchemy import func
|
||
import json
|
||
import hashlib
|
||
|
||
from bd import make_engine
|
||
from bd.tables.response import Response
|
||
from bd.tables.poll import Poll
|
||
from bd.tables.group import Group
|
||
from bd.tables.organization import Organization
|
||
from bd.tables.radar_result import RadarResult, RadarResultItem
|
||
from bd.tables.users import User
|
||
from redis_db import cache_get, cache_set
|
||
|
||
router = APIRouter(tags=["stats"], prefix="/stats")
|
||
|
||
_MONTH_NAMES = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн",
|
||
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
|
||
|
||
_CACHE_TTL = 300 # 5 минут
|
||
|
||
|
||
def _cache_key(*parts) -> str:
|
||
raw = ":".join(str(p) for p in parts)
|
||
return hashlib.md5(raw.encode()).hexdigest()
|
||
|
||
|
||
def get_session():
|
||
from sqlalchemy.orm import sessionmaker
|
||
return sessionmaker(bind=make_engine(), future=True)
|
||
|
||
|
||
@router.get("/summary")
|
||
def get_summary():
|
||
"""Общая сводка: кол-во ответов, тестов, активных тестов, организаций с активностью."""
|
||
key = _cache_key("summary")
|
||
cached = cache_get(key)
|
||
if cached:
|
||
return json.loads(cached)
|
||
|
||
Session = get_session()
|
||
with Session() as session:
|
||
total_responses = session.query(func.count(Response.id)).scalar() or 0
|
||
total_polls = session.query(func.count(Poll.id)).scalar() or 0
|
||
active_polls = session.query(func.count(Poll.id)).filter(Poll.is_active.is_(True)).scalar() or 0
|
||
orgs_active = session.query(func.count(func.distinct(Response.organization_id))).scalar() or 0
|
||
result = {
|
||
"total_responses": total_responses,
|
||
"total_polls": total_polls,
|
||
"active_polls": active_polls,
|
||
"orgs_active": orgs_active,
|
||
}
|
||
cache_set(key, json.dumps(result), _CACHE_TTL)
|
||
return result
|
||
|
||
|
||
@router.get("/years")
|
||
def get_years():
|
||
"""Список лет, в которых есть хотя бы один ответ."""
|
||
key = _cache_key("years")
|
||
cached = cache_get(key)
|
||
if cached:
|
||
return json.loads(cached)
|
||
Session = get_session()
|
||
with Session() as session:
|
||
rows = session.query(
|
||
func.extract("year", Response.submitted_at).label("yr")
|
||
).distinct().order_by("yr").all()
|
||
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")
|
||
def get_responses_grouped(
|
||
group_by: str = Query("poll", pattern="^(poll|organization|group|month)$"),
|
||
poll_id: Optional[str] = None,
|
||
org_id: Optional[str] = None,
|
||
group_id: Optional[str] = None,
|
||
year: Optional[int] = None,
|
||
):
|
||
"""
|
||
Возвращает агрегированные данные для графиков.
|
||
|
||
- group_by=poll → количество ответов по каждому тесту
|
||
- group_by=organization → количество ответов по каждой организации
|
||
- group_by=group → количество ответов по каждой группе
|
||
- group_by=month → количество ответов по месяцам (хронологически)
|
||
|
||
Все фильтры опциональны и комбинируются.
|
||
"""
|
||
key = _cache_key("responses", group_by, poll_id, org_id, group_id, year)
|
||
cached = cache_get(key)
|
||
if cached:
|
||
return json.loads(cached)
|
||
|
||
Session = get_session()
|
||
result = []
|
||
with Session() as session:
|
||
|
||
if group_by == "poll":
|
||
q = session.query(Poll.title, func.count(Response.id).label("cnt"))
|
||
q = q.join(Response, Poll.id == Response.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)
|
||
if poll_id:
|
||
q = q.filter(Response.poll_id == poll_id)
|
||
rows = q.group_by(Poll.title).order_by(func.count(Response.id).desc()).limit(20).all()
|
||
result = [{"label": r[0], "count": r[1]} for r in rows]
|
||
|
||
elif group_by == "organization":
|
||
q = session.query(Organization.name_organization, func.count(Response.id).label("cnt"))
|
||
q = q.outerjoin(Response, Organization.id == Response.organization_id)
|
||
if poll_id:
|
||
q = q.filter(Response.poll_id == poll_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(Organization.name_organization).order_by(
|
||
func.count(Response.id).desc()
|
||
).limit(20).all()
|
||
result = [{"label": r[0], "count": r[1] or 0} for r in rows]
|
||
|
||
elif group_by == "group":
|
||
q = session.query(Group.name_group, func.count(Response.id).label("cnt"))
|
||
q = q.outerjoin(Response, Group.id == Response.group_id)
|
||
if poll_id:
|
||
q = q.filter(Response.poll_id == poll_id)
|
||
if org_id:
|
||
q = q.filter(Response.organization_id == org_id)
|
||
if year:
|
||
q = q.filter(func.extract("year", Response.submitted_at) == year)
|
||
rows = q.group_by(Group.name_group).order_by(
|
||
func.count(Response.id).desc()
|
||
).limit(20).all()
|
||
result = [{"label": r[0], "count": r[1] or 0} for r in rows]
|
||
|
||
elif group_by == "month":
|
||
yr_col = func.extract("year", Response.submitted_at).label("yr")
|
||
mo_col = func.extract("month", Response.submitted_at).label("mo")
|
||
q = session.query(yr_col, mo_col, func.count(Response.id).label("cnt"))
|
||
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("yr", "mo").order_by("yr", "mo").all()
|
||
result = [
|
||
{
|
||
"label": f"{_MONTH_NAMES[int(r.mo) - 1]} {int(r.yr)}",
|
||
"count": r.cnt,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
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
|