Объявления: модель, 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:
@@ -428,6 +428,69 @@ class Notification(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
|
||||
|
||||
|
||||
# ─── Объявления администрации ────────────────────────────────────────────────
|
||||
|
||||
class Announcement(SQLModel, table=True):
|
||||
"""Объявление администрации (#84): окно поверх приложения в период показа, каждому
|
||||
игроку — пока он его не закроет.
|
||||
|
||||
body_html — уже очищенный сервером HTML (announcement_service.sanitize_body), фронт
|
||||
вставляет его как есть. revision растёт, когда админ сохраняет правку с «показать
|
||||
заново»: закрывшие прежнюю версию увидят объявление ещё раз с пометкой «обновлено».
|
||||
show_to_new_players=False — не показывать зарегистрировавшимся после starts_at."""
|
||||
|
||||
__tablename__ = "announcements"
|
||||
__table_args__ = (
|
||||
CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
|
||||
Index("ix_announcements_period", "starts_at", "ends_at"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
title: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
body_html: str = Field(sa_column=Column(Text, nullable=False))
|
||||
starts_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
|
||||
ends_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
|
||||
show_to_new_players: bool = Field(
|
||||
default=True,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="1"),
|
||||
)
|
||||
revision: int = Field(default=1, sa_column=Column(Integer, nullable=False, server_default="1"))
|
||||
created_by: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
),
|
||||
)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class AnnouncementView(SQLModel, table=True):
|
||||
"""Игрок закрыл объявление. revision — какую версию он видел: если админ выпустил
|
||||
новую, объявление покажется снова."""
|
||||
|
||||
__tablename__ = "announcement_views"
|
||||
|
||||
announcement_id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("announcements.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
)
|
||||
user_id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, index=True
|
||||
),
|
||||
)
|
||||
revision: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
closed_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
class AuditLog(SQLModel, table=True):
|
||||
|
||||
Reference in New Issue
Block a user