From f37ba4a6b382be83bc94b59e1c7e46c6f42f4511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 08:46:14 +0300 Subject: [PATCH 01/19] feat: add announcement model --- .../1c21a0056027_add_announcement_model.py | 52 ++++++++++++ app/models/__init__.py | 2 + app/models/announcement.py | 82 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 app/alembic/versions/1c21a0056027_add_announcement_model.py create mode 100644 app/models/announcement.py diff --git a/app/alembic/versions/1c21a0056027_add_announcement_model.py b/app/alembic/versions/1c21a0056027_add_announcement_model.py new file mode 100644 index 0000000..4b359c5 --- /dev/null +++ b/app/alembic/versions/1c21a0056027_add_announcement_model.py @@ -0,0 +1,52 @@ +"""add announcement model + +Revision ID: 1c21a0056027 +Revises: 19b42a1d4b20 +Create Date: 2026-06-14 08:44:47.930238 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '1c21a0056027' +down_revision: str | Sequence[str] | None = '19b42a1d4b20' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('announcement', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('kind', sa.String(length=20), nullable=False), + sa.Column('template_key', sa.String(length=100), nullable=True), + sa.Column('variables', postgresql.JSON(astext_type=sa.Text()), nullable=False), + sa.Column('translations', postgresql.JSON(astext_type=sa.Text()), nullable=False), + sa.Column('level', sa.String(length=20), nullable=False), + sa.Column('audience', sa.String(length=20), nullable=False), + sa.Column('role_filter', sa.String(length=20), nullable=True), + sa.Column('show_banner', sa.Boolean(), nullable=False), + sa.Column('send_email', sa.Boolean(), nullable=False), + sa.Column('created_by', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_announcement_banner_created', 'announcement', ['show_banner', 'created_at'], unique=False) + op.create_index('ix_announcement_created', 'announcement', ['created_at'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_announcement_created', table_name='announcement') + op.drop_index('ix_announcement_banner_created', table_name='announcement') + op.drop_table('announcement') + # ### end Alembic commands ### diff --git a/app/models/__init__.py b/app/models/__init__.py index bf988b2..b21f600 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.admin_permission import AdminPermission +from app.models.announcement import Announcement from app.models.file import File from app.models.notification import Notification from app.models.support import ( @@ -12,6 +13,7 @@ __all__ = [ "AdminPermission", + "Announcement", "File", "Notification", "SupportMessage", diff --git a/app/models/announcement.py b/app/models/announcement.py new file mode 100644 index 0000000..b1e9942 --- /dev/null +++ b/app/models/announcement.py @@ -0,0 +1,82 @@ +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db import Base +from app.schemas.common import JsonValue +from app.utils import utc_now + +if TYPE_CHECKING: + from app.models.user import User + + +class Announcement(Base): + """A broadcast announcement composed by an admin and fanned out to users. + + Content is stored language-agnostically so it renders in each recipient's + own locale: a ``template`` announcement carries a ``template_key`` plus + ``variables`` (the frontend/email render the localized text from a catalog), + while a ``custom`` announcement stores ``translations`` ({lang: {title, + body}}) authored by the admin. The fan-out itself lives in ``notification`` + rows; this table is the source of truth for the admin history and the + active banner. + """ + + __tablename__ = "announcement" + __table_args__ = ( + # Admin history listing ("sent announcements, newest first") and the + # active-banner lookup both scan by recency. + Index("ix_announcement_created", "created_at"), + # Active-banner lookup: WHERE show_banner = true ORDER BY created_at DESC. + Index("ix_announcement_banner_created", "show_banner", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + # "template" or "custom"; validated at the schema layer (no DB enum, so a new + # kind never needs an ALTER TYPE migration — mirrors Notification.type). + kind: Mapped[str] = mapped_column(String(20), nullable=False) + # Set for kind="template": stable catalog key (e.g. "maintenance"). + template_key: Mapped[str | None] = mapped_column(String(100), default=None) + # Set for kind="template": placeholder values ({"date": "...", "time": "..."}). + variables: Mapped[dict[str, JsonValue]] = mapped_column( + JSON, default=dict, nullable=False + ) + # Set for kind="custom": admin-authored text per language + # ({"tr": {"title": ..., "body": ...}, "en": {...}}). + translations: Mapped[dict[str, JsonValue]] = mapped_column( + JSON, default=dict, nullable=False + ) + # Visual severity: "info" | "warning" | "critical". Plain string, validated + # at the schema layer. + level: Mapped[str] = mapped_column(String(20), default="info", nullable=False) + # Target selector: "all" | "active" | "role". + audience: Mapped[str] = mapped_column(String(20), default="all", nullable=False) + # Set when audience="role": which role to target (e.g. "admin"). + role_filter: Mapped[str | None] = mapped_column(String(20), default=None) + # Whether this announcement is surfaced as the top-of-page banner/modal. + show_banner: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False + ) + # Whether an email copy was requested at send time (audit/history only). + send_email: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False + ) + # Author. SET NULL keeps the announcement (and its history row) readable + # after the sending admin is deleted. + created_by: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), + default=None, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, nullable=False + ) + + # Sending admin, surfaced in the admin history ("sent by ..."). + creator: Mapped["User | None"] = relationship("User") From 25a7b0762fabe9a4a31105988fec433db42b608b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 08:56:50 +0300 Subject: [PATCH 02/19] feat: add broadcast schemas + RBAC --- app/core/messages/error_message.py | 6 ++ app/core/messages/success_message.py | 3 + app/models/announcement.py | 8 +- app/schemas/admin_permission.py | 2 + app/schemas/announcement.py | 146 +++++++++++++++++++++++++++ app/schemas/notification.py | 1 + app/schemas/user_activity.py | 1 + 7 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 app/schemas/announcement.py diff --git a/app/core/messages/error_message.py b/app/core/messages/error_message.py index c45c57e..9cf5bc2 100644 --- a/app/core/messages/error_message.py +++ b/app/core/messages/error_message.py @@ -76,3 +76,9 @@ class ErrorMessages: ATTACHMENT_NOT_OWNED = "error.support.attachment_not_owned" ATTACHMENT_WRONG_CATEGORY = "error.support.attachment_wrong_category" INVALID_ASSIGNED_ADMIN = "error.support.invalid_assigned_admin" + + # Announcements / broadcast + ANNOUNCEMENT_NOT_FOUND = "error.announcement.not_found" + ANNOUNCEMENT_TEMPLATE_NOT_FOUND = "error.announcement.template_not_found" + ANNOUNCEMENT_INVALID_CONTENT = "error.announcement.invalid_content" + ANNOUNCEMENT_INVALID_AUDIENCE = "error.announcement.invalid_audience" diff --git a/app/core/messages/success_message.py b/app/core/messages/success_message.py index e3a3642..80ab887 100644 --- a/app/core/messages/success_message.py +++ b/app/core/messages/success_message.py @@ -52,3 +52,6 @@ class SuccessMessages: TICKET_MESSAGE_SENT = "success.support.message_sent" ADMIN_TICKET_UPDATED = "success.support.admin_ticket_updated" ADMIN_TICKET_REPLIED = "success.support.admin_ticket_replied" + + # Announcements / broadcast + BROADCAST_SENT = "success.announcement.sent" diff --git a/app/models/announcement.py b/app/models/announcement.py index b1e9942..64d65e6 100644 --- a/app/models/announcement.py +++ b/app/models/announcement.py @@ -61,13 +61,9 @@ class Announcement(Base): # Set when audience="role": which role to target (e.g. "admin"). role_filter: Mapped[str | None] = mapped_column(String(20), default=None) # Whether this announcement is surfaced as the top-of-page banner/modal. - show_banner: Mapped[bool] = mapped_column( - Boolean, default=False, nullable=False - ) + show_banner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # Whether an email copy was requested at send time (audit/history only). - send_email: Mapped[bool] = mapped_column( - Boolean, default=False, nullable=False - ) + send_email: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # Author. SET NULL keeps the announcement (and its history row) readable # after the sending admin is deleted. created_by: Mapped[uuid.UUID | None] = mapped_column( diff --git a/app/schemas/admin_permission.py b/app/schemas/admin_permission.py index b5b4bd8..c22323e 100644 --- a/app/schemas/admin_permission.py +++ b/app/schemas/admin_permission.py @@ -24,3 +24,5 @@ class Permission(StrEnum): SUPPORT_UPDATE = "support:update" ACTIVITIES_READ = "activities:read" STATS_READ = "stats:read" + BROADCAST_READ = "broadcast:read" + BROADCAST_WRITE = "broadcast:write" diff --git a/app/schemas/announcement.py b/app/schemas/announcement.py new file mode 100644 index 0000000..6fdd155 --- /dev/null +++ b/app/schemas/announcement.py @@ -0,0 +1,146 @@ +import uuid +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator + +from app.core.messages.error_message import ErrorMessages +from app.schemas.user import Language, SystemRole + + +class AnnouncementKind(StrEnum): + """How an announcement's content is sourced.""" + + TEMPLATE = "template" # text rendered from the backend catalog + variables + CUSTOM = "custom" # admin-authored text, stored per language + + +class AnnouncementLevel(StrEnum): + """Visual severity of an announcement.""" + + INFO = "info" + WARNING = "warning" + CRITICAL = "critical" + + +class AnnouncementAudience(StrEnum): + """Which users an announcement is fanned out to.""" + + ALL = "all" # every user + ACTIVE = "active" # only active accounts + ROLE = "role" # only users holding ``role_filter`` + + +# Per-language content limits — kept short so the banner stays single-line on +# desktop and the modal readable on mobile. +TITLE_MAX_LENGTH = 120 +BODY_MAX_LENGTH = 600 + + +class AnnouncementContent(BaseModel): + """Admin-authored title/body for a single language (custom announcements).""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=TITLE_MAX_LENGTH) + body: str = Field(min_length=1, max_length=BODY_MAX_LENGTH) + + +class BroadcastCreate(BaseModel): + """Admin payload to compose and send a broadcast announcement. + + Exactly one content source is used: ``template`` announcements carry a + ``template_key`` + ``variables`` (text comes from the backend catalog), + while ``custom`` announcements carry ``translations`` keyed by language. + Content is stored language-agnostically so each recipient sees it in their + own locale. + """ + + model_config = ConfigDict(extra="forbid") + + kind: AnnouncementKind + # Template mode: + template_key: str | None = Field(default=None, max_length=100) + variables: dict[str, str] = Field(default_factory=dict) + # Custom mode: at least one language; missing languages fall back at render. + translations: dict[Language, AnnouncementContent] = Field(default_factory=dict) + # Presentation + targeting: + level: AnnouncementLevel = AnnouncementLevel.INFO + audience: AnnouncementAudience = AnnouncementAudience.ALL + role_filter: SystemRole | None = None + show_banner: bool = False + send_email: bool = False + + @model_validator(mode="after") + def _check_consistency(self) -> "BroadcastCreate": + """Enforce that the chosen kind/audience carry their required fields.""" + if self.kind == AnnouncementKind.TEMPLATE: + if not self.template_key: + raise ValueError(ErrorMessages.ANNOUNCEMENT_INVALID_CONTENT) + if self.translations: + raise ValueError(ErrorMessages.ANNOUNCEMENT_INVALID_CONTENT) + else: # CUSTOM + if not self.translations: + raise ValueError(ErrorMessages.ANNOUNCEMENT_INVALID_CONTENT) + if self.template_key: + raise ValueError(ErrorMessages.ANNOUNCEMENT_INVALID_CONTENT) + + if self.audience == AnnouncementAudience.ROLE and self.role_filter is None: + raise ValueError(ErrorMessages.ANNOUNCEMENT_INVALID_AUDIENCE) + if self.audience != AnnouncementAudience.ROLE and self.role_filter is not None: + raise ValueError(ErrorMessages.ANNOUNCEMENT_INVALID_AUDIENCE) + return self + + +class AnnouncementCreator(BaseModel): + """Minimal identity of the admin who sent an announcement.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: EmailStr + first_name: str | None = None + last_name: str | None = None + + +class AnnouncementRead(BaseModel): + """An announcement as returned by the admin history and the active banner.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + kind: AnnouncementKind + template_key: str | None = None + variables: dict[str, str] = Field(default_factory=dict) + translations: dict[Language, AnnouncementContent] = Field(default_factory=dict) + level: AnnouncementLevel + audience: AnnouncementAudience + role_filter: SystemRole | None = None + show_banner: bool + send_email: bool + created_by: uuid.UUID | None = None + creator: AnnouncementCreator | None = None + created_at: datetime + + +class BroadcastCreateResponse(BaseModel): + """Response after a broadcast is composed and fanned out.""" + + announcement: AnnouncementRead + recipients: int + message: str + + +class AnnouncementListResponse(BaseModel): + """Paginated admin history of sent announcements.""" + + data: list[AnnouncementRead] + total: int + skip: int + limit: int + + +class ActiveBannerResponse(BaseModel): + """The currently active banner announcement, or ``None``.""" + + announcement: AnnouncementRead | None = None diff --git a/app/schemas/notification.py b/app/schemas/notification.py index 8b5af15..2bc5f31 100644 --- a/app/schemas/notification.py +++ b/app/schemas/notification.py @@ -17,6 +17,7 @@ class NotificationType(StrEnum): SUPPORT_TICKET_REPLIED = "support_ticket_replied" SUPPORT_TICKET_STATUS_CHANGED = "support_ticket_status_changed" ADMIN_PERMISSIONS_CHANGED = "admin_permissions_changed" + ADMIN_ANNOUNCEMENT = "admin_announcement" class NotificationRead(BaseModel): diff --git a/app/schemas/user_activity.py b/app/schemas/user_activity.py index 097b915..69e543d 100644 --- a/app/schemas/user_activity.py +++ b/app/schemas/user_activity.py @@ -28,6 +28,7 @@ class ResourceType(StrEnum): FILE = "file" ACTIVITY = "activity" SUPPORT_TICKET = "support_ticket" + ANNOUNCEMENT = "announcement" class ActivityStatus(StrEnum): From f295ec13e8465d7a138d7056bde03f5841d9be6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 09:28:17 +0300 Subject: [PATCH 03/19] feat: add broadcast template catalog --- app/core/broadcast_templates.py | 182 ++++++++++++++++++++++++++++++++ app/schemas/announcement.py | 40 ++++++- pyproject.toml | 1 + uv.lock | 11 ++ 4 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 app/core/broadcast_templates.py diff --git a/app/core/broadcast_templates.py b/app/core/broadcast_templates.py new file mode 100644 index 0000000..8ca6323 --- /dev/null +++ b/app/core/broadcast_templates.py @@ -0,0 +1,182 @@ +"""Catalog of reusable announcement templates (single source of truth). + +Template announcements store only a ``template_key`` + ``variables``; the actual +localized text lives here so both the in-app render (served to the frontend via +the catalog endpoint) and the outgoing email (rendered server-side) draw from +one place and can never drift apart. + +The data source is intentionally a plain module-level dict: the public helpers +below (``list_templates`` / ``get_template`` / ``render_template``) form a stable +interface, so a future "admin-managed templates" phase can swap the dict for a +DB table without touching any caller. +""" + +from dataclasses import dataclass +from datetime import datetime + +from babel.dates import format_datetime + +from app.core.config import settings +from app.schemas.announcement import ( + AnnouncementContent, + BroadcastTemplate, + TemplateVariable, + VariableType, + VariableValue, +) +from app.schemas.user import Language + + +@dataclass(frozen=True) +class RenderedContent: + """A template's title/body after placeholder substitution for one language.""" + + title: str + body: str + + +# The catalog. Each entry lists the placeholders it expects (name + type, so the +# compose form renders the right input and render-time formatting is correct) and +# the localized text for every supported language, with ``{placeholder}`` markers +# filled in at render time. +_TEMPLATES: dict[str, BroadcastTemplate] = { + "maintenance": BroadcastTemplate( + key="maintenance", + variables=[ + TemplateVariable(name="starts_at", type=VariableType.DATETIME), + TemplateVariable(name="ends_at", type=VariableType.DATETIME), + ], + translations={ + Language.TR: AnnouncementContent( + title="Planlı Bakım", + body="Sistem {starts_at} – {ends_at} arasında bakımda olacak; " + "kısa kesintiler yaşanabilir.", + ), + Language.EN: AnnouncementContent( + title="Scheduled Maintenance", + body="The system will be under maintenance from {starts_at} to " + "{ends_at}; brief interruptions may occur.", + ), + }, + ), + "welcome": BroadcastTemplate( + key="welcome", + variables=[], + translations={ + Language.TR: AnnouncementContent( + title="Hoş Geldiniz", + body="Aramıza hoş geldiniz! Hesabınız hazır; dilediğiniz zaman " + "profilinizi tamamlayabilirsiniz.", + ), + Language.EN: AnnouncementContent( + title="Welcome", + body="Welcome aboard! Your account is ready — complete your " + "profile whenever you like.", + ), + }, + ), + "new_feature": BroadcastTemplate( + key="new_feature", + variables=[TemplateVariable(name="feature", type=VariableType.TEXT)], + translations={ + Language.TR: AnnouncementContent( + title="Yeni Özellik", + body="Yeni bir özellik yayında: {feature}. Hemen göz atın!", + ), + Language.EN: AnnouncementContent( + title="New Feature", + body="A new feature is now live: {feature}. Check it out!", + ), + }, + ), + "security_notice": BroadcastTemplate( + key="security_notice", + variables=[], + translations={ + Language.TR: AnnouncementContent( + title="Güvenlik Bildirimi", + body="Hesabınızın güvenliği için şifrenizi gözden geçirmenizi ve " + "güçlü bir şifre kullanmanızı öneririz.", + ), + Language.EN: AnnouncementContent( + title="Security Notice", + body="For your account's safety, we recommend reviewing your " + "password and using a strong one.", + ), + }, + ), +} + + +class _SafeDict(dict): + """A dict that leaves unknown ``{placeholders}`` untouched during format.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def _format_datetime(value: str, language: Language) -> str: + """Format an ISO datetime string for ``language``; pass through on failure.""" + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return str(value) + return format_datetime(dt, "d MMMM y, HH:mm", locale=str(language)) + + +def list_templates() -> list[BroadcastTemplate]: + """Return every available template, for building the compose UI.""" + return list(_TEMPLATES.values()) + + +def get_template(key: str) -> BroadcastTemplate | None: + """Return the template named ``key``, or ``None`` if it does not exist.""" + return _TEMPLATES.get(key) + + +def template_exists(key: str) -> bool: + """Whether ``key`` names a known template.""" + return key in _TEMPLATES + + +def render_template( + key: str, language: Language, variables: dict[str, VariableValue] +) -> RenderedContent | None: + """Render a template's title/body for ``language`` with ``variables`` filled. + + Each placeholder is resolved by its declared type: ``datetime`` values are + locale-formatted, ``text`` values are picked per language (with fallback). + Content language falls back to the default then any available one. Returns + ``None`` for unknown keys. + """ + template = _TEMPLATES.get(key) + if template is None: + return None + + default_lang = Language(settings.DEFAULT_LANGUAGE) + content = ( + template.translations.get(language) + or template.translations.get(default_lang) + or next(iter(template.translations.values())) + ) + + types = {variable.name: variable.type for variable in template.variables} + resolved: dict[str, str] = {} + for name, value in (variables or {}).items(): + vtype = types.get(name) + if vtype == VariableType.DATETIME and isinstance(value, str): + resolved[name] = _format_datetime(value, language) + elif vtype == VariableType.TEXT and isinstance(value, dict): + resolved[name] = ( + value.get(language) + or value.get(default_lang) + or (next(iter(value.values())) if value else "") + ) + else: + resolved[name] = str(value) + + safe = _SafeDict(resolved) + return RenderedContent( + title=content.title.format_map(safe), + body=content.body.format_map(safe), + ) diff --git a/app/schemas/announcement.py b/app/schemas/announcement.py index 6fdd155..4b4af54 100644 --- a/app/schemas/announcement.py +++ b/app/schemas/announcement.py @@ -31,6 +31,18 @@ class AnnouncementAudience(StrEnum): ROLE = "role" # only users holding ``role_filter`` +class VariableType(StrEnum): + """The kind of value a template variable holds (drives input + formatting).""" + + TEXT = "text" # localized per language: {"tr": ..., "en": ...} + DATETIME = "datetime" # a single ISO instant, formatted per locale at render + + +# A submitted variable value: a single neutral value (a datetime as an ISO +# string) or a per-language map for free text. +VariableValue = str | dict[Language, str] + + # Per-language content limits — kept short so the banner stays single-line on # desktop and the modal readable on mobile. TITLE_MAX_LENGTH = 120 @@ -61,7 +73,10 @@ class BroadcastCreate(BaseModel): kind: AnnouncementKind # Template mode: template_key: str | None = Field(default=None, max_length=100) - variables: dict[str, str] = Field(default_factory=dict) + # Template placeholder values, keyed by variable name. A datetime value is a + # single ISO string; a text value is a per-language map. The variable's type + # (from the catalog) decides which shape applies. + variables: dict[str, VariableValue] = Field(default_factory=dict) # Custom mode: at least one language; missing languages fall back at render. translations: dict[Language, AnnouncementContent] = Field(default_factory=dict) # Presentation + targeting: @@ -111,7 +126,7 @@ class AnnouncementRead(BaseModel): id: uuid.UUID kind: AnnouncementKind template_key: str | None = None - variables: dict[str, str] = Field(default_factory=dict) + variables: dict[str, VariableValue] = Field(default_factory=dict) translations: dict[Language, AnnouncementContent] = Field(default_factory=dict) level: AnnouncementLevel audience: AnnouncementAudience @@ -144,3 +159,24 @@ class ActiveBannerResponse(BaseModel): """The currently active banner announcement, or ``None``.""" announcement: AnnouncementRead | None = None + + +class TemplateVariable(BaseModel): + """A single placeholder a template expects, with its value type.""" + + name: str + type: VariableType + + +class BroadcastTemplate(BaseModel): + """A reusable announcement template surfaced to the admin compose UI.""" + + key: str + variables: list[TemplateVariable] = Field(default_factory=list) + translations: dict[Language, AnnouncementContent] + + +class BroadcastTemplateCatalogResponse(BaseModel): + """All available broadcast templates, for building the compose form.""" + + templates: list[BroadcastTemplate] diff --git a/pyproject.toml b/pyproject.toml index 37f6f9e..d6ca0a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "alembic>=1.18.4", "arq>=0.25.0", "asyncpg>=0.31.0", + "babel>=2.18.0", "bcrypt>=5.0.0", "cloudinary>=1.44.2", "dnspython>=2.8.0", diff --git a/uv.lock b/uv.lock index df445be..043e469 100644 --- a/uv.lock +++ b/uv.lock @@ -124,6 +124,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -654,6 +663,7 @@ dependencies = [ { name = "alembic" }, { name = "arq" }, { name = "asyncpg" }, + { name = "babel" }, { name = "bcrypt" }, { name = "cloudinary" }, { name = "dnspython" }, @@ -697,6 +707,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, { name = "arq", specifier = ">=0.25.0" }, { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "babel", specifier = ">=2.18.0" }, { name = "bcrypt", specifier = ">=5.0.0" }, { name = "cloudinary", specifier = ">=1.44.2" }, { name = "dnspython", specifier = ">=2.8.0" }, From a975f5f8d9a981115c79d64a6cf984f9dd59a44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 09:28:39 +0300 Subject: [PATCH 04/19] feat: add announcement repositories --- app/repositories/announcement.py | 69 ++++++++++++++++++++++++++++++++ app/repositories/user.py | 23 +++++++++++ 2 files changed, 92 insertions(+) create mode 100644 app/repositories/announcement.py diff --git a/app/repositories/announcement.py b/app/repositories/announcement.py new file mode 100644 index 0000000..6ef8b1b --- /dev/null +++ b/app/repositories/announcement.py @@ -0,0 +1,69 @@ +import uuid +from collections.abc import Sequence + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.announcement import Announcement + + +async def create_announcement( + session: AsyncSession, announcement: Announcement +) -> Announcement: + """Persist a new announcement.""" + session.add(announcement) + await session.commit() + await session.refresh(announcement) + return announcement + + +async def get_announcement( + session: AsyncSession, announcement_id: uuid.UUID +) -> Announcement | None: + """Get a single announcement by id, with its sending admin loaded.""" + statement = ( + select(Announcement) + .where(Announcement.id == announcement_id) + .options(selectinload(Announcement.creator)) + ) + return (await session.execute(statement)).scalars().first() + + +async def list_announcements( + session: AsyncSession, *, skip: int = 0, limit: int = 50 +) -> tuple[Sequence[Announcement], int]: + """Return announcements (newest first) plus total count, for admin history. + + ``creator`` is eager-loaded with ``selectinload`` so serializing the page to + ``AnnouncementRead`` never triggers a per-row lazy load (no N+1). + """ + count_stmt = select(func.count()).select_from(Announcement) + total = (await session.execute(count_stmt)).scalar_one() + + list_stmt = ( + select(Announcement) + .options(selectinload(Announcement.creator)) + .order_by(Announcement.created_at.desc()) + .offset(skip) + .limit(limit) + ) + announcements = (await session.execute(list_stmt)).scalars().all() + return announcements, total + + +async def get_active_banner(session: AsyncSession) -> Announcement | None: + """Return the most recent banner announcement, or ``None``. + + Backs the ``active_announcement`` field embedded in ``/users/me`` and the + fallback ``GET /announcements/active`` endpoint. Served by the + ``(show_banner, created_at)`` index. + """ + statement = ( + select(Announcement) + .where(Announcement.show_banner.is_(True)) + .options(selectinload(Announcement.creator)) + .order_by(Announcement.created_at.desc()) + .limit(1) + ) + return (await session.execute(statement)).scalars().first() diff --git a/app/repositories/user.py b/app/repositories/user.py index 06cec87..38e1d56 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.user import User +from app.schemas.announcement import AnnouncementAudience +from app.schemas.user import SystemRole from app.utils import utc_now @@ -177,3 +179,24 @@ async def delete_user(session: AsyncSession, db_user: User) -> None: """Hard-delete a user and commit. Caller owns the detach before calling.""" await session.delete(db_user) await session.commit() + + +async def get_target_user_ids( + session: AsyncSession, + *, + audience: AnnouncementAudience, + role: SystemRole | None = None, +) -> Sequence[uuid.UUID]: + """Return the ids of users a broadcast targets for the given audience. + + ``all`` covers every user, ``active`` only active accounts, and ``role`` + only users holding ``role``. Returns ids only (not full rows) so the caller + can fan out notifications cheaply over large audiences. + """ + statement = select(User.id) + if audience == AnnouncementAudience.ACTIVE: + statement = statement.where(User.is_active.is_(True)) + elif audience == AnnouncementAudience.ROLE: + statement = statement.where(User.role == role) + result = await session.execute(statement) + return result.scalars().all() From b841863641232e98788198425c8e4312582617a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 09:43:41 +0300 Subject: [PATCH 05/19] feat: add broadcast fan-out service --- app/services/admin/broadcast_service.py | 119 ++++++++++++++++++++++++ app/use_cases/notify.py | 43 +++++++++ 2 files changed, 162 insertions(+) create mode 100644 app/services/admin/broadcast_service.py diff --git a/app/services/admin/broadcast_service.py b/app/services/admin/broadcast_service.py new file mode 100644 index 0000000..1f3d97f --- /dev/null +++ b/app/services/admin/broadcast_service.py @@ -0,0 +1,119 @@ +from fastapi import HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.broadcast_templates import template_exists +from app.core.messages.error_message import ErrorMessages +from app.core.messages.success_message import SuccessMessages +from app.models.announcement import Announcement +from app.models.user import User +from app.repositories.announcement import ( + create_announcement, + get_announcement, + list_announcements, +) +from app.repositories.user import get_target_user_ids +from app.schemas.announcement import ( + AnnouncementKind, + AnnouncementListResponse, + AnnouncementRead, + BroadcastCreate, + BroadcastCreateResponse, +) +from app.schemas.common import JsonValue +from app.schemas.notification import NotificationType +from app.schemas.user_activity import ActivityType, ResourceType +from app.use_cases.log_activity import log_activity +from app.use_cases.notify import notify_many + + +async def create_broadcast_service( + session: AsyncSession, + *, + admin: User, + payload: BroadcastCreate, + request: Request | None = None, +) -> BroadcastCreateResponse: + """Compose, persist, and fan out a broadcast announcement.""" + # Runtime guard: a template announcement must reference a known catalog key. + if payload.kind == AnnouncementKind.TEMPLATE and not template_exists( + payload.template_key + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorMessages.ANNOUNCEMENT_TEMPLATE_NOT_FOUND, + ) + + # JSON-safe view of the content (enum keys / nested models flattened). + dumped = payload.model_dump(mode="json") + + announcement = Announcement( + kind=payload.kind.value, + template_key=payload.template_key, + variables=dumped["variables"], + translations=dumped["translations"], + level=payload.level.value, + audience=payload.audience.value, + role_filter=payload.role_filter.value if payload.role_filter else None, + show_banner=payload.show_banner, + send_email=payload.send_email, + created_by=admin.id, + ) + announcement = await create_announcement(session, announcement) + + target_ids = await get_target_user_ids( + session, audience=payload.audience, role=payload.role_filter + ) + + # Everything the frontend needs to render this notification in the user's + # own locale at view time. + data: dict[str, JsonValue] = { + "announcement_id": str(announcement.id), + "kind": payload.kind.value, + "level": payload.level.value, + "show_banner": payload.show_banner, + } + if payload.kind == AnnouncementKind.TEMPLATE: + data["template_key"] = payload.template_key + data["variables"] = dumped["variables"] + else: + data["translations"] = dumped["translations"] + + await notify_many( + session, + user_ids=target_ids, + notification_type=NotificationType.ADMIN_ANNOUNCEMENT, + data=data, + ) + + # send_email hook — filled in by BE-6 (enqueue a background email job). + # if payload.send_email: + # ... + + await log_activity( + session, + user_id=admin.id, + activity_type=ActivityType.EXECUTE, + resource_type=ResourceType.ANNOUNCEMENT, + resource_id=announcement.id, + request=request, + ) + + detail = await get_announcement(session, announcement.id) + return BroadcastCreateResponse( + announcement=AnnouncementRead.model_validate(detail), + recipients=len(target_ids), + message=SuccessMessages.BROADCAST_SENT, + ) + + +async def list_broadcasts_service( + session: AsyncSession, *, skip: int = 0, limit: int = 50 +) -> AnnouncementListResponse: + """Return the paginated admin history of sent announcements.""" + rows, total = await list_announcements(session, skip=skip, limit=limit) + return AnnouncementListResponse( + data=[AnnouncementRead.model_validate(row) for row in rows], + total=total, + skip=skip, + limit=limit, + ) diff --git a/app/use_cases/notify.py b/app/use_cases/notify.py index d532237..3c94683 100644 --- a/app/use_cases/notify.py +++ b/app/use_cases/notify.py @@ -6,6 +6,7 @@ """ import uuid +from collections.abc import Sequence from sqlalchemy.ext.asyncio import AsyncSession @@ -49,3 +50,45 @@ async def notify( ), ) return notification + + +CHUNK_SIZE = 500 + + +async def notify_many( + session: AsyncSession, + *, + user_ids: Sequence[uuid.UUID], + notification_type: NotificationType, + data: dict[str, JsonValue] | None = None, + chunk_size: int = CHUNK_SIZE, +) -> int: + """Persist one notification per user id and push it over each user's feed. + + Inserts in fixed-size chunks (one commit per chunk) so a large fan-out stays + within DB parameter/memory limits. All recipients get the same payload — the + language is resolved at render time — so the rows are cheap to build. The + realtime publish per recipient is best-effort. Returns the number created. + """ + ids = list(user_ids) + payload = data or {} + created = 0 + for start in range(0, len(ids), chunk_size): + chunk = ids[start : start + chunk_size] + notifications = [ + Notification(user_id=uid, type=notification_type.value, data=payload) + for uid in chunk + ] + session.add_all(notifications) + await session.commit() + + for notification in notifications: + await publish_safe( + notifications_topic(notification.user_id), + NotificationRealtimeEvent( + type=NotificationEventType.NOTIFICATION_CREATED, + notification=NotificationRead.model_validate(notification), + ), + ) + created += len(notifications) + return created From 1b752329cade356c0e310b5083d36bea1be67623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 10:00:44 +0300 Subject: [PATCH 06/19] feat: add multilingual broadcast email builder --- app/core/arq.py | 31 ++++++++++ app/core/lifespan.py | 3 + app/repositories/user.py | 35 +++++++++-- app/services/admin/broadcast_email.py | 85 +++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 app/core/arq.py create mode 100644 app/services/admin/broadcast_email.py diff --git a/app/core/arq.py b/app/core/arq.py new file mode 100644 index 0000000..9db19e0 --- /dev/null +++ b/app/core/arq.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from arq import create_pool +from arq.connections import ArqRedis, RedisSettings + +from app.core.config import settings + +_pool: ArqRedis | None = None + + +async def init_arq_pool() -> ArqRedis: + """Initialize and return the shared arq enqueue pool.""" + global _pool + if _pool is None: + _pool = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) + return _pool + + +async def close_arq_pool() -> None: + """Close the shared arq enqueue pool.""" + global _pool + if _pool is not None: + await _pool.aclose() + _pool = None + + +def get_arq_pool() -> ArqRedis: + """Return the shared arq enqueue pool. Raises if not initialized.""" + if _pool is None: + raise RuntimeError("arq pool is not initialized") + return _pool diff --git a/app/core/lifespan.py b/app/core/lifespan.py index 68c4bae..977ba88 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -5,6 +5,7 @@ from fastapi import FastAPI +from app.core.arq import close_arq_pool, init_arq_pool from app.core.bootstrap import ensure_first_superadmin from app.core.realtime import start_realtime, stop_realtime from app.core.redis import close_redis, init_redis @@ -14,10 +15,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: """Initialise and dispose shared resources (Redis, realtime) for the API.""" await init_redis() + await init_arq_pool() await start_realtime() await ensure_first_superadmin() try: yield finally: await stop_realtime() + await close_arq_pool() await close_redis() diff --git a/app/repositories/user.py b/app/repositories/user.py index 38e1d56..45d97da 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from datetime import datetime, timedelta -from sqlalchemy import delete, func, select +from sqlalchemy import Select, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.user import User @@ -181,6 +181,17 @@ async def delete_user(session: AsyncSession, db_user: User) -> None: await session.commit() +def _apply_audience( + statement: Select, audience: AnnouncementAudience, role: SystemRole | None +) -> Select: + """Apply the audience/role WHERE clause shared by the target resolvers.""" + if audience == AnnouncementAudience.ACTIVE: + return statement.where(User.is_active.is_(True)) + if audience == AnnouncementAudience.ROLE: + return statement.where(User.role == role) + return statement + + async def get_target_user_ids( session: AsyncSession, *, @@ -193,10 +204,22 @@ async def get_target_user_ids( only users holding ``role``. Returns ids only (not full rows) so the caller can fan out notifications cheaply over large audiences. """ - statement = select(User.id) - if audience == AnnouncementAudience.ACTIVE: - statement = statement.where(User.is_active.is_(True)) - elif audience == AnnouncementAudience.ROLE: - statement = statement.where(User.role == role) + statement = _apply_audience(select(User.id), audience, role) result = await session.execute(statement) return result.scalars().all() + + +async def get_target_user_emails( + session: AsyncSession, + *, + audience: AnnouncementAudience, + role: SystemRole | None = None, +) -> Sequence[tuple[uuid.UUID, str]]: + """Return (id, email) pairs of a broadcast's target users, for email fan-out. + + Same audience filter as ``get_target_user_ids``; the id rides along so the + email job can set a per-recipient bounce address. + """ + statement = _apply_audience(select(User.id, User.email), audience, role) + result = await session.execute(statement) + return [(row[0], row[1]) for row in result.all()] diff --git a/app/services/admin/broadcast_email.py b/app/services/admin/broadcast_email.py new file mode 100644 index 0000000..5fee2b6 --- /dev/null +++ b/app/services/admin/broadcast_email.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from html import escape + +from app.core.broadcast_templates import render_template +from app.core.config import settings +from app.models.announcement import Announcement +from app.schemas.announcement import AnnouncementKind +from app.schemas.user import Language + + +@dataclass(frozen=True) +class BroadcastEmail: + """A composed broadcast email, ready to hand to ``send_email``.""" + + subject: str + html: str + text: str + + +def _content_for( + announcement: Announcement, language: Language +) -> tuple[str, str] | None: + """Resolve an announcement's (title, body) for one language, or ``None``. + + Templates are rendered from the catalog (datetime/text variables resolved); + custom announcements read their stored per-language translations, falling + back to the default language then any available one. + """ + if announcement.kind == AnnouncementKind.TEMPLATE.value: + rendered = render_template( + announcement.template_key, language, announcement.variables + ) + return (rendered.title, rendered.body) if rendered else None + + translations = announcement.translations or {} + default = Language(settings.DEFAULT_LANGUAGE).value + content = ( + translations.get(language.value) + or translations.get(default) + or next(iter(translations.values()), None) + ) + if not content: + return None + return content.get("title", ""), content.get("body", "") + + +def build_broadcast_email(announcement: Announcement) -> BroadcastEmail: + """Build an email carrying every language for one announcement. + + Each supported language gets its own section (default language first), so a + single email is readable by every recipient regardless of their preference — + no stored language preference required. The subject uses the default + language's title. + """ + default_lang = Language(settings.DEFAULT_LANGUAGE) + languages = [default_lang] + [lang for lang in Language if lang != default_lang] + + subject = "" + html_sections: list[str] = [] + text_sections: list[str] = [] + for language in languages: + content = _content_for(announcement, language) + if content is None: + continue + title, body = content + if not subject: + subject = title + html_sections.append( + f'' + f'

{escape(title)}

' + f'

' + f"{escape(body)}

" + ) + text_sections.append(f"{title}\n{body}") + + html = ( + '' + + ''.join( + html_sections + ) + + "

" + ) + text = "\n\n— — —\n\n".join(text_sections) + return BroadcastEmail(subject=subject, html=html, text=text) From 9ac84c6c1eaff187fc22d197d1666ad9a0df8891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 10:00:55 +0300 Subject: [PATCH 07/19] docs: clarify schema vs dataclass convention --- CLAUDE.md | 3 ++- REVIEW.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7017715..5ec3673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ api → services → repositories → models - `services/` → All business logic. Receives session as argument. May raise `HTTPException` directly. - `repositories/` → All DB queries. No business rules. Receives session as argument. - `models/` → SQLAlchemy ORM definitions only. No logic. -- `schemas/` → Pydantic DTOs. Separate `Create`, `Update`, `Response` per domain. +- `schemas/` → Pydantic DTOs **at the API boundary** (request/response). Separate `Create`, `Update`, `Response` per domain. Data that never crosses the API (internal value objects passed between layers) uses a plain `@dataclass`, not a Pydantic schema — e.g. `core/broadcast_templates.py:RenderedContent`, `services/admin/broadcast_email.py:BroadcastEmail`. - `core/` → Config, JWT auth, bcrypt, exceptions, logging. - `utils/` → Pure helper functions shared across layers. @@ -105,6 +105,7 @@ These are non-negotiable: - Services never call other services — use shared repository functions or `use_cases/` instead - No sync DB calls — all queries must be `async/await` - No raw ORM objects returned from endpoints — always map to Pydantic schemas +- `schemas/` (Pydantic) is for API-boundary DTOs only; internal value objects passed between layers use a plain `@dataclass`, never a Pydantic schema - No secrets in code — always use `pydantic-settings` and `.env` - Error messages must ALWAYS come from `app/core/messages/error_message.py` - Success messages must ALWAYS come from `app/core/messages/success_message.py` diff --git a/REVIEW.md b/REVIEW.md index 99f43b8..236c10c 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -101,6 +101,7 @@ Yeni domain: model → schema → repository → service → route (her katman s - `model_config = ConfigDict(from_attributes=True)` ORM → Pydantic mapping için zorunlu ([schemas/user.py:22](app/schemas/user.py)). - Validator hata mesajları **`ErrorMessages.X`**'tan gelir, hardcode yasak ([schemas/user.py:43](app/schemas/user.py)). - `field_validator` + `@classmethod` paterni ([schemas/user.py:39-44](app/schemas/user.py)). +- **`schemas/` yalnızca API sınırını geçen DTO'lar içindir** (request/response). Katmanlar arası taşınan ama API'yi geçmeyen **iç value object'ler `@dataclass`** olur, Pydantic schema değil — kanıt: [core/broadcast_templates.py](app/core/broadcast_templates.py) `RenderedContent`, [services/admin/broadcast_email.py](app/services/admin/broadcast_email.py) `BroadcastEmail`. Doğrulama/serialize gerektirmeyen iç veriye Pydantic koymak `schemas/`'ı kirletir ve gereksiz overhead getirir. ### 3.5 Error/Success messages — single source of truth - **Tüm hata mesajları [app/core/messages/error_message.py](app/core/messages/error_message.py)** — `ErrorMessages.X` class attribute olarak. @@ -268,6 +269,7 @@ Yeni global concern → kendi modülüne `register_X(app)` / `init_X(app)` ile e - `field_validator` hata mesajının inline string olması. - `UserPublic`'e password/hashed_password sızması. - `model_dump()` yerine `dict()` (Pydantic v2'de deprecated). +- **API sınırını geçmeyen iç value object için Pydantic schema kullanılmış** — `@dataclass` olmalı (`schemas/` yalnızca API DTO'ları; bkz. 3.4 ve `RenderedContent`/`BroadcastEmail`). Tersi de geçerli: gerçek request/response DTO'sunun `@dataclass` ile yazılması (Pydantic validation/serialize kaybı). ### 5.6 Migrations / models - **Model değişikliği var ama `app/alembic/versions/` altında migration yok.** @@ -348,4 +350,4 @@ Bu maddeleri **flag etme**: --- -Son güncelleme: 2026-06-09. +Son güncelleme: 2026-06-14. From df8ec5b6f75f554bc2f9b01b2b736982ab63bdb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 10:15:02 +0300 Subject: [PATCH 08/19] feat: add broadcast email arq job --- .env.example | 5 ++ app/core/config.py | 5 ++ app/schemas/worker.py | 8 ++ app/worker/jobs/send_broadcast_emails.py | 93 ++++++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 app/worker/jobs/send_broadcast_emails.py diff --git a/.env.example b/.env.example index bc1690a..d66c55b 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,11 @@ SESSION_REVOKED_RETENTION_DAYS=30 SESSION_PURGE_CRON_HOUR=4 SESSION_PURGE_CRON_MINUTE=0 +# Broadcast email fan-out (admin announcements with "send email"). Recipients +# are emailed in batches of this size with a pause (seconds) between batches. +BROADCAST_EMAIL_BATCH_SIZE=50 +BROADCAST_EMAIL_THROTTLE_SECONDS=1.0 + # Sentry (only initialized when ENVIRONMENT != "local") SENTRY_DSN= diff --git a/app/core/config.py b/app/core/config.py index 6845ca0..97fab1a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -132,6 +132,11 @@ def trusted_hosts(self) -> list[str]: SESSION_PURGE_CRON_HOUR: int = 4 SESSION_PURGE_CRON_MINUTE: int = 0 + # Broadcast email fan-out: recipients are emailed in batches with a pause + # between them to stay within the email provider's rate limits. + BROADCAST_EMAIL_BATCH_SIZE: int = 50 + BROADCAST_EMAIL_THROTTLE_SECONDS: float = 1.0 + # Database connection pool (tuned per API worker) DB_POOL_SIZE: int = 20 DB_MAX_OVERFLOW: int = 10 diff --git a/app/schemas/worker.py b/app/schemas/worker.py index 3084d02..dea49e7 100644 --- a/app/schemas/worker.py +++ b/app/schemas/worker.py @@ -18,3 +18,11 @@ class SessionPurgeJobResult(BaseModel): purged: int = Field(ge=0, description="Session rows deleted this run.") duration_ms: int = Field(ge=0, description="Total wall-clock time in ms.") + + +class BroadcastEmailJobResult(BaseModel): + """Outcome of a single broadcast-email fan-out job.""" + + sent: int = Field(ge=0, description="Emails successfully sent this run.") + failed: int = Field(ge=0, description="Emails that failed to send.") + duration_ms: int = Field(ge=0, description="Total wall-clock time in ms.") diff --git a/app/worker/jobs/send_broadcast_emails.py b/app/worker/jobs/send_broadcast_emails.py new file mode 100644 index 0000000..652431b --- /dev/null +++ b/app/worker/jobs/send_broadcast_emails.py @@ -0,0 +1,93 @@ +"""Send a broadcast announcement to its target users by email. + +Enqueued by the admin broadcast service when ``send_email`` is set. Recipients +are resolved from the announcement's stored audience (not passed in the job +payload, which stays tiny) and emailed in throttled batches so the provider's +rate limits are respected. Per-recipient failures are isolated — one bad +address never stops the fan-out — and the in-app notification already delivered +the announcement, so email is a best-effort extra channel. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from typing import TypedDict + +from app.core.config import settings +from app.core.db import AsyncSessionLocal +from app.core.email import send_email +from app.repositories.announcement import get_announcement +from app.repositories.user import get_target_user_emails +from app.schemas.announcement import AnnouncementAudience +from app.schemas.user import SystemRole +from app.schemas.worker import BroadcastEmailJobResult +from app.services.admin.broadcast_email import build_broadcast_email + +logger = logging.getLogger(__name__) + + +class JobContext(TypedDict, total=False): + """Subset of the arq context this job relies on.""" + + job_id: str + + +async def send_broadcast_emails( + ctx: JobContext, announcement_id: str +) -> BroadcastEmailJobResult: + """Email a broadcast announcement to its target users in throttled batches.""" + _ = ctx + start = time.monotonic() + + # Load the announcement + recipients and build the email inside the session; + # the slow SMTP sends happen afterwards so no DB connection is held open. + async with AsyncSessionLocal() as session: + announcement = await get_announcement(session, uuid.UUID(announcement_id)) + if announcement is None: + logger.warning( + "send_broadcast_emails: announcement not found", + extra={"announcement_id": announcement_id}, + ) + return BroadcastEmailJobResult(sent=0, failed=0, duration_ms=0) + + role = ( + SystemRole(announcement.role_filter) + if announcement.role_filter + else None + ) + recipients = await get_target_user_emails( + session, + audience=AnnouncementAudience(announcement.audience), + role=role, + ) + email = build_broadcast_email(announcement) + + sent = 0 + failed = 0 + batch_size = settings.BROADCAST_EMAIL_BATCH_SIZE + for offset in range(0, len(recipients), batch_size): + batch = recipients[offset : offset + batch_size] + for user_id, address in batch: + ok = await send_email( + to=address, + subject=email.subject, + body=email.html, + plain_text=email.text, + user_id=str(user_id), + ) + sent += 1 if ok else 0 + failed += 0 if ok else 1 + # Throttle between batches to respect provider rate limits. + if offset + batch_size < len(recipients): + await asyncio.sleep(settings.BROADCAST_EMAIL_THROTTLE_SECONDS) + + duration_ms = int((time.monotonic() - start) * 1000) + result = BroadcastEmailJobResult(sent=sent, failed=failed, duration_ms=duration_ms) + logger.info( + "send_broadcast_emails: completed", + extra={**result.model_dump(), "announcement_id": announcement_id}, + ) + return result From 9e3ba5745001147fee2c2c32b54adf7dfc8c70cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 10:15:17 +0300 Subject: [PATCH 09/19] feat: wire broadcast email enqueue --- app/services/admin/broadcast_service.py | 19 ++++++++++++++++--- app/worker/settings.py | 7 ++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/services/admin/broadcast_service.py b/app/services/admin/broadcast_service.py index 1f3d97f..e8919f6 100644 --- a/app/services/admin/broadcast_service.py +++ b/app/services/admin/broadcast_service.py @@ -1,6 +1,9 @@ +import logging + from fastapi import HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession +from app.core.arq import get_arq_pool from app.core.broadcast_templates import template_exists from app.core.messages.error_message import ErrorMessages from app.core.messages.success_message import SuccessMessages @@ -25,6 +28,8 @@ from app.use_cases.log_activity import log_activity from app.use_cases.notify import notify_many +logger = logging.getLogger(__name__) + async def create_broadcast_service( session: AsyncSession, @@ -85,9 +90,17 @@ async def create_broadcast_service( data=data, ) - # send_email hook — filled in by BE-6 (enqueue a background email job). - # if payload.send_email: - # ... + # Best-effort email channel: the in-app notifications are already committed, + # so a queue hiccup must not fail the request — log and move on. + if payload.send_email: + try: + pool = get_arq_pool() + await pool.enqueue_job("send_broadcast_emails", str(announcement.id)) + except Exception: # noqa: BLE001 - email is a best-effort extra channel + logger.exception( + "Failed to enqueue broadcast email job", + extra={"announcement_id": str(announcement.id)}, + ) await log_activity( session, diff --git a/app/worker/settings.py b/app/worker/settings.py index eae21e5..7181665 100644 --- a/app/worker/settings.py +++ b/app/worker/settings.py @@ -12,6 +12,7 @@ from app.core.config import settings from app.worker.jobs.delete_expired_accounts import delete_expired_accounts from app.worker.jobs.purge_stale_sessions import purge_stale_sessions +from app.worker.jobs.send_broadcast_emails import send_broadcast_emails def _redis_settings() -> RedisSettings: @@ -23,7 +24,11 @@ class WorkerSettings: """Entry point for ``arq app.worker.settings.WorkerSettings``.""" redis_settings = _redis_settings() - functions: list = [delete_expired_accounts, purge_stale_sessions] + functions: list = [ + delete_expired_accounts, + purge_stale_sessions, + send_broadcast_emails, + ] cron_jobs = [ cron( delete_expired_accounts, From 0c00d062f217a393daedd4edea9ec60317d56016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 10:26:52 +0300 Subject: [PATCH 10/19] feat: add broadcast + active-banner routes --- app/api/main.py | 14 +++++- app/api/routes/admin/__init__.py | 11 ++++- app/api/routes/admin/broadcast.py | 78 +++++++++++++++++++++++++++++++ app/api/routes/announcements.py | 18 +++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 app/api/routes/admin/broadcast.py create mode 100644 app/api/routes/announcements.py diff --git a/app/api/main.py b/app/api/main.py index 8769eb3..1ccd68e 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -1,6 +1,15 @@ from fastapi import APIRouter -from app.api.routes import admin, auth, files, health, notifications, support, users +from app.api.routes import ( + admin, + announcements, + auth, + files, + health, + notifications, + support, + users, +) api_router = APIRouter() @@ -10,6 +19,9 @@ api_router.include_router( notifications.router, prefix="/notifications", tags=["notifications"] ) +api_router.include_router( + announcements.router, prefix="/announcements", tags=["announcements"] +) api_router.include_router(support.router, prefix="/support", tags=["support"]) api_router.include_router(admin.router, prefix="/admin", tags=["admin"]) api_router.include_router(files.router, tags=["files"]) diff --git a/app/api/routes/admin/__init__.py b/app/api/routes/admin/__init__.py index d622cee..2567f04 100644 --- a/app/api/routes/admin/__init__.py +++ b/app/api/routes/admin/__init__.py @@ -6,7 +6,15 @@ from fastapi import APIRouter -from app.api.routes.admin import activities, admins, files, stats, support, users +from app.api.routes.admin import ( + activities, + admins, + broadcast, + files, + stats, + support, + users, +) router = APIRouter() router.include_router(users.router, prefix="/users") @@ -15,3 +23,4 @@ router.include_router(support.router, prefix="/support") router.include_router(activities.router) router.include_router(stats.router) +router.include_router(broadcast.router) diff --git a/app/api/routes/admin/broadcast.py b/app/api/routes/admin/broadcast.py new file mode 100644 index 0000000..1dc70d4 --- /dev/null +++ b/app/api/routes/admin/broadcast.py @@ -0,0 +1,78 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Request, status + +from app.api.decorators import audit_unexpected_failure +from app.api.deps import SessionDep, require_permission +from app.core.broadcast_templates import list_templates +from app.core.rate_limit import rate_limit_authenticated +from app.models.user import User +from app.schemas.admin_permission import Permission +from app.schemas.announcement import ( + AnnouncementListResponse, + BroadcastCreate, + BroadcastCreateResponse, + BroadcastTemplateCatalogResponse, +) +from app.schemas.user_activity import ActivityType, ResourceType +from app.services.admin.broadcast_service import ( + create_broadcast_service, + list_broadcasts_service, +) + +router = APIRouter() + +AdminBroadcastRead = Annotated[ + User, Depends(require_permission(Permission.BROADCAST_READ)) +] +AdminBroadcastWrite = Annotated[ + User, Depends(require_permission(Permission.BROADCAST_WRITE)) +] + + +@router.post( + "/broadcasts", + response_model=BroadcastCreateResponse, + status_code=status.HTTP_201_CREATED, +) +@rate_limit_authenticated("20/minute") +@audit_unexpected_failure( + activity_type=ActivityType.EXECUTE, + resource_type=ResourceType.ANNOUNCEMENT, + endpoint="/admin/broadcasts", +) +async def create_broadcast( + request: Request, + admin: AdminBroadcastWrite, + session: SessionDep, + payload: BroadcastCreate, +) -> BroadcastCreateResponse: + """Compose, persist and fan out a broadcast announcement.""" + return await create_broadcast_service( + session=session, admin=admin, payload=payload, request=request + ) + + +@router.get("/broadcasts", response_model=AnnouncementListResponse) +@audit_unexpected_failure( + activity_type=ActivityType.READ, + resource_type=ResourceType.ANNOUNCEMENT, + endpoint="/admin/broadcasts", +) +async def list_broadcasts( + _request: Request, + _admin: AdminBroadcastRead, + session: SessionDep, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, +) -> AnnouncementListResponse: + """List sent announcements (admin history).""" + return await list_broadcasts_service(session=session, skip=skip, limit=limit) + + +@router.get("/broadcast-templates", response_model=BroadcastTemplateCatalogResponse) +async def get_broadcast_templates( + _admin: AdminBroadcastWrite, +) -> BroadcastTemplateCatalogResponse: + """Return the reusable announcement template catalog for the compose form.""" + return BroadcastTemplateCatalogResponse(templates=list_templates()) diff --git a/app/api/routes/announcements.py b/app/api/routes/announcements.py new file mode 100644 index 0000000..a5f0e6e --- /dev/null +++ b/app/api/routes/announcements.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.api.deps import CurrentActiveUser, SessionDep +from app.repositories.announcement import get_active_banner +from app.schemas.announcement import ActiveBannerResponse, AnnouncementRead + +router = APIRouter() + + +@router.get("/active", response_model=ActiveBannerResponse) +async def get_active_announcement( + _user: CurrentActiveUser, session: SessionDep +) -> ActiveBannerResponse: + """Return the currently active banner announcement, or null.""" + banner = await get_active_banner(session) + return ActiveBannerResponse( + announcement=AnnouncementRead.model_validate(banner) if banner else None + ) From efa6d9c3b8e97b0f7d527a56a20a81657d9f709c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 11:36:46 +0300 Subject: [PATCH 11/19] docs: note template text dual-source in catalog --- app/core/broadcast_templates.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/core/broadcast_templates.py b/app/core/broadcast_templates.py index 8ca6323..92fd308 100644 --- a/app/core/broadcast_templates.py +++ b/app/core/broadcast_templates.py @@ -1,9 +1,17 @@ -"""Catalog of reusable announcement templates (single source of truth). - -Template announcements store only a ``template_key`` + ``variables``; the actual -localized text lives here so both the in-app render (served to the frontend via -the catalog endpoint) and the outgoing email (rendered server-side) draw from -one place and can never drift apart. +"""Catalog of reusable announcement templates. + +A template announcement stores only a ``template_key`` + ``variables``; the +localized text is resolved at render time. This catalog is the source for the +**email** channel (rendered server-side here) and exposes each template's +**variable schema** (name + type) to the admin compose form. + +IMPORTANT — template TEXT lives in two places, one per channel (this is by +design, not accidental duplication): + * EMAIL (server-side) -> the ``translations`` below. + * IN-APP (client-side) -> NextJS-Template/src/i18n/locales/{en,tr}/ + broadcasts.json under ``templates.``. +When you add/edit/remove a template, change BOTH places and keep the +``template_key`` and the placeholder variable names identical across them. The data source is intentionally a plain module-level dict: the public helpers below (``list_templates`` / ``get_template`` / ``render_template``) form a stable From 8c425a5e30e26f4c608400bac1e56af0a93304a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 11:47:19 +0300 Subject: [PATCH 12/19] feat: render email datetimes in UTC --- app/core/broadcast_templates.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/core/broadcast_templates.py b/app/core/broadcast_templates.py index 92fd308..6fd938b 100644 --- a/app/core/broadcast_templates.py +++ b/app/core/broadcast_templates.py @@ -124,12 +124,17 @@ def __missing__(self, key: str) -> str: def _format_datetime(value: str, language: Language) -> str: - """Format an ISO datetime string for ``language``; pass through on failure.""" + """Format an ISO datetime string for ``language`` in UTC; passthrough on error. + + Emails render server-side with no per-recipient timezone, so datetimes are + shown in UTC with a literal ``UTC`` label to stay unambiguous. The in-app + render localizes to each viewer's own timezone instead. + """ try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) except (ValueError, AttributeError): return str(value) - return format_datetime(dt, "d MMMM y, HH:mm", locale=str(language)) + return format_datetime(dt, "d MMMM y, HH:mm 'UTC'", locale=str(language)) def list_templates() -> list[BroadcastTemplate]: From ac144408ec1840727ef7268c6deb07f26a3ddfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 11:52:58 +0300 Subject: [PATCH 13/19] feat: bilingual broadcast email subject --- app/services/admin/broadcast_email.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/services/admin/broadcast_email.py b/app/services/admin/broadcast_email.py index 5fee2b6..6d40735 100644 --- a/app/services/admin/broadcast_email.py +++ b/app/services/admin/broadcast_email.py @@ -49,13 +49,13 @@ def build_broadcast_email(announcement: Announcement) -> BroadcastEmail: Each supported language gets its own section (default language first), so a single email is readable by every recipient regardless of their preference — - no stored language preference required. The subject uses the default - language's title. + no stored language preference required. The subject joins every language's + title (e.g. "Scheduled Maintenance · Planlı Bakım"). """ default_lang = Language(settings.DEFAULT_LANGUAGE) languages = [default_lang] + [lang for lang in Language if lang != default_lang] - subject = "" + titles: list[str] = [] html_sections: list[str] = [] text_sections: list[str] = [] for language in languages: @@ -63,8 +63,8 @@ def build_broadcast_email(announcement: Announcement) -> BroadcastEmail: if content is None: continue title, body = content - if not subject: - subject = title + if title not in titles: + titles.append(title) html_sections.append( f'' f'

{escape(title)}

' @@ -82,4 +82,4 @@ def build_broadcast_email(announcement: Announcement) -> BroadcastEmail: + "" ) text = "\n\n— — —\n\n".join(text_sections) - return BroadcastEmail(subject=subject, html=html, text=text) + return BroadcastEmail(subject=" · ".join(titles), html=html, text=text) From 345ad6f4c26a095256a81148e2378c9bda6ff544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 15:41:19 +0300 Subject: [PATCH 14/19] feat: keep the active banner a singleton --- app/repositories/announcement.py | 19 ++++++++++++++++++- app/services/admin/broadcast_service.py | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/repositories/announcement.py b/app/repositories/announcement.py index 6ef8b1b..8be94bf 100644 --- a/app/repositories/announcement.py +++ b/app/repositories/announcement.py @@ -1,7 +1,7 @@ import uuid from collections.abc import Sequence -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -52,6 +52,23 @@ async def list_announcements( return announcements, total +async def deactivate_other_banners( + session: AsyncSession, *, keep_id: uuid.UUID +) -> None: + """Clear ``show_banner`` on every announcement except ``keep_id``. + + Keeps the active banner a singleton: activating a new banner supersedes the + previously active one (only one banner is ever shown at a time). + """ + statement = ( + update(Announcement) + .where(Announcement.show_banner.is_(True), Announcement.id != keep_id) + .values(show_banner=False) + ) + await session.execute(statement) + await session.commit() + + async def get_active_banner(session: AsyncSession) -> Announcement | None: """Return the most recent banner announcement, or ``None``. diff --git a/app/services/admin/broadcast_service.py b/app/services/admin/broadcast_service.py index e8919f6..c3cfbb9 100644 --- a/app/services/admin/broadcast_service.py +++ b/app/services/admin/broadcast_service.py @@ -11,6 +11,7 @@ from app.models.user import User from app.repositories.announcement import ( create_announcement, + deactivate_other_banners, get_announcement, list_announcements, ) @@ -65,6 +66,10 @@ async def create_broadcast_service( ) announcement = await create_announcement(session, announcement) + # A new banner supersedes any currently active one (single active banner). + if payload.show_banner: + await deactivate_other_banners(session, keep_id=announcement.id) + target_ids = await get_target_user_ids( session, audience=payload.audience, role=payload.role_filter ) From 78ba65c4bbbbd3ffcb2b49133cc8d3999a54c9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 15:41:38 +0300 Subject: [PATCH 15/19] docs: document broadcast announcements in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1276c38..b28e3a5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This template integrates the best-in-class Python ecosystem tools to provide a s - **Role-Based Access Control (RBAC):** Three roles — `user` / `admin` / `superadmin`. Superadmins bypass every check and **create/delete admins** and assign their permissions; plain admins are gated per **`resource:action`** permission (`users:read`, `users:write`, `support:update`, …) stored in `admin_permission` and enforced by a single `require_permission(...)` dependency. The **superadmin tier** (promote an admin to superadmin, demote a superadmin, transfer root via email OTP) is reserved for the **root superadmin** (`is_root_superadmin`); the last active superadmin is always protected. See [Admin & RBAC](#-admin--rbac-roles--permissions). - **Support / Ticketing System:** Full ticket lifecycle — open, reply with image attachments, status/priority, admin assignment — fanned out over a **Redis-backed WebSocket bus** so the ticket thread, the admin queue, and per-user feeds update live across workers. See [Support System & Realtime](#-support-ticketing-system--realtime). - **Notification System:** Persistent in-app notifications (`notification` table) layered on the same realtime bus — domain events (a support reply, a ticket status change, an RBAC grant change) call a single `notify()` use case that stores the row **and** pushes it to the recipient's `notifications:{id}` feed, so offline users still find it in their inbox on the next fetch. Notification copy is stored as a stable machine `type` + `data` payload (no human text), so the frontend renders it in any locale. REST covers paginated listing (`unread_only` filter), the unread badge count, and mark-(all-)read. See [Notification System](#-notification-system). +- **Broadcast / Announcements:** Admins compose an announcement — from a reusable **template catalog** (typed variables: localized datetimes + per-language text) or **custom** per-language text — and fan it out to an audience (`all` / `active` / by `role`). Each recipient gets a persistent notification via a chunked `notify_many` use case (bulk insert + realtime), and optionally a **multilingual email** sent in throttled batches by an `arq` job. Content is stored language-agnostically so it renders in each user's locale; datetimes are stored UTC. A `show_banner` flag exposes the latest as a top banner (`GET /announcements/active`). Gated by `broadcast:read` / `broadcast:write`. - **Session / Device Management:** Every login opens a `user_session` row whose id rides in both tokens as the JWT `sid` claim, so a user can list their active devices and revoke any one (or all but the current). Refresh **rotates** the session's `jti` and detects **replay** — a stale refresh token revokes the whole session as compromised; logout and password change revoke sessions too. Revocation kills a session's still-valid access tokens instantly via a Redis `sid` flag, and broadcasts `sessions_revoked` so open tabs drop to login live. Admins can list/terminate a user's sessions, gated by the `users:sessions` permission. A nightly arq job purges stale rows. See [Session Management](#-session--device-management). - **First Superadmin Seed:** On startup a **root superadmin** (`is_root_superadmin`) is created from `FIRST_SUPERUSER` / `FIRST_SUPERUSER_PASSWORD` if none exists (a matching existing account is promoted instead; older deployments get their oldest superadmin flagged as root) — guaranteeing a root account. - **Tooling:** [uv](https://docs.astral.sh/uv/) for blazing-fast package management, and [Ruff](https://docs.astral.sh/ruff/) for linting and formatting. From 70550e7d587434be224b3b5a7b93b024d5f79766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 16:41:25 +0300 Subject: [PATCH 16/19] test: add backend pytest coverage for broadcasts --- app/tests/admin/test_broadcasts.py | 359 ++++++++++++++++++++++++++ app/tests/test_announcements.py | 89 +++++++ app/tests/test_broadcast_templates.py | 190 ++++++++++++++ 3 files changed, 638 insertions(+) create mode 100644 app/tests/admin/test_broadcasts.py create mode 100644 app/tests/test_announcements.py create mode 100644 app/tests/test_broadcast_templates.py diff --git a/app/tests/admin/test_broadcasts.py b/app/tests/admin/test_broadcasts.py new file mode 100644 index 0000000..e14fcb1 --- /dev/null +++ b/app/tests/admin/test_broadcasts.py @@ -0,0 +1,359 @@ +"""End-to-end tests for the admin /admin/broadcasts endpoints. + +Covers composing template and custom announcements, the compose-time +validation guards, RBAC gating, the single-active-banner rule, the best-effort +email enqueue, and the paginated history listing. Each test drives the real +service stack (validation -> persist -> fan-out) through the ASGI client. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select + +from app.core.messages.error_message import ErrorMessages +from app.core.messages.success_message import SuccessMessages +from app.models.notification import Notification +from app.schemas.notification import NotificationType +from app.tests.conftest import TestingSessionLocal + + +async def _notification_count() -> int: + """Count persisted in-app notifications across all users.""" + async with TestingSessionLocal() as session: + return ( + await session.execute(select(func.count()).select_from(Notification)) + ).scalar_one() + + +async def _announcement_notifications() -> list[Notification]: + """Return every admin-announcement notification that was fanned out.""" + async with TestingSessionLocal() as session: + result = await session.execute( + select(Notification).where( + Notification.type == NotificationType.ADMIN_ANNOUNCEMENT.value + ) + ) + return list(result.scalars().all()) + + +# --- Templates catalog ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_template_catalog_lists_known_templates(admin_client: AsyncClient): + """The compose catalog exposes every seeded template with its variables.""" + response = await admin_client.get("/admin/broadcast-templates") + + assert response.status_code == 200, response.text + keys = {tpl["key"] for tpl in response.json()["templates"]} + assert {"maintenance", "welcome", "new_feature", "security_notice"} <= keys + + maintenance = next( + tpl for tpl in response.json()["templates"] if tpl["key"] == "maintenance" + ) + var_names = {v["name"] for v in maintenance["variables"]} + assert var_names == {"starts_at", "ends_at"} + + +# --- Create: happy paths ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_template_broadcast_fans_out(admin_client: AsyncClient): + """A template broadcast is created, returns the success key, and notifies.""" + payload = { + "kind": "template", + "template_key": "new_feature", + "variables": {"feature": {"tr": "Karanlik mod", "en": "Dark mode"}}, + "level": "info", + "audience": "all", + } + + response = await admin_client.post("/admin/broadcasts", json=payload) + + assert response.status_code == 201, response.text + body = response.json() + assert body["message"] == SuccessMessages.BROADCAST_SENT + assert body["recipients"] >= 1 + assert body["announcement"]["kind"] == "template" + assert body["announcement"]["template_key"] == "new_feature" + + notifications = await _announcement_notifications() + assert len(notifications) == body["recipients"] + # The stored payload carries everything the FE needs to render per-locale. + sample = notifications[0] + assert sample.data["template_key"] == "new_feature" + assert sample.data["variables"]["feature"]["en"] == "Dark mode" + + +@pytest.mark.asyncio +async def test_create_custom_broadcast(admin_client: AsyncClient): + """A custom announcement stores its per-language translations and fans out.""" + payload = { + "kind": "custom", + "translations": { + "tr": {"title": "Merhaba", "body": "Ozel duyuru govdesi."}, + "en": {"title": "Hello", "body": "A custom announcement body."}, + }, + "level": "warning", + "audience": "all", + } + + response = await admin_client.post("/admin/broadcasts", json=payload) + + assert response.status_code == 201, response.text + body = response.json() + assert body["announcement"]["kind"] == "custom" + assert body["announcement"]["translations"]["en"]["title"] == "Hello" + + notifications = await _announcement_notifications() + assert notifications[0].data["translations"]["tr"]["title"] == "Merhaba" + + +# --- Create: validation guards --------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_template_without_key_is_rejected(admin_client: AsyncClient): + """A template broadcast missing its template_key fails schema validation.""" + response = await admin_client.post( + "/admin/broadcasts", + json={"kind": "template", "audience": "all"}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_unknown_template_key_404(admin_client: AsyncClient): + """A syntactically valid but unknown template key returns a keyed 404.""" + response = await admin_client.post( + "/admin/broadcasts", + json={"kind": "template", "template_key": "does_not_exist", "audience": "all"}, + ) + assert response.status_code == 404 + assert response.json()["error"] == ErrorMessages.ANNOUNCEMENT_TEMPLATE_NOT_FOUND + + +@pytest.mark.asyncio +async def test_create_custom_with_template_key_is_rejected(admin_client: AsyncClient): + """Mixing a custom kind with a template_key is a content conflict (422).""" + response = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "template_key": "welcome", + "translations": {"en": {"title": "T", "body": "B"}}, + "audience": "all", + }, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_custom_without_translations_is_rejected( + admin_client: AsyncClient, +): + """A custom announcement with no translations fails validation.""" + response = await admin_client.post( + "/admin/broadcasts", + json={"kind": "custom", "audience": "all"}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_role_audience_without_role_is_rejected( + admin_client: AsyncClient, +): + """Audience=role requires a role_filter; omitting it fails validation.""" + response = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "T", "body": "B"}}, + "audience": "role", + }, + ) + assert response.status_code == 422 + + +# --- Single active banner --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_new_banner_supersedes_previous(admin_client: AsyncClient): + """Activating a second banner deactivates the first (single active banner).""" + first = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "First", "body": "B"}}, + "audience": "all", + "show_banner": True, + }, + ) + assert first.status_code == 201, first.text + + second = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "Second", "body": "B"}}, + "audience": "all", + "show_banner": True, + }, + ) + assert second.status_code == 201, second.text + + active = await admin_client.get("/announcements/active") + assert active.status_code == 200 + banner = active.json()["announcement"] + assert banner is not None + assert banner["id"] == second.json()["announcement"]["id"] + assert banner["translations"]["en"]["title"] == "Second" + + +# --- Email channel ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_email_enqueues_job(admin_client: AsyncClient): + """When send_email is set, the broadcast enqueues the email worker job.""" + pool = AsyncMock() + with patch("app.services.admin.broadcast_service.get_arq_pool", return_value=pool): + response = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "Mailed", "body": "B"}}, + "audience": "all", + "send_email": True, + }, + ) + + assert response.status_code == 201, response.text + announcement_id = response.json()["announcement"]["id"] + pool.enqueue_job.assert_awaited_once_with("send_broadcast_emails", announcement_id) + + +@pytest.mark.asyncio +async def test_email_enqueue_failure_does_not_fail_request(admin_client: AsyncClient): + """A queue hiccup is swallowed: in-app delivery already succeeded (201).""" + with patch( + "app.services.admin.broadcast_service.get_arq_pool", + side_effect=RuntimeError("redis down"), + ): + response = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "Best effort", "body": "B"}}, + "audience": "all", + "send_email": True, + }, + ) + + assert response.status_code == 201, response.text + assert await _notification_count() >= 1 + + +# --- RBAC ------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_requires_authentication(client: AsyncClient): + """Anonymous callers cannot compose a broadcast.""" + response = await client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "T", "body": "B"}}, + "audience": "all", + }, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_create_forbidden_for_regular_user(regular_client: AsyncClient): + """A user without the broadcast:write permission is refused (403).""" + response = await regular_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "T", "body": "B"}}, + "audience": "all", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_list_forbidden_for_regular_user(regular_client: AsyncClient): + """Listing history requires broadcast:read; a plain user is refused.""" + response = await regular_client.get("/admin/broadcasts") + assert response.status_code == 403 + + +# --- History listing -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_history_lists_newest_first_with_pagination(admin_client: AsyncClient): + """History returns sent announcements newest-first and honours skip/limit.""" + for index in range(3): + response = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": f"Item {index}", "body": "B"}}, + "audience": "all", + }, + ) + assert response.status_code == 201, response.text + + full = await admin_client.get("/admin/broadcasts") + assert full.status_code == 200 + body = full.json() + assert body["total"] == 3 + assert body["data"][0]["translations"]["en"]["title"] == "Item 2" + + page = await admin_client.get("/admin/broadcasts", params={"skip": 0, "limit": 2}) + assert page.json()["limit"] == 2 + assert len(page.json()["data"]) == 2 + + +@pytest.mark.asyncio +async def test_active_banner_absent_returns_null(admin_client: AsyncClient): + """With no banner ever activated, the active endpoint reports null.""" + await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "No banner", "body": "B"}}, + "audience": "all", + "show_banner": False, + }, + ) + + active = await admin_client.get("/announcements/active") + assert active.status_code == 200 + assert active.json()["announcement"] is None + + +@pytest.mark.asyncio +async def test_create_active_audience_targets_active_users(admin_client: AsyncClient): + """Audience=active resolves to active accounts and still fans out.""" + response = await admin_client.post( + "/admin/broadcasts", + json={ + "kind": "custom", + "translations": {"en": {"title": "Active only", "body": "B"}}, + "audience": "active", + }, + ) + assert response.status_code == 201, response.text + # The seeded admin is active, so at least one recipient is targeted. + assert response.json()["recipients"] >= 1 diff --git a/app/tests/test_announcements.py b/app/tests/test_announcements.py new file mode 100644 index 0000000..3461ecd --- /dev/null +++ b/app/tests/test_announcements.py @@ -0,0 +1,89 @@ +"""Tests for the user-facing GET /announcements/active endpoint. + +The banner endpoint backs the in-app top banner: it requires an authenticated +active user and returns the single most-recent banner announcement (or null). +Banners are seeded straight through the repository so the test exercises the +same read path production uses. +""" + +import pytest +from httpx import AsyncClient + +from app.models.announcement import Announcement +from app.repositories.announcement import ( + create_announcement, + deactivate_other_banners, +) +from app.tests.admin.conftest import login, register_and_verify +from app.tests.conftest import TestingSessionLocal + + +async def _seed_banner(title: str, *, show_banner: bool = True) -> Announcement: + """Persist a custom banner announcement and make it the active one.""" + async with TestingSessionLocal() as session: + announcement = await create_announcement( + session, + Announcement( + kind="custom", + template_key=None, + variables={}, + translations={"en": {"title": title, "body": "Body."}}, + level="info", + audience="all", + role_filter=None, + show_banner=show_banner, + send_email=False, + created_by=None, + ), + ) + if show_banner: + await deactivate_other_banners(session, keep_id=announcement.id) + return announcement + + +@pytest.mark.asyncio +async def test_active_requires_authentication(client: AsyncClient): + """Anonymous callers cannot read the active banner.""" + response = await client.get("/announcements/active") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_active_returns_null_when_no_banner(client: AsyncClient): + """An authenticated user sees null when no banner is active.""" + await register_and_verify(client, "viewer@test.com") + await login(client, "viewer@test.com") + + response = await client.get("/announcements/active") + + assert response.status_code == 200 + assert response.json() == {"announcement": None} + + +@pytest.mark.asyncio +async def test_active_returns_the_current_banner(client: AsyncClient): + """The endpoint surfaces the active banner's content to the viewer.""" + await register_and_verify(client, "viewer@test.com") + await login(client, "viewer@test.com") + seeded = await _seed_banner("Heads up") + + response = await client.get("/announcements/active") + + assert response.status_code == 200 + banner = response.json()["announcement"] + assert banner is not None + assert banner["id"] == str(seeded.id) + assert banner["translations"]["en"]["title"] == "Heads up" + + +@pytest.mark.asyncio +async def test_active_ignores_non_banner_announcements(client: AsyncClient): + """An announcement sent without show_banner never appears as the banner.""" + await register_and_verify(client, "viewer@test.com") + await login(client, "viewer@test.com") + await _seed_banner("Silent", show_banner=False) + + response = await client.get("/announcements/active") + + assert response.status_code == 200 + assert response.json()["announcement"] is None diff --git a/app/tests/test_broadcast_templates.py b/app/tests/test_broadcast_templates.py new file mode 100644 index 0000000..3eb8355 --- /dev/null +++ b/app/tests/test_broadcast_templates.py @@ -0,0 +1,190 @@ +"""Unit tests for the broadcast template catalog and email builder. + +These exercise the pure rendering helpers with no DB or HTTP client: template +placeholder substitution, UTC datetime formatting, and the multilingual email +assembly (subject joining, per-language sections, fallback). +""" + +import pytest + +from app.core.broadcast_templates import ( + _format_datetime, + get_template, + render_template, + template_exists, +) +from app.models.announcement import Announcement +from app.schemas.user import Language +from app.services.admin.broadcast_email import build_broadcast_email + +# --- render_template -------------------------------------------------------- + + +def test_render_template_unknown_key_returns_none(): + """An unknown template key renders to None rather than raising.""" + assert render_template("nope", Language.EN, {}) is None + + +def test_render_template_formats_datetime_variables(): + """Datetime placeholders are substituted with a UTC-labelled, formatted value.""" + rendered = render_template( + "maintenance", + Language.EN, + { + "starts_at": "2026-01-01T14:00:00Z", + "ends_at": "2026-01-02T15:30:00Z", + }, + ) + + assert rendered is not None + assert "{starts_at}" not in rendered.body + assert "{ends_at}" not in rendered.body + assert "UTC" in rendered.body + assert "14:00" in rendered.body + + +def test_render_template_picks_text_variable_for_language(): + """A text placeholder resolves to the requested language's value.""" + variables = {"feature": {"tr": "Karanlik mod", "en": "Dark mode"}} + + en = render_template("new_feature", Language.EN, variables) + tr = render_template("new_feature", Language.TR, variables) + + assert en is not None and "Dark mode" in en.body + assert tr is not None and "Karanlik mod" in tr.body + + +def test_render_template_text_variable_falls_back_when_language_missing(): + """A text value missing the asked language falls back to the default (en).""" + rendered = render_template( + "new_feature", Language.TR, {"feature": {"en": "Only English"}} + ) + + assert rendered is not None + assert "Only English" in rendered.body + + +def test_render_template_keeps_unknown_placeholder_intact(): + """A placeholder with no supplied value is left untouched (SafeDict).""" + rendered = render_template( + "maintenance", Language.EN, {"starts_at": "2026-01-01T14:00:00Z"} + ) + + assert rendered is not None + # ends_at was not provided, so its marker survives the format pass. + assert "{ends_at}" in rendered.body + + +def test_template_helpers_agree_on_existence(): + """``template_exists`` and ``get_template`` agree for known/unknown keys.""" + assert template_exists("welcome") is True + assert get_template("welcome") is not None + assert template_exists("ghost") is False + assert get_template("ghost") is None + + +# --- _format_datetime ------------------------------------------------------- + + +def test_format_datetime_renders_utc_label(): + """A valid ISO instant is formatted with a literal UTC label.""" + formatted = _format_datetime("2026-01-01T09:05:00Z", Language.EN) + assert "UTC" in formatted + assert "09:05" in formatted + + +def test_format_datetime_passthrough_on_invalid(): + """A non-ISO string is returned unchanged rather than raising.""" + assert _format_datetime("not-a-date", Language.EN) == "not-a-date" + + +# --- build_broadcast_email -------------------------------------------------- + + +def _custom(translations: dict) -> Announcement: + """Build an in-memory custom announcement for the email builder.""" + return Announcement( + kind="custom", + template_key=None, + variables={}, + translations=translations, + level="info", + audience="all", + role_filter=None, + show_banner=False, + send_email=True, + created_by=None, + ) + + +def test_build_email_joins_every_language_title_in_subject(): + """The subject concatenates each language's title with a separator.""" + announcement = _custom( + { + "en": {"title": "Scheduled Maintenance", "body": "EN body"}, + "tr": {"title": "Planli Bakim", "body": "TR govde"}, + } + ) + + email = build_broadcast_email(announcement) + + assert "Scheduled Maintenance" in email.subject + assert "Planli Bakim" in email.subject + assert " · " in email.subject + # Both language sections land in the rendered body. + assert "EN body" in email.html + assert "TR govde" in email.text + + +def test_build_email_default_language_section_comes_first(): + """The default language (en) leads the subject and body ordering.""" + announcement = _custom( + { + "tr": {"title": "TR Title", "body": "TR"}, + "en": {"title": "EN Title", "body": "EN"}, + } + ) + + email = build_broadcast_email(announcement) + + assert email.subject.startswith("EN Title") + assert email.text.index("EN") < email.text.index("TR") + + +def test_build_email_renders_template_announcement(): + """A template announcement is rendered from the catalog for the email.""" + announcement = Announcement( + kind="template", + template_key="welcome", + variables={}, + translations={}, + level="info", + audience="all", + role_filter=None, + show_banner=False, + send_email=True, + created_by=None, + ) + + email = build_broadcast_email(announcement) + + assert "Welcome" in email.subject + + +def test_build_email_escapes_html_in_custom_content(): + """User-authored content is HTML-escaped in the email body.""" + announcement = _custom({"en": {"title": "