Объявления: модель, 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:
@@ -0,0 +1,77 @@
|
||||
"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл».
|
||||
|
||||
Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||
на существующей БД создаёт недостающие таблицы.
|
||||
|
||||
Revision ID: 0015_announcements
|
||||
Revises: 0014_rating_inputs
|
||||
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] = "0014_rating_inputs"
|
||||
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")
|
||||
+3
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"})
|
||||
|
||||
@@ -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": "<p>Суббота, <b>11:00</b></p>",
|
||||
"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 = (
|
||||
'<div>Привет <strong onclick="steal()">мир</strong></div>'
|
||||
'<p style="color:red"><i>курсив</i> <mark class="red big">красный</mark> '
|
||||
'<mark class="gold" onmouseover="x()">золотой</mark></p>'
|
||||
"<script>alert(1)</script><style>p{display:none}</style>"
|
||||
'<img src=x onerror="alert(1)"><a href="javascript:alert(1)">ссылка</a>'
|
||||
"<svg><text>svg-текст</text></svg><b>"
|
||||
)
|
||||
html, text = sanitize_body(raw)
|
||||
assert html == (
|
||||
"<p>Привет <b>мир</b></p>"
|
||||
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
|
||||
"ссылка<b>"
|
||||
)
|
||||
# Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает.
|
||||
assert text == "Привет миркурсив красный золотойссылка<b>"
|
||||
|
||||
|
||||
def test_sanitize_fixes_nesting_and_keeps_line_breaks():
|
||||
html, _ = sanitize_body("<div><div>один</div><div>два<br></div></div><b>жирный<br/>хвост")
|
||||
# Вложенные <div> из contenteditable → плоские абзацы без пустых <p></p>;
|
||||
# <br> внутри <b> не путается с самим <b>; незакрытое закрывается.
|
||||
assert html == "<p>один</p><p>два<br></p><b>жирный<br>хвост</b>"
|
||||
|
||||
|
||||
# ─── Права ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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="<p>Сбор <b>в 11:00</b></p><script>alert(1)</script>")
|
||||
assert ann["body_html"] == "<p>Сбор <b>в 11:00</b></p>"
|
||||
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="<p>Сбор в 12:00</p>")
|
||||
assert ann["revision"] == 1
|
||||
assert _pending(client) == []
|
||||
|
||||
ann = _update(client, ann, body_html="<p>Сбор в 13:00</p>", 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, "<p>Сбор в 13:00</p>")
|
||||
]
|
||||
|
||||
# Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью.
|
||||
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": "<p> </p><script>текст</script>"}, "пустым"),
|
||||
({"body_html": "<p>" + "а" * 601 + "</p>"}, "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": "<p>текст</p>",
|
||||
"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"]
|
||||
Reference in New Issue
Block a user