Publications — Blueprint
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
- Comms — periodical issue dispatch via
send_to_segmentfor journal subscribers. - Newsletter — Publications subscribers may auto-populate Newsletter mailing lists (e.g. journal print subscribers segment).
- Reports —
publications_donor_wall_analytics,publications_journal_subscribers,publications_media_engagementregistered. - Members Suite — member-typed subscribers FK back to
members.id. - Asset Management (Agent 4) — physical book inventory cross-linked via
Publication.metadata.asset_inventory_id. - Audit — publish/unpublish events recorded for Books and Press Releases (legal-weight publications).
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
- Editor tooling — same TipTap as Newsletter, or different per type? Recommendation: Same TipTap base; type-specific extensions (book chapter outline, journal article-list).
- Media storage — GCS direct vs CDN (Cloudflare R2)? Recommendation: GCS for v1 (already used for reports outputs); CDN swap if media traffic grows.
- 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).
- Public URL structure — per-tenant subdomain vs path-based? Rec:
Path-based on tenant's primary domain (
https://{tenant.domain}/publications/...). - 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. - Donor wall PII — strict opt-in display, or default-show? Recommendation:
Honour existing
members.hide_name/hide_amountflags (already DISA convention). - Transcripts for electronic media — required, or optional? Rec: Optional, but Sanskrit recordings strongly encouraged to ship transcripts (accessibility + searchability).
- 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.