Добавлена функция удаления аккаунтов с каскадным удалением из всех записей для дева
This commit is contained in:
+2
-1
@@ -13,9 +13,10 @@ frontend/node_modules/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
|
|
||||||
# DEV-вход (по нику) физически НЕ попадает в прод-образ
|
# DEV-код физически НЕ попадает в прод-образ (вход по нику, удаление аккаунтов)
|
||||||
backend/app/auth/dev_stub.py
|
backend/app/auth/dev_stub.py
|
||||||
backend/app/routers/dev_auth.py
|
backend/app/routers/dev_auth.py
|
||||||
|
backend/app/routers/dev_admin.py
|
||||||
|
|
||||||
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
|
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
|
||||||
backend/tests/
|
backend/tests/
|
||||||
|
|||||||
+9
-8
@@ -111,15 +111,16 @@ def create_app() -> FastAPI:
|
|||||||
for r in api_routers:
|
for r in api_routers:
|
||||||
app.include_router(r, prefix="/api")
|
app.include_router(r, prefix="/api")
|
||||||
|
|
||||||
# DEV-вход (по нику) — только в development и только если код физически есть
|
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||||
# (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится).
|
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
|
||||||
|
# .dockerignore, импорт просто не выполнится).
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
try:
|
for mod_name in ("dev_auth", "dev_admin"):
|
||||||
from app.routers import dev_auth
|
try:
|
||||||
|
mod = __import__(f"app.routers.{mod_name}", fromlist=["router"])
|
||||||
app.include_router(dev_auth.router, prefix="/api")
|
app.include_router(mod.router, prefix="/api")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@app.get("/api/health", tags=["meta"])
|
@app.get("/api/health", tags=["meta"])
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
|
|||||||
@@ -104,20 +104,9 @@ def update_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||||
def delete_user(
|
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
|
||||||
user_id: int,
|
# отключается (PATCH is_active), удалять нельзя.
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.OkResponse:
|
|
||||||
admin_service.delete_user(session, user_id)
|
|
||||||
audit_service.record(
|
|
||||||
session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id,
|
|
||||||
ip=request.client.host if request.client else None,
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return s.OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||||
|
|
||||||
|
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
||||||
|
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||||
|
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||||
|
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
||||||
|
отключить (PATCH is_active).
|
||||||
|
|
||||||
|
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||||
|
строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому
|
||||||
|
их статистика может стать несогласованной — осознанный выбор). Созданные им партии
|
||||||
|
и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить
|
||||||
|
пользователя, а сами партии/группы мы сохраняем).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_admin
|
||||||
|
from app.core.errors import NotFoundError, ValidationError
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models import Group, Match, MatchParticipant, User
|
||||||
|
from app.schemas import api as s
|
||||||
|
from app.services import audit_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/dev", tags=["admin-dev"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
||||||
|
def delete_user_hard(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.OkResponse:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
if user.role == "admin":
|
||||||
|
raise ValidationError("Нельзя удалить администратора.")
|
||||||
|
nickname = user.nickname # сохраняем до удаления (для аудита)
|
||||||
|
|
||||||
|
# 1. Вычёркиваем игрока из всех партий (его строки участия).
|
||||||
|
for part in session.exec(
|
||||||
|
select(MatchParticipant).where(MatchParticipant.user_id == user_id)
|
||||||
|
).all():
|
||||||
|
session.delete(part)
|
||||||
|
# 2. Партии и группы, где он создатель/владелец, переназначаем на админа.
|
||||||
|
for match in session.exec(select(Match).where(Match.created_by == user_id)).all():
|
||||||
|
match.created_by = admin.id # type: ignore[assignment]
|
||||||
|
session.add(match)
|
||||||
|
for group in session.exec(select(Group).where(Group.owner_id == user_id)).all():
|
||||||
|
group.owner_id = admin.id # type: ignore[assignment]
|
||||||
|
session.add(group)
|
||||||
|
session.flush()
|
||||||
|
# 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL.
|
||||||
|
session.delete(user)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=admin.id,
|
||||||
|
action="delete",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user_id,
|
||||||
|
payload={"hard": True, "nickname": nickname},
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return s.OkResponse()
|
||||||
@@ -48,12 +48,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def delete_user(session: Session, user_id: int) -> None:
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||||
user = session.get(User, user_id)
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||||
if user is None:
|
|
||||||
raise NotFoundError("Пользователь не найден.")
|
|
||||||
session.delete(user)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -106,6 +106,64 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
|||||||
assert bp["faction_name"] == "Хаоситы"
|
assert bp["faction_name"] == "Хаоситы"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
|
||||||
|
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
||||||
|
его созданные партии и группы переназначаются на админа."""
|
||||||
|
me = login(client, "Аня") # владелец группы и создатель партии
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||||
|
b = client.post(
|
||||||
|
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||||
|
).json()["user_id"]
|
||||||
|
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||||
|
mid = create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
|
||||||
|
],
|
||||||
|
)["id"]
|
||||||
|
|
||||||
|
admin_id = make_admin("admin", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Удаляем участника Борю → партия жива, его строка участия вычеркнута.
|
||||||
|
r = client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert all(u["id"] != b for u in client.get("/api/admin/users").json())
|
||||||
|
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||||
|
assert [p["user_id"] for p in detail["participants"]] == [me["id"]] # Боря вычеркнут
|
||||||
|
assert detail["player_count"] == 2 # player_count НЕ пересчитывается (осознанно)
|
||||||
|
|
||||||
|
# Удаляем создателя/владельца Аню → партия и группа сохраняются, переходят к админу.
|
||||||
|
r = client.delete(f"/api/admin/dev/users/{me['id']}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.get(f"/api/admin/matches/{mid}").json()["created_by"] == admin_id
|
||||||
|
grp = next(g for g in client.get("/api/admin/groups").json() if g["id"] == gid)
|
||||||
|
assert grp["owner_id"] == admin_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
|
||||||
|
"""Админа удалить нельзя."""
|
||||||
|
admin_id = make_admin("admin", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
r = client.delete(f"/api/admin/dev/users/{admin_id}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||||
me = login(client, "Кто-то")
|
me = login(client, "Кто-то")
|
||||||
gid = client.post(
|
gid = client.post(
|
||||||
|
|||||||
Vendored
+49
-33
@@ -518,8 +518,7 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
/** Delete User */
|
delete?: never;
|
||||||
delete: operations["delete_user_api_admin_users__user_id__delete"];
|
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
/** Update User */
|
/** Update User */
|
||||||
@@ -682,6 +681,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/dev/users/{user_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete User Hard */
|
||||||
|
delete: operations["delete_user_hard_api_admin_dev_users__user_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/health": {
|
"/api/health": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2479,37 +2495,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
delete_user_api_admin_users__user_id__delete: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
user_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"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
update_user_api_admin_users__user_id__patch: {
|
update_user_api_admin_users__user_id__patch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2903,6 +2888,37 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
delete_user_hard_api_admin_dev_users__user_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
health_api_health_get: {
|
health_api_health_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||||
|
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||||
|
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||||
|
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||||
|
|
||||||
export function AdminAccountsPage() {
|
export function AdminAccountsPage() {
|
||||||
const { data: users, isLoading } = useAdminUsers();
|
const { data: users, isLoading } = useAdminUsers();
|
||||||
@@ -23,17 +26,22 @@ export function AdminAccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{u.role !== "admin" && (
|
{u.role !== "admin" && (
|
||||||
<button
|
<div className="row">
|
||||||
className="btn small"
|
{import.meta.env.DEV && (
|
||||||
onClick={async () => {
|
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||||
await update
|
)}
|
||||||
.mutateAsync({ userId: u.id, is_active: !u.is_active })
|
<button
|
||||||
.catch(() => {});
|
className="btn small"
|
||||||
toast.show("Сохранено");
|
onClick={async () => {
|
||||||
}}
|
await update
|
||||||
>
|
.mutateAsync({ userId: u.id, is_active: !u.is_active })
|
||||||
{u.is_active ? "Отключить" : "Включить"}
|
.catch(() => {});
|
||||||
</button>
|
toast.show("Сохранено");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.is_active ? "Отключить" : "Включить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { ApiError, api, unwrap } from "../../api/client";
|
||||||
|
import { qk } from "../../api/queryKeys";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
|
||||||
|
*
|
||||||
|
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
|
||||||
|
* (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль
|
||||||
|
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
|
||||||
|
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно.
|
||||||
|
*/
|
||||||
|
export function DevDeleteAccountButton({
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
nickname: string;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const toast = useToast();
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await api.DELETE("/api/admin/dev/users/{user_id}", {
|
||||||
|
params: { path: { user_id: userId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn small"
|
||||||
|
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
||||||
|
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
|
||||||
|
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
||||||
|
disabled={del.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` +
|
||||||
|
`вычеркнут из них. Действие необратимо.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync();
|
||||||
|
toast.show("Аккаунт удалён");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user