199 lines
8.3 KiB
Python
199 lines
8.3 KiB
Python
"""Админ: аутентификация (логин=ник + пароль) и управление сущностями."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.errors import (
|
|
ConflictError,
|
|
InvalidCredentialsError,
|
|
NicknameTakenError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
from app.core.security import verify_password
|
|
from app.core.timeutil import iso_utc
|
|
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
|
from app.services import user_service
|
|
|
|
|
|
def authenticate_admin(session: Session, username: str, password: str) -> User:
|
|
user = session.exec(
|
|
select(User).where(
|
|
User.nickname == username,
|
|
User.role == "admin",
|
|
User.auth_provider == "local",
|
|
User.is_active == True, # noqa: E712
|
|
)
|
|
).first()
|
|
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
|
raise InvalidCredentialsError()
|
|
return user
|
|
|
|
|
|
# ─── Пользователи ────────────────────────────────────────────────────────────
|
|
|
|
def list_users(session: Session, query: str | None = None) -> list[User]:
|
|
stmt = select(User).order_by(User.created_at.desc())
|
|
if query:
|
|
stmt = stmt.where(User.nickname.contains(query))
|
|
return list(session.exec(stmt).all())
|
|
|
|
|
|
def update_user(session: Session, user_id: int, *, nickname: str | None = None, is_active: bool | None = None) -> User:
|
|
user = session.get(User, user_id)
|
|
if user is None:
|
|
raise NotFoundError("Пользователь не найден.")
|
|
if nickname is not None:
|
|
nickname = nickname.strip()
|
|
# Та же валидация, что и для самостоятельной смены ника игроком:
|
|
# корректный формат и уникальность (иначе занятый ник упал бы в 500).
|
|
if not user_service.nickname_format_ok(nickname):
|
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
|
if not user_service.nickname_available(session, nickname, exclude_user_id=user_id):
|
|
raise NicknameTakenError()
|
|
user.nickname = nickname
|
|
if is_active is not None:
|
|
user.is_active = is_active
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return user
|
|
|
|
|
|
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
|
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
|
он задаётся ADMIN_PASSWORD в .env."""
|
|
user = session.get(User, user_id)
|
|
if user is None:
|
|
raise NotFoundError("Пользователь не найден.")
|
|
if user.role != "player":
|
|
raise ValidationError("Пароль администратора здесь не меняется.")
|
|
return user_service.set_password(session, user, new_password)
|
|
|
|
|
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
|
|
|
|
|
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
|
|
|
def list_groups(session: Session) -> list[Group]:
|
|
return list(session.exec(select(Group).order_by(Group.created_at.desc())).all())
|
|
|
|
|
|
def delete_group(session: Session, group_id: int) -> None:
|
|
group = session.get(Group, group_id)
|
|
if group is None:
|
|
raise NotFoundError("Группа не найдена.")
|
|
# matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст.
|
|
# Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта.
|
|
if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None:
|
|
raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.")
|
|
session.delete(group)
|
|
session.commit()
|
|
|
|
|
|
# ─── Партии ──────────────────────────────────────────────────────────────────
|
|
|
|
def list_matches(
|
|
session: Session,
|
|
limit: int = 200,
|
|
offset: int = 0,
|
|
group_id: int | None = None,
|
|
user_id: int | None = None,
|
|
faction_id: int | None = None,
|
|
) -> list[tuple[Match, str]]:
|
|
"""Возвращает партии вместе с названием группы (для группировки в админке).
|
|
|
|
Фильтры: по группе, по игроку-участнику, по фракции участника.
|
|
"""
|
|
stmt = (
|
|
select(Match, Group.name)
|
|
.join(Group, Group.id == Match.group_id)
|
|
.order_by(Match.group_id, Match.played_at.desc(), Match.id.desc())
|
|
)
|
|
if group_id is not None:
|
|
stmt = stmt.where(Match.group_id == group_id)
|
|
if user_id is not None:
|
|
stmt = stmt.where(
|
|
Match.id.in_(
|
|
select(MatchParticipant.match_id).where(MatchParticipant.user_id == user_id)
|
|
)
|
|
)
|
|
if faction_id is not None:
|
|
stmt = stmt.where(
|
|
Match.id.in_(
|
|
select(MatchParticipant.match_id).where(
|
|
MatchParticipant.faction_id == faction_id
|
|
)
|
|
)
|
|
)
|
|
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
|
return [(mt, gname) for mt, gname in rows]
|
|
|
|
|
|
def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
|
|
"""Переименовывает фракцию во всей системе (имя хранится один раз)."""
|
|
name_ru = (name_ru or "").strip()
|
|
if not (1 <= len(name_ru) <= 64):
|
|
raise ValidationError("Название фракции: 1–64 символа.")
|
|
faction = session.get(Faction, faction_id)
|
|
if faction is None:
|
|
raise NotFoundError("Фракция не найдена.")
|
|
faction.name_ru = name_ru
|
|
session.add(faction)
|
|
session.commit()
|
|
session.refresh(faction)
|
|
return faction
|
|
|
|
|
|
def delete_match(session: Session, match_id: int) -> None:
|
|
from app.services import attachment_service # избегаем цикла импорта
|
|
|
|
match = session.get(Match, match_id)
|
|
if match is None:
|
|
raise NotFoundError("Партия не найдена.")
|
|
session.delete(match)
|
|
session.commit()
|
|
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
|
|
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
|
|
attachment_service.delete_match_files(match_id)
|
|
|
|
|
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
|
|
|
def list_audit_logs(
|
|
session: Session,
|
|
*,
|
|
action: str | None = None,
|
|
entity_type: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> dict[str, Any]:
|
|
stmt = select(AuditLog).order_by(AuditLog.created_at.desc())
|
|
if action:
|
|
stmt = stmt.where(AuditLog.action == action)
|
|
if entity_type:
|
|
stmt = stmt.where(AuditLog.entity_type == entity_type)
|
|
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r.id,
|
|
"actor_id": r.actor_id,
|
|
"action": r.action,
|
|
"entity_type": r.entity_type,
|
|
"entity_id": r.entity_id,
|
|
"payload": r.payload,
|
|
"ip": r.ip,
|
|
"user_agent": r.user_agent,
|
|
"created_at": iso_utc(r.created_at),
|
|
}
|
|
for r in rows
|
|
],
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|