Files
api-copp/route/stats_crud.py
jze9 94e78a9e77 fix: единый singleton engine в bd, убраны 17 локальных make_engine()
- bd/__init__.py: добавлены get_engine() и get_session() — один SQLAlchemy engine на весь процесс
- make_engine(): pool_size=5, max_overflow=5, pool_timeout=30, pool_recycle=1800
- Все 17 route-файлов: убраны локальные get_session()/make_engine(), импорт из bd
- transfer_crud.py: _get_session() → get_session() из bd
- init_data_base.py: get_engine() вместо make_engine()
- Результат: 78 idle соединений → 1
2026-05-18 11:13:28 +00:00

416 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, Query
from typing import Optional
from sqlalchemy import func
import json
import hashlib
from bd import get_session
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()
@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