PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.
POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.
PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.
#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
475 lines
17 KiB
Python
475 lines
17 KiB
Python
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
|
|
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.security import client_ip
|
|
from app.core.errors import NotFoundError
|
|
from app.core.timeutil import iso_utc
|
|
from app.db.session import get_session
|
|
from app.models import User
|
|
from app.routers.matches import attachment_read, build_match_read
|
|
from app.schemas import api as s
|
|
from app.services.match_service import ParticipantInput
|
|
from app.services import (
|
|
achievement_service,
|
|
admin_service,
|
|
attachment_service,
|
|
audit_service,
|
|
faction_service,
|
|
match_service,
|
|
notify,
|
|
user_service,
|
|
)
|
|
|
|
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
# ─── Аутентификация админа ───────────────────────────────────────────────────
|
|
|
|
@router.post("/auth/login", response_model=s.AdminMe)
|
|
def admin_login(
|
|
body: s.AdminLogin,
|
|
request: Request,
|
|
response: Response,
|
|
session: Session = Depends(get_session),
|
|
) -> s.AdminMe:
|
|
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
|
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
|
audit_service.record(
|
|
session,
|
|
actor_id=admin.id,
|
|
action="login",
|
|
entity_type="admin",
|
|
entity_id=admin.id,
|
|
ip=client_ip(request),
|
|
user_agent=request.headers.get("user-agent"),
|
|
)
|
|
session.commit()
|
|
return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type]
|
|
|
|
|
|
@router.post("/auth/logout", response_model=s.OkResponse)
|
|
def admin_logout(response: Response) -> s.OkResponse:
|
|
security.clear_admin_session(response)
|
|
return s.OkResponse()
|
|
|
|
|
|
@router.get("/me", response_model=s.AdminMe)
|
|
def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
|
return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type]
|
|
|
|
|
|
# ─── Пользователи ────────────────────────────────────────────────────────────
|
|
|
|
def _admin_user_read(u: User) -> s.AdminUserRead:
|
|
return s.AdminUserRead(
|
|
id=u.id, # type: ignore[arg-type]
|
|
nickname=u.nickname,
|
|
role=u.role,
|
|
is_active=u.is_active,
|
|
auth_provider=u.auth_provider,
|
|
telegram_id=u.telegram_id,
|
|
created_at=iso_utc(u.created_at),
|
|
has_password=u.password_hash is not None,
|
|
)
|
|
|
|
|
|
@router.get("/users", response_model=list[s.AdminUserRead])
|
|
def list_users(
|
|
query: str | None = Query(None),
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> list[s.AdminUserRead]:
|
|
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
|
def update_user(
|
|
user_id: int,
|
|
body: s.AdminUserUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.AdminUserRead:
|
|
u = admin_service.update_user(session, user_id, nickname=body.nickname, is_active=body.is_active)
|
|
audit_service.record(
|
|
session,
|
|
actor_id=admin.id,
|
|
action="update",
|
|
entity_type="user",
|
|
entity_id=user_id,
|
|
payload=body.model_dump(exclude_none=True),
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return _admin_user_read(u)
|
|
|
|
|
|
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
|
def set_user_password(
|
|
user_id: int,
|
|
body: s.AdminPasswordSet,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.AdminUserRead:
|
|
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
|
u = admin_service.set_player_password(session, user_id, body.new_password)
|
|
audit_service.record(
|
|
session,
|
|
actor_id=admin.id,
|
|
action="update",
|
|
entity_type="user",
|
|
entity_id=user_id,
|
|
payload={"password": "set_by_admin"},
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return _admin_user_read(u)
|
|
|
|
|
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
|
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
|
|
# отключается (PATCH is_active), удалять нельзя.
|
|
|
|
|
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/groups", response_model=list[s.AdminGroupRead])
|
|
def list_groups(
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> list[s.AdminGroupRead]:
|
|
return [
|
|
s.AdminGroupRead(
|
|
id=g.id, name=g.name, owner_id=g.owner_id, created_at=iso_utc(g.created_at) # type: ignore[arg-type]
|
|
)
|
|
for g in admin_service.list_groups(session)
|
|
]
|
|
|
|
|
|
@router.delete("/groups/{group_id}", response_model=s.OkResponse)
|
|
def delete_group(
|
|
group_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.OkResponse:
|
|
admin_service.delete_group(session, group_id)
|
|
audit_service.record(
|
|
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return s.OkResponse()
|
|
|
|
|
|
# ─── Партии ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/matches", response_model=list[s.AdminMatchRead])
|
|
def list_matches(
|
|
group_id: int | None = Query(None),
|
|
user_id: int | None = Query(None),
|
|
faction_id: int | None = Query(None),
|
|
limit: int = Query(200, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> list[s.AdminMatchRead]:
|
|
return [
|
|
s.AdminMatchRead(
|
|
id=m.id, # type: ignore[arg-type]
|
|
group_id=m.group_id,
|
|
group_name=gname,
|
|
status=m.status,
|
|
played_at=str(m.played_at),
|
|
duration_minutes=m.duration_minutes,
|
|
win_reason=m.win_reason, # type: ignore[arg-type]
|
|
player_count=m.player_count,
|
|
created_by=m.created_by,
|
|
created_at=iso_utc(m.created_at),
|
|
)
|
|
for m, gname in admin_service.list_matches(
|
|
session,
|
|
limit=limit,
|
|
offset=offset,
|
|
group_id=group_id,
|
|
user_id=user_id,
|
|
faction_id=faction_id,
|
|
)
|
|
]
|
|
|
|
|
|
@router.get("/matches/{match_id}", response_model=s.MatchRead)
|
|
def get_match(
|
|
match_id: int,
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> s.MatchRead:
|
|
match = match_service.get_match(session, match_id)
|
|
return build_match_read(session, match, can_modify=True)
|
|
|
|
|
|
@router.patch("/matches/{match_id}", response_model=s.MatchRead)
|
|
def update_match(
|
|
match_id: int,
|
|
body: s.MatchUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.MatchRead:
|
|
match = match_service.get_match(session, match_id)
|
|
participants = None
|
|
if body.participants is not None:
|
|
participants = [
|
|
ParticipantInput(
|
|
user_id=p.user_id,
|
|
faction_id=p.faction_id,
|
|
place=p.place,
|
|
eliminated=p.eliminated,
|
|
was_random=p.was_random,
|
|
comment=p.comment,
|
|
)
|
|
for p in body.participants
|
|
]
|
|
match = match_service.update_match(
|
|
session,
|
|
match,
|
|
played_at=body.played_at,
|
|
overall_comment=body.overall_comment,
|
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
|
win_reason=body.win_reason,
|
|
win_reason_set=("win_reason" in body.model_fields_set),
|
|
participants=participants,
|
|
expected_version=body.expected_version,
|
|
)
|
|
audit_service.record(
|
|
session,
|
|
actor_id=admin.id,
|
|
action="update",
|
|
entity_type="match",
|
|
entity_id=match.id,
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
notify.match_changed(session, match)
|
|
return build_match_read(session, match, can_modify=True)
|
|
|
|
|
|
# ─── Фракции (переименование во всей системе) ─────────────────────────────────
|
|
|
|
@router.get("/factions", response_model=list[s.FactionRead])
|
|
def list_factions(
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> list[s.FactionRead]:
|
|
return [
|
|
s.FactionRead(
|
|
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
|
|
)
|
|
for f in faction_service.list_factions(session)
|
|
]
|
|
|
|
|
|
@router.patch("/factions/{faction_id}", response_model=s.FactionRead)
|
|
def rename_faction(
|
|
faction_id: int,
|
|
body: s.FactionRename,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.FactionRead:
|
|
f = admin_service.rename_faction(session, faction_id, body.name_ru)
|
|
audit_service.record(
|
|
session,
|
|
actor_id=admin.id,
|
|
action="update",
|
|
entity_type="faction",
|
|
entity_id=faction_id,
|
|
payload={"name_ru": f.name_ru},
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return s.FactionRead(
|
|
id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
@router.delete("/matches/{match_id}", response_model=s.OkResponse)
|
|
def delete_match(
|
|
match_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.OkResponse:
|
|
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
|
# До удаления: каскад унесёт участников вместе с партией.
|
|
participant_ids = notify.match_participant_ids(session, match_id)
|
|
admin_service.delete_match(session, match_id)
|
|
audit_service.record(
|
|
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
notify.match_removed(session, match_id, group_id, participant_ids)
|
|
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, ext = user_service.read_capped_image(
|
|
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
|
|
)
|
|
att = attachment_service.add_photo(
|
|
session, match, admin, content, ext, user_service.avatar_media_type(ext)
|
|
)
|
|
notify.match_changed(session, match)
|
|
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)
|
|
notify.match_changed(session, match)
|
|
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])
|
|
def list_achievements(
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> list[dict]:
|
|
return achievement_service.list_achievements()
|
|
|
|
|
|
@router.post("/achievements", response_model=s.AchievementRead)
|
|
def create_achievement(
|
|
body: s.AchievementCreate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> dict:
|
|
ach = achievement_service.create(body.name, body.description, body.condition)
|
|
audit_service.record(
|
|
session, actor_id=admin.id, action="create", entity_type="achievement",
|
|
payload={"slug": ach["slug"], "name": ach["name"]},
|
|
ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return ach
|
|
|
|
|
|
@router.patch("/achievements/{slug}", response_model=s.AchievementRead)
|
|
def update_achievement(
|
|
slug: str,
|
|
body: s.AchievementUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> dict:
|
|
ach = achievement_service.update(
|
|
slug, name=body.name, description=body.description, condition=body.condition
|
|
)
|
|
audit_service.record(
|
|
session, actor_id=admin.id, action="update", entity_type="achievement",
|
|
payload={"slug": slug}, ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return ach
|
|
|
|
|
|
@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead)
|
|
def upload_achievement_icon(
|
|
slug: str,
|
|
file: UploadFile = File(...),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> dict:
|
|
content, ext = user_service.read_capped_image(
|
|
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
|
)
|
|
return achievement_service.set_icon(slug, content, ext)
|
|
|
|
|
|
@router.delete("/achievements/{slug}", response_model=s.OkResponse)
|
|
def delete_achievement(
|
|
slug: str,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(get_current_admin),
|
|
) -> s.OkResponse:
|
|
achievement_service.delete(slug)
|
|
audit_service.record(
|
|
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
|
payload={"slug": slug}, ip=client_ip(request),
|
|
)
|
|
session.commit()
|
|
return s.OkResponse()
|
|
|
|
|
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
|
|
|
@router.get("/audit-logs", response_model=s.AuditLogList)
|
|
def audit_logs(
|
|
action: str | None = Query(None),
|
|
entity_type: str | None = Query(None),
|
|
limit: int = Query(100, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> dict:
|
|
return admin_service.list_audit_logs(
|
|
session, action=action, entity_type=entity_type, limit=limit, offset=offset
|
|
)
|