Untitled
4ae4d
plain_text
02/03/2026 6:57 AM
28.7 KB
11
Indexable
f=src/backend/main.py
f=src/backend/app/application.py
f=src/backend/app/api/v1/__init__.py
f=src/backend/app/api/v1/event.py
f=src/backend/app/services/event.py
f=src/backend/app/schemas/event_search.py
f=src/backend/app/schemas/event.py
--- src/backend/main.py ---
import logging
import subprocess
import sys
import pytest
from app.application import app
from app.config.settings import settings
from server.gunicorn import GunicornServer, create_options
logger = logging.getLogger(__name__)
def main():
if len(sys.argv) != 2 or sys.argv[1] not in ["test", "production"]:
print("Usage: python main.py [test|production]")
sys.exit(1)
option = sys.argv[1]
output = subprocess.check_output(["alembic", "upgrade", "head"], text=True)
logger.critical(output)
if option == "test":
pytest.main(["-s", "-v"])
elif option == "production":
server = GunicornServer(
app=app,
options=create_options(
host=settings.HOST,
port=settings.PORT,
workers=settings.WORKERS,
forwarded_allow_ips=settings.FORWARDED_ALLOW_IPS,
keepalive=settings.KEEPALIVE,
),
)
server.run()
if __name__ == "__main__":
main()
--- src/backend/app/application.py ---
import traceback
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse
from app.api.v1 import (
city_router,
event_router,
health_router,
report_router,
tag_router,
user_router,
)
from app.cache import redis_client
from app.config.settings import settings
from app.middlewares import LoggerMiddleware
# from app.admin.setup import setup_admin
from app.utils.logger import logger
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("The API service started")
app.state.redis = await redis_client.get_client(settings.REDIS_DSN)
yield
logger.info("The API service has shut down")
await app.state.redis.close()
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# setup_admin(app)
api = FastAPI(root_path="/api/v1")
@api.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
tb = traceback.format_exc()
logger.error("Unhandled exception: %s\n%s", exc, tb)
print("UNHANDLED EXCEPTION in /api/v1:", repr(exc))
print(f"tb: {tb}")
return PlainTextResponse(f"Internal Server Error. tb: {tb}", status_code=500)
api.add_middleware(LoggerMiddleware)
api.include_router(city_router)
api.include_router(event_router)
api.include_router(report_router)
api.include_router(tag_router)
api.include_router(user_router)
api.include_router(health_router)
app.mount("/api/v1", api)
--- src/backend/app/api/v1/__init__.py ---
from .city import api_router as city_router
from .event import api_router as event_router
from .health import api_router as health_router
from .report import api_router as report_router
from .tag import api_router as tag_router
from .user import api_router as user_router
__all__ = [
"user_router",
"event_router",
"city_router",
"tag_router",
"report_router",
"health_router",
]
--- src/backend/app/api/v1/event.py ---
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
from fastapi.responses import ORJSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ...db.connection import get_db
from ...dependencies.administrator import Administrator
from ...schemas import (
DefaultResponseSchema,
EventCodeSchema,
EventCreateSchema,
EventSearchQuerySchema,
EventSignOutSchema,
EventSignUpSchema,
EventUpdateSchema,
ResponseEventParticipantSchema,
ResponseEventSchema,
ResponseUserSchema,
)
from ...services import EventService
api_router = APIRouter(prefix="/events", tags=["Events"])
SessionDep = Annotated[AsyncSession, Depends(get_db)]
@api_router.get(
"/search",
status_code=status.HTTP_200_OK,
response_model=list[ResponseEventSchema],
)
async def search_events(
session: SessionDep,
city_id: UUID | None = None,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1),
requester_messenger_id: int | None = None,
only_claimed: bool = False,
only_organizer: bool = False,
tag_ids: list[UUID] | None = None,
):
tag_ids = tag_ids or []
admin = Administrator(session)
service = EventService(admin)
query = EventSearchQuerySchema(
city_id=city_id,
offset=offset,
limit=limit,
requester_messenger_id=requester_messenger_id,
only_claimed=only_claimed,
only_organizer=only_organizer,
tag_ids=tag_ids or [],
)
return await service.search_events(query)
@api_router.get(
"/id/{city_id}", status_code=status.HTTP_200_OK, response_model=list[ResponseEventSchema]
)
async def get_cities_events(
city_id: Annotated[UUID, Path()],
offset: Annotated[int, Query()],
limit: Annotated[int, Query()],
session: SessionDep,
):
admin = Administrator(session)
service = EventService(admin)
return_data = await service.get_cities_events(city_id, offset, limit)
return return_data
@api_router.get(
"/info/{event_id}", status_code=status.HTTP_200_OK, response_model=ResponseEventSchema
)
async def get_event_info(event_id: Annotated[UUID, Path()], session: SessionDep):
admin = Administrator(session)
service = EventService(admin)
return_data = await service.get_by_id(event_id)
return return_data
@api_router.get(
"/{event_id}/participants",
status_code=status.HTTP_200_OK,
response_model=list[ResponseEventParticipantSchema],
)
async def get_event_participants(
event_id: Annotated[UUID, Path()],
requester_messenger_id: Annotated[int, Query()],
session: SessionDep,
offset: Annotated[int, Query()] = 0,
limit: Annotated[int, Query()] = 50,
):
admin = Administrator(session)
service = EventService(admin)
return await service.get_event_participants(
event_id=event_id,
requester_messenger_id=requester_messenger_id,
offset=offset,
limit=limit,
)
@api_router.get("/my", status_code=status.HTTP_200_OK)
async def get_users_events(user_id: Annotated[int, Query()], session: SessionDep):
admin = Administrator(session)
service = EventService(admin)
return_data = await service.get_users_events(user_id)
return return_data
@api_router.post("/code", status_code=status.HTTP_200_OK, response_model=ResponseUserSchema)
async def event_enter_code(body: EventCodeSchema, session: SessionDep):
admin = Administrator(session)
service = EventService(admin)
return_data = await service.enter_code(body.participation_id, body.bonus_code)
return return_data
@api_router.post("/sign-up", status_code=status.HTTP_200_OK, response_model=DefaultResponseSchema)
async def event_sign_up(body: EventSignUpSchema, session: SessionDep):
admin = Administrator(session)
service = EventService(admin)
await service.sign_up(body)
return ORJSONResponse(
{
"status": "OK",
"description": f"User {body.messenger_id} successfully signed up for event {body.event_id}",
}
)
@api_router.patch("/{event_id}", status_code=status.HTTP_200_OK, response_model=ResponseEventSchema)
async def update_event(
event_id: Annotated[UUID, Path()],
body: EventUpdateSchema,
session: SessionDep,
):
admin = Administrator(session)
service = EventService(admin)
return await service.update_event(event_id, body)
@api_router.delete(
"/sign-out", status_code=status.HTTP_200_OK, response_model=DefaultResponseSchema
)
async def event_sign_out(body: EventSignOutSchema, session: SessionDep):
admin = Administrator(session)
service = EventService(admin)
await service.sign_out(body)
return ORJSONResponse(
{
"status": "OK",
"description": f"User successfully signed out for participation {body.participation_id}",
}
)
@api_router.post("", status_code=status.HTTP_201_CREATED, response_model=ResponseEventSchema)
async def create_event(body: EventCreateSchema, session: SessionDep):
"""
создать мероприятие.
пока без привязки к конкретному модератору:
creator_id передаём как None (в модели поле nullable)
позже можно будет подставлять id текущего пользователя
""" # TODO
admin = Administrator(session)
service = EventService(admin)
event = await service.create_event(body)
return event
@api_router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event(event_id: Annotated[UUID, Path()], session: SessionDep):
"""удалить мероприятие по id"""
admin = Administrator(session)
service = EventService(admin)
deleted = await service.delete_event(event_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)
--- src/backend/app/services/event.py ---
from uuid import UUID
from ..dependencies.administrator import Administrator
from ..filters import FilterContext, FilterSpecification
from ..filters.filters.claimed import ClaimedEventsFilter
from ..filters.filters.organizer import OrganizerEventsFilter
from ..filters.filters.tags import TagsEventsFilter
from ..schemas import (
EventCreateSchema,
EventSearchQuerySchema,
EventSignOutSchema,
EventSignUpSchema,
EventUpdateSchema,
ResponseEventParticipantSchema,
)
from ..utils.errors import BadRequestError, ConflictError, NotFoundError
class EventService:
def __init__(self, administrator: Administrator):
self.administrator = administrator
async def get_cities_events(self, city_id: UUID, offset: int, limit: int):
async with self.administrator.start() as admin:
events = await admin.event.get_cities_events(
city_id=city_id, offset=offset, limit=limit
)
return events
async def search_events(self, query: EventSearchQuerySchema):
if query.city_id is None:
raise BadRequestError("city_id is required")
needs_requester = bool(query.only_claimed or query.only_organizer)
if needs_requester and query.requester_messenger_id is None:
raise BadRequestError(
"requester_messenger_id is required for claimed/organizer filters"
)
async with self.administrator.start() as admin:
requester = None
if query.requester_messenger_id is not None:
requester = await admin.user.get_by_messenger_id(query.requester_messenger_id)
if not requester:
raise NotFoundError("User not found")
ctx = FilterContext(
{
"requester_user_id": getattr(requester, "id", None),
"requester_messenger_id": query.requester_messenger_id,
}
)
spec = FilterSpecification()
spec.add(ClaimedEventsFilter(enabled=bool(query.only_claimed)))
spec.add(OrganizerEventsFilter(enabled=bool(query.only_organizer)))
spec.add(TagsEventsFilter(enabled=bool(query.tag_ids or [])))
return await admin.event.get_cities_events(
city_id=query.city_id,
offset=query.offset,
limit=query.limit,
spec=spec,
ctx=ctx,
)
async def get_by_id(self, event_id: UUID):
async with self.administrator.start() as admin:
event = await admin.event.get_by_id(event_id)
return event
async def enter_code(self, participation_id: UUID, bonus_code: str):
async with self.administrator.start() as admin:
participation = await admin.participation.get_by_id(participation_id)
if not participation:
raise NotFoundError()
if participation.is_claimed:
raise BadRequestError("User already claimed points")
event = await admin.event.get_by_id(participation.event_id)
if event.bonus_code != bonus_code:
raise BadRequestError("Codes do not match")
await admin.participation.update(participation_id, {"is_claimed": True})
updated_user = await admin.user.add_points(participation.user_id, event.points)
return updated_user
async def sign_up(self, data: EventSignUpSchema):
async with self.administrator.start() as admin:
user = await admin.user.get_by_messenger_id(data.messenger_id)
if not user:
raise NotFoundError("User not found")
participation = await admin.participation.get_participation(user.id, data.event_id)
if participation:
raise ConflictError()
participation_data = {"user_id": user.id, "event_id": data.event_id}
participation = await admin.participation.insert(participation_data)
return participation
async def sign_out(self, data: EventSignOutSchema):
async with self.administrator.start() as admin:
participation = await admin.participation.get_by_id(data.participation_id)
if not participation:
raise NotFoundError()
if participation.is_claimed:
raise BadRequestError()
deleted_participation = await admin.participation.delete(participation.id)
return deleted_participation
async def get_users_events(self, user_id: int):
async with self.administrator.start() as admin:
user = await admin.user.get_by_messenger_id(user_id)
user_events = await admin.participation.get_user_participations(user.id)
return user_events
async def create_event(self, data: EventCreateSchema):
"""
создать мероприятие в БД.
creator_id можно пробросить из текущего пользователя (модератора),
пока можно передавать None - поле в модели nullable.
"""
async with self.administrator.start() as admin:
event_data = {
"creator_id": data.creator_id,
"city_id": data.city_id,
"name": data.name,
"description": data.description,
"location": data.location,
"bonus_code": data.bonus_code,
"points": data.points,
"date": data.date,
}
event = await admin.event.insert(event_data)
if data.app_tag_ids:
await admin.event.set_app_tags(event.id, data.app_tag_ids)
return await admin.event.get_by_id(event.id)
async def delete_event(self, event_id: UUID) -> bool:
"""
удалить мероприятие по id.
true - получилось удалить
false - не получилось удалить
"""
async with self.administrator.start() as admin:
event = await admin.event.get_by_id(event_id)
if not event:
return False
await admin.event.delete(event_id)
return True
async def update_event(self, event_id: UUID, data: EventUpdateSchema):
"""
обновляем мероприятие,
проверяем что количество поинтов адекватное
"""
async with self.administrator.start() as admin:
event = await admin.event.get_by_id(event_id)
if not event:
raise NotFoundError("Event not found")
patch = data.model_dump(exclude_unset=True)
tag_ids = patch.pop("app_tag_ids", None)
if "points" in patch and patch["points"] is not None:
if patch["points"] <= 0 or patch["points"] >= 2**31:
raise BadRequestError("Invalid points value")
if patch:
updated = await admin.event.update_fields(event_id, patch)
if not updated:
raise BadRequestError("Nothing to update")
if tag_ids is not None:
await admin.event.set_app_tags(event_id, tag_ids)
refreshed = await admin.event.get_by_id(event_id)
if not refreshed:
raise BadRequestError("Event not found")
return refreshed
async def get_event_participants(
self,
event_id: UUID,
requester_messenger_id: int,
offset: int = 0,
limit: int = 50,
) -> list[ResponseEventParticipantSchema]:
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 = getattr(requester, "role", None)
role_val = getattr(role, "value", role)
is_admin = str(role_val) == "admin"
if not is_admin:
if not event.creator_id or str(event.creator_id) != str(requester.id):
raise BadRequestError("Access denied: only organizer can view participants")
# TODO replace with AuthError
parts = await admin.participation.get_event_participations(
event_id=event_id,
offset=offset,
limit=limit,
)
out: list[ResponseEventParticipantSchema] = []
for p in parts:
u = getattr(p, "user", None)
if not u:
continue
out.append(
ResponseEventParticipantSchema(
participation_id=p.id,
is_claimed=bool(getattr(p, "is_claimed", False)),
user_id=u.id,
firstname=getattr(u, "firstname", None),
messenger_id=int(getattr(u, "messenger_id", 0)),
employee_number=int(getattr(u, "employee_number", 0)),
points=int(getattr(u, "points", 0)),
)
)
return out
--- src/backend/app/schemas/event_search.py ---
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class EventSearchQuerySchema(BaseModel):
"""
схема будущего /events/search
сейчас нигде не используется
"""
model_config = ConfigDict(extra="forbid")
city_id: UUID | None = None
offset: int = 0
limit: int = 50
requester_messenger_id: int | None = None
only_claimed: bool = False
only_organizer: bool = False
tag_ids: list[UUID] = Field(default_factory=list)
--- src/backend/app/schemas/event.py ---
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, constr
from .app_tag import ResponseAppTagSchema
from .base import TimestampSchema
class BaseEvent(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
description: str | None = None
location: str | None = None
tags: list[ResponseAppTagSchema] = Field(default_factory=list)
bonus_code: str
points: int
date: datetime
class EventCreateSchema(BaseModel):
"""то, что приходит от бота при создании мероприятия"""
model_config = ConfigDict(extra="forbid")
name: constr(max_length=128)
description: str | None = None
location: constr(max_length=255) | None = None
app_tag_ids: list[UUID] = Field(default_factory=list)
bonus_code: constr(max_length=16)
points: int = 0
date: datetime
city_id: UUID
creator_id: UUID | None = None
class RequestEventSchema(BaseEvent):
pass
class ResponseEventSchema(BaseEvent, TimestampSchema):
id: UUID
creator_id: UUID | None = (
None # TODO решить, оставляем ли или всегда приписываем UUID создателя
)
creator_name: str | None = None
city_id: UUID
class EventSignUpSchema(BaseModel):
event_id: UUID
messenger_id: int
class EventSignOutSchema(BaseModel):
participation_id: UUID
class EventCodeSchema(EventSignOutSchema):
bonus_code: str
class EventUpdateSchema(BaseModel):
model_config = ConfigDict(extra="forbid")
name: constr(max_length=128) | None = None
description: str | None = None
location: str | None = None
app_tag_ids: list[UUID] | None = None
bonus_code: constr(max_length=16) | None = None
points: int | None = None
date: datetime | None = None
city_id: UUID | None = None
creator_id: UUID | None = None
class ResponseEventParticipantSchema(BaseModel):
"""один участник мероприятия"""
model_config = ConfigDict(extra="forbid")
participation_id: UUID
is_claimed: bool
user_id: UUID
firstname: str | None = None
messenger_id: int
employee_number: int
points: int
Editor is loading...
Leave a Comment