Files
NotBigGhostandClaude Opus 5 0ee7cd1274 Безопасность: команда ротации пароля администратора из .env
В prod пароль админа задавался только при первом создании: обычный bootstrap
его из .env не берёт, панель не меняет — комментарии отсылали друг к другу.
Теперь `python -m app.bootstrap --reset-admin-password` применяет ADMIN_PASSWORD
к существующему админу через user_service.set_password: пароль проверяется,
token_version растёт, и все админские сессии (в том числе чужие) отзываются.
Обычный старт по-прежнему пароль не трогает.

Процедура на Pi — в deploy/pi/README.md (правка .env → docker compose up -d →
exec команды), кратко — в README. #73

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 00:08:04 +03:00

200 lines
8.5 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 и применяется к существующему админу
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
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,
}