11
📰

Publications — Blueprint

Website · donor wall · journals · electronic media

Blueprint — Publications

Status: partial (Donor Wall live) · Slug: publications · Kind: ERP · Module #11

1. Module summary

Publications is the institution's outward-voice content management module — website pages, periodical journals, books, electronic media, and the existing public donor wall. Each publication type is a content category with editorial workflow (draft → review → published), version history, and audience-aware distribution (print subscribers, electronic followers, public web). Donor Wall is already live (aayojana.routers.donor_wall) and becomes the first publication-type implementation; Website Content, Journal, Books, and Electronic Media follow the same model. Subscriptions live here (publication ↔ subscriber) and tie to Newsletter for periodical dispatch + Reports for archive exports.

2. Data model

# src/aayojana/publications/models.py
from datetime import datetime, date
from sqlalchemy import (
    JSON, Boolean, Date, DateTime, ForeignKey, Integer, String, Text,
    UniqueConstraint, Index,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship

from aayojana.models.base import AuditMixin, Base, TenantMixin, uuid4_str


class Publication(Base, TenantMixin, AuditMixin):
    """A publication unit — one website page OR one journal issue OR one
    book OR one electronic-media item OR one donor-wall edition."""
    __tablename__ = "publications"
    __table_args__ = (
        UniqueConstraint("tenant_id", "type", "slug",
                         name="uq_publication_tenant_type_slug"),
        Index("ix_publications_type_status", "type", "status"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    type: Mapped[str] = mapped_column(String(32), nullable=False)
    # 'website_page' | 'donor_wall' | 'journal_issue' | 'book' | 'electronic_media' | 'press_release'
    slug: Mapped[str] = mapped_column(String(120), nullable=False)
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    title_sa: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Sanskrit title — VijayaDV PUA preserved (TEXT).
    summary: Mapped[str | None] = mapped_column(Text, nullable=True)
    cover_image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)

    parent_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("publications.id"), nullable=True
    )
    # Hierarchy: book ← chapter, journal-volume ← journal-issue, page ← sub-page.

    status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
    # 'draft' | 'in_review' | 'published' | 'archived' | 'unpublished'
    published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    published_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )

    # Type-specific fields kept generic via JSON
    metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
    # Books: {isbn, edition, pages, publisher_name}
    # Journal issues: {volume, issue_number, fy, theme}
    # Electronic media: {duration_seconds, format, mime, transcript_available}
    # Donor wall: {fy, display_until, snapshot_of_count}

    visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
    # 'public' | 'tenant_only' | 'subscribers_only' | 'private'
    seo_description: Mapped[str | None] = mapped_column(Text, nullable=True)


class PublicationVersion(Base, TenantMixin, AuditMixin):
    """Versioned content. Every save is a new row."""
    __tablename__ = "publication_versions"
    __table_args__ = (
        UniqueConstraint("publication_id", "version",
                         name="uq_pubver_publication_version"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    publication_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("publications.id"), nullable=False
    )
    version: Mapped[int] = mapped_column(Integer, nullable=False)
    body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
    body_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
    body_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
    # For structured content (donor wall snapshot data, journal articles list).
    editor_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)


class PublicationSubscription(Base, TenantMixin, AuditMixin):
    """Subscribers to a publication (mainly Journal, Books, Electronic Media).
    Mailing-list-style subscribers live in Newsletter; this is the SUBSCRIBE-TO-
    A-PUBLICATION-TYPE relation (e.g. "I want every Journal issue mailed")."""
    __tablename__ = "publication_subscriptions"
    __table_args__ = (
        UniqueConstraint("publication_id", "subscriber_kind", "subscriber_key",
                         name="uq_pubsub_pub_subscriber"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    publication_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("publications.id"), nullable=False
    )
    # Or could subscribe to a parent (entire journal series). Resolved by traversal.
    subscriber_kind: Mapped[str] = mapped_column(String(16), nullable=False)
    # 'member' | 'external'
    subscriber_key: Mapped[str] = mapped_column(String(120), nullable=False)
    member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    external_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
    external_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
    postal_address_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("addresses.id"), nullable=True
    )

    channel: Mapped[str] = mapped_column(String(16), nullable=False)
    # 'print' | 'electronic'
    paid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
    # Some publications charge for print subscriptions; tracked here, not in Vitta.
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")


class MediaAsset(Base, TenantMixin, AuditMixin):
    """Audio, video, image files referenced from publications. Separate from
    Asset Management (which tracks physical assets) — this is for digital
    media files. URL points to GCS / CDN."""
    __tablename__ = "media_assets"
    __table_args__ = (
        Index("ix_media_kind", "kind"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    kind: Mapped[str] = mapped_column(String(16), nullable=False)
    # 'audio' | 'video' | 'image' | 'pdf' | 'document'
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    storage_url: Mapped[str] = mapped_column(String(500), nullable=False)
    # GCS path or external CDN URL.
    mime_type: Mapped[str | None] = mapped_column(String(80), nullable=True)
    size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
    duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
    transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Sanskrit transcripts preserved verbatim.
    rights: Mapped[str | None] = mapped_column(String(80), nullable=True)
    # 'cc-by' | 'all-rights-reserved' | 'public-domain'
    captured_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)


class PublicationMediaLink(Base, TenantMixin, AuditMixin):
    """N:M between Publication and MediaAsset."""
    __tablename__ = "publication_media_links"
    __table_args__ = (
        UniqueConstraint("publication_id", "media_id", name="uq_pubmedia"),
    )
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    publication_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("publications.id"), nullable=False
    )
    media_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("media_assets.id"), nullable=False
    )
    role: Mapped[str | None] = mapped_column(String(40), nullable=True)
    # 'cover' | 'inline' | 'attachment' | 'transcript'

3. Reuse map

Existing artefact How publications uses it
aayojana.routers.donor_wall First Publication-type implementation. Migrates to Publication(type='donor_wall') rows; existing public URL /donor-wall continues to work.
aayojana.templates.donor_wall.display.html Becomes templates/publications/donor_wall.html.
aayojana.comms.service.send_to_segment Used to dispatch Journal issues to print/electronic subscribers.
aayojana.newsletter.service Newsletter mailing lists may be auto-derived from Publication subscriptions (e.g. "Journal print subscribers" → MailingList).
aayojana.reports.service.run_report publications_donor_wall_analytics, publications_journal_subscribers reports registered here.
aayojana.assets (Agent 4) Physical Books in Asset Management (inventory of printed copies); Publications models the editorial book — same item, different angle. Cross-link via metadata.asset_inventory_id.
aayojana.models.member.Member Member-typed subscribers FK here.

4. API surface

Method Path Purpose
GET /api/publications List filterable by type, status.
POST /api/publications Create.
GET /api/publications/{id} Detail + version history.
PUT /api/publications/{id} Update metadata.
POST /api/publications/{id}/save-version New content version.
POST /api/publications/{id}/publish Flip to published, set published_at.
POST /api/publications/{id}/unpublish Reverse.
GET /api/publications/{id}/subscribers List.
POST /api/publications/{id}/subscribers Add.
DELETE /api/publications/subscriptions/{id} Remove.
GET /api/media-assets List media.
POST /api/media-assets Upload (signed-URL pattern).
GET /admin/publications Dashboard.
GET /admin/publications/{id}/edit Editor (per-type).
GET /donor-wall Existing — reads from Publication(type='donor_wall', status='published').
GET /publications/{type}/{slug} Public single-publication view.
GET /publications/journal/{vol}/{issue} Public journal issue.
GET /publications/books Public books catalog.

5. Service layer

# src/aayojana/publications/service.py

async def create_publication(
    db, *, tenant_id, type, slug, title, title_sa=None, parent_id=None,
    metadata=None, visibility="public",
) -> "Publication": ...

async def save_version(
    db, *, tenant_id, publication_id,
    body_html=None, body_markdown=None, body_json=None,
    editor_user_id=None,
) -> "PublicationVersion": ...

async def publish(
    db, *, tenant_id, publication_id, version_id=None, user_id=None,
) -> "Publication":
    """Activates the given version (or latest if None). For periodical types,
    optionally triggers comms.send_to_segment to subscribers."""

async def subscribe(
    db, *, tenant_id, publication_id, member_id=None,
    external_email=None, external_name=None,
    channel="electronic", postal_address_id=None,
) -> "PublicationSubscription": ...

async def upload_media_asset(
    db, *, tenant_id, kind, title, storage_url, mime_type=None,
    size_bytes=None, transcript=None,
) -> "MediaAsset": ...

async def attach_media(
    db, *, tenant_id, publication_id, media_id, role="inline",
) -> None: ...

6. UI / Templates

src/aayojana/templates/publications/. Reuses existing donor_wall/display.html relocated under this module.

Page Purpose
publications/dashboard.html All publications grouped by type.
publications/website_pages.html CMS for static website pages.
publications/journal_editor.html Issue composer with article-list structure.
publications/book_editor.html Book metadata + chapter tree + ISBN.
publications/electronic_media_editor.html Audio/video upload + transcript editor.
publications/donor_wall_editor.html Curated donor list editor; existing logic migrated.
publications/subscribers.html Per-publication subscriber admin.
publications/media_library.html All media assets, filter by kind.
publications/public/donor_wall.html Existing display, relocated.
publications/public/journal_issue.html Public single-issue.
publications/public/book_detail.html Public book page.
publications/public/media_player.html Audio/video player + transcript pane.

7. Migration plan

Rev Slug Tables / changes
0033 publications publications, publication_versions, publication_subscriptions, media_assets, publication_media_links. Backfill: existing donor wall config → one Publication(type='donor_wall') row per tenant with current donor list as a body_json snapshot.

8. Cross-module dependencies

9. Implementation phases

Phase A — Migrate donor wall + website pages (1 week): 1. Land migration 0033. 2. Migrate existing donor wall logic into Publication-backed flow. 3. Implement website-page CMS.

Phase B — Journal + Subscriptions (2 weeks): 4. Journal issue editor. 5. Subscribers admin. 6. Integration with Comms for issue dispatch. 7. Public journal archive.

Phase C — Books + Electronic Media (2 weeks): 8. Book editor with chapter tree. 9. Media asset library + upload-to-GCS signed URL flow. 10. Audio/video player with transcript. 11. Public catalogs.

10. Open questions

  1. Editor tooling — same TipTap as Newsletter, or different per type? Recommendation: Same TipTap base; type-specific extensions (book chapter outline, journal article-list).
  2. Media storage — GCS direct vs CDN (Cloudflare R2)? Recommendation: GCS for v1 (already used for reports outputs); CDN swap if media traffic grows.
  3. ISBN allocation — manual entry, or integrate with national-ISBN-agency? Recommendation: Manual for India (ISBN-issued by Raja Rammohun Roy National Agency, not API-ised).
  4. Public URL structure — per-tenant subdomain vs path-based? Rec: Path-based on tenant's primary domain (https://{tenant.domain}/publications/...).
  5. Journal pricing/payment — free for Indian subscribers, paid for foreign? Recommendation: Out of scope for Publications; if needed, integrate with existing payment flow at subscribe time and store paid_until.
  6. Donor wall PII — strict opt-in display, or default-show? Recommendation: Honour existing members.hide_name / hide_amount flags (already DISA convention).
  7. Transcripts for electronic media — required, or optional? Rec: Optional, but Sanskrit recordings strongly encouraged to ship transcripts (accessibility + searchability).
  8. Press releases — separate type, or sub-type of website-page? Rec: Separate type (type='press_release') so Outreach Module's media-coverage tracker can FK back to specific releases.