Events Planner & Management — Blueprint
Events Planner & Management — Implementation Blueprint
Architect Agent 4 of the Pancha · 2026-05-02 · Module 7 of 17 in the Aayojana catalog.
1. Module summary
- Name: Events Planner & Management
- Slug:
events - Kind: ERP
- Status: planned (subpackage
src/aayojana/events/to be created).
The operational heart of the institution. Maha Shivaratri, Datta Jayanti, Sri Rama Navami, kalyanotsavams, vratams, daily nitya pooja — every operational day is a sequence of events. The Events module does not duplicate the Seva catalog (DISA's sevas and sevaCategories already model the ritual templates). What we add here is the operational layer: a specific date instance of an event (event_instances), the archaka roster (from Staff), the volunteer assignment (from Members:Volunteers), the resource booking (rooms from Asset Management), the samagri indent (from Inventory's kit templates), the attendee/RSVP list, and post-event reporting that flows into Publications.
2. Data model
# src/aayojana/events/models.py
from datetime import date, datetime, time
from sqlalchemy import (
Boolean, Date, DateTime, ForeignKey, Index, Integer, JSON, Numeric, String, Text, Time,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from aayojana.models.base import AuditMixin, Base, TenantMixin, uuid4_str
class EventInstance(Base, AuditMixin, TenantMixin):
"""A specific dated occurrence of an event.
Reuses sevas.id when the event is a known ritual (kalyanotsavam, homa).
For festivals or composite events, seva_id is NULL and event_template_id
points to the festival template.
"""
__tablename__ = "event_instances"
__table_args__ = (
UniqueConstraint("tenant_id", "event_code", name="uq_event_code"),
Index("ix_event_date", "event_date"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_code: Mapped[str] = mapped_column(String(40), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
name_sanskrit: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
event_kind: Mapped[str] = mapped_column(String(20), nullable=False)
# 'festival' | 'kalyanotsavam' | 'homa' | 'vratam' | 'nitya-pooja' | 'lecture' |
# 'concert' | 'sammelan' | 'satsang' | 'other'
seva_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("sevas.id"), nullable=True, index=True
)
seva_category_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("sevaCategories.id"), nullable=True
)
event_template_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("event_templates.id"), nullable=True
)
branch_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("branches.id"), nullable=True
)
event_date: Mapped[date] = mapped_column(Date, nullable=False)
start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
tithi_or_lunar: Mapped[str | None] = mapped_column(String(127), nullable=True)
nakshatram_id: Mapped[str | None] = mapped_column(
"nakshatram", String(63), ForeignKey("nakshatramTypes.nakshatram"), nullable=True
)
expected_attendance: Mapped[int | None] = mapped_column(Integer, nullable=True)
actual_attendance: Mapped[int | None] = mapped_column(Integer, nullable=True)
booking_member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
# If a devotee booked it (kalyanotsavam, vratam).
in_the_name_of: Mapped[str | None] = mapped_column(String(255), nullable=True)
gotram: Mapped[str | None] = mapped_column(String(127), nullable=True)
occasion: Mapped[str | None] = mapped_column(String(127), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="planned", nullable=False)
# 'planned' | 'confirmed' | 'in-progress' | 'completed' | 'cancelled' | 'postponed'
is_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class EventTemplate(Base, AuditMixin, TenantMixin):
"""Composite/festival templates that aren't a single seva.
e.g. 'Datta Jayanti 3-day program' — drives roster, indent, capacity defaults.
"""
__tablename__ = "event_templates"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_event_template_code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
code: Mapped[str] = mapped_column(String(40), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
name_sanskrit: Mapped[str | None] = mapped_column(Text, nullable=True)
event_kind: Mapped[str] = mapped_column(String(20), nullable=False)
typical_duration_days: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
typical_archaka_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
typical_volunteer_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
default_samagri_kit_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("samagri_kit_templates.id"), nullable=True
)
default_archaka_qualifications: Mapped[list | None] = mapped_column(JSON, nullable=True)
# ['Sandhya Vandanam', 'Agnikaryam', 'Mahanayasam']
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class EventArchakaRoster(Base, AuditMixin, TenantMixin):
__tablename__ = "event_archaka_roster"
__table_args__ = (
UniqueConstraint("event_id", "staff_member_id", "role", name="uq_event_archaka"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_instances.id", ondelete="CASCADE"), nullable=False
)
staff_member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("staff_profiles.id"), nullable=False
)
# Agent 3's Staff Details staff_profiles table; NOT plain members.
role: Mapped[str] = mapped_column(String(40), nullable=False)
# 'pradhana-archaka' | 'sahaaya-archaka' | 'ritvik' | 'vedic-recitation' |
# 'specific-homa-lead' | 'kalasha-pooja'
qualification_matched: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sambhavana_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
sambhavana_paid: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class EventVolunteerAssignment(Base, AuditMixin, TenantMixin):
__tablename__ = "event_volunteer_assignment"
__table_args__ = (
UniqueConstraint("event_id", "member_id", "shift", name="uq_event_volunteer_shift"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_instances.id", ondelete="CASCADE"), nullable=False
)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id"), nullable=False
)
skill: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'cooking' | 'serving' | 'parking' | 'crowd-control' | 'registration' |
# 'cleaning' | 'audio-video' | 'reception' | 'driving' | 'accounting'
shift: Mapped[str] = mapped_column(String(20), nullable=False)
# 'morning' | 'afternoon' | 'evening' | 'all-day'
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
actually_attended: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
feedback: Mapped[str | None] = mapped_column(Text, nullable=True)
class EventAttendee(Base, AuditMixin, TenantMixin):
"""RSVPs / attendees for invitation-managed events."""
__tablename__ = "event_attendees"
__table_args__ = (
UniqueConstraint("event_id", "member_id", name="uq_event_attendee"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_instances.id", ondelete="CASCADE"), nullable=False
)
member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
# NULL for walk-in / non-member registrations.
walk_in_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
walk_in_phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
rsvp_status: Mapped[str] = mapped_column(String(20), default="invited", nullable=False)
# 'invited' | 'accepted' | 'declined' | 'maybe' | 'attended' | 'no-show'
party_size: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
invited_via: Mapped[str | None] = mapped_column(String(20), nullable=True)
# 'email' | 'whatsapp' | 'sms' | 'postal' | 'phone' | 'walk-in'
accommodation_room_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("rooms.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class EventResourceBooking(Base, AuditMixin, TenantMixin):
"""Hall, room, kitchen, parking — anything physical the event uses."""
__tablename__ = "event_resource_bookings"
__table_args__ = (
Index("ix_resource_booking_window", "resource_type", "resource_id", "starts_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_instances.id", ondelete="CASCADE"), nullable=False
)
resource_type: Mapped[str] = mapped_column(String(20), nullable=False)
# 'room' | 'building' | 'kitchen' | 'parking' | 'hall' | 'audio-system'
resource_id: Mapped[str] = mapped_column(String(36), nullable=False)
# FK target depends on resource_type — rooms.id for 'room', building_assets.id for 'building'.
starts_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
ends_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
capacity_required: Mapped[int | None] = mapped_column(Integer, nullable=True)
capacity_available: Mapped[int | None] = mapped_column(Integer, nullable=True)
booking_status: Mapped[str] = mapped_column(String(20), default="held", nullable=False)
# 'held' | 'confirmed' | 'in-use' | 'released' | 'cancelled'
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class EventSamagriIndent(Base, AuditMixin, TenantMixin):
"""The shopping/dispense list generated for an event from kit templates."""
__tablename__ = "event_samagri_indents"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_instances.id", ondelete="CASCADE"), nullable=False
)
kit_template_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("samagri_kit_templates.id"), nullable=True
)
multiplier: Mapped[float] = mapped_column(Numeric(8, 2), default=1, nullable=False)
# e.g. 50 kalyanotsavams × 1 kit each = 50.00
generated_on: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="draft", nullable=False)
# 'draft' | 'approved' | 'partially-fulfilled' | 'fulfilled' | 'cancelled'
fulfilment_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# 'dispense-from-stock' | 'purchase-order' | 'mixed'
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class EventSamagriIndentLine(Base, AuditMixin):
__tablename__ = "event_samagri_indent_lines"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
indent_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_samagri_indents.id", ondelete="CASCADE"), nullable=False
)
item_id: Mapped[str] = mapped_column(
String(36), ForeignKey("inventory_items.id"), nullable=False
)
quantity_required: Mapped[float] = mapped_column(Numeric(14, 3), nullable=False)
uom: Mapped[str] = mapped_column(String(20), nullable=False)
quantity_dispensed: Mapped[float] = mapped_column(Numeric(14, 3), default=0, nullable=False)
quantity_purchased: Mapped[float] = mapped_column(Numeric(14, 3), default=0, nullable=False)
line_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
is_critical: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
po_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("purchase_orders.id"), nullable=True
)
class EventPostReport(Base, AuditMixin, TenantMixin):
"""Closes the loop: attendance, finance, learnings, photos, public-facing
summary that Publications/Newsletter consume.
"""
__tablename__ = "event_post_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
event_id: Mapped[str] = mapped_column(
String(36), ForeignKey("event_instances.id"), nullable=False, unique=True
)
submitted_on: Mapped[date] = mapped_column(Date, nullable=False)
submitted_by_user_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id"), nullable=True
)
attendance_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
attendance_breakup: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# {members: 100, walk-ins: 250, archakas: 8, volunteers: 20}
income_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# {sankalpa: 25000, hundi: 15000, special-donation: 50000}
expense_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# {samagri: 30000, sambhavana: 20000, food: 15000, transport: 5000}
net_outcome: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
learnings: Mapped[str | None] = mapped_column(Text, nullable=True)
photo_paths: Mapped[list | None] = mapped_column(JSON, nullable=True)
video_paths: Mapped[list | None] = mapped_column(JSON, nullable=True)
public_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
# Sanitised summary for Publications/Newsletter.
is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
3. Reuse map
| Existing model | Used here as | Why |
|---|---|---|
organisations.id |
tenant_id | Multi-tenant |
branches.id |
event branch | Events are branch-local |
sevas.id, sevaCategories.id |
event_instances template | Critical: ritual catalog already exists; do not duplicate |
nakshatramTypes.nakshatram |
event auspicious-day binding | Reuse existing reference table |
members.id |
booking member, attendees, volunteers | Devotees are members |
staff_profiles.id (Agent 3) |
archaka roster | Archakas are staff with Vedic qualifications |
rooms.id (Asset Mgmt — this agent) |
resource bookings | Halls/rooms come from Asset Mgmt |
building_assets.id (Asset Mgmt) |
building-level resource booking | |
inventory_items.id (Inventory — this agent) |
indent lines | Stock is from Inventory |
samagri_kit_templates.id (Inventory) |
indent template | Kits are pre-defined in Inventory |
purchase_orders.id (Inventory) |
indent line PO link | When indent triggers a PO |
users.id |
report submitter | Auth |
4. API surface
Routes under /api/events/.
| Method | Path | Purpose |
|---|---|---|
| GET | /instances |
List/filter events by date/kind/branch/status |
| GET | /calendar |
Festival-calendar view (12-month) |
| POST | /instances |
Create event (from seva or template) |
| GET | /instances/{id} |
Detail with all sub-objects (roster, indent, attendees) |
| PATCH | /instances/{id} |
Edit |
| POST | /instances/{id}/confirm |
Lock plan; freeze roster |
| POST | /instances/{id}/cancel |
Cancel with reason; release resources |
| POST | /instances/{id}/postpone |
Reschedule; cascade resource bookings |
| GET | /templates |
List event templates |
| POST | /templates |
New template |
| POST | /instances/{id}/archakas |
Assign archaka (validates qualification) |
| DELETE | /instances/{id}/archakas/{rid} |
Remove archaka |
| GET | /archakas/availability |
Archaka availability matrix for a date range |
| POST | /instances/{id}/volunteers |
Assign volunteer |
| GET | /volunteers/skill-match |
Find volunteers with skill+availability |
| POST | /instances/{id}/attendees |
Add attendee/RSVP |
| PATCH | /instances/{id}/attendees/{aid} |
Update RSVP status |
| POST | /instances/{id}/attendees/import |
Bulk import RSVP CSV |
| POST | /instances/{id}/resource-bookings |
Book a room/hall (capacity check) |
| DELETE | /instances/{id}/resource-bookings/{rbid} |
Release booking |
| POST | /instances/{id}/generate-indent |
From kit template (calls Inventory service) |
| GET | /instances/{id}/indent |
Indent worksheet |
| POST | /instances/{id}/post-report |
Submit post-event report |
| GET | /instances/{id}/post-report |
Read report |
| POST | /instances/{id}/send-invitations |
Trigger Comms invitation campaign |
| GET | /dashboard |
Branch event dashboard |
5. Service layer
src/aayojana/events/services.py:
create_event_from_seva(seva_id, event_date, branch_id, expected_attendance) -> EventInstance— pre-fills name, naivedyam etc from seva.create_event_from_template(template_id, event_date, branch_id) -> EventInstance— composite/festival path.assign_archaka_roster(event_id, staff_member_ids, roles) -> list[EventArchakaRoster]— validates each staff member's Vedic qualifications against required (from event_template.default_archaka_qualifications); flagsqualification_matched=Falseif mismatch but allows override; computes sambhavana from staff profile rate card.find_available_archakas(date, qualification_required) -> list[Staff]— consults staff registry's qualifications + availability calendar (no other event same window).assign_volunteer(event_id, member_id, skill, shift) -> EventVolunteerAssignment— validates member is tagged 'volunteer' with availability matching shift.book_resource(event_id, resource_type, resource_id, starts, ends) -> EventResourceBooking— capacity collision check against existing bookings; returns 409 if conflict.release_resource(booking_id)— frees slot.generate_indent(event_id, kit_id=None, multiplier=1.0) -> EventSamagriIndent— delegates to Inventory'sgenerate_samagri_indent_for_event; lines populated with required vs current-stock shortfall.fulfil_indent(indent_id, strategy='auto') -> dict— strategy 'auto': dispense from stock for items in stock; create PO for shortfalls; mixed strategy possible.confirm_event(event_id)— sets status='confirmed'; locks roster (further changes require unlock with reason); freezes resource bookings to 'confirmed'.record_attendance(event_id, breakdown)— populates actual_attendance + post_report.attendance_breakup.submit_post_report(event_id, attendance, income, expense, learnings, photos) -> EventPostReport— Vitta reads income/expense to reconcile; Comms is triggered for thank-you to attendees; ifis_published, photo_paths flow to Publications/Newsletter.send_invitation_campaign(event_id, audience_filter) -> CampaignId— calls Comms (Agent 5) with audience derived from members tags and event_template metadata.compute_capacity_utilisation(event_id) -> dict— expected/actual/seats-booked across bookings.
6. UI / Templates
- Festival Calendar — 12-month grid, click-day to see all events; festival markers vs daily nitya; tithi overlay.
- Event detail page — tabbed: Overview / Archaka Roster / Volunteers / Resources / Indent / Attendees / Post-Report.
- Archaka Roster Builder — drag staff profiles into role slots; qualification chips coloured green/red; sambhavana auto-fills from staff rate.
- Volunteer Assignment grid — skills × shifts matrix; click a cell, search volunteers, assign.
- Resource Booking Gantt — building/hall/room rows × time on x-axis; existing bookings shown; conflicting drops blocked.
- Indent Worksheet — kit lines × multiplier with current stock vs shortfall; "Dispense" / "PO" buttons per line.
- Attendees panel — RSVP statuses, walk-in entry, accommodation assignment.
- Post-event report form — sections: attendance breakup, income/expense capture, learnings textarea, photo uploader, public summary.
- Public event landing page — for is_public events; published from event_instances; Comms invites link here.
- Branch event dashboard — today's events, upcoming festivals (T-30), pending indents, unconfirmed rosters.
7. Migration plan
| Rev | Title | Tables added |
|---|---|---|
| 0023 | events_base | event_templates, event_instances, event_resource_bookings |
| 0024 | events_extensions | event_archaka_roster, event_volunteer_assignment, event_attendees, event_samagri_indents, event_samagri_indent_lines, event_post_reports |
Rationale: split allows 0023 to land independently for festival-calendar viewing while 0024 brings in the full operational stack (depends on Staff Details 0017, Inventory 0021, Asset Mgmt rooms 0019).
8. Cross-module dependencies
Reads from
- Sevas (existing DISA):
sevas.id,sevaCategories.id— ritual templates. - Members (Agent 3): booking devotee, attendees, volunteers (via volunteer tag).
- Staff (Agent 3):
staff_profiles.idand qualifications for archaka roster. - Asset Mgmt (this agent):
rooms.id,building_assets.idfor resource bookings. - Inventory (this agent):
inventory_items,samagri_kit_templates, current stock_levels.
Writes to
- Inventory (this agent): triggers PO generation or dispense via
event_samagri_indents. - Vitta (Agent 1): post-report income → Hundi/donation reconciliation; sambhavana to archakas → payroll/sambhavana category; expenses → vendor_payables (via Inventory).
- Comms (Agent 5): invitation campaigns, archaka roster notice, volunteer reminder, post-event thank-you, post-report photos to Publications.
- Newsletter (Agent 5): post-event public summary auto-feeds upcoming newsletter draft.
- Publications (Agent 5): event photos / videos archive.
- Audit Trail (Agent 2): event cancellation, archaka roster lock-break — audit_event.
- Reports (Agent 5): festival-attendance trend, archaka-utilisation, room-occupancy KPIs.
9. Implementation phases
- Phase A — Schemas + basic CRUD. Migrations 0023+0024. Event create from seva, calendar view, basic roster + volunteer + attendee CRUD, simple resource booking with capacity check.
- Phase B — Cross-module integrations. Indent generation from Inventory kits; archaka qualification validation against staff profiles; PO/dispense trigger from indent; attendee invite via Comms; post-report Vitta reconciliation hook.
- Phase C — Workflows + dashboards. Festival calendar polish, Gantt resource booking, drag-drop roster builder, public event landing pages, post-report → Newsletter feed, archaka availability matrix.
10. Open questions
- Seva vs event_template precedence — when both
seva_idandevent_template_idare set, which drives kit/qualifications? Recommend: template overrides seva if set; document explicitly. - Recurring nitya pooja — should daily Sandhya / Pratahkal / Sayamkal generate one event_instance per day (heavy) or be tracked at template level with attendance log? Recommend: per-day for first 30-days lookahead (lazy materialise), to share infrastructure.
- Archaka qualification override — when no qualified archaka available, allow manual override + audit_event? Recommend: yes; require role
events-adminand reason. - Capacity overbooking — soft warning or hard block on resource bookings? Recommend: soft warning for 'held', hard block for 'confirmed'.
- Multi-day events — single event_instance with start_date and end_date, or N daily instances? Recommend: single instance for short events (2-3 days); template-driven multi-instance series for week-long festivals.
- Walk-in attendees — capture phone for follow-up? Lightweight form with one field. Recommend: optional name+phone, no member creation by default; offer "convert to member" CTA.
- External archakas — visiting acharya not in staff registry. Recommend: temporary staff_profile with
is_external=Trueflag; sambhavana flows the same way. - Sambhavana payment timing — at confirmation, on completion, or post-report? Recommend: on completion (manual confirmation by event-admin), payable in next Vitta payroll batch.
- Volunteer self-signup — public form for known members to volunteer for upcoming events? Defer to Phase C.
- Event cancellation cascade — when event cancelled, what happens to PO already raised? Recommend: PO not auto-cancelled; flag for review; treasurer decides.