Объявления: модель, API игрока и админки, очистка HTML
Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.
Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.
API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.
Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84
Перенесено в main без рейтинга из a0a0e52; миграция 0015 — сразу от 0013
(как в ec0445f).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ from app.services.match_service import ParticipantInput
|
||||
from app.services import (
|
||||
achievement_service,
|
||||
admin_service,
|
||||
announcement_service,
|
||||
attachment_service,
|
||||
audit_service,
|
||||
faction_service,
|
||||
@@ -475,6 +476,127 @@ def delete_achievement(
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
# ─── Объявления ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _announcement_read(item: dict) -> s.AdminAnnouncementRead:
|
||||
a = item["a"]
|
||||
return s.AdminAnnouncementRead(
|
||||
id=a.id,
|
||||
title=a.title,
|
||||
body_html=a.body_html,
|
||||
starts_at=iso_utc(a.starts_at), # type: ignore[arg-type]
|
||||
ends_at=iso_utc(a.ends_at), # type: ignore[arg-type]
|
||||
show_to_new_players=a.show_to_new_players,
|
||||
revision=a.revision,
|
||||
status=item["status"],
|
||||
seen_count=item["seen"],
|
||||
audience_count=item["audience"],
|
||||
created_at=iso_utc(a.created_at), # type: ignore[arg-type]
|
||||
updated_at=iso_utc(a.updated_at), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead:
|
||||
return _announcement_read(announcement_service.admin_item(session, announcement_id))
|
||||
|
||||
|
||||
@router.get("/announcements", response_model=list[s.AdminAnnouncementRead])
|
||||
def list_announcements(
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminAnnouncementRead]:
|
||||
return [_announcement_read(i) for i in announcement_service.list_admin(session)]
|
||||
|
||||
|
||||
@router.post("/announcements", response_model=s.AdminAnnouncementRead)
|
||||
def create_announcement(
|
||||
body: s.AnnouncementWrite,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AdminAnnouncementRead:
|
||||
a = announcement_service.create(
|
||||
session,
|
||||
title=body.title,
|
||||
body_html=body.body_html,
|
||||
starts_at=body.starts_at,
|
||||
ends_at=body.ends_at,
|
||||
show_to_new_players=body.show_to_new_players,
|
||||
actor_id=admin.id,
|
||||
)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="create", entity_type="announcement",
|
||||
entity_id=a.id, payload={"title": a.title}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.announcements_changed(session)
|
||||
return _announcement_by_id(session, a.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead)
|
||||
def update_announcement(
|
||||
announcement_id: int,
|
||||
body: s.AnnouncementUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AdminAnnouncementRead:
|
||||
a = announcement_service.update(
|
||||
session,
|
||||
announcement_id,
|
||||
title=body.title,
|
||||
body_html=body.body_html,
|
||||
starts_at=body.starts_at,
|
||||
ends_at=body.ends_at,
|
||||
show_to_new_players=body.show_to_new_players,
|
||||
reshow=body.reshow,
|
||||
)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="update", entity_type="announcement",
|
||||
entity_id=announcement_id,
|
||||
payload={"title": a.title, "reshow": body.reshow, "revision": a.revision},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.announcements_changed(session)
|
||||
return _announcement_by_id(session, announcement_id)
|
||||
|
||||
|
||||
@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead)
|
||||
def stop_announcement(
|
||||
announcement_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AdminAnnouncementRead:
|
||||
"""«Снять с показа»: период идущего объявления заканчивается сейчас."""
|
||||
announcement_service.stop(session, announcement_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="update", entity_type="announcement",
|
||||
entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.announcements_changed(session)
|
||||
return _announcement_by_id(session, announcement_id)
|
||||
|
||||
|
||||
@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse)
|
||||
def delete_announcement(
|
||||
announcement_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
announcement_service.delete(session, announcement_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="announcement",
|
||||
entity_id=announcement_id, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.announcements_changed(session)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/audit-logs", response_model=s.AuditLogList)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Объявления администрации для игрока: что показать сейчас и «Понятно».
|
||||
|
||||
Появление нового объявления у открытой вкладки обеспечивает SSE-сигнал
|
||||
`{type:"announcements"}`; объявление с отложенным началом клиент подхватывает
|
||||
периодическим перезапросом."""
|
||||
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 announcement_service
|
||||
|
||||
router = APIRouter(prefix="/announcements", tags=["announcements"])
|
||||
|
||||
|
||||
@router.get("/pending", response_model=list[s.AnnouncementRead])
|
||||
def pending(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[s.AnnouncementRead]:
|
||||
return [
|
||||
s.AnnouncementRead(
|
||||
id=a.id, # type: ignore[arg-type]
|
||||
title=a.title,
|
||||
body_html=a.body_html,
|
||||
revision=a.revision,
|
||||
updated=updated,
|
||||
)
|
||||
for a, updated in announcement_service.pending_for_user(session, user)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{announcement_id}/ack", response_model=s.OkResponse)
|
||||
def acknowledge(
|
||||
announcement_id: int,
|
||||
body: s.AnnouncementAck,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
announcement_service.acknowledge(session, user.id, announcement_id, body.revision) # type: ignore[arg-type]
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
Reference in New Issue
Block a user