v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
View File
+311
View File
@@ -0,0 +1,311 @@
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request, Response
from sqlmodel import Session
from app.auth.deps import get_current_admin
from app.core import security
from app.core.timeutil import iso_utc
from app.db.session import get_session
from app.models import User
from app.routers.matches import build_match_read
from app.schemas import api as s
from app.services import admin_service, audit_service, faction_service, match_service
from app.services.match_service import ParticipantInput
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=request.client.host if request.client else None,
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]
# ─── Пользователи ────────────────────────────────────────────────────────────
@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 [
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),
)
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=request.client.host if request.client else None,
)
session.commit()
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=u.created_at.isoformat(),
)
@router.delete("/users/{user_id}", response_model=s.OkResponse)
def delete_user(
user_id: int,
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()
# ─── Группы ──────────────────────────────────────────────────────────────────
@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=request.client.host if request.client else None,
)
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,
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,
)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="match",
entity_id=match.id,
ip=request.client.host if request.client else None,
)
session.commit()
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=request.client.host if request.client else None,
)
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:
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=request.client.host if request.client else None,
)
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
)
+45
View File
@@ -0,0 +1,45 @@
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session
from app.auth.login import login_with_identity
from app.auth.registry import enabled_methods
from app.auth.telegram import TelegramProvider
from app.core import security
from app.core.config import settings
from app.db.session import get_session
from app.routers.users import build_me
from app.schemas import api as s
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/config", response_model=s.AuthConfig)
def auth_config() -> s.AuthConfig:
return s.AuthConfig(
methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username,
)
@router.post("/telegram", response_model=s.MeRead)
def telegram_login(
body: s.TelegramAuthPayload,
request: Request,
response: Response,
session: Session = Depends(get_session),
) -> s.MeRead:
identity = TelegramProvider().authenticate(body.model_dump())
user = login_with_identity(session, response, request, identity)
return build_me(session, user)
@router.post("/logout", response_model=s.OkResponse)
def logout(response: Response) -> s.OkResponse:
security.clear_user_session(response)
return s.OkResponse()
+54
View File
@@ -0,0 +1,54 @@
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
входа по логину остаётся только на деве.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session, select
from app.auth.dev_stub import DevStubProvider
from app.auth.login import login_with_identity
from app.auth.provider import ExternalIdentity
from app.db.session import get_session
from app.models import User
from app.routers.users import build_me
from app.schemas import api as s
from app.services import user_service
router = APIRouter(prefix="/auth/dev", tags=["auth-dev"])
@router.post("/login", response_model=s.MeRead)
def dev_login(
body: s.DevLogin,
request: Request,
response: Response,
session: Session = Depends(get_session),
) -> s.MeRead:
identity = DevStubProvider().authenticate({"nickname": body.nickname})
user = login_with_identity(session, response, request, identity)
return build_me(session, user)
@router.get("/users", response_model=list[s.DevUserRead])
def dev_list_users(session: Session = Depends(get_session)) -> list[s.DevUserRead]:
users = session.exec(
select(User).where(User.role == "player").order_by(User.nickname)
).all()
return [
s.DevUserRead(id=u.id, nickname=u.nickname, is_active=u.is_active) # type: ignore[arg-type]
for u in users
]
@router.post("/users", response_model=s.DevUserRead)
def dev_create_user(
body: s.DevUserCreate, session: Session = Depends(get_session)
) -> s.DevUserRead:
nickname = body.nickname.strip()
identity = ExternalIdentity(provider="stub", external_id=nickname, suggested_nickname=nickname)
user = user_service.get_or_create_from_identity(session, identity)
return s.DevUserRead(id=user.id, nickname=user.nickname) # type: ignore[arg-type]
+209
View File
@@ -0,0 +1,209 @@
"""Группы, членство, доступные фракции, список партий и статистика группы."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import (
audit_service,
group_service,
membership_service,
stats_service,
)
router = APIRouter(prefix="/groups", tags=["groups"])
def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
group = group_service.get_group(session, group_id)
member = group_service.assert_member(session, group_id, user_id)
return s.GroupDetail(
id=group.id, # type: ignore[arg-type]
name=group.name,
owner_id=group.owner_id,
my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id),
)
@router.get("", response_model=list[s.GroupBrief])
def my_groups(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[s.GroupBrief]:
return [
s.GroupBrief(id=g.id, name=g.name, role=role)
for g, role in group_service.list_user_groups(session, user.id) # type: ignore[arg-type]
]
@router.post("", response_model=s.GroupDetail)
def create_group(
body: s.GroupCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.GroupDetail:
group = group_service.create_group(session, user, body.name, body.expansion_ids)
audit_service.record(
session,
actor_id=user.id,
action="create",
entity_type="group",
entity_id=group.id,
payload={"name": group.name},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return _detail(session, group.id, user.id) # type: ignore[arg-type]
@router.get("/{group_id}", response_model=s.GroupDetail)
def get_group(
group_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.GroupDetail:
return _detail(session, group_id, user.id) # type: ignore[arg-type]
@router.patch("/{group_id}", response_model=s.GroupDetail)
def rename_group(
group_id: int,
body: s.GroupRename,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.GroupDetail:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
group_service.rename_group(session, group, body.name)
return _detail(session, group_id, user.id) # type: ignore[arg-type]
@router.put("/{group_id}/expansions", response_model=s.GroupDetail)
def set_expansions(
group_id: int,
body: s.GroupExpansionsUpdate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.GroupDetail:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
group_service.set_expansions(session, group, body.expansion_ids)
return _detail(session, group_id, user.id) # type: ignore[arg-type]
@router.get("/{group_id}/factions", response_model=list[s.FactionRead])
def group_factions(
group_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[s.FactionRead]:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
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 group_service.available_factions(session, group_id)
]
# ─── Членство ────────────────────────────────────────────────────────────────
@router.get("/{group_id}/members", response_model=list[s.MemberRead])
def list_members(
group_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[s.MemberRead]:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
return [
s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role)
for m, u in membership_service.list_members(session, group_id)
]
@router.post("/{group_id}/members", response_model=s.MemberRead)
def add_member(
group_id: int,
body: s.MemberAdd,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MemberRead:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
member, added = membership_service.add_member_by_nickname(session, group, body.nickname)
audit_service.record(
session,
actor_id=user.id,
action="create",
entity_type="group_member",
entity_id=group_id,
payload={"added_user_id": added.id, "nickname": added.nickname},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role)
@router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse)
def remove_member(
group_id: int,
user_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
# Любой участник управляет составом; защита создателя — в membership_service
# (нельзя удалить последнего владельца).
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
membership_service.remove_member(session, group, user_id)
return s.OkResponse()
@router.patch("/{group_id}/members/{user_id}", response_model=s.MemberRead)
def change_member_role(
group_id: int,
user_id: int,
body: s.MemberRoleUpdate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MemberRead:
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
member = membership_service.change_role(session, group, user_id, body.role)
from app.services.user_service import get_user
u = get_user(session, user_id)
return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role)
# ─── Партии и статистика группы ──────────────────────────────────────────────
@router.get("/{group_id}/matches", response_model=s.MatchList)
def group_matches(
group_id: int,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> dict:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
return stats_service.group_match_list(session, group_id, limit=limit, offset=offset)
@router.get("/{group_id}/stats", response_model=s.GroupStats)
def group_stats(
group_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> dict:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
return stats_service.group_stats(session, group_id)
+215
View File
@@ -0,0 +1,215 @@
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.core.errors import NoGroupError
from app.core.timeutil import iso_utc
from app.db.session import get_session
from app.models import Match, User
from app.schemas import api as s
from app.services import audit_service, group_service, match_service
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
router = APIRouter(prefix="/matches", tags=["matches"])
def _ensure_has_any_group(session: Session, user: User) -> None:
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
raise NoGroupError()
def build_match_read(session: Session, match: Match, *, can_modify: bool = False) -> s.MatchRead:
parts = [
s.MatchParticipantRead(
user_id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
faction_id=f.id, # type: ignore[arg-type]
faction_name=f.name_ru,
place=p.place,
was_random=p.was_random,
comment=p.comment,
)
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
]
return s.MatchRead(
id=match.id, # type: ignore[arg-type]
group_id=match.group_id,
status=match.status,
played_at=match.played_at,
started_at=iso_utc(match.started_at),
finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type]
player_count=match.player_count,
overall_comment=match.overall_comment,
created_by=match.created_by,
can_modify=can_modify,
participants=parts,
attachments=[],
)
@router.post("/randomize-faction", response_model=s.RandomizeResponse)
def randomize_faction(
body: s.RandomizeRequest,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.RandomizeResponse:
_ensure_has_any_group(session, user)
group_service.assert_member(session, body.group_id, user.id) # type: ignore[arg-type]
faction = match_service.randomize_faction(session, body.group_id, body.exclude_faction_ids)
return s.RandomizeResponse(
faction=s.FactionRead(
id=faction.id, code=faction.code, name_ru=faction.name_ru, expansion_id=faction.expansion_id # type: ignore[arg-type]
)
)
@router.post("", response_model=s.MatchRead)
def start_match(
body: s.MatchCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
"""Этап 1: старт партии (выбор игроков и фракций)."""
_ensure_has_any_group(session, user)
roster = [
RosterInput(user_id=p.user_id, faction_id=p.faction_id, was_random=p.was_random)
for p in body.participants
]
match = match_service.create_match(session, user, group_id=body.group_id, roster=roster)
audit_service.record(
session,
actor_id=user.id,
action="create",
entity_type="match",
entity_id=match.id,
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.post("/{match_id}/finish", response_model=s.MatchRead)
def finish_match(
match_id: int,
body: s.MatchFinish,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
"""Этап 2: завершение партии (места, причина победы, длительность)."""
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
finish = [
FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
for p in body.participants
]
match = match_service.finish_match(
session,
match,
finish=finish,
win_reason=body.win_reason,
overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set),
)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="match",
entity_id=match.id,
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.get("/{match_id}", response_model=s.MatchRead)
def get_match(
match_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.patch("/{match_id}", response_model=s.MatchRead)
def update_match(
match_id: int,
body: s.MatchUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
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,
)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="match",
entity_id=match.id,
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.delete("/{match_id}", response_model=s.OkResponse)
def delete_match(
match_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
match_id_val = match.id
group_id_val = match.group_id
match_service.delete_match(session, match)
audit_service.record(
session,
actor_id=user.id,
action="delete",
entity_type="match",
entity_id=match_id_val,
payload={"group_id": group_id_val},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return s.OkResponse()
+37
View File
@@ -0,0 +1,37 @@
"""Справочники: дополнения и фракции (для всех авторизованных)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import faction_service
router = APIRouter(tags=["reference"])
@router.get("/expansions", response_model=list[s.ExpansionRead])
def list_expansions(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[s.ExpansionRead]:
return [
s.ExpansionRead(id=e.id, code=e.code, name_ru=e.name_ru, is_base=e.is_base) # type: ignore[arg-type]
for e in faction_service.list_expansions(session)
]
@router.get("/factions", response_model=list[s.FactionRead])
def list_factions(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> 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)
]
+32
View File
@@ -0,0 +1,32 @@
"""Рейтинги и агрегат главной страницы."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import stats_service
router = APIRouter(tags=["stats"])
@router.get("/stats/leaderboard", response_model=s.Leaderboard)
def overall_leaderboard(
limit: int = Query(50, ge=1, le=200),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
board = stats_service.leaderboard(session, group_id=None)
board["entries"] = board["entries"][:limit]
return board
@router.get("/home", response_model=s.HomeResponse)
def home(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> dict:
return stats_service.home(session, user.id, user.active_group_id) # type: ignore[arg-type]
+94
View File
@@ -0,0 +1,94 @@
"""Роутер текущего пользователя."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import audit_service, group_service, stats_service, user_service
router = APIRouter(prefix="/users", tags=["users"])
def build_me(session: Session, user: User) -> s.MeRead:
groups = [
s.GroupBrief(id=g.id, name=g.name, role=role)
for g, role in group_service.list_user_groups(session, user.id) # type: ignore[arg-type]
]
return s.MeRead(
id=user.id, # type: ignore[arg-type]
nickname=user.nickname,
role=user.role,
auth_provider=user.auth_provider,
telegram_id=user.telegram_id,
active_group_id=user.active_group_id,
groups=groups,
)
@router.get("/me", response_model=s.MeRead)
def get_me(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
return build_me(session, user)
@router.patch("/me", response_model=s.UserRead)
def update_me(
body: s.NicknameUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.UserRead:
user_service.update_nickname(session, user, body.nickname)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"nickname": user.nickname},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return s.UserRead(
id=user.id, # type: ignore[arg-type]
nickname=user.nickname,
role=user.role,
auth_provider=user.auth_provider,
telegram_id=user.telegram_id,
active_group_id=user.active_group_id,
)
@router.get("/me/stats", response_model=s.ProfileStats)
def my_stats(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> dict:
return stats_service.profile_stats(session, user.id) # type: ignore[arg-type]
@router.put("/me/active-group", response_model=s.MeRead)
def set_active_group(
body: s.ActiveGroupUpdate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.set_active_group(session, user, body.group_id)
return build_me(session, user)
@router.get("/nickname-available", response_model=s.NicknameAvailable)
def nickname_available(
value: str = Query(..., min_length=1),
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.NicknameAvailable:
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
return s.NicknameAvailable(available=available)