Объявления: модель, 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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 21:20:55 +03:00
co-authored by Claude Opus 5
parent 2e496a3f21
commit a0a0e522ef
9 changed files with 998 additions and 3 deletions
+3 -1
View File
@@ -17,6 +17,7 @@ from app.core.errors import AppError, app_error_handler
from app.routers import (
achievements,
admin,
announcements,
auth,
events,
groups,
@@ -199,7 +200,8 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.router,
events.router, notifications.router, admin.router]
events.router, notifications.router, announcements.router,
admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
+63
View File
@@ -447,6 +447,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):
+122
View File
@@ -18,6 +18,7 @@ from app.schemas import api as s
from app.services import (
achievement_service,
admin_service,
announcement_service,
attachment_service,
audit_service,
faction_service,
@@ -463,6 +464,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)
+46
View File
@@ -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()
+54 -1
View File
@@ -1,7 +1,7 @@
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
from __future__ import annotations
from datetime import date
from datetime import date, datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -233,6 +233,23 @@ class NotificationMarkRead(BaseModel):
ids: list[int] | None = None
# ─── Объявления (игрок) ──────────────────────────────────────────────────────
class AnnouncementRead(BaseModel):
id: int
title: str
# HTML, уже очищенный сервером по белому списку — фронт вставляет как есть.
body_html: str
revision: int
# Игрок закрывал прежнюю версию — окно показывает пометку «обновлено».
updated: bool = False
class AnnouncementAck(BaseModel):
# Версия, которую игрок видел и закрыл (AnnouncementRead.revision).
revision: int = Field(ge=1)
# ─── Партии ──────────────────────────────────────────────────────────────────
class RandomizeRequest(BaseModel):
@@ -627,3 +644,39 @@ class AuditLogList(BaseModel):
items: list[AuditLogItem] = []
limit: int
offset: int
# ─── Объявления (админ) ──────────────────────────────────────────────────────
AnnouncementStatus = Literal["planned", "live", "finished"]
class AnnouncementWrite(BaseModel):
# Точные правила (заголовок до 60 символов, текст до 600 видимых) — в
# announcement_service; здесь только отсечка мегабайтных тел.
title: str = Field(max_length=200)
body_html: str = Field(max_length=20_000)
starts_at: datetime
ends_at: datetime
show_to_new_players: bool = True
class AnnouncementUpdate(AnnouncementWrite):
# Показать заново тем, кто уже закрыл (с пометкой «обновлено»).
reshow: bool = False
class AdminAnnouncementRead(BaseModel):
id: int
title: str
body_html: str
starts_at: str
ends_at: str
show_to_new_players: bool
revision: int
status: AnnouncementStatus
# Закрыли текущую версию / сколько активных игроков в адресатах.
seen_count: int
audience_count: int
created_at: str
updated_at: str
@@ -0,0 +1,336 @@
"""Объявления администрации (#84): очистка текста, период показа, кому и что показать.
Объявление видно игроку, пока идёт его период и игрок не закрыл текущую версию. Закрытие
(«Понятно») пишет отметку с номером версии; правка с «показать заново» поднимает версию —
и закрывшие прежнюю увидят объявление снова, с пометкой «обновлено».
Текст хранится HTML-ом из редактора админки, но только после очистки по белому списку
(sanitize_body): фронт вставляет его без экранирования, так что это единственный барьер
между полем редактора и страницей игрока.
"""
from __future__ import annotations
import html
from datetime import datetime, timezone
from html.parser import HTMLParser
from sqlalchemy import func
from sqlmodel import Session, select
from app.core.errors import NotFoundError, ValidationError
from app.core.timeutil import utcnow
from app.models import Announcement, AnnouncementView, User
TITLE_MAX = 60
TEXT_MAX = 600 # видимых символов, без разметки
# ─── Очистка HTML ────────────────────────────────────────────────────────────
# Что оставляем и во что превращаем. Редактор (contenteditable + execCommand) в разных
# браузерах пишет то <b>, то <strong>, абзацы — <div> или <p>; приводим к одному виду.
_TAGS = {
"b": "b",
"strong": "b",
"i": "em",
"em": "em",
"mark": "mark",
"p": "p",
"div": "p",
"br": "br",
}
# Теги, которые выбрасываются вместе с содержимым: их текст не предназначен для показа.
_DROP_WITH_CONTENT = {
"script", "style", "template", "noscript", "iframe", "object", "embed",
"svg", "math", "head", "title", "textarea", "select",
}
_VOID = {"br"}
class _Sanitizer(HTMLParser):
"""Пересобирает HTML из разобранных токенов: теги — только из белого списка и без
атрибутов (кроме class="red" у <mark>), весь текст экранируется заново. Всё, что
парсер не распознал как тег из списка, становится текстом или пропадает."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out: list[str] = []
self.text: list[str] = []
self.stack: list[str] = []
self.drop_depth = 0
def _pop(self) -> str:
top = self.stack.pop()
last = self.out[-1] if self.out else ""
# Точное сравнение: startswith("<b") поймал бы и <br>.
if last == f"<{top}>" or last.startswith(f"<{top} "):
self.out.pop() # пустая пара (<p></p> от вложенных <div>) — выбрасываем
else:
self.out.append(f"</{top}>")
return top
def _close_to(self, tag: str) -> None:
while self.stack:
if self._pop() == tag:
return
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in _DROP_WITH_CONTENT:
self.drop_depth += 1
return
if self.drop_depth or tag not in _TAGS:
return
name = _TAGS[tag]
if name in _VOID:
self.out.append(f"<{name}>")
return
if name == "p" and "p" in self.stack:
# Абзац внутри абзаца (вложенные <div> из contenteditable) — закрываем прежний.
self._close_to("p")
opening = f"<{name}>"
if name == "mark":
classes = next((v or "" for k, v in attrs if k == "class"), "").split()
if "red" in classes:
opening = '<mark class="red">'
self.stack.append(name)
self.out.append(opening)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
# <br/> и прочие самозакрытые: содержимого нет, так что drop-теги тут ни при чём.
if not self.drop_depth and _TAGS.get(tag) in _VOID:
self.out.append(f"<{_TAGS[tag]}>")
def handle_endtag(self, tag: str) -> None:
if tag in _DROP_WITH_CONTENT:
self.drop_depth = max(0, self.drop_depth - 1)
return
if self.drop_depth:
return
name = _TAGS.get(tag)
if name and name not in _VOID and name in self.stack:
self._close_to(name)
def handle_data(self, data: str) -> None:
if self.drop_depth:
return
self.text.append(data)
self.out.append(html.escape(data, quote=False))
def result(self) -> tuple[str, str]:
self.close()
while self.stack:
self._pop()
return "".join(self.out), "".join(self.text)
def sanitize_body(raw: str) -> tuple[str, str]:
"""(очищенный HTML, видимый текст). Видимый текст нужен для проверки длины."""
parser = _Sanitizer()
parser.feed(raw)
return parser.result()
# ─── Проверки и время ─────────────────────────────────────────────────────────
def _now() -> datetime:
# В SQLite моменты лежат наивными (UTC) — сравниваем с наивным же «сейчас».
return utcnow().replace(tzinfo=None)
def _naive_utc(dt: datetime) -> datetime:
"""Момент из запроса → наивный UTC. Время без смещения считаем UTC."""
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.replace(second=0, microsecond=0)
def _clean_fields(
title: str, body_html: str, starts_at: datetime, ends_at: datetime
) -> tuple[str, str, datetime, datetime]:
title = title.strip()
if not title:
raise ValidationError("Заголовок не может быть пустым.")
if len(title) > TITLE_MAX:
raise ValidationError(f"Заголовок длиннее {TITLE_MAX} символов.")
clean_html, text = sanitize_body(body_html)
text = text.strip()
if not text:
raise ValidationError("Текст объявления не может быть пустым.")
if len(text) > TEXT_MAX:
raise ValidationError(f"Текст длиннее {TEXT_MAX} символов ({len(text)}).")
start, end = _naive_utc(starts_at), _naive_utc(ends_at)
if end <= start:
raise ValidationError("Конец показа должен быть позже начала.")
return title, clean_html, start, end
def status_of(a: Announcement, now: datetime | None = None) -> str:
now = now or _now()
if now < a.starts_at:
return "planned"
if now >= a.ends_at:
return "finished"
return "live"
def get(session: Session, announcement_id: int) -> Announcement:
a = session.get(Announcement, announcement_id)
if a is None:
raise NotFoundError("Объявление не найдено.")
return a
# ─── Админка ─────────────────────────────────────────────────────────────────
def create(
session: Session,
*,
title: str,
body_html: str,
starts_at: datetime,
ends_at: datetime,
show_to_new_players: bool,
actor_id: int | None,
) -> Announcement:
title, body_html, start, end = _clean_fields(title, body_html, starts_at, ends_at)
if end <= _now():
raise ValidationError("Период показа уже закончился.")
a = Announcement(
title=title,
body_html=body_html,
starts_at=start,
ends_at=end,
show_to_new_players=show_to_new_players,
created_by=actor_id,
)
session.add(a)
session.flush()
return a
def update(
session: Session,
announcement_id: int,
*,
title: str,
body_html: str,
starts_at: datetime,
ends_at: datetime,
show_to_new_players: bool,
reshow: bool,
) -> Announcement:
a = get(session, announcement_id)
a.title, a.body_html, a.starts_at, a.ends_at = _clean_fields(
title, body_html, starts_at, ends_at
)
a.show_to_new_players = show_to_new_players
if reshow:
# Новая версия: отметки о закрытии прежней больше не прячут объявление.
a.revision += 1
a.updated_at = utcnow()
session.add(a)
session.flush()
return a
def stop(session: Session, announcement_id: int) -> Announcement:
"""«Снять с показа»: период заканчивается сейчас. Только у идущего объявления —
у запланированного конец раньше начала нарушил бы период; его просто удаляют."""
a = get(session, announcement_id)
now = _now()
if status_of(a, now) != "live" or now <= a.starts_at:
raise ValidationError("Снять с показа можно только идущее объявление.")
a.ends_at = now
a.updated_at = utcnow()
session.add(a)
session.flush()
return a
def delete(session: Session, announcement_id: int) -> None:
session.delete(get(session, announcement_id))
session.flush()
def _audience_filter(a: Announcement):
"""Условия «игрок — адресат объявления» (для счётчиков и для показа)."""
conds = [User.role == "player", User.is_active.is_(True)] # type: ignore[union-attr]
if not a.show_to_new_players:
conds.append(User.created_at <= a.starts_at)
return conds
def _admin_item(session: Session, a: Announcement, now: datetime) -> dict:
"""Объявление со статусом и счётчиком «закрыли N из M»: N — закрывшие текущую
версию, M — сколько активных игроков сейчас в адресатах."""
audience = _audience_filter(a)
audience_count = session.exec(select(func.count()).select_from(User).where(*audience)).one()
seen_count = session.exec(
select(func.count())
.select_from(AnnouncementView)
.join(User, User.id == AnnouncementView.user_id)
.where(
AnnouncementView.announcement_id == a.id,
AnnouncementView.revision >= a.revision,
*audience,
)
).one()
return {"a": a, "status": status_of(a, now), "seen": seen_count, "audience": audience_count}
def admin_item(session: Session, announcement_id: int) -> dict:
return _admin_item(session, get(session, announcement_id), _now())
def list_admin(session: Session) -> list[dict]:
"""Все объявления, новые сверху. Объявлений единицы, поэтому счётчики — по паре
запросов на объявление."""
now = _now()
rows = session.exec(
select(Announcement).order_by(Announcement.starts_at.desc(), Announcement.id.desc()) # type: ignore[union-attr]
).all()
return [_admin_item(session, a, now) for a in rows]
# ─── Игрок ───────────────────────────────────────────────────────────────────
def pending_for_user(session: Session, user: User) -> list[tuple[Announcement, bool]]:
"""Что показать игроку сейчас — от старого к новому. Второй элемент — «обновлено»:
игрок закрывал прежнюю версию этого объявления."""
now = _now()
registered = user.created_at.replace(tzinfo=None) # только что созданный — aware UTC
rows = session.exec(
select(Announcement, AnnouncementView.revision)
.join(
AnnouncementView,
(AnnouncementView.announcement_id == Announcement.id)
& (AnnouncementView.user_id == user.id),
isouter=True,
)
.where(Announcement.starts_at <= now, Announcement.ends_at > now)
.order_by(Announcement.starts_at, Announcement.id)
).all()
result = []
for a, seen_revision in rows:
if seen_revision is not None and seen_revision >= a.revision:
continue
if not a.show_to_new_players and registered > a.starts_at:
continue
result.append((a, seen_revision is not None))
return result
def acknowledge(session: Session, user_id: int, announcement_id: int, revision: int) -> None:
"""Игрок закрыл объявление в версии revision. Версию берём с клиента: если админ
выпустил новую, пока окно было открыто, игрок закрыл старую и новую ещё увидит."""
a = get(session, announcement_id)
revision = min(revision, a.revision)
view = session.get(AnnouncementView, (announcement_id, user_id))
if view is None:
session.add(
AnnouncementView(announcement_id=announcement_id, user_id=user_id, revision=revision)
)
elif revision > view.revision:
view.revision = revision
view.closed_at = utcnow()
session.add(view)
session.flush()
+11 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from sqlmodel import Session, select
from app.core.events import hub
from app.models import GroupMember, Match, MatchParticipant
from app.models import GroupMember, Match, MatchParticipant, User
def _group_member_ids(session: Session, group_id: int) -> list[int]:
@@ -87,3 +87,13 @@ def invitations_changed(user_id: int) -> None:
def notifications_changed(user_id: int) -> None:
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
hub.publish([user_id], {"type": "notifications"})
def announcements_changed(session: Session) -> None:
"""Админ создал, поправил, снял или удалил объявление — всем активным игрокам:
открытые вкладки перезапросят, что показать. Объявление, чей период начнётся
позже, клиент подхватит сам — периодическим перезапросом."""
ids = session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all()
hub.publish(ids, {"type": "announcements"})