Админ: управление медиа партии (добавление/удаление в любой момент)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:38:06 +03:00
co-authored by Claude Opus 4.8
parent 8ac1b5acef
commit 043595beff
5 changed files with 362 additions and 4 deletions
+70 -3
View File
@@ -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 МБ).")
+39
View File
@@ -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)
+171
View File
@@ -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;
+59
View File
@@ -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({
+23 -1
View File
@@ -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<Row[]>([]);
@@ -150,6 +161,17 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} />
</div>
<div className="field">
<label className="label">Медиа (до 10)</label>
<MatchMedia
attachments={attachments ?? []}
editable
onUpload={(f) => uploadAtt.mutateAsync(f)}
onDelete={(aid) => deleteAtt.mutateAsync(aid)}
pending={uploadAtt.isPending || deleteAtt.isPending}
/>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" style={{ width: "100%" }} onClick={save} disabled={update.isPending}>