Партии: фото в общем комментарии (до 10), загрузка при завершении
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Медиа партии: таблица match_attachments (фото в общем комментарии).
|
||||
|
||||
Идемпотентна (как 0006): на свежей БД таблицу создаёт 0001 (create_all); на существующей —
|
||||
create_table здесь.
|
||||
|
||||
Revision ID: 0007_match_attachments
|
||||
Revises: 0006_group_invitations
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0007_match_attachments"
|
||||
down_revision: Union[str, None] = "0006_group_invitations"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
if "match_attachments" in insp.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"match_attachments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"match_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("matches.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"uploaded_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("kind", sa.String(16), nullable=False, server_default="photo"),
|
||||
sa.Column("storage_path", sa.String(255), nullable=False),
|
||||
sa.Column("mime_type", sa.String(64), nullable=False),
|
||||
sa.Column("size_bytes", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_match_attachments_match_id", "match_attachments", ["match_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
if "match_attachments" in insp.get_table_names():
|
||||
op.drop_table("match_attachments")
|
||||
@@ -310,6 +310,32 @@ class MatchParticipant(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class MatchAttachment(SQLModel, table=True):
|
||||
"""Медиа партии (фото в общем комментарии). Файл — на томе uploads, в БД метаданные.
|
||||
|
||||
kind пока всегда 'photo' (задел под видео). storage_path — относительный путь под
|
||||
settings.upload_dir."""
|
||||
|
||||
__tablename__ = "match_attachments"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
match_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
uploaded_by: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
)
|
||||
kind: str = Field(default="photo", sa_column=Column(String(16), nullable=False, server_default="photo"))
|
||||
storage_path: str = Field(sa_column=Column(String(255), nullable=False))
|
||||
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
size_bytes: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
class AuditLog(SQLModel, table=True):
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import NoGroupError
|
||||
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, User
|
||||
from app.models import Match, MatchAttachment, User
|
||||
from app.schemas import api as s
|
||||
from app.services import audit_service, group_service, match_service
|
||||
from app.services import (
|
||||
attachment_service,
|
||||
audit_service,
|
||||
group_service,
|
||||
match_service,
|
||||
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]
|
||||
@@ -48,7 +70,10 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
created_by=match.created_by,
|
||||
can_modify=can_modify,
|
||||
participants=parts,
|
||||
attachments=[],
|
||||
attachments=[
|
||||
attachment_read(a, f"/api/matches/{match.id}")
|
||||
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -189,6 +214,66 @@ def update_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)
|
||||
)
|
||||
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)
|
||||
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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -220,6 +220,15 @@ class MatchParticipantRead(BaseModel):
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class AttachmentRead(BaseModel):
|
||||
id: int
|
||||
kind: str # 'photo' (задел под видео)
|
||||
url: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class MatchRead(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
@@ -234,7 +243,7 @@ class MatchRead(BaseModel):
|
||||
created_by: int
|
||||
can_modify: bool = False # может ли текущий зритель править/завершать партию
|
||||
participants: list[MatchParticipantRead] = []
|
||||
attachments: list[Any] = [] # задел под вложения (всегда пусто в v1)
|
||||
attachments: list[AttachmentRead] = []
|
||||
|
||||
|
||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Вложения партии (фото). Файлы — на томе settings.upload_dir, в БД метаданные.
|
||||
|
||||
Общий сервис для игрового и админского путей (роутеры лишь строят URL с нужным префиксом).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ConflictError, NotFoundError
|
||||
from app.models import Match, MatchAttachment, User
|
||||
|
||||
MAX_ATTACHMENTS = 10
|
||||
_SUBDIR = "matches"
|
||||
|
||||
|
||||
def list_for_match(session: Session, match_id: int) -> list[MatchAttachment]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(MatchAttachment)
|
||||
.where(MatchAttachment.match_id == match_id)
|
||||
.order_by(MatchAttachment.created_at, MatchAttachment.id)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count(session: Session, match_id: int) -> int:
|
||||
return len(
|
||||
session.exec(
|
||||
select(MatchAttachment.id).where(MatchAttachment.match_id == match_id)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def file_path(att: MatchAttachment) -> Path:
|
||||
return Path(settings.upload_dir) / att.storage_path
|
||||
|
||||
|
||||
def add_photo(
|
||||
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
|
||||
) -> MatchAttachment:
|
||||
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
|
||||
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
|
||||
att = MatchAttachment(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
uploaded_by=user.id,
|
||||
kind="photo",
|
||||
storage_path="",
|
||||
mime_type=mime,
|
||||
size_bytes=len(content),
|
||||
)
|
||||
session.add(att)
|
||||
session.flush() # получаем id для имени файла
|
||||
rel = f"{_SUBDIR}/{match.id}/{att.id}.{ext}"
|
||||
abs_path = Path(settings.upload_dir) / rel
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
abs_path.write_bytes(content)
|
||||
att.storage_path = rel
|
||||
session.add(att)
|
||||
session.commit()
|
||||
session.refresh(att)
|
||||
return att
|
||||
|
||||
|
||||
def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachment:
|
||||
att = session.get(MatchAttachment, att_id)
|
||||
if att is None or att.match_id != match_id:
|
||||
raise NotFoundError("Вложение не найдено.")
|
||||
return att
|
||||
|
||||
|
||||
def delete(session: Session, match: Match, att_id: int) -> None:
|
||||
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
|
||||
path = file_path(att)
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
session.delete(att)
|
||||
session.commit()
|
||||
|
||||
|
||||
def delete_match_files(match_id: int) -> None:
|
||||
"""Удалить всю папку медиа партии (при удалении партии; строки БД уйдут каскадом)."""
|
||||
folder = Path(settings.upload_dir) / _SUBDIR / str(match_id)
|
||||
if folder.is_dir():
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
@@ -318,5 +318,9 @@ def update_match(
|
||||
|
||||
|
||||
def delete_match(session: Session, match: Match) -> None:
|
||||
session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE)
|
||||
from app.services import attachment_service # избегаем цикла импорта
|
||||
|
||||
match_id = match.id
|
||||
session.delete(match) # участники и вложения (БД) удалятся каскадом (FK ON DELETE CASCADE)
|
||||
session.commit()
|
||||
attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Медиа партии (фото): загрузка/просмотр/удаление, валидация, лимит, запрет после финиша."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
|
||||
|
||||
def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
def _start(client: TestClient, engine) -> tuple[dict, int, int, int]:
|
||||
me = login(client, "Хост")
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
p2 = add_group_member(engine, gid, "Игрок2")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
started = start_match(
|
||||
client,
|
||||
gid,
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
return me, gid, p2, started.json()["id"]
|
||||
|
||||
|
||||
def _upload(client: TestClient, mid: int, name: str = "a.png", data: bytes = PNG, mime: str = "image/png"):
|
||||
return client.post(
|
||||
f"/api/matches/{mid}/attachments",
|
||||
files={"file": (name, data, mime)},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
|
||||
def test_upload_view_delete(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
|
||||
r = _upload(client, mid)
|
||||
assert r.status_code == 200, r.text
|
||||
aid = r.json()["id"]
|
||||
assert r.json()["url"] == f"/api/matches/{mid}/attachments/{aid}"
|
||||
|
||||
detail = client.get(f"/api/matches/{mid}").json()
|
||||
assert [a["id"] for a in detail["attachments"]] == [aid]
|
||||
|
||||
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
|
||||
assert g.status_code == 200 and g.content == PNG
|
||||
assert g.headers["content-type"] == "image/png"
|
||||
|
||||
d = client.delete(f"/api/matches/{mid}/attachments/{aid}", headers=csrf_headers(client))
|
||||
assert d.status_code == 200, d.text
|
||||
assert client.get(f"/api/matches/{mid}").json()["attachments"] == []
|
||||
|
||||
|
||||
def test_reject_non_image_and_limit(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
_me, _gid, _p2, mid = _start(client, engine)
|
||||
|
||||
assert _upload(client, mid, "x.txt", b"nope", "text/plain").status_code == 422
|
||||
|
||||
for i in range(10):
|
||||
assert _upload(client, mid, f"{i}.png").status_code == 200
|
||||
assert _upload(client, mid, "over.png").status_code == 409
|
||||
|
||||
|
||||
def test_no_changes_after_finish(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
aid = _upload(client, mid).json()["id"]
|
||||
|
||||
fin = finish_match(
|
||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
|
||||
win_reason="objectives",
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
# После завершения игрок не может ни добавлять, ни удалять.
|
||||
assert _upload(client, mid).status_code == 409
|
||||
assert client.delete(
|
||||
f"/api/matches/{mid}/attachments/{aid}", headers=csrf_headers(client)
|
||||
).status_code == 409
|
||||
# Но просмотр сохраняется.
|
||||
assert client.get(f"/api/matches/{mid}").json()["attachments"][0]["id"] == aid
|
||||
|
||||
|
||||
def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
_me, _gid, _p2, mid = _start(client, engine)
|
||||
aid = _upload(client, mid).json()["id"]
|
||||
|
||||
login(client, "Чужак") # не состоит в группе
|
||||
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
|
||||
assert g.status_code in (401, 403)
|
||||
Reference in New Issue
Block a user