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