7
🪔

Events Planner & Management — Blueprint

Festivals · daily rituals · kalyanotsavams · capacity

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

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:

6. UI / Templates

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

Writes to

9. Implementation phases

10. Open questions

  1. Seva vs event_template precedence — when both seva_id and event_template_id are set, which drives kit/qualifications? Recommend: template overrides seva if set; document explicitly.
  2. 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.
  3. Archaka qualification override — when no qualified archaka available, allow manual override + audit_event? Recommend: yes; require role events-admin and reason.
  4. Capacity overbooking — soft warning or hard block on resource bookings? Recommend: soft warning for 'held', hard block for 'confirmed'.
  5. 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.
  6. 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.
  7. External archakas — visiting acharya not in staff registry. Recommend: temporary staff_profile with is_external=True flag; sambhavana flows the same way.
  8. Sambhavana payment timing — at confirmation, on completion, or post-report? Recommend: on completion (manual confirmation by event-admin), payable in next Vitta payroll batch.
  9. Volunteer self-signup — public form for known members to volunteer for upcoming events? Defer to Phase C.
  10. Event cancellation cascade — when event cancelled, what happens to PO already raised? Recommend: PO not auto-cancelled; flag for review; treasurer decides.