Untitled
4ae4d
plain_text
01/30/2026 12:46 PM
34.2 KB
9
Indexable
--- src/backend/app/api/v1/report.py ---
from __future__ import annotations
from datetime import date, datetime
from io import BytesIO
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Path, Query, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlalchemy.ext.asyncio import AsyncSession
from ...db.connection import get_db
from ...dependencies.administrator import Administrator
from ...schemas.report import ReportCreateSchema, ResponseReportSchema
from ...services.report import ReportService
api_router = APIRouter(prefix="/reports", tags=["Reports"])
SessionDep = Annotated[AsyncSession, Depends(get_db)]
REPORT_COLUMNS: list[tuple[str, str]] = [
("event_name", "Название"),
("city", "Город"),
("location", "Локация"),
("date", "Дата"),
("time", "Время"),
("participants_count", "Кол-во участников"),
("points", "Кол-во баллов"),
("tags", "Теги"),
("organizer_fio", "ФИО организатора"),
("beneficiaries_count", "Кол-во благополучателей"),
("how_it_went", "Как прошло"),
]
@api_router.get("/export")
async def export_reports_xlsx(
session: SessionDep,
date_from: Annotated[date | None, Query(description="YYYY-MM-DD")] = None,
date_to: Annotated[date | None, Query(description="YYYY-MM-DD")] = None,
):
"""
Эскпорт отчётов в xlsx из БД за период.
сортировка по дате мероприятия.
в колонках нет описания мероприятия
"""
admin = Administrator(session)
service = ReportService(admin)
wb = Workbook()
ws = wb.active
ws.title = "Reports"
ws.append([title for _, title in REPORT_COLUMNS])
rows = await service.export_reports_rows(date_from=date_from, date_to=date_to)
for row in rows:
ws.append(row)
buf = BytesIO()
wb.save(buf)
buf.seek(0)
export_day = datetime.utcnow().date().isoformat()
name_parts = ["reports", export_day]
if date_from and date_to:
name_parts.append(f"{date_from}_{date_to}")
elif date_from:
name_parts.append(f"from_{date_from}")
elif date_to:
name_parts.append(f"to_{date_to}")
filename = "_".join(name_parts) + ".xlsx"
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
@api_router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=ResponseReportSchema,
)
async def create_report(body: ReportCreateSchema, session: SessionDep):
admin = Administrator(session)
service = ReportService(admin)
return await service.create_report(
event_id=body.event_id,
requester_messenger_id=body.requester_messenger_id,
how_it_went=body.how_it_went,
beneficiaries_count=body.beneficiaries_count,
)
@api_router.get("/{event_id}", status_code=status.HTTP_200_OK, response_model=ResponseReportSchema)
async def get_report(
event_id: Annotated[str, Path()],
requester_messenger_id: Annotated[int, Query()],
session: SessionDep,
):
admin = Administrator(session)
service = ReportService(admin)
return await service.get_report(
event_id=UUID(event_id),
requester_messenger_id=requester_messenger_id,
)
--- src/backend/app/repositories/report.py ---
from __future__ import annotations
from datetime import date
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from ..db.models import AppTagEvent, Event, EventReport
from .base import SQLRepository
class ReportRepository(SQLRepository):
model = EventReport
async def get_by_event_id(self, event_id: UUID) -> EventReport | None:
query = select(self.model).where(self.model.event_id == event_id)
result = await self.session.execute(query)
return result.scalar_one_or_none()
async def list_for_export(
self,
*,
date_from: date | None = None,
date_to: date | None = None,
) -> list[EventReport]:
query = (
select(self.model)
.join(self.model.event)
.options(
selectinload(self.model.event).selectinload(Event.city),
selectinload(self.model.event).selectinload(Event.creator),
selectinload(self.model.event)
.selectinload(Event.app_tag_events)
.selectinload(AppTagEvent.app_tag),
)
.order_by(Event.date)
)
if date_from:
query = query.where(func.date(Event.date) >= date_from)
if date_to:
query = query.where(func.date(Event.date) <= date_to)
result = await self.session.execute(query)
return list(result.scalars().all())
--- src/backend/app/services/report.py ---
from __future__ import annotations
from datetime import date
from typing import Any
from uuid import UUID
from ..dependencies.administrator import Administrator
from ..utils.errors import AuthError, ConflictError, NotFoundError
class ReportService:
def __init__(self, administrator: Administrator):
self.administrator = administrator
@staticmethod
def _role_value(role) -> str | None:
if role is None:
return None
return str(getattr(role, "value", role))
async def create_report(
self,
*,
event_id: UUID,
requester_messenger_id: int,
how_it_went: str,
beneficiaries_count: int | None = None,
):
async with self.administrator.start() as admin:
requester = await admin.user.get_by_messenger_id(requester_messenger_id)
if not requester:
raise NotFoundError("User not found")
event = await admin.event.get_by_id(event_id)
if not event:
raise NotFoundError("Event not found")
role = self._role_value(getattr(requester, "role", None))
is_admin = role == "admin"
# создавать отчёт: организатор + админ
if not is_admin:
if not event.creator_id or str(event.creator_id) != str(requester.id):
raise AuthError("Access denied: only organizer or admin can create report")
exists = await admin.report.get_by_event_id(event_id)
if exists:
raise ConflictError("Report already exists for this event")
participants_count = await admin.participation.count_by_event_id(event_id)
payload = {
"event_id": event_id,
"how_it_went": how_it_went,
"beneficiaries_count": beneficiaries_count,
"participants_count_snapshot": participants_count,
}
created = await admin.report.insert(payload)
return created
async def get_report(
self,
*,
event_id: UUID,
requester_messenger_id: int,
):
async with self.administrator.start() as admin:
requester = await admin.user.get_by_messenger_id(requester_messenger_id)
if not requester:
raise NotFoundError("User not found")
event = await admin.event.get_by_id(event_id)
if not event:
raise NotFoundError("Event not found")
role = self._role_value(getattr(requester, "role", None))
is_admin = role == "admin"
is_moderator = role == "moderator"
is_organizer = bool(event.creator_id) and str(event.creator_id) == str(requester.id)
# смотреть отчёт: модератор/админ + организатор
if not (is_admin or is_moderator or is_organizer):
raise AuthError("Access denied")
report = await admin.report.get_by_event_id(event_id)
if not report:
raise NotFoundError("Report not found")
return report
async def export_reports_rows(
self,
*,
date_from: date | None = None,
date_to: date | None = None,
) -> list[list[Any]]:
"""
возвращает стоки для xlsx без шапки, по одному отчёту в строке.
пока выгружаем только существующие отчёты
"""
async with self.administrator.start() as admin:
reports = await admin.report.list_for_export(date_from=date_from, date_to=date_to)
rows: list[list[Any]] = []
for r in reports:
ev = getattr(r, "event", None)
if ev is None:
continue
dt = getattr(ev, "date", None)
d = dt.date() if dt else None
t = dt.time().replace(microsecond=0) if dt else None
city_name = getattr(getattr(ev, "city", None), "name", None) or ""
organizer = getattr(getattr(ev, "creator", None), "firstname", None) or ""
tags = ", ".join(
[
getattr(tag, "name", "")
for tag in (getattr(ev, "tags", None) or [])
if getattr(tag, "name", None)
]
)
rows.append(
[
getattr(ev, "name", "") or "",
city_name,
getattr(ev, "location", "") or "",
d,
t,
getattr(r, "participants_count_snapshot", 0) or 0,
getattr(ev, "points", 0) or 0,
tags,
organizer,
getattr(r, "beneficiaries_count", None),
getattr(r, "how_it_went", "") or "",
]
)
return rows
--- src/bot/core/clients/event.py ---
from core.clients.base import BaseApiClient
class EventClient(BaseApiClient):
def get_city_events(self, city_id: str, offset: int = 0, limit: int = 50):
"""GET /events/id/{city_id}"""
return self.get(f"/events/id/{city_id}", params={"offset": offset, "limit": limit})
def get_event_info(self, event_id: str):
"""GET /events/info/{event_id}"""
return self.get(f"/events/info/{event_id}")
def get_event_participants(
self,
event_id: str,
requester_messenger_id: int,
offset: int = 0,
limit: int = 50,
):
"""GET /events/{event_id}/participants?requester_messenger_id=..."""
return self.get(
f"/events/{event_id}/participants",
params={
"requester_messenger_id": requester_messenger_id,
"offset": offset,
"limit": limit,
},
)
def get_my_events(self, messenger_id: int):
"""GET /events/my?user_id={messenger_id}"""
return self.get("/events/my", params={"user_id": messenger_id})
def create_event(
self,
*,
name: str,
description: str,
bonus_code: str,
points: int,
date: str,
city_id: str,
location: str | None = None,
app_tag_ids: list[str] | None = None,
creator_id: str | None = None,
):
"""POST /events"""
payload = {
"name": name,
"description": description,
"bonus_code": bonus_code,
"points": points,
"date": date,
"city_id": city_id,
"location": location,
"app_tag_ids": app_tag_ids or [],
}
if creator_id is not None:
payload["creator_id"] = creator_id
return self.post("/events", json=payload)
def delete_event(self, event_id: str):
"""DELETE /events/{event_id}"""
return self.delete(f"/events/{event_id}")
def sign_out(self, participation_id: str):
"""DELETE /events/sign-out"""
return self.delete("/events/sign-out", json={"participation_id": participation_id})
def sign_up(self, event_id: str, messenger_id: str):
"""POST /events/sign-up"""
return self.post(
"/events/sign-up", json={"event_id": event_id, "messenger_id": messenger_id}
)
def enter_code(self, participation_id: str, bonus_code: str):
"""POST /events/code"""
return self.post(
"/events/code", json={"participation_id": participation_id, "bonus_code": bonus_code}
)
def update_event(self, event_id: str, payload: dict):
"""PATCH /events/{event_id}"""
return self.patch(f"/events/{event_id}", json=payload)
--- src/bot/core/dependencies/user.py ---
from dishka import Scope, provide
from core.clients import UserClient
from core.config.settings import settings
from core.dependencies.base import BaseMessengerProvider, PeerID
from core.schemas import UserSchema
from core.services import UserService
from core.services.registry import BaseServiceRegistry
class UserProvider(BaseMessengerProvider):
@provide(scope=Scope.REQUEST)
def get_user_service(
self, registry: BaseServiceRegistry, peer_id: PeerID, user_client: UserClient
) -> UserService:
return UserService(registry, peer_id.value, user_client)
@provide(scope=Scope.APP)
def get_user_client(self) -> UserClient:
return UserClient(base_url=f"{settings.API_URL}/api/v1")
@provide(scope=Scope.REQUEST)
def get_user_optional(self, user_service: UserService) -> UserSchema | None:
return user_service.get()
@provide(scope=Scope.REQUEST)
def get_user_required(self, user_service: UserService) -> UserSchema:
user = user_service.get()
if user is None:
raise Exception("User not found for current peer (UserSchema is required)")
return user_service.get()
--- src/bot/core/services/event.py ---
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from core.clients.event import EventClient
from core.schemas.event import EventSchema
from core.schemas.user import UserSchema
from core.services.base import BaseService
from core.services.registry import BaseServiceRegistry
from core.utils.logger import logger
DEFAULT_CITY_UUID = "be9da3ba-a0e4-42f9-9ae2-9875c8625083" # SPB
# TODO убрать хардкод, брать city_id из профиля пользователя
class EventService(BaseService):
"""
сервис мероприятий на стороне бота.
на данный момент:
- ходит в backend через EventClient
- умеет получать список мероприятий по городу
- умеет получать одно мероприятие по id
- создает мероприятие через клиент используя мастера создания
"""
def __init__(self, registry: BaseServiceRegistry, client: EventClient):
# DI может вызвать клиент без аргументов.
super().__init__(registry)
self.client = client
def get_event_participants(
self,
event_id: str,
requester_messenger_id: int,
offset: int = 0,
limit: int = 50,
) -> list[dict[str, Any]]:
"""
возвращает список EventSchema для города
пока что используем дефолтный город - спб
"""
resp = self.client.get_event_participants(
event_id=event_id,
requester_messenger_id=requester_messenger_id,
offset=offset,
limit=limit,
)
if resp.status_code != 200:
raise Exception(
f"Error while getting event participants: {resp.status_code}\n{resp.text}"
)
body = resp.json()
return body if isinstance(body, list) else []
def get_events(self, city_id: str, offset: int = 0, limit: int = 50) -> list[EventSchema]:
"""
возвращает список EventSchema для города
пока что используем дефолтный город - спб
"""
resp = self.client.get_city_events(city_id=city_id, offset=offset, limit=limit)
if resp.status_code != 200:
raise Exception(f"Error while getting events: {resp.status_code}\n{resp.text}")
body = resp.json()
return [self._map_event(item) for item in body]
def get_event_by_id(self, id: str) -> EventSchema | None:
resp = self.client.get_event_info(id)
logger.info(
"[EventService::get_event_by_id] event_id=%r status=%s url=%r method=%r body=%r",
id,
resp.status_code,
getattr(getattr(resp, "request", None), "url", None),
getattr(getattr(resp, "request", None), "method", None),
resp.text,
)
if resp.status_code == 404:
return None
if resp.status_code != 200:
# TODO сделать нормальную обработку
logger.error(f"[EventService] get_events failed: {resp.status_code} {resp.text}")
return None
return self._map_event(resp.json())
def sign_up(self, event_id: str, messenger_id: int) -> bool:
"""messenger_id= = peer.id (как на бэкенде: user.get_by_messenger_id)"""
resp = self.client.sign_up(event_id=event_id, messenger_id=messenger_id)
return resp.status_code == 200
def delete_event(self, event_id: str) -> bool:
resp = self.client.delete_event(event_id)
return resp.status_code == 204 # in [200, 204]
def update_event(self, event_id: str, payload: dict) -> EventSchema | None:
resp = self.client.update_event(event_id, payload)
if resp.status_code != 200:
logger.error(f"[EventService] update_event failed: {resp.status_code}\n{resp.text}")
return None
return self._map_event(resp.json())
def _get_user_participations_raw(self, messenger_id: int) -> list[dict]:
"""
GET /events/my?user_id=<messenger_id>
на бекэнд возвращается список participation (без response_model),
поэтому на стороне бота парсим максимально терпимо
"""
resp = self.client.get_my_events(messenger_id=messenger_id)
if resp.status_code != 200:
raise Exception(f"Error while getting user events: {resp.status_code}\n{resp.text}")
body = resp.json()
# ожидаем list[dict]
if isinstance(body, list):
return body
return []
def _find_participation_for_event(
self, messenger_id: int, event_id: str
) -> tuple[str | None, bool | None]:
"""
ищем participation_id для конкретного event_id
возвращаем (participation_id, is claimed)
"""
items = self._get_user_participations_raw(messenger_id)
target_event_id = str(event_id)
for item in items:
if not isinstance(item, dict):
continue
pid = item.get("id")
ev_id = item.get("event_id")
is_claimed = item.get("is_claimed")
# если event вложен
if ev_id is None and isinstance(item.get("event"), dict):
ev_id = item["event"].get("id")
if ev_id is None:
continue
if str(ev_id) == target_event_id:
return (
str(pid) if pid else None,
bool(is_claimed) if is_claimed is not None else None,
)
return (None, None)
def sign_out_by_event(self, event_id: str, messenger_id: int) -> bool:
"""
backend sign-out requires participation_id, so:
1. GET /event/my
2. находим participation_id для event_id
3. DELETE /events/sign-out
"""
participation_id, _ = self._find_participation_for_event(messenger_id, event_id)
if not participation_id:
return False
resp = self.client.sign_out(participation_id=participation_id)
return resp.status_code == 200
def enter_code_by_event(
self, event_id: str, messenger_id: int, bonus_code: str
) -> dict[str, Any] | None:
"""
аналогично sign-out: нужен participation_id
возвращает обновленного пользователя (ResponseUserSchema) на успехе
"""
participation_id, _ = self._find_participation_for_event(messenger_id, event_id)
if not participation_id:
return None
resp = self.client.enter_code(participation_id=participation_id, bonus_code=bonus_code)
if resp.status_code != 200:
return None
return resp.json()
def get_participation_status(self, event_id: str, messenger_id: int) -> dict[str, Any] | None:
participation_id, is_claimed = self._find_participation_for_event(messenger_id, event_id)
if not participation_id:
return None
return {
"participation_id": participation_id,
"is_claimed": bool(is_claimed) if is_claimed is not None else False,
}
def enter_code(self, user_id: int, code: str):
# TODO: replace with client usage
return code in ["code", "код"]
def create_event(
self,
*,
name: str,
description: str,
bonus_code: str,
points: int,
date_obj: datetime,
city_id: str,
creator_id: str | None = None,
):
"""
создает мероприятие через backend и возвращает dict из backend'a
- ожидает, что все данные уже провалидированы и разобраны
- метод не мапит ответ backend'a в EventSchema и не делает никакой обработки, а возвращает ровно тот json, который вернул backend (dect)
- может бросить Exception, если status_code не 201/200
"""
date_iso = date_obj.isoformat()
resp = self.client.create_event(
name=name,
description=description,
bonus_code=bonus_code,
points=points,
date=date_iso,
city_id=city_id,
creator_id=creator_id,
)
if resp.status_code not in [200, 201]:
raise Exception(f"Error while creating event: {resp.status_code}\n{resp.text}")
return resp.json()
def create_from_wizard(self, user: UserSchema | None, data: dict[str, Any]) -> dict[str, Any]:
"""
обрабатываем данные полученный из мастера создания,
посылаем их в бекенд.
"""
name = data.get("event_name")
date_obj = data.get("event_date")
time_obj = data.get("event_time")
location = data.get("event_location")
organizer_raw = data.get("event_organizer") or ""
description = data.get("event_description") or ""
points = data.get("event_points")
app_tag_ids = data.get("event_app_tag_ids") or []
bonus_code = data.get("event_bonus_code")
event_dt = datetime.combine(date_obj, time_obj).replace(tzinfo=timezone.utc)
date = event_dt.isoformat().replace("+00:00", "Z")
creator_id: str | None = None
if organizer_raw == "-":
if not user.id:
raise Exception("Cannot use '-' for organizer, current user has no id")
creator_id = user.id
else:
try:
creator_id = str(UUID(organizer_raw))
except Exception as e:
raise ValueError(f"Invalid organizer UUID: {organizer_raw!r}") from e
resp = self.client.create_event(
name=name,
description=description,
bonus_code=bonus_code,
points=points,
date=date,
city_id=DEFAULT_CITY_UUID,
location=location,
app_tag_ids=app_tag_ids,
creator_id=creator_id,
)
if resp.status_code not in [200, 201]:
raise Exception(f"Error while creating event: {resp.status_code}\n{resp.text}")
return self._map_event(resp.json())
def _map_event(self, body: dict) -> EventSchema:
"""преобразует json из backend'a в EventSchema бота"""
date_raw = body.get("date")
date = self._parse_iso_datetime(date_raw)
return EventSchema(
id=body.get("id"),
name=body.get("name", ""),
date=date,
description=body.get("description", "") or "",
location=body.get("location"),
tags=body.get("tags"),
bonus_code=body.get("bonus_code"),
points=body.get("points"),
city_id=body.get("city_id"),
creator_id=body.get("creator_id"),
creator_name=body.get("creator_name"),
)
def _parse_iso_datetime(self, value: str | None) -> datetime:
if not value:
return datetime.now()
if value.endswith("Z"):
value = value[:-1] + "+00:00"
try:
return datetime.fromisoformat(value)
except Exception as e:
logger.error(f"[EventService] failed to parse date {value!r}, returing now(): {e}")
return datetime.now()
Editor is loading...
Leave a Comment