9
📣

Outreach & Promotional — Blueprint

Expansion initiatives · branding · public engagement

Blueprint — Outreach & Promotional

Status: planned · Slug: outreach · Kind: ERP · Module #9

1. Module summary

Outreach tracks the institution's expansion-and-engagement projects: new-branch launches, branding campaigns, public lectures and workshops, media coverage, and ROI of outward-facing activities. Operationally lighter than core ERP modules — its primary purpose is institutional memory across leadership transitions: which expansion projects ran, what came of them, where the brand assets live, what the press said. Each row is a project or event with outcome notes; reports aggregate engagement KPIs quarterly. All outbound communication (invitations, press kits) flows through Comms; quarterly Outreach summaries flow through Reports.

2. Data model

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

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


class ExpansionInitiative(Base, TenantMixin, AuditMixin):
    """A multi-month/year project to expand the institution — new branch,
    new program, new geography. Project-level — not the daily-ops record."""
    __tablename__ = "expansion_initiatives"
    __table_args__ = (
        Index("ix_expansion_status", "status"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    name: Mapped[str] = mapped_column(String(200), nullable=False)
    kind: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'new_branch' | 'new_program' | 'geographic_expansion' | 'digital_expansion' | 'community_engagement'
    description: Mapped[str | None] = mapped_column(Text, nullable=True)

    target_branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True
    )
    # If new_branch and the branch eventually got created, link it.
    target_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
    # City / region for projects without a created branch yet.

    start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="planning")
    # 'planning' | 'active' | 'paused' | 'completed' | 'abandoned'

    budget: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
    actual_spend: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
    funding_source: Mapped[str | None] = mapped_column(String(120), nullable=True)
    # 'general_fund' | 'specific_donor' | 'building_fund' | etc.

    lead_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    outcomes: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Free-text post-mortem on what was achieved.
    metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True)
    # {'devotees_engaged': 1200, 'media_mentions': 5, 'donations_attributed': 250000}


class BrandingAsset(Base, TenantMixin, AuditMixin):
    """Logos, brochures, banners, social-media kits, brand-guideline docs.
    The asset itself lives in MediaAsset (publications); this is the brand-
    catalogue layer recording version + usage rights."""
    __tablename__ = "branding_assets"
    __table_args__ = (
        Index("ix_branding_kind", "kind"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    name: Mapped[str] = mapped_column(String(200), nullable=False)
    kind: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'logo' | 'brochure' | 'banner' | 'social_media_kit' | 'brand_guideline' | 'pitch_deck' | 'video_intro'
    version: Mapped[str | None] = mapped_column(String(40), nullable=True)
    media_asset_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("media_assets.id"), nullable=True
    )
    storage_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
    # Direct URL when not registered in MediaAsset (e.g. external Drive link).
    valid_from: Mapped[date | None] = mapped_column(Date, nullable=True)
    valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
    # Some assets (festival branding) are time-bound.

    custodian_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    usage_rights: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 'internal_only' | 'public_press' | 'partner_distribution' | etc.
    is_current: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)


class PublicEngagement(Base, TenantMixin, AuditMixin):
    """A lecture, workshop, conference, exhibition, satsang the institution
    delivered or hosted — external-audience facing."""
    __tablename__ = "public_engagements"
    __table_args__ = (
        Index("ix_engagements_date", "engagement_date"),
        Index("ix_engagements_kind", "kind"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    kind: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'public_lecture' | 'workshop' | 'conference_talk' | 'exhibition' | 'satsang' | 'panel'
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    engagement_date: Mapped[date] = mapped_column(Date, nullable=False)
    duration_hours: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)

    presenter_member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    presenter_external_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
    venue: Mapped[str | None] = mapped_column(String(255), nullable=True)
    venue_branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True
    )

    audience_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
    impact_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Free-text: outcomes, follow-up, donations attributed.
    initiative_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("expansion_initiatives.id"), nullable=True
    )
    # Optional link to a parent expansion initiative.
    media_asset_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
    # Photos/videos/recordings — IDs into media_assets.


class MediaCoverageLog(Base, TenantMixin, AuditMixin):
    """Press, TV, online mentions of the institution. One row per coverage event."""
    __tablename__ = "media_coverage_log"
    __table_args__ = (
        Index("ix_media_coverage_date", "coverage_date"),
        Index("ix_media_coverage_outlet", "outlet"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    coverage_date: Mapped[date] = mapped_column(Date, nullable=False)
    outlet: Mapped[str] = mapped_column(String(160), nullable=False)
    # 'The Hindu' | 'Vijayavani' | 'Doordarshan' | 'Asianet' | 'YouTube channel name'
    outlet_kind: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'print' | 'tv' | 'online' | 'radio' | 'social_media'
    title: Mapped[str | None] = mapped_column(String(500), nullable=True)
    summary: Mapped[str | None] = mapped_column(Text, nullable=True)
    sentiment: Mapped[str | None] = mapped_column(String(20), nullable=True)
    # 'positive' | 'neutral' | 'critical'
    archive_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
    archive_pdf_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("media_assets.id"), nullable=True
    )
    # If we archive a snapshot to GCS in case the source URL dies.
    related_engagement_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("public_engagements.id"), nullable=True
    )
    related_initiative_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("expansion_initiatives.id"), nullable=True
    )
    reach_estimate: Mapped[int | None] = mapped_column(Integer, nullable=True)

3. Reuse map

Existing artefact How outreach uses it
aayojana.publications.MediaAsset Branding files, engagement photos, archived press clippings stored here.
aayojana.models.branch.Branch Existing branches FK from target_branch_id and venue_branch_id.
aayojana.models.member.Member Presenters at lectures FK back to members.
aayojana.comms.service.send_to_segment Used to invite audiences to public engagements + send press releases.
aayojana.reports.service.register_report Quarterly engagement summary, media coverage roll-up registered.
aayojana.audit.service.record_audit_event Initiative status changes (planning → active → completed) recorded if legal_weight.

4. API surface

Method Path Purpose
GET /api/outreach/initiatives List filterable by status, kind.
POST /api/outreach/initiatives Create.
GET /api/outreach/initiatives/{id} Detail with linked engagements + media.
PUT /api/outreach/initiatives/{id} Update.
POST /api/outreach/initiatives/{id}/complete Mark complete with outcomes notes.
GET /api/outreach/branding-assets List.
POST /api/outreach/branding-assets Create.
GET /api/outreach/engagements List filterable by date-range, kind.
POST /api/outreach/engagements Create.
GET /api/outreach/media-coverage List filterable by outlet, sentiment.
POST /api/outreach/media-coverage Add.
GET /admin/outreach Dashboard.
GET /admin/outreach/initiatives/{id} Initiative detail UI.

5. Service layer

async def create_initiative(db, *, tenant_id, name, kind, **kwargs) -> "ExpansionInitiative": ...

async def log_engagement(
    db, *, tenant_id, title, kind, engagement_date, **kwargs,
) -> "PublicEngagement": ...

async def add_media_coverage(
    db, *, tenant_id, coverage_date, outlet, outlet_kind, **kwargs,
) -> "MediaCoverageLog": ...

async def quarterly_engagement_summary(
    db, *, tenant_id, quarter_start: date, quarter_end: date,
) -> dict:
    """Aggregates engagements + media + initiative-completion for a quarter.
    Used by Reports for `outreach_engagement_summary`."""

6. UI / Templates

src/aayojana/templates/outreach/. Reuses frame.html.

Page Purpose
outreach/dashboard.html KPIs, active initiatives, this-quarter engagements.
outreach/initiatives_list.html All initiatives by status.
outreach/initiative_detail.html Single initiative with linked engagements + budget.
outreach/branding_library.html Logo + collateral catalog with download links.
outreach/engagements_calendar.html Past + upcoming engagements timeline.
outreach/media_log.html Press mentions table with filter.

7. Migration plan

Rev Slug Tables / changes
0034 outreach_collaborations (combined with collaborations) expansion_initiatives, branding_assets, public_engagements, media_coverage_log + the four collaborations tables.

8. Cross-module dependencies

9. Implementation phases

Phase A — Initiatives + Engagements (1 week): 1. Migration 0034 (combined with Collaborations). 2. CRUD for initiatives + engagements. 3. Dashboard + detail pages.

Phase B — Branding + Media coverage (1 week): 4. Branding asset library tied to MediaAsset. 5. Media coverage log + outlet directory. 6. Quarterly engagement summary report.

Phase C — Reporting + automation (1 week): 7. Auto-suggest "log media coverage" when a press release goes out via Comms. 8. Initiative ROI reporting (spend vs metrics vs donations attributed). 9. Public-facing impact page (recent engagements, press) under Publications.

10. Open questions

  1. Initiative budget vs Vitta — track spend here, or only in Vitta with initiative_id tag? Recommendation: Track aggregate actual_spend here (denormalised) refreshed nightly from Vitta sum-by-tag.
  2. Media coverage archiving — automatically PDF-snapshot online articles? Recommendation: Manual upload v1; auto-snapshot via headless browser in v2 if outlet-link rot becomes a problem.
  3. Presenter compensation — track here, or in Vitta payroll? Recommendation: Vitta only (sambhavana category); link from engagement via metadata.
  4. Public engagement RSVPs — own table, or reuse Events module? Rec: For institutional-hosted engagements use Events module's RSVP. This module logs that the engagement happened, not seat-bookings.
  5. Branding asset versioning — keep all versions or only current? Rec: Keep all (is_current flag) — auditing brand-evolution is useful.
  6. Sentiment auto-classification — use an LLM, or manual entry? Rec: Manual v1; LLM-suggest in v2 with admin override.
  7. Initiative governance — does an initiative need trustee approval before starting? Recommendation: Yes for budget > threshold (configurable); gate via Audit module's two-key flow.