From 976b8fc622fd371bd859aa040cbbbd45a925b1b4 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 21:57:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F:=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C,=20A?= =?UTF-8?q?PI=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8,=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Объявление администрации показывается игроку окном в свой период, пока игрок не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю увидят объявление снова — ответ помечен 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 --- .../alembic/versions/0015_announcements.py | 80 +++++ backend/app/main.py | 4 +- backend/app/models.py | 63 ++++ backend/app/routers/admin.py | 122 +++++++ backend/app/routers/announcements.py | 46 +++ backend/app/schemas/api.py | 55 ++- backend/app/services/announcement_service.py | 336 ++++++++++++++++++ backend/app/services/notify.py | 12 +- backend/tests/test_announcements.py | 286 +++++++++++++++ 9 files changed, 1001 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/0015_announcements.py create mode 100644 backend/app/routers/announcements.py create mode 100644 backend/app/services/announcement_service.py create mode 100644 backend/tests/test_announcements.py diff --git a/backend/alembic/versions/0015_announcements.py b/backend/alembic/versions/0015_announcements.py new file mode 100644 index 0000000..4f03dc5 --- /dev/null +++ b/backend/alembic/versions/0015_announcements.py @@ -0,0 +1,80 @@ +"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл». + +Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД создаёт недостающие таблицы. + +Отходит от 0013, а не от 0014: объявления выпущены в main раньше рейтинга. Ветки рейтинга +(0014) и объявлений (0015) сводит пустая миграция 0016. + +Revision ID: 0015_announcements +Revises: 0013_user_token_version +Create Date: 2026-09-18 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0015_announcements" +down_revision: Union[str, None] = "0013_user_token_version" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + tables = set(inspect(bind).get_table_names()) + + if "announcements" not in tables: + op.create_table( + "announcements", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(64), nullable=False), + sa.Column("body_html", sa.Text(), nullable=False), + sa.Column("starts_at", sa.DateTime(), nullable=False), + sa.Column("ends_at", sa.DateTime(), nullable=False), + sa.Column("show_to_new_players", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("revision", sa.Integer(), nullable=False, server_default="1"), + sa.Column( + "created_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.CheckConstraint("ends_at > starts_at", name="ck_announcement_period"), + ) + op.create_index("ix_announcements_period", "announcements", ["starts_at", "ends_at"]) + + if "announcement_views" not in tables: + op.create_table( + "announcement_views", + sa.Column( + "announcement_id", + sa.Integer(), + sa.ForeignKey("announcements.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("closed_at", sa.DateTime(), nullable=False), + ) + op.create_index( + "ix_announcement_views_user_id", "announcement_views", ["user_id"] + ) + + +def downgrade() -> None: + tables = set(inspect(op.get_bind()).get_table_names()) + if "announcement_views" in tables: + op.drop_table("announcement_views") + if "announcements" in tables: + op.drop_table("announcements") diff --git a/backend/app/main.py b/backend/app/main.py index b91cb9c..52a56e0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 4c5d45d..52d6582 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a3e5314..3c143ac 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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) diff --git a/backend/app/routers/announcements.py b/backend/app/routers/announcements.py new file mode 100644 index 0000000..65b457d --- /dev/null +++ b/backend/app/routers/announcements.py @@ -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() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 60026aa..5ebc45d 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -1,7 +1,7 @@ """Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" from __future__ import annotations -from datetime import date +from datetime import date, datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -222,6 +222,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): @@ -588,3 +605,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 diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py new file mode 100644 index 0000000..9caadc4 --- /dev/null +++ b/backend/app/services/announcement_service.py @@ -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) в разных +# браузерах пишет то , то , абзацы —
или

; приводим к одному виду. +_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" у ), весь текст экранируется заново. Всё, что + парсер не распознал как тег из списка, становится текстом или пропадает.""" + + 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(". + if last == f"<{top}>" or last.startswith(f"<{top} "): + self.out.pop() # пустая пара (

от вложенных
) — выбрасываем + else: + self.out.append(f"") + 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: + # Абзац внутри абзаца (вложенные
из 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 = '' + self.stack.append(name) + self.out.append(opening) + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + #
и прочие самозакрытые: содержимого нет, так что 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() diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index 849c3de..2dad33c 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -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"}) diff --git a/backend/tests/test_announcements.py b/backend/tests/test_announcements.py new file mode 100644 index 0000000..2ab40f6 --- /dev/null +++ b/backend/tests/test_announcements.py @@ -0,0 +1,286 @@ +"""Объявления администрации (#84): очистка HTML, права, период показа, «новые игроки», +повторный показ с пометкой «обновлено», снятие с показа и удаление.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.models import AnnouncementView, User +from app.services import notify +from app.services.announcement_service import sanitize_body +from tests.conftest import csrf_headers, login + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _admin_login(client: TestClient, make_admin) -> None: + make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + +def _create(client: TestClient, **over) -> dict: + body = { + "title": "Турнир", + "body_html": "

Суббота, 11:00

", + "starts_at": _iso(_now() - timedelta(hours=1)), + "ends_at": _iso(_now() + timedelta(days=1)), + "show_to_new_players": True, + } + body.update(over) + r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + return r.json() + + +def _update(client: TestClient, ann: dict, **over) -> dict: + body = { + "title": ann["title"], + "body_html": ann["body_html"], + "starts_at": ann["starts_at"], + "ends_at": ann["ends_at"], + "show_to_new_players": ann["show_to_new_players"], + "reshow": False, + } + body.update(over) + r = client.put( + f"/api/admin/announcements/{ann['id']}", json=body, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + return r.json() + + +def _pending(client: TestClient) -> list[dict]: + r = client.get("/api/announcements/pending") + assert r.status_code == 200, r.text + return r.json() + + +def _ack(client: TestClient, ann_id: int, revision: int): + return client.post( + f"/api/announcements/{ann_id}/ack", + json={"revision": revision}, + headers=csrf_headers(client), + ) + + +def _admin_item(client: TestClient, ann_id: int) -> dict: + return next(a for a in client.get("/api/admin/announcements").json() if a["id"] == ann_id) + + +# ─── Очистка HTML ──────────────────────────────────────────────────────────── + +def test_sanitize_keeps_allowed_and_strips_everything_else(): + raw = ( + '
Привет мир
' + '

курсив красный ' + 'золотой

' + "" + 'ссылка' + "svg-текст<b>" + ) + html, text = sanitize_body(raw) + assert html == ( + "

Привет мир

" + '

курсив красный золотой

' + "ссылка<b>" + ) + # Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает. + assert text == "Привет миркурсив красный золотойссылка" + + +def test_sanitize_fixes_nesting_and_keeps_line_breaks(): + html, _ = sanitize_body("
один
два
жирный
хвост") + # Вложенные
из contenteditable → плоские абзацы без пустых

; + #
внутри не путается с самим ; незакрытое закрывается. + assert html == "

один

два

жирный
хвост
" + + +# ─── Права ─────────────────────────────────────────────────────────────────── + +def test_admin_endpoints_are_closed_to_players(client: TestClient): + assert client.get("/api/admin/announcements").status_code == 401 + login(client, "Игрок") + assert client.get("/api/admin/announcements").status_code == 401 + r = client.post( + "/api/admin/announcements", + json={ + "title": "x", + "body_html": "y", + "starts_at": _iso(_now()), + "ends_at": _iso(_now() + timedelta(days=1)), + }, + headers=csrf_headers(client), + ) + assert r.status_code == 401 + + +def test_pending_requires_player_session(client: TestClient): + assert client.get("/api/announcements/pending").status_code == 401 + + +# ─── Показ ─────────────────────────────────────────────────────────────────── + +def test_created_announcement_is_shown_once(client: TestClient, make_admin, monkeypatch): + events: list[dict] = [] + monkeypatch.setattr(notify.hub, "publish", lambda ids, ev: events.append(ev)) + login(client, "Игрок") + _admin_login(client, make_admin) + + ann = _create(client, body_html="

Сбор в 11:00

") + assert ann["body_html"] == "

Сбор в 11:00

" + assert ann["status"] == "live" + assert (ann["seen_count"], ann["audience_count"]) == (0, 1) + assert {"type": "announcements"} in events + + items = _pending(client) + assert [(a["id"], a["revision"], a["updated"]) for a in items] == [(ann["id"], 1, False)] + assert _ack(client, ann["id"], 1).status_code == 200 + assert _pending(client) == [] + assert _admin_item(client, ann["id"])["seen_count"] == 1 + + logs = client.get("/api/admin/audit-logs?entity_type=announcement").json()["items"] + assert [(l["action"], l["entity_id"]) for l in logs] == [("create", ann["id"])] + + +def test_pending_respects_period_and_order(client: TestClient, make_admin): + login(client, "Игрок") + _admin_login(client, make_admin) + later = _create(client, title="Позже", starts_at=_iso(_now() - timedelta(minutes=30))) + earlier = _create(client, title="Раньше", starts_at=_iso(_now() - timedelta(hours=3))) + planned = _create( + client, + title="Завтра", + starts_at=_iso(_now() + timedelta(days=1)), + ends_at=_iso(_now() + timedelta(days=2)), + ) + assert planned["status"] == "planned" + # Пересекающиеся периоды — от старого к новому; запланированного пока нет. + assert [a["title"] for a in _pending(client)] == ["Раньше", "Позже"] + assert earlier["id"] != later["id"] + + # Период, который уже закончился, создать нельзя. + r = client.post( + "/api/admin/announcements", + json={ + "title": "Прошлое", + "body_html": "текст", + "starts_at": _iso(_now() - timedelta(days=2)), + "ends_at": _iso(_now() - timedelta(days=1)), + }, + headers=csrf_headers(client), + ) + assert r.status_code == 422 + + +def test_hidden_from_players_registered_after_start(client: TestClient, make_admin, engine): + login(client, "Старожил") + with Session(engine) as s: + old = s.exec(select(User).where(User.nickname == "Старожил")).one() + old.created_at = datetime.now(timezone.utc) - timedelta(days=2) + s.add(old) + s.commit() + _admin_login(client, make_admin) + ann = _create(client, show_to_new_players=False) + assert ann["audience_count"] == 1 + + assert [a["id"] for a in _pending(client)] == [ann["id"]] + login(client, "Новичок") # зарегистрирован уже после начала показа + assert _pending(client) == [] + # Адресаты — по-прежнему только старожил. + assert _admin_item(client, ann["id"])["audience_count"] == 1 + + +def test_reshow_brings_announcement_back_marked_updated(client: TestClient, make_admin): + login(client, "Игрок") + _admin_login(client, make_admin) + ann = _create(client) + assert _ack(client, ann["id"], 1).status_code == 200 + + # Правка без «показать заново» — закрывшие её не видят. + ann = _update(client, ann, body_html="

Сбор в 12:00

") + assert ann["revision"] == 1 + assert _pending(client) == [] + + ann = _update(client, ann, body_html="

Сбор в 13:00

", reshow=True) + assert ann["revision"] == 2 + assert ann["seen_count"] == 0 # закрывших новую версию ещё нет + items = _pending(client) + assert [(a["revision"], a["updated"], a["body_html"]) for a in items] == [ + (2, True, "

Сбор в 13:00

") + ] + + # Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью. + ann = _update(client, ann, reshow=True) + assert _ack(client, ann["id"], 2).status_code == 200 + assert [(a["revision"], a["updated"]) for a in _pending(client)] == [(3, True)] + assert _ack(client, ann["id"], 3).status_code == 200 + assert _pending(client) == [] + + +def test_stop_and_delete(client: TestClient, make_admin, engine): + login(client, "Игрок") + _admin_login(client, make_admin) + live = _create(client) + planned = _create( + client, + starts_at=_iso(_now() + timedelta(days=1)), + ends_at=_iso(_now() + timedelta(days=2)), + ) + + r = client.post(f"/api/admin/announcements/{planned['id']}/stop", headers=csrf_headers(client)) + assert r.status_code == 422 # снять можно только идущее + + r = client.post(f"/api/admin/announcements/{live['id']}/stop", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert r.json()["status"] == "finished" + assert _pending(client) == [] + + assert _ack(client, live["id"], 1).status_code == 200 # закрыть можно и снятое + r = client.delete(f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client)) + assert r.status_code == 200 + with Session(engine) as s: + assert s.exec(select(AnnouncementView)).all() == [] # отметки ушли каскадом + assert client.delete( + f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client) + ).status_code == 404 + assert _ack(client, live["id"], 1).status_code == 404 + + +@pytest.mark.parametrize( + ("over", "message_part"), + [ + ({"title": " "}, "Заголовок"), + ({"title": "Я" * 61}, "Заголовок"), + ({"body_html": "

"}, "пустым"), + ({"body_html": "

" + "а" * 601 + "

"}, "600"), + ({"ends_at": _iso(_now() - timedelta(hours=2))}, "позже начала"), + ], +) +def test_validation(client: TestClient, make_admin, over: dict, message_part: str): + _admin_login(client, make_admin) + body = { + "title": "Заголовок", + "body_html": "

текст

", + "starts_at": _iso(_now() - timedelta(hours=1)), + "ends_at": _iso(_now() + timedelta(days=1)), + } + body.update(over) + r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client)) + assert r.status_code == 422, r.text + err = r.json()["error"] + assert err["code"] == "VALIDATION_ERROR" + assert message_part in err["message"]