10
🤝

Collaborations & Partnerships — Blueprint

Partner institutions · joint programs · MOUs

Blueprint — Collaborations & Partnerships

Status: planned · Slug: collaborations · Kind: ERP · Module #10

1. Module summary

Collaborations is the partnership ledger of the institution: sister mathas, mandirs, schools, scholarly conferences, exchange visits, MOUs, and shared resources. Each row is a long-lived relationship with continuity across leadership changes — capturing who-knows-whom, what agreements are in force, which equipment was lent to which partner, and which acharyas visited when. Operationally lighter than core ERP, but high in institutional-memory value; the data is read often, written rarely. Outbound communications (visit invitations, MOU acknowledgments) flow through Comms; reports surface active MOUs and recent visits.

2. Data model

# src/aayojana/collaborations/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 PartnerInstitution(Base, TenantMixin, AuditMixin):
    """A peer institution we have a relationship with."""
    __tablename__ = "partner_institutions"
    __table_args__ = (
        UniqueConstraint("tenant_id", "slug", name="uq_partner_tenant_slug"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    name_sa: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Sanskrit / Devanagari name; VijayaDV PUA preserved.
    slug: Mapped[str] = mapped_column(String(120), nullable=False)
    kind: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'matha' | 'mandir' | 'school' | 'university' | 'gurukula' | 'trust' | 'ngo' | 'government_body'
    parampara: Mapped[str | None] = mapped_column(String(120), nullable=True)
    # Sringeri / Ahobila / Pejavara / SGS / etc.
    sampradaya: Mapped[str | None] = mapped_column(String(80), nullable=True)
    # Smarta / Vaishnava / Shaiva / Madhva / etc.

    primary_contact_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
    primary_contact_role: Mapped[str | None] = mapped_column(String(120), nullable=True)
    primary_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
    primary_contact_phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
    address: Mapped[str | None] = mapped_column(Text, nullable=True)
    city: Mapped[str | None] = mapped_column(String(120), nullable=True)
    state: Mapped[str | None] = mapped_column(String(120), nullable=True)
    country: Mapped[str | None] = mapped_column(String(80), nullable=True, default="India")
    website: Mapped[str | None] = mapped_column(String(255), nullable=True)

    relationship_history: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Free-text narrative — when the relationship started, why, who introduced.
    relationship_status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
    # 'active' | 'dormant' | 'estranged' | 'historical'
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class JointProgram(Base, TenantMixin, AuditMixin):
    """A co-organised event, scholarly conference, festival, or exchange."""
    __tablename__ = "joint_programs"
    __table_args__ = (
        Index("ix_joint_program_partner", "partner_id"),
        Index("ix_joint_program_date", "program_date"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    partner_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("partner_institutions.id"), nullable=False
    )
    program_name: Mapped[str] = mapped_column(String(255), nullable=False)
    kind: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'festival' | 'conference' | 'workshop' | 'pilgrimage' | 'exchange' | 'fundraiser'
    program_date: Mapped[date] = mapped_column(Date, nullable=False)
    end_date: Mapped[date | None] = mapped_column(Date, 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
    )

    contribution_ours: Mapped[str | None] = mapped_column(Text, nullable=True)
    contribution_theirs: Mapped[str | None] = mapped_column(Text, nullable=True)
    outcome: Mapped[str | None] = mapped_column(Text, nullable=True)
    attendees: Mapped[int | None] = mapped_column(Integer, nullable=True)


class MOU(Base, TenantMixin, AuditMixin):
    """A formal agreement with a partner."""
    __tablename__ = "mous"
    __table_args__ = (
        Index("ix_mou_partner", "partner_id"),
        Index("ix_mou_validity", "valid_from", "valid_to"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    partner_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("partner_institutions.id"), nullable=False
    )
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    scope: Mapped[str | None] = mapped_column(Text, nullable=True)
    document_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
    document_media_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("media_assets.id"), nullable=True
    )
    # Scanned signed copy in MediaAsset.

    valid_from: Mapped[date] = mapped_column(Date, nullable=False)
    valid_to: Mapped[date | None] = mapped_column(Date, nullable=True)
    auto_renews: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

    signatory_ours_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    signatory_theirs_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
    # 'draft' | 'active' | 'expired' | 'terminated'
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class InterInstitutionalVisit(Base, TenantMixin, AuditMixin):
    """Visiting acharyas, swamis, scholars from partner institutions."""
    __tablename__ = "inter_institutional_visits"
    __table_args__ = (
        Index("ix_visit_dates", "arrival_date", "departure_date"),
        Index("ix_visit_partner", "from_partner_id"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    visitor_name: Mapped[str] = mapped_column(String(255), nullable=False)
    visitor_role: Mapped[str | None] = mapped_column(String(120), nullable=True)
    # 'acharya' | 'swami' | 'pandit' | 'professor' | 'jeeyar' | etc.
    visitor_member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    # If we already have them as a member.
    from_partner_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("partner_institutions.id"), nullable=True
    )

    arrival_date: Mapped[date] = mapped_column(Date, nullable=False)
    departure_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    purpose: Mapped[str | None] = mapped_column(Text, nullable=True)

    accommodation_room_id: Mapped[str | None] = mapped_column(
        String(36), nullable=True
    )
    # FK to Asset Management's rooms table when that lands (Agent 4). String for now.
    accommodation_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
    travel_arranged_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
    # 'us' | 'them' | 'shared'

    pickup_arranged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    program_link: Mapped[str | None] = mapped_column(String(36), nullable=True)
    # Optional FK to joint_programs.id (string for now to avoid FK ordering).
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class SharedResource(Base, TenantMixin, AuditMixin):
    """Lent equipment / staff / library to a partner — accountability trail."""
    __tablename__ = "shared_resources"
    __table_args__ = (
        Index("ix_shared_partner", "partner_id"),
        Index("ix_shared_period", "from_date", "to_date"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    partner_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("partner_institutions.id"), nullable=False
    )
    direction: Mapped[str] = mapped_column(String(8), nullable=False)
    # 'lent' (we → them) | 'borrowed' (them → us)
    resource_kind: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'equipment' | 'vehicle' | 'staff' | 'archaka' | 'library_item' | 'samagri'

    # Polymorphic FKs — exactly one set.
    asset_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    # FK to assets table (Agent 4) — string for now.
    staff_member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Free-text fallback when not a registered asset.

    from_date: Mapped[date] = mapped_column(Date, nullable=False)
    to_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    expected_return_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    actual_return_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    condition_at_lending: Mapped[str | None] = mapped_column(String(40), nullable=True)
    condition_returned: Mapped[str | None] = mapped_column(String(40), nullable=True)
    # 'as-issued' | 'minor-wear' | 'damaged' | 'lost'

    estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
    custodian_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
    # 'active' | 'returned' | 'overdue' | 'written_off'
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)

3. Reuse map

Existing artefact How collaborations uses it
aayojana.models.member.Member Visitor-as-member, staff lending, signatory references.
aayojana.models.branch.Branch Joint-program venue link.
aayojana.publications.MediaAsset Scanned MOU PDFs stored here.
aayojana.assets (Agent 4 — Asset Management) accommodation_room_id and lent asset_id cross-reference Asset Management. Stored as String(36) until that module ships, then promoted to FK in a follow-up migration.
aayojana.comms.service.send MOU renewal reminders, visit-prep checklists, joint-program invitations.
aayojana.reports.service.register_report Active MOUs, partner directory, recent visits, lent-but-not-returned report.

4. API surface

Method Path Purpose
GET /api/collaborations/partners List filterable by kind, status, parampara.
POST /api/collaborations/partners Create.
GET /api/collaborations/partners/{id} Detail with timeline of programs + MOUs + visits.
PUT /api/collaborations/partners/{id} Update.
GET /api/collaborations/joint-programs List filterable by date, partner.
POST /api/collaborations/joint-programs Create.
GET /api/collaborations/mous List filterable by status, expiring.
POST /api/collaborations/mous Create.
GET /api/collaborations/mous/expiring MOUs expiring within N days (used by Comms reminder cron).
GET /api/collaborations/visits List filterable by date-range.
POST /api/collaborations/visits Create.
GET /api/collaborations/visits/upcoming Visits within next N days.
GET /api/collaborations/shared-resources List filterable by direction, status.
POST /api/collaborations/shared-resources Lend / borrow.
POST /api/collaborations/shared-resources/{id}/return Mark returned with condition.
GET /admin/collaborations Dashboard.
GET /admin/collaborations/partners/{id} Partner detail UI.

5. Service layer

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

async def record_joint_program(
    db, *, tenant_id, partner_id, program_name, kind, program_date, **kwargs,
) -> "JointProgram": ...

async def create_mou(
    db, *, tenant_id, partner_id, title, valid_from, **kwargs,
) -> "MOU": ...

async def schedule_visit(
    db, *, tenant_id, visitor_name, arrival_date,
    from_partner_id=None, accommodation_room_id=None, **kwargs,
) -> "InterInstitutionalVisit":
    """Creates the visit. If accommodation_room_id given, marks the room as
    reserved in Asset Management for the period (cross-module call when that
    lands)."""

async def lend_resource(
    db, *, tenant_id, partner_id, resource_kind, from_date,
    asset_id=None, staff_member_id=None, **kwargs,
) -> "SharedResource": ...

async def return_resource(
    db, *, tenant_id, shared_resource_id,
    actual_return_date, condition_returned,
) -> "SharedResource": ...

async def expiring_mous(
    db, *, tenant_id, within_days: int = 90,
) -> list["MOU"]:
    """Used by the Comms reminder cron + Reports for MOU-renewal calendar."""

6. UI / Templates

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

Page Purpose
collaborations/dashboard.html KPIs, expiring MOUs, upcoming visits, lent items overdue.
collaborations/partners_list.html Searchable partner directory.
collaborations/partner_detail.html Single partner with timeline of all interactions.
collaborations/mous_list.html All MOUs with expiry filter.
collaborations/mou_detail.html Single MOU with PDF preview.
collaborations/joint_programs.html Past + upcoming joint events timeline.
collaborations/visits_calendar.html Visit calendar with accommodation column.
collaborations/shared_resources.html Lending ledger with overdue highlighted.

7. Migration plan

Rev Slug Tables / changes
0034 outreach_collaborations (combined) partner_institutions, joint_programs, mous, inter_institutional_visits, shared_resources + the four outreach tables. Following Asset Management migration (Agent 4), a follow-up migration promotes accommodation_room_id and asset_id columns to proper FKs.

8. Cross-module dependencies

9. Implementation phases

Phase A — Partners + MOUs (1 week): 1. Migration 0034 (combined with Outreach). 2. Partner CRUD + MOU CRUD + dashboard. 3. Expiring-MOU report registered with Reports. 4. Comms-driven MOU renewal reminders (cron daily).

Phase B — Visits + Joint Programs (1 week): 5. Visit calendar. 6. Joint programs timeline. 7. Cross-link visits → Asset Management accommodation (string-FK; promote later).

Phase C — Shared Resources + Reporting (1 week): 8. Shared-resource lending ledger. 9. Overdue-resource report + auto-Comms reminder. 10. Partner-relationship analytics dashboard (top-N partners by interaction count).

10. Open questions

  1. Cross-tenant federation — when two Aayojana tenants have a partnership, should there be bilateral data-sharing? Recommendation: No automatic sharing; each tenant maintains their own row. Defer until there's a real case (Phase D+).
  2. Visit accommodation cross-link — promote accommodation_room_id to FK once Assets ships, or keep loose? Rec: Promote (Phase B).
  3. MOU document storage — direct GCS upload or via MediaAsset? Rec: MediaAsset (consistent with rest of Publications/Outreach).
  4. Lent items value cap — require trustee approval above some threshold? Rec: Yes, cap at ₹50k (configurable); above triggers Audit two-key.
  5. Visitor accommodation conflict — what if Asset Management says room is unavailable? Rec: Booking attempt rejects with conflict; collaborator coordinator picks alternate.
  6. Partner-contact sync — auto-create a Member row when a partner contact is added? Rec: No — partner contacts are explicitly NOT members (separate concern); cross-link only if they donate.
  7. Multi-language partner names — Sanskrit + English columns is enough, or also vernacular? Rec: Add name_local (TEXT) in Phase B for Kannada/Tamil/Telugu names if needed.
  8. Historical relationships — pre-Aayojana partnerships seeded from VKG's notes? Rec: Yes — seed-script in Phase A imports a CSV of historical relationships with relationship_status='historical'.