Open-source event platforms like FOSSASIA Eventyay cater to events ranging from 50-person meetups to international tech conferences with 10,000+ attendees. Each organizer requires vastly different speaker attributes—some ask for dietary constraints and t-shirt sizes; others require academic affiliations, ORCID IDs, and travel grant applications.

Hardcoding form fields into Django models becomes untenable when every event demands a bespoke data schema. In FOSSASIA PR #1777 and PR #1828, we re-engineered the speaker proposal architecture around a declarative custom field schema engine.

[Organizer UI] -> [Form Schema JSON] -> [PostgreSQL JSONB] -> [Dynamic Vue Component] -> [Validated Submission]

The Anti-Pattern: Schema Proliferation

Earlier iterations frequently introduced new database columns to core Django models:

# The Antipattern: Altering core models for event-specific attributes
class Speaker(models.Model):
    name = models.CharField(max_length=120)
    email = models.EmailField()
    # Ad-hoc fields that don't apply to 90% of events:
    orcid_id = models.CharField(max_length=64, blank=True)
    travel_stipend_requested = models.BooleanField(default=False)

Running migrations across large production databases during live events introduces schema lockups, index rebuilding pauses, and brittle migration rollbacks.

The Solution: Declarative Field Schemas

Instead of model-level mutation, we modeled dynamic fields as first-class meta-entities coupled to specific events:

class CustomFormField(models.Model):
    class FieldType(models.TextChoices):
        TEXT = "text", "Short Text"
        PARAGRAPH = "paragraph", "Paragraph"
        SELECT = "select", "Dropdown Choice"
        CHECKBOX = "checkbox", "Checkbox"
        FILE = "file", "Document Upload"

    event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="custom_fields")
    field_type = models.CharField(max_length=32, choices=FieldType.choices)
    label = models.CharField(max_length=255)
    is_required = models.BooleanField(default=False)
    options = models.JSONField(default=list, blank=True)  # For select/radio choices

Speaker responses are stored against the submission in a indexed JSONB column with schema validation enforced at the API boundary:

class ProposalSubmission(models.Model):
    proposal = models.ForeignKey(Proposal, on_delete=models.CASCADE)
    custom_responses = models.JSONField(default=dict)

Isomorphic Rendering with Vue.js

On the client, a unified form engine dynamically parses the schema array and renders matching reactive inputs:

<template>
  <div class="dynamic-form-fields space-y-4">
    <div v-for="field in fields" :key="field.id" class="form-group">
      <label :for="'field-' + field.id" class="block text-sm font-medium">
        {{ field.label }}
        <span v-if="field.is_required" class="text-rose-500">*</span>
      </label>

      <component
        :is="resolveFieldComponent(field.field_type)"
        :id="'field-' + field.id"
        v-model="responses[field.id]"
        :options="field.options"
        :required="field.is_required"
      />
    </div>
  </div>
</template>

Refactoring Organizer Settings (PR #1961)

In PR #1961, we tackled a long-standing series of production 500 errors in Eventyay’s organizer navigation. Previous logic scattered organizer permissions across multiple disjoint controllers. By consolidating navigation guards, centralizing proposal permission scopes, and unifying Call for Proposals (CfP) workflows, we eliminated redundant query cascades and established a clean, error-free organizer experience.

Engineering in open source taught us that the cleanest systems are those that decouple event-specific variance from core database models.