diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3d3cecf..dd5e639 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -2,24 +2,29 @@ from __future__ import annotations from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile +from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_admin from app.core import security +from app.core.errors import NotFoundError, ValidationError from app.core.timeutil import iso_utc from app.db.session import get_session 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.services import ( achievement_service, admin_service, + attachment_service, audit_service, faction_service, match_service, + user_service, ) _ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ +_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ from app.services.match_service import ParticipantInput router = APIRouter(prefix="/admin", tags=["admin"]) @@ -292,6 +297,70 @@ def delete_match( 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]) @@ -343,8 +412,6 @@ def upload_achievement_icon( file: UploadFile = File(...), _admin: User = Depends(get_current_admin), ) -> dict: - from app.core.errors import ValidationError - content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1) if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES: raise ValidationError("Файл слишком большой (макс. 2 МБ).") diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index 787760b..5bce5d0 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -91,6 +91,45 @@ def test_no_changes_after_finish(client: TestClient, engine, monkeypatch, tmp_pa 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) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 7f9bd2c..a309b3c 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -834,6 +834,42 @@ export interface paths { patch: operations["rename_faction_api_admin_factions__faction_id__patch"]; 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": { parameters: { query?: never; @@ -1163,6 +1199,11 @@ export interface components { /** 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: { /** File */ @@ -3523,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: { parameters: { query?: never; diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 24ad51f..8c4fa7a 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, api, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AdminMe, MatchUpdate } from "../domain/types"; +import { resizeImage } from "../lib/image"; function readCsrfToken(): string | null { 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() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index 487f03e..91a93a9 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -1,10 +1,18 @@ import { useEffect, useState } from "react"; import { ApiError } from "../../api/client"; +import { MatchMedia } from "../../components/MatchMedia"; import { Spinner } from "../../components/Spinner"; import { WIN_REASONS, type WinReason } from "../../domain/winReasons"; import { useToast } from "../../context/ToastContext"; -import { useAdminFactions, useAdminMatch, useAdminUpdateMatch } from "../../hooks/admin"; +import { + useAdminDeleteAttachment, + useAdminFactions, + useAdminMatch, + useAdminMatchAttachments, + useAdminUpdateMatch, + useAdminUploadAttachment, +} from "../../hooks/admin"; interface Row { user_id: number; @@ -18,7 +26,10 @@ interface Row { export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) { const { data: match, isLoading } = useAdminMatch(matchId); const { data: factions } = useAdminFactions(); + const { data: attachments } = useAdminMatchAttachments(matchId); const update = useAdminUpdateMatch(); + const uploadAtt = useAdminUploadAttachment(matchId); + const deleteAtt = useAdminDeleteAttachment(matchId); const toast = useToast(); const [rows, setRows] = useState([]); @@ -150,6 +161,17 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: