324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||
from fastapi.responses import FileResponse
|
||
from sqlmodel import Session
|
||
|
||
from app.auth.deps import get_current_user
|
||
from app.core.errors import ConflictError, NoGroupError, NotFoundError, ValidationError
|
||
from app.core.timeutil import iso_utc
|
||
from app.db.session import get_session
|
||
from app.models import Match, MatchAttachment, User
|
||
from app.schemas import api as s
|
||
from app.services import (
|
||
attachment_service,
|
||
audit_service,
|
||
group_service,
|
||
match_service,
|
||
notification_service,
|
||
notify,
|
||
user_service,
|
||
)
|
||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||
|
||
router = APIRouter(prefix="/matches", tags=["matches"])
|
||
|
||
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||
|
||
|
||
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
||
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
|
||
'/api/admin/matches/{id}'); cookie игрока/админа доходит до своего пути."""
|
||
return s.AttachmentRead(
|
||
id=att.id, # type: ignore[arg-type]
|
||
kind=att.kind,
|
||
url=f"{base}/attachments/{att.id}",
|
||
mime_type=att.mime_type,
|
||
size_bytes=att.size_bytes,
|
||
created_at=iso_utc(att.created_at),
|
||
)
|
||
|
||
|
||
def _ensure_has_any_group(session: Session, user: User) -> None:
|
||
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
|
||
raise NoGroupError()
|
||
|
||
|
||
def build_match_read(session: Session, match: Match, *, can_modify: bool = False) -> s.MatchRead:
|
||
parts = [
|
||
s.MatchParticipantRead(
|
||
user_id=u.id, # type: ignore[arg-type]
|
||
nickname=u.nickname,
|
||
faction_id=f.id, # type: ignore[arg-type]
|
||
faction_name=f.name_ru,
|
||
place=p.place,
|
||
eliminated=p.eliminated,
|
||
was_random=p.was_random,
|
||
comment=p.comment,
|
||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||
)
|
||
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
||
]
|
||
return s.MatchRead(
|
||
id=match.id, # type: ignore[arg-type]
|
||
group_id=match.group_id,
|
||
status=match.status,
|
||
played_at=match.played_at,
|
||
started_at=iso_utc(match.started_at),
|
||
finished_at=iso_utc(match.finished_at),
|
||
duration_minutes=match.duration_minutes,
|
||
win_reason=match.win_reason, # type: ignore[arg-type]
|
||
player_count=match.player_count,
|
||
overall_comment=match.overall_comment,
|
||
created_by=match.created_by,
|
||
can_modify=can_modify,
|
||
version=match_service.match_version(match),
|
||
participants=parts,
|
||
attachments=[
|
||
attachment_read(a, f"/api/matches/{match.id}")
|
||
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
|
||
],
|
||
)
|
||
|
||
|
||
@router.post("/randomize-faction", response_model=s.RandomizeResponse)
|
||
def randomize_faction(
|
||
body: s.RandomizeRequest,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.RandomizeResponse:
|
||
_ensure_has_any_group(session, user)
|
||
group_service.assert_member(session, body.group_id, user.id) # type: ignore[arg-type]
|
||
faction = match_service.randomize_faction(session, body.group_id, body.exclude_faction_ids)
|
||
return s.RandomizeResponse(
|
||
faction=s.FactionRead(
|
||
id=faction.id, code=faction.code, name_ru=faction.name_ru, expansion_id=faction.expansion_id # type: ignore[arg-type]
|
||
)
|
||
)
|
||
|
||
|
||
@router.post("", response_model=s.MatchRead)
|
||
def start_match(
|
||
body: s.MatchCreate,
|
||
request: Request,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.MatchRead:
|
||
"""Этап 1: старт партии (выбор игроков и фракций)."""
|
||
_ensure_has_any_group(session, user)
|
||
roster = [
|
||
RosterInput(user_id=p.user_id, faction_id=p.faction_id, was_random=p.was_random)
|
||
for p in body.participants
|
||
]
|
||
match = match_service.create_match(session, user, group_id=body.group_id, roster=roster)
|
||
audit_service.record(
|
||
session,
|
||
actor_id=user.id,
|
||
action="create",
|
||
entity_type="match",
|
||
entity_id=match.id,
|
||
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
|
||
ip=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
)
|
||
session.commit()
|
||
notify.match_changed(session, match)
|
||
notification_service.match_started(session, match, user.id) # type: ignore[arg-type]
|
||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||
|
||
|
||
@router.post("/{match_id}/finish", response_model=s.MatchRead)
|
||
def finish_match(
|
||
match_id: int,
|
||
body: s.MatchFinish,
|
||
request: Request,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.MatchRead:
|
||
"""Этап 2: завершение партии (места, причина победы, длительность)."""
|
||
match = match_service.get_match(session, match_id)
|
||
match_service.assert_can_modify(session, match, user)
|
||
finish = [
|
||
FinishInput(
|
||
user_id=p.user_id,
|
||
place=p.place,
|
||
eliminated=p.eliminated,
|
||
comment=p.comment,
|
||
faction_id=p.faction_id,
|
||
)
|
||
for p in body.participants
|
||
]
|
||
match = match_service.finish_match(
|
||
session,
|
||
match,
|
||
finish=finish,
|
||
win_reason=body.win_reason,
|
||
overall_comment=body.overall_comment,
|
||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||
expected_version=body.expected_version,
|
||
)
|
||
audit_service.record(
|
||
session,
|
||
actor_id=user.id,
|
||
action="update",
|
||
entity_type="match",
|
||
entity_id=match.id,
|
||
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
|
||
ip=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
)
|
||
session.commit()
|
||
notify.match_changed(session, match)
|
||
notification_service.match_finished(session, match, user.id) # type: ignore[arg-type]
|
||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||
|
||
|
||
@router.get("/{match_id}", response_model=s.MatchRead)
|
||
def get_match(
|
||
match_id: int,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.MatchRead:
|
||
match = match_service.get_match(session, match_id)
|
||
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
|
||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||
|
||
|
||
@router.patch("/{match_id}", response_model=s.MatchRead)
|
||
def update_match(
|
||
match_id: int,
|
||
body: s.MatchUpdate,
|
||
request: Request,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.MatchRead:
|
||
match = match_service.get_match(session, match_id)
|
||
match_service.assert_can_modify(session, match, user)
|
||
participants = None
|
||
if body.participants is not None:
|
||
participants = [
|
||
ParticipantInput(
|
||
user_id=p.user_id,
|
||
faction_id=p.faction_id,
|
||
place=p.place,
|
||
eliminated=p.eliminated,
|
||
was_random=p.was_random,
|
||
comment=p.comment,
|
||
)
|
||
for p in body.participants
|
||
]
|
||
match = match_service.update_match(
|
||
session,
|
||
match,
|
||
played_at=body.played_at,
|
||
overall_comment=body.overall_comment,
|
||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||
win_reason=body.win_reason,
|
||
win_reason_set=("win_reason" in body.model_fields_set),
|
||
participants=participants,
|
||
expected_version=body.expected_version,
|
||
)
|
||
audit_service.record(
|
||
session,
|
||
actor_id=user.id,
|
||
action="update",
|
||
entity_type="match",
|
||
entity_id=match.id,
|
||
ip=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
)
|
||
session.commit()
|
||
notify.match_changed(session, match)
|
||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||
|
||
|
||
# ─── Медиа партии (фото) ──────────────────────────────────────────────────────
|
||
|
||
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
|
||
match_service.assert_can_modify(session, match, user)
|
||
if match.status != "in_progress":
|
||
raise ConflictError("Медиа можно прикреплять только до завершения партии.")
|
||
|
||
|
||
@router.post("/{match_id}/attachments", response_model=s.AttachmentRead)
|
||
def add_attachment(
|
||
match_id: int,
|
||
file: UploadFile = File(...),
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.AttachmentRead:
|
||
match = match_service.get_match(session, match_id)
|
||
_assert_can_attach(session, match, user)
|
||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
||
ext = user_service.sniff_image_ext(content)
|
||
if ext is None:
|
||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||
att = attachment_service.add_photo(
|
||
session, match, user, content, ext, user_service.avatar_media_type(ext)
|
||
)
|
||
notify.match_changed(session, match)
|
||
return attachment_read(att, f"/api/matches/{match_id}")
|
||
|
||
|
||
@router.delete("/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse)
|
||
def delete_attachment(
|
||
match_id: int,
|
||
attachment_id: int,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.OkResponse:
|
||
match = match_service.get_match(session, match_id)
|
||
_assert_can_attach(session, match, user)
|
||
attachment_service.delete(session, match, attachment_id)
|
||
notify.match_changed(session, match)
|
||
return s.OkResponse()
|
||
|
||
|
||
@router.get("/{match_id}/attachments/{attachment_id}")
|
||
def get_attachment(
|
||
match_id: int,
|
||
attachment_id: int,
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> FileResponse:
|
||
match = match_service.get_match(session, match_id)
|
||
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
|
||
att = attachment_service.get_for_match(session, match_id, attachment_id)
|
||
path = attachment_service.file_path(att)
|
||
if not path.exists():
|
||
raise NotFoundError("Файл не найден.")
|
||
return FileResponse(
|
||
path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"}
|
||
)
|
||
|
||
|
||
@router.delete("/{match_id}", response_model=s.OkResponse)
|
||
def delete_match(
|
||
match_id: int,
|
||
request: Request,
|
||
expected_version: str | None = Query(None),
|
||
session: Session = Depends(get_session),
|
||
user: User = Depends(get_current_user),
|
||
) -> s.OkResponse:
|
||
match = match_service.get_match(session, match_id)
|
||
match_service.assert_can_modify(session, match, user)
|
||
match_id_val = match.id
|
||
group_id_val = match.group_id
|
||
match_service.delete_match(session, match, expected_version=expected_version)
|
||
audit_service.record(
|
||
session,
|
||
actor_id=user.id,
|
||
action="delete",
|
||
entity_type="match",
|
||
entity_id=match_id_val,
|
||
payload={"group_id": group_id_val},
|
||
ip=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
)
|
||
session.commit()
|
||
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type]
|
||
return s.OkResponse()
|