Outreach & Promotional — Blueprint
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
- Comms — invitation dispatch via
send_to_segment(engagement RSVPs); press-release distribution to media segment. - Reports —
outreach_engagement_summary,outreach_media_coverage_quarterly,outreach_initiative_statusregistered. - Publications — branding assets reference
MediaAsset; press releases cross-link viaPublication(type='press_release'). - Vitta —
actual_spendof initiatives reconciles with Vitta journal entries tagged withinitiative_id. - Members Suite — presenters link to member records.
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
- Initiative budget vs Vitta — track spend here, or only in Vitta with
initiative_idtag? Recommendation: Track aggregateactual_spendhere (denormalised) refreshed nightly from Vitta sum-by-tag. - 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.
- Presenter compensation — track here, or in Vitta payroll? Recommendation: Vitta only (sambhavana category); link from engagement via metadata.
- 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.
- Branding asset versioning — keep all versions or only current? Rec:
Keep all (
is_currentflag) — auditing brand-evolution is useful. - Sentiment auto-classification — use an LLM, or manual entry? Rec: Manual v1; LLM-suggest in v2 with admin override.
- Initiative governance — does an initiative need trustee approval before starting? Recommendation: Yes for budget > threshold (configurable); gate via Audit module's two-key flow.