Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""announcement severity + audience targeting (#40)

Existing announcements pick up the server defaults (info / all), so they keep
behaving exactly as before: whole-competition, informational.

Revision ID: 2d3e4f5a6b7c
Revises: 1c2d3e4f5a6b
Create Date: 2026-07-26
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "2d3e4f5a6b7c"
down_revision = "1c2d3e4f5a6b"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("announcements") as batch:
batch.add_column(
sa.Column(
"severity", sa.String(), nullable=False, server_default="info"
)
)
batch.add_column(
sa.Column(
"audience_type", sa.String(), nullable=False, server_default="all"
)
)
batch.add_column(sa.Column("audience_ids", sa.JSON(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("announcements") as batch:
batch.drop_column("audience_ids")
batch.drop_column("audience_type")
batch.drop_column("severity")
41 changes: 36 additions & 5 deletions backend/models/announcement.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
"""Announcement model (ARCHITECTURE.md §4.3, §11.3, ROADMAP #14).

A broadcast message an organiser posts to a competition — every competitor sees
it, pushed live over the §4.1 WebSocket layer (announcement room) rather than
requiring a refresh. Tenant-scoped (§6.2): an announcement belongs to exactly
one competition and is only ever read through that competition's scope.
A broadcast message an organiser posts to a competition, pushed live over the
§4.1 WebSocket layer rather than requiring a refresh. Tenant-scoped (§6.2): an
announcement belongs to exactly one competition and is only ever read through
that competition's scope.

Distinct from the per-user notification inbox (§4.4): announcements are a
one-to-many broadcast, not per-user read/unread state.
one-to-many broadcast, not per-user read/unread state — though posting one now
*also* creates a bell notification per recipient (#40), so an announcement can't
be missed by looking away while the banner is up.

An announcement carries a **severity** (the urgency ladder) and an **audience**:
either the whole competition, or a chosen set of teams/users. Targeting is
enforced on read *and* on delivery — see ``utils/announcements``.
"""

from uuid import uuid4

from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON

from db import Base, CompetitionScopedMixin, TimestampMixin

# Urgency ladder (#40). `critical` is the one tier that overrides a recipient's
# in-app notification mute (utils/notifications), so it stays deliberately
# narrow — three rungs, each with a distinct §9 token in the UI.
SEVERITIES: tuple[str, ...] = ("info", "warning", "critical")
DEFAULT_SEVERITY = "info"

# Who an announcement is for. "all" is the fast path (whole competition, one
# shared WS broadcast); "teams"/"users" resolve `audience_ids` against membership.
AUDIENCE_TYPES: tuple[str, ...] = ("all", "teams", "users")
DEFAULT_AUDIENCE = "all"


class Announcement(Base, CompetitionScopedMixin, TimestampMixin):
__tablename__ = "announcements"
Expand All @@ -25,6 +43,19 @@ class Announcement(Base, CompetitionScopedMixin, TimestampMixin):
)
title: Mapped[str] = mapped_column(String, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
# Urgency (SEVERITIES). Drives the banner's §9 treatment, and `critical`
# bypasses the recipient's in-app notification mute.
severity: Mapped[str] = mapped_column(
String, nullable=False, default=DEFAULT_SEVERITY, server_default=DEFAULT_SEVERITY
)
# Audience (AUDIENCE_TYPES). "all" = the whole competition.
audience_type: Mapped[str] = mapped_column(
String, nullable=False, default=DEFAULT_AUDIENCE, server_default=DEFAULT_AUDIENCE
)
# Team ids or user ids for a targeted announcement; null/[] when audience is
# "all". A JSON id list, like `challenge.prerequisites` / `competition.brackets`
# — same idiom, and the same known backup limitation (ids aren't remapped).
audience_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Who posted it, for the audit trail. SET NULL so removing a staff account
# doesn't erase the competition's announcement history.
created_by: Mapped[str | None] = mapped_column(
Expand Down
130 changes: 91 additions & 39 deletions backend/plugins/announcements/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@

Mounts the announcements router and owns the "announcements" WebSocket room: on
join a client is authorized exactly like the REST read (``challenge_view`` on
the competition) and handed the recent-announcements snapshot, and every
``announcement.published`` event is fanned out to the room as a live frame. The
router stays transport-agnostic — it emits the event; this module broadcasts it.
the competition) and handed the recent-announcements snapshot. The router stays
transport-agnostic — it emits ``announcement.published``; this module delivers.

Delivery has two lanes (#40), because the shared room fans a frame to *every*
connected member:

- **audience "all"** — the cheap path: broadcast the announcement to the shared
room, plus one bell notification per member.
- **targeted** — never touches the shared room (that would leak the body to the
whole competition while merely *looking* targeted). Recipients are resolved
and reached over their own ``/ws/user/<id>`` rooms, which the notification
frame already rides; their clients then refetch the server-filtered list.

Either way the join snapshot is audience-filtered too, so a reconnect can't
reveal what the live path withheld. Audience rules live in ``utils/announcements``.
"""

from __future__ import annotations
Expand All @@ -20,28 +32,52 @@ def setup(app, event_bus, db_factory) -> None:
from models.competition import Competition
from realtime import manager, register_room_type
from routers.announcements import router as announcements_router
from utils.announcements import (
resolve_recipients,
user_team_ids,
visible_to_user,
)
from utils.notifications import broadcast_notifications, create_notifications

app.include_router(announcements_router)

async def _recent(db, competition_id: str) -> list[dict]:
rows = (
await db.execute(
select(Announcement)
.where(Announcement.competition_id == competition_id)
.order_by(Announcement.created_at.desc())
.limit(RECENT_LIMIT)
)
).scalars()
return [
{
"id": a.id,
"competition_id": a.competition_id,
"title": a.title,
"body": a.body,
"created_at": a.created_at.isoformat(),
}
for a in rows
]
def _frame(a: Announcement) -> dict:
return {
"id": a.id,
"competition_id": a.competition_id,
"title": a.title,
"body": a.body,
"severity": a.severity,
"audience_type": a.audience_type,
"created_at": a.created_at.isoformat(),
}

async def _recent(db, user, competition_id: str) -> list[dict]:
rows = list(
(
await db.execute(
select(Announcement)
.where(Announcement.competition_id == competition_id)
.order_by(Announcement.created_at.desc())
.limit(RECENT_LIMIT)
)
).scalars()
)
# Same audience gate as the REST read — a snapshot must not hand a
# reconnecting client an announcement it was never targeted with.
is_staff = await user_has_permission(
db, user.id, "announcement_create", competition_id
)
if not is_staff:
team_ids = await user_team_ids(db, competition_id, user.id)
rows = [
a
for a in rows
if visible_to_user(
a, user_id=user.id, team_ids=team_ids, is_staff=False
)
]
return [_frame(a) for a in rows]

async def authorize(db, user, competition_id: str) -> bool:
if await db.get(Competition, competition_id) is None:
Expand All @@ -54,27 +90,43 @@ async def snapshot(db, user, competition_id: str) -> dict:
return {
"type": "announcements",
"competition_id": competition_id,
"announcements": await _recent(db, competition_id),
"announcements": await _recent(db, user, competition_id),
}

register_room_type("announcements", authorize=authorize, snapshot=snapshot)

@event_bus.on("announcement.published", owner="announcements")
async def broadcast_announcement(event_name: str, payload: dict) -> None:
async def deliver_announcement(event_name: str, payload: dict) -> None:
competition_id = payload.get("competition_id")
if not competition_id:
announcement_id = payload.get("announcement_id")
if not competition_id or not announcement_id:
return
await manager.broadcast(
"announcements",
competition_id,
{
"type": "announcement",
"announcement": {
"id": payload.get("announcement_id"),
"competition_id": competition_id,
"title": payload.get("title"),
"body": payload.get("body"),
"created_at": payload.get("created_at"),
},
},
)

async with db_factory() as db:
announcement = await db.get(Announcement, announcement_id)
if announcement is None:
return
recipients = await resolve_recipients(db, announcement)
# A critical announcement overrides a muted in-app category — the
# one sanctioned bypass (§4.4), because the operator is saying
# something the competition can't afford to miss.
notifications = await create_notifications(
db,
recipients,
type=f"announcement.{announcement.severity}",
title=announcement.title,
body=announcement.body,
competition_id=competition_id,
force=announcement.severity == "critical",
)
await db.commit()
targeted = announcement.audience_type != "all"
frame = {"type": "announcement", "announcement": _frame(announcement)}

# Broadcast after commit, so a client refetching on the ping never sees
# a row the transaction could still roll back.
if not targeted:
await manager.broadcast("announcements", competition_id, frame)
# Targeted announcements ride the per-user notification rooms only; the
# recipient's client turns that frame into a refetch of its filtered list.
await broadcast_notifications(notifications)
39 changes: 34 additions & 5 deletions backend/routers/announcements.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Announcement routes (ROADMAP #14, §4.3).
"""Announcement routes (ROADMAP #14, §4.3, #40).

Reads gate on ``challenge_view`` (competitor access to the competition — the
same gate as the scoreboard); posting gates on ``announcement_create`` (§7.1).
Everything is competition-scoped (§6.2). Posting emits ``announcement.published``
(§3.2); the announcements module turns that event into a live broadcast to the
competition's WebSocket room, so this route stays transport-agnostic.
(§3.2); the announcements module turns that event into the live push + the
per-recipient bell notifications, so this route stays transport-agnostic.

Audience targeting (#40) is enforced here on read — a targeted announcement is
simply absent from the list for anyone outside its audience — through the shared
resolver in ``utils/announcements``, so read and delivery can't drift.
"""

from __future__ import annotations
Expand All @@ -13,12 +17,13 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from auth.deps import require_permission
from auth.deps import require_permission, user_has_permission
from db import get_db
from models.announcement import Announcement
from models.competition import Competition
from models.user import User
from schemas.announcement import AnnouncementCreate, AnnouncementOut
from utils.announcements import user_team_ids, visible_to_user
from utils.event_bus import event_bus

router = APIRouter(
Expand All @@ -37,7 +42,23 @@ async def list_announcements(
.where(Announcement.competition_id == competition_id)
.order_by(Announcement.created_at.desc())
)
return list(result.scalars().all())
rows = list(result.scalars().all())
# Audience filter (#40). Staff who can post see everything (their own sent
# history); everyone else sees "all" plus what targets them. The team set is
# resolved once, not per row.
is_staff = await user_has_permission(
db, current_user.id, "announcement_create", competition_id
)
if is_staff:
return rows
team_ids = await user_team_ids(db, competition_id, current_user.id)
return [
a
for a in rows
if visible_to_user(
a, user_id=current_user.id, team_ids=team_ids, is_staff=False
)
]


@router.post("", response_model=AnnouncementOut, status_code=status.HTTP_201_CREATED)
Expand All @@ -56,6 +77,9 @@ async def create_announcement(
competition_id=competition_id,
title=body.title,
body=body.body,
severity=body.severity,
audience_type=body.audience_type,
audience_ids=body.audience_ids or None,
created_by=current_user.id,
)
db.add(announcement)
Expand All @@ -69,6 +93,11 @@ async def create_announcement(
"announcement_id": announcement.id,
"title": announcement.title,
"body": announcement.body,
"severity": announcement.severity,
# The audience the module needs to decide shared-broadcast vs
# per-recipient delivery. Ids stay off the event: the handler reads
# the row, and an audit entry shouldn't carry a recipient list.
"audience_type": announcement.audience_type,
"created_at": announcement.created_at.isoformat(),
},
)
Expand Down
37 changes: 35 additions & 2 deletions backend/schemas/announcement.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
"""Pydantic schemas for announcements (ROADMAP #14)."""
"""Pydantic schemas for announcements (ROADMAP #14, #40)."""

from datetime import datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from models.announcement import DEFAULT_AUDIENCE, DEFAULT_SEVERITY

Severity = Literal["info", "warning", "critical"]
AudienceType = Literal["all", "teams", "users"]


class AnnouncementCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
body: str = Field(min_length=1, max_length=5000)
severity: Severity = DEFAULT_SEVERITY
audience_type: AudienceType = DEFAULT_AUDIENCE
# Team ids or user ids, per audience_type. Cleared for "all".
audience_ids: list[str] = Field(default_factory=list, max_length=500)

@model_validator(mode="after")
def _check_audience(self) -> "AnnouncementCreate":
if self.audience_type == "all":
# Normalize: a whole-competition announcement carries no id list, so
# a stored row can never imply a narrower audience than it has.
self.audience_ids = []
elif not self.audience_ids:
raise ValueError(
"Select at least one recipient for a targeted announcement"
)
return self


class AnnouncementOut(BaseModel):
Expand All @@ -17,4 +39,15 @@ class AnnouncementOut(BaseModel):
competition_id: str
title: str
body: str
severity: str
audience_type: str
# Meaningful to staff (who see every announcement, including ones they sent
# to a subset); a recipient only ever receives rows meant for them.
audience_ids: list[str] = Field(default_factory=list)
created_at: datetime

@field_validator("audience_ids", mode="before")
@classmethod
def _ids_default(cls, v: object) -> object:
# Null for "all" rows — present it as an empty list.
return v or []
Loading
Loading