v0.5 - Закрепление медиа в комментарии к партии

This commit is contained in:
2026-06-17 22:09:15 +03:00
17 changed files with 1162 additions and 17 deletions
@@ -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")
+26
View File
@@ -310,6 +310,32 @@ class MatchParticipant(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow, nullable=False) 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): class AuditLog(SQLModel, table=True):
+70 -3
View File
@@ -2,24 +2,29 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_admin from app.auth.deps import get_current_admin
from app.core import security from app.core import security
from app.core.errors import NotFoundError, ValidationError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.routers.matches import build_match_read from app.routers.matches import attachment_read, build_match_read
from app.schemas import api as s from app.schemas import api as s
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
attachment_service,
audit_service, audit_service,
faction_service, faction_service,
match_service, match_service,
user_service,
) )
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ _ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
from app.services.match_service import ParticipantInput from app.services.match_service import ParticipantInput
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -292,6 +297,70 @@ def delete_match(
return s.OkResponse() return s.OkResponse()
# ─── Медиа партии (админ правит в любой момент) ───────────────────────────────
@router.get("/matches/{match_id}/attachments", response_model=list[s.AttachmentRead])
def admin_list_attachments(
match_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AttachmentRead]:
match_service.get_match(session, match_id) # 404 если партии нет
return [
attachment_read(a, f"/api/admin/matches/{match_id}")
for a in attachment_service.list_for_match(session, match_id)
]
@router.post("/matches/{match_id}/attachments", response_model=s.AttachmentRead)
def admin_add_attachment(
match_id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AttachmentRead:
match = match_service.get_match(session, match_id)
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, admin, content, ext, user_service.avatar_media_type(ext)
)
return attachment_read(att, f"/api/admin/matches/{match_id}")
@router.delete("/matches/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse)
def admin_delete_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
attachment_service.delete(session, match, attachment_id)
return s.OkResponse()
@router.get("/matches/{match_id}/attachments/{attachment_id}")
def admin_get_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> FileResponse:
match_service.get_match(session, match_id)
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.get("/achievements", response_model=list[s.AchievementRead]) @router.get("/achievements", response_model=list[s.AchievementRead])
@@ -343,8 +412,6 @@ def upload_achievement_icon(
file: UploadFile = File(...), file: UploadFile = File(...),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
from app.core.errors import ValidationError
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1) content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES: if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
raise ValidationError("Файл слишком большой (макс. 2 МБ).") raise ValidationError("Файл слишком большой (макс. 2 МБ).")
+90 -5
View File
@@ -1,20 +1,42 @@
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление.""" """Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
from __future__ import annotations 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 sqlmodel import Session
from app.auth.deps import get_current_user 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.core.timeutil import iso_utc
from app.db.session import get_session 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.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 from app.services.match_service import FinishInput, ParticipantInput, RosterInput
router = APIRouter(prefix="/matches", tags=["matches"]) 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: def _ensure_has_any_group(session: Session, user: User) -> None:
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type] 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, created_by=match.created_by,
can_modify=can_modify, can_modify=can_modify,
participants=parts, 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)) 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) @router.delete("/{match_id}", response_model=s.OkResponse)
def delete_match( def delete_match(
match_id: int, match_id: int,
+11 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from typing import Any, Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -220,6 +220,15 @@ class MatchParticipantRead(BaseModel):
comment: str | None = None 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): class MatchRead(BaseModel):
id: int id: int
group_id: int group_id: int
@@ -234,7 +243,7 @@ class MatchRead(BaseModel):
created_by: int created_by: int
can_modify: bool = False # может ли текущий зритель править/завершать партию can_modify: bool = False # может ли текущий зритель править/завершать партию
participants: list[MatchParticipantRead] = [] 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)
+5 -1
View File
@@ -318,5 +318,9 @@ def update_match(
def delete_match(session: Session, match: Match) -> None: 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() session.commit()
attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска
+140
View File
@@ -0,0 +1,140 @@
"""Медиа партии (фото): загрузка/просмотр/удаление, валидация, лимит, запрет после финиша."""
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_admin_manage_attachments_on_finished(
client: TestClient, engine, make_admin, monkeypatch, tmp_path
):
_use_tmp_uploads(monkeypatch, tmp_path)
me, gid, p2, mid = _start(client, engine)
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
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
# Админ добавляет медиа к ЗАВЕРШЁННОЙ партии (игроку это запрещено).
up = client.post(
f"/api/admin/matches/{mid}/attachments",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert up.status_code == 200, up.text
aid = up.json()["id"]
assert up.json()["url"] == f"/api/admin/matches/{mid}/attachments/{aid}"
assert any(a["id"] == aid for a in client.get(f"/api/admin/matches/{mid}/attachments").json())
g = client.get(f"/api/admin/matches/{mid}/attachments/{aid}")
assert g.status_code == 200 and g.content == PNG
d = client.delete(
f"/api/admin/matches/{mid}/attachments/{aid}", headers=csrf_headers(client)
)
assert d.status_code == 200, d.text
assert client.get(f"/api/admin/matches/{mid}/attachments").json() == []
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)
+326 -1
View File
@@ -508,6 +508,41 @@ export interface paths {
patch: operations["update_match_api_matches__match_id__patch"]; patch: operations["update_match_api_matches__match_id__patch"];
trace?: never; trace?: never;
}; };
"/api/matches/{match_id}/attachments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Add Attachment */
post: operations["add_attachment_api_matches__match_id__attachments_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/matches/{match_id}/attachments/{attachment_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Attachment */
get: operations["get_attachment_api_matches__match_id__attachments__attachment_id__get"];
put?: never;
post?: never;
/** Delete Attachment */
delete: operations["delete_attachment_api_matches__match_id__attachments__attachment_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/expansions": { "/api/expansions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -799,6 +834,42 @@ export interface paths {
patch: operations["rename_faction_api_admin_factions__faction_id__patch"]; patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
trace?: never; trace?: never;
}; };
"/api/admin/matches/{match_id}/attachments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Admin List Attachments */
get: operations["admin_list_attachments_api_admin_matches__match_id__attachments_get"];
put?: never;
/** Admin Add Attachment */
post: operations["admin_add_attachment_api_admin_matches__match_id__attachments_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/matches/{match_id}/attachments/{attachment_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Admin Get Attachment */
get: operations["admin_get_attachment_api_admin_matches__match_id__attachments__attachment_id__get"];
put?: never;
post?: never;
/** Admin Delete Attachment */
delete: operations["admin_delete_attachment_api_admin_matches__match_id__attachments__attachment_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/achievements": { "/api/admin/achievements": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1063,6 +1134,21 @@ export interface components {
/** Is Active */ /** Is Active */
is_active?: boolean | null; is_active?: boolean | null;
}; };
/** AttachmentRead */
AttachmentRead: {
/** Id */
id: number;
/** Kind */
kind: string;
/** Url */
url: string;
/** Mime Type */
mime_type: string;
/** Size Bytes */
size_bytes: number;
/** Created At */
created_at: string;
};
/** AuditLogItem */ /** AuditLogItem */
AuditLogItem: { AuditLogItem: {
/** Id */ /** Id */
@@ -1108,6 +1194,16 @@ export interface components {
/** Telegram Bot Username */ /** Telegram Bot Username */
telegram_bot_username?: string | null; telegram_bot_username?: string | null;
}; };
/** Body_add_attachment_api_matches__match_id__attachments_post */
Body_add_attachment_api_matches__match_id__attachments_post: {
/** File */
file: string;
};
/** Body_admin_add_attachment_api_admin_matches__match_id__attachments_post */
Body_admin_add_attachment_api_admin_matches__match_id__attachments_post: {
/** File */
file: string;
};
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */ /** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: { Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
/** File */ /** File */
@@ -1540,7 +1636,7 @@ export interface components {
* Attachments * Attachments
* @default [] * @default []
*/ */
attachments: unknown[]; attachments: components["schemas"]["AttachmentRead"][];
}; };
/** MatchUpdate */ /** MatchUpdate */
MatchUpdate: { MatchUpdate: {
@@ -2850,6 +2946,105 @@ export interface operations {
}; };
}; };
}; };
add_attachment_api_matches__match_id__attachments_post: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_add_attachment_api_matches__match_id__attachments_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AttachmentRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_attachment_api_matches__match_id__attachments__attachment_id__get: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
attachment_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_attachment_api_matches__match_id__attachments__attachment_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
attachment_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_expansions_api_expansions_get: { list_expansions_api_expansions_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3369,6 +3564,136 @@ export interface operations {
}; };
}; };
}; };
admin_list_attachments_api_admin_matches__match_id__attachments_get: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AttachmentRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_add_attachment_api_admin_matches__match_id__attachments_post: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_admin_add_attachment_api_admin_matches__match_id__attachments_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AttachmentRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_get_attachment_api_admin_matches__match_id__attachments__attachment_id__get: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
attachment_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_delete_attachment_api_admin_matches__match_id__attachments__attachment_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
attachment_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_achievements_api_admin_achievements_get: { list_achievements_api_admin_achievements_get: {
parameters: { parameters: {
query?: never; query?: never;
+108
View File
@@ -0,0 +1,108 @@
import { Paperclip } from "lucide-react";
import { useRef } from "react";
import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import type { AttachmentRead } from "../domain/types";
/**
* Сетка фото партии. Переиспользуется на странице партии (игрок) и в админ-правке —
* URL/хуки приходят снаружи (cookie игрока и админа доходят до своих путей).
*/
export function MatchMedia({
attachments,
editable = false,
onUpload,
onDelete,
pending = false,
max = 10,
}: {
attachments: AttachmentRead[];
editable?: boolean;
onUpload?: (file: File) => Promise<unknown>;
onDelete?: (id: number) => Promise<unknown>;
pending?: boolean;
max?: number;
}) {
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const full = attachments.length >= max;
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (!onUpload) return;
let slots = max - attachments.length;
for (const f of files) {
if (slots <= 0) {
toast.show(`Максимум ${max} фото`);
break;
}
try {
await onUpload(f);
slots--;
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
break;
}
}
};
const remove = async (id: number) => {
if (!onDelete) return;
try {
await onDelete(id);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
};
if (!editable && attachments.length === 0) return null;
return (
<div>
{attachments.length > 0 && (
<div className="media-grid">
{attachments.map((a) => (
<div key={a.id} className="media-item">
<a href={a.url} target="_blank" rel="noreferrer">
<img src={a.url} alt="фото партии" />
</a>
{editable && (
<button
className="media-del"
onClick={() => remove(a.id)}
disabled={pending}
aria-label="Удалить фото"
>
✕
</button>
)}
</div>
))}
</div>
)}
{editable && !full && (
<>
<button
className="btn small"
style={{ marginTop: 8, gap: 6 }}
onClick={() => fileRef.current?.click()}
disabled={pending}
>
<Paperclip size={16} />
Прикрепить медиа
</button>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
style={{ display: "none" }}
onChange={pick}
/>
</>
)}
</div>
);
}
+1
View File
@@ -11,6 +11,7 @@ export type FactionRead = S["FactionRead"];
export type MemberRead = S["MemberRead"]; export type MemberRead = S["MemberRead"];
export type InvitationRead = S["InvitationRead"]; export type InvitationRead = S["InvitationRead"];
export type MatchRead = S["MatchRead"]; export type MatchRead = S["MatchRead"];
export type AttachmentRead = S["AttachmentRead"];
export type MatchList = S["MatchList"]; export type MatchList = S["MatchList"];
export type MatchListItem = S["MatchListItem"]; export type MatchListItem = S["MatchListItem"];
export type Leaderboard = S["Leaderboard"]; export type Leaderboard = S["Leaderboard"];
+59
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client"; import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types"; import type { AdminMe, MatchUpdate } from "../domain/types";
import { resizeImage } from "../lib/image";
function readCsrfToken(): string | null { function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
@@ -87,6 +88,64 @@ export function useAdminMatch(matchId: number | null) {
}); });
} }
// ─── Медиа партии (админ правит в любой момент) ──────────────────────────────
export function useAdminMatchAttachments(matchId: number | null) {
return useQuery({
queryKey: ["adminMatchAttachments", matchId],
enabled: matchId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/admin/matches/{match_id}/attachments", {
params: { path: { match_id: matchId as number } },
}),
),
});
}
export function useAdminUploadAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const blob = await resizeImage(file);
const form = new FormData();
form.append("file", blob, "photo.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/matches/${matchId}/attachments`, {
method: "POST",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
});
}
export function useAdminDeleteAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (attachmentId: number) =>
unwrap(
await api.DELETE("/api/admin/matches/{match_id}/attachments/{attachment_id}", {
params: { path: { match_id: matchId, attachment_id: attachmentId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
});
}
export function useAdminUpdateMatch() { export function useAdminUpdateMatch() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
+51 -1
View File
@@ -1,8 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client"; import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { qk } from "../api/queryKeys";
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
import { resizeImage } from "../lib/image";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useMatch(matchId: number | null) { export function useMatch(matchId: number | null) {
return useQuery({ return useQuery({
@@ -73,3 +79,47 @@ export function useDeleteMatch() {
onSuccess: () => qc.invalidateQueries(), onSuccess: () => qc.invalidateQueries(),
}); });
} }
// Медиа партии: фото грузим multipart'ом отдельным fetch (с ресайзом и CSRF из cookie).
export function useUploadMatchAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const blob = await resizeImage(file);
const form = new FormData();
form.append("file", blob, "photo.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/matches/${matchId}/attachments`, {
method: "POST",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
});
}
export function useDeleteMatchAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (attachmentId: number) =>
unwrap(
await api.DELETE("/api/matches/{match_id}/attachments/{attachment_id}", {
params: { path: { match_id: matchId, attachment_id: attachmentId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Уменьшение изображения на клиенте перед загрузкой (canvas → JPEG).
* Ограничивает большую сторону до `max` px, чтобы не грузить тяжёлые файлы.
* При любой ошибке возвращает исходный файл — сервер всё равно проверит тип/размер.
*/
export async function resizeImage(file: File, max = 1600): Promise<Blob> {
try {
const dataUrl = await new Promise<string>((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result as string);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = () => rej(new Error("image load failed"));
i.src = dataUrl;
});
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(img, 0, 0, width, height);
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/jpeg", 0.85));
return blob ?? file;
} catch {
return file;
}
}
+33 -2
View File
@@ -2,11 +2,18 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { MatchMedia } from "../components/MatchMedia";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { formatDuration, formatTime } from "../domain/format"; import { formatDuration, formatTime } from "../domain/format";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useDeleteMatch, useFinishMatch, useMatch } from "../hooks/matches"; import {
useDeleteMatch,
useDeleteMatchAttachment,
useFinishMatch,
useMatch,
useUploadMatchAttachment,
} from "../hooks/matches";
interface FinishRow { interface FinishRow {
user_id: number; user_id: number;
@@ -22,6 +29,8 @@ export function MatchDetailPage() {
const { data: match, isLoading } = useMatch(id); const { data: match, isLoading } = useMatch(id);
const finish = useFinishMatch(); const finish = useFinishMatch();
const del = useDeleteMatch(); const del = useDeleteMatch();
const uploadAtt = useUploadMatchAttachment(id ?? 0);
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -126,6 +135,14 @@ export function MatchDetailPage() {
</div> </div>
)} )}
{/* Медиа (read-only): для завершённых и для зрителей без прав на правку */}
{!(inProgress && canModify) && match.attachments.length > 0 && (
<div className="card">
<h3>Медиа</h3>
<MatchMedia attachments={match.attachments} editable={false} />
</div>
)}
{/* Незавершённая партия — состав + форма завершения */} {/* Незавершённая партия — состав + форма завершения */}
{inProgress && ( {inProgress && (
<> <>
@@ -184,7 +201,21 @@ export function MatchDetailPage() {
<div className="field"> <div className="field">
<label className="label">Комментарий о партии</label> <label className="label">Комментарий о партии</label>
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} /> <textarea
rows={2}
style={{ resize: "none" }}
value={overall}
onChange={(e) => setOverall(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<MatchMedia
attachments={match.attachments}
editable
onUpload={(f) => uploadAtt.mutateAsync(f)}
onDelete={(aid) => deleteAtt.mutateAsync(aid)}
pending={uploadAtt.isPending || deleteAtt.isPending}
/>
</div>
</div> </div>
{error && <p className="error-text">{error}</p>} {error && <p className="error-text">{error}</p>}
+27 -2
View File
@@ -1,10 +1,18 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons"; import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext"; import { useToast } from "../../context/ToastContext";
import { useAdminFactions, useAdminMatch, useAdminUpdateMatch } from "../../hooks/admin"; import {
useAdminDeleteAttachment,
useAdminFactions,
useAdminMatch,
useAdminMatchAttachments,
useAdminUpdateMatch,
useAdminUploadAttachment,
} from "../../hooks/admin";
interface Row { interface Row {
user_id: number; user_id: number;
@@ -18,7 +26,10 @@ interface Row {
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) { export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
const { data: match, isLoading } = useAdminMatch(matchId); const { data: match, isLoading } = useAdminMatch(matchId);
const { data: factions } = useAdminFactions(); const { data: factions } = useAdminFactions();
const { data: attachments } = useAdminMatchAttachments(matchId);
const update = useAdminUpdateMatch(); const update = useAdminUpdateMatch();
const uploadAtt = useAdminUploadAttachment(matchId);
const deleteAtt = useAdminDeleteAttachment(matchId);
const toast = useToast(); const toast = useToast();
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
@@ -147,7 +158,21 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
<div className="field"> <div className="field">
<label className="label">Комментарий о партии</label> <label className="label">Комментарий о партии</label>
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} /> <textarea
rows={2}
style={{ resize: "none" }}
value={overall}
onChange={(e) => setOverall(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<MatchMedia
attachments={attachments ?? []}
editable
onUpload={(f) => uploadAtt.mutateAsync(f)}
onDelete={(aid) => deleteAtt.mutateAsync(aid)}
pending={uploadAtt.isPending || deleteAtt.isPending}
/>
</div>
</div> </div>
{error && <p className="error-text">{error}</p>} {error && <p className="error-text">{error}</p>}
+31
View File
@@ -135,6 +135,37 @@
.admin-tabs::-webkit-scrollbar { display: none; } /* WebKit */ .admin-tabs::-webkit-scrollbar { display: none; } /* WebKit */
.admin-tabs > * { flex: 0 0 auto; white-space: nowrap; } .admin-tabs > * { flex: 0 0 auto; white-space: nowrap; }
/* Сетка медиа (фото партии) */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 8px;
}
.media-item { position: relative; }
.media-item img {
width: 100%;
height: 88px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
display: block;
}
.media-del {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
display: grid;
place-items: center;
font-size: 12px;
line-height: 1;
}
.participant-row { .participant-row {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;