Collaborations & Partnerships — Blueprint
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
- Comms — MOU renewal reminders (T-90, T-30, T-7), visit prep checklists, joint-program invitations sent to partner contacts + relevant Members segment.
- Reports —
collaborations_active_mous,collaborations_partner_directory,collaborations_visits_log,collaborations_lent_outstandingregistered. - Asset Management (Agent 4) —
accommodation_room_id(visit) andasset_id(shared resource) cross-link; lend operation sets asset status to 'on-loan' via Asset Management API. - Members Suite — visitors-as-members, lending staff member references.
- Audit — MOU creation/termination is legal-weight; visits over 30 days may be audit-relevant.
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
- 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+).
- Visit accommodation cross-link — promote
accommodation_room_idto FK once Assets ships, or keep loose? Rec: Promote (Phase B). - MOU document storage — direct GCS upload or via MediaAsset? Rec: MediaAsset (consistent with rest of Publications/Outreach).
- Lent items value cap — require trustee approval above some threshold? Rec: Yes, cap at ₹50k (configurable); above triggers Audit two-key.
- Visitor accommodation conflict — what if Asset Management says room is unavailable? Rec: Booking attempt rejects with conflict; collaborator coordinator picks alternate.
- 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.
- 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. - 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'.