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

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 __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 МБ).")
+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 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): def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path) _use_tmp_uploads(monkeypatch, tmp_path)
_me, _gid, _p2, mid = _start(client, engine) _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"]; 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;
@@ -1163,6 +1199,11 @@ export interface components {
/** File */ /** File */
file: string; 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 */
@@ -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: { list_achievements_api_admin_achievements_get: {
parameters: { parameters: {
query?: never; 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 { 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({
+23 -1
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[]>([]);
@@ -150,6 +161,17 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} /> <textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} />
</div> </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>} {error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" style={{ width: "100%" }} onClick={save} disabled={update.isPending}> <button className="btn btn-primary" style={{ width: "100%" }} onClick={save} disabled={update.isPending}>