Source code for django_program.manage.forms
"""Model forms for the conference management dashboard.
Each form that wraps a pretalx-synced model accepts an ``is_synced`` flag.
When the record has been synced from Pretalx (``synced_at`` is not None),
all fields are rendered as disabled so organizers cannot accidentally
overwrite upstream data.
"""
import datetime
from django import forms
from django.core.validators import RegexValidator
from django_program.conference.models import Conference, Expense, ExpenseCategory, KPITargets, Section
from django_program.pretalx.models import Room, ScheduleSlot, Talk
from django_program.programs.models import Activity, TravelGrant, TravelGrantMessage
from django_program.registration.badge import BadgeTemplate
from django_program.registration.conditions import (
DiscountForCategory,
DiscountForProduct,
GroupMemberCondition,
IncludedProductCondition,
SpeakerCondition,
TimeOrStockLimitCondition,
)
from django_program.registration.models import AddOn, Payment, TicketType, Voucher
from django_program.sponsors.models import Sponsor, SponsorLevel
[docs]
class ImportFromPretalxForm(forms.Form):
"""Form for importing a new conference from a Pretalx event slug.
Accepts a Pretalx event slug and optional conference slug override.
The event slug is used to fetch event metadata from the Pretalx API
and bootstrap a new Conference object with a full data sync.
"""
pretalx_event_slug = forms.CharField(
max_length=200,
label="Pretalx Event Slug",
help_text='The event identifier from the Pretalx URL, e.g. "pyconus2025".',
validators=[
RegexValidator(
regex=r"^[a-zA-Z0-9_-]+$",
message="Slug may only contain letters, numbers, hyphens, and underscores.",
),
],
)
conference_slug = forms.SlugField(
max_length=200,
required=False,
label="Conference Slug (optional)",
help_text="Override the URL slug for this conference. Defaults to the Pretalx event slug.",
)
api_token = forms.CharField(
max_length=500,
required=False,
label="API Token (optional)",
help_text="Pretalx API token. Overrides the configured token for this import.",
widget=forms.PasswordInput(
render_value=True,
attrs={
# Prevent browser password managers from autofilling account passwords.
"autocomplete": "new-password",
"autocapitalize": "none",
"spellcheck": "false",
"data-lpignore": "true",
},
),
)
[docs]
class ConferenceForm(forms.ModelForm):
"""Form for editing conference details.
Stripe secret keys are excluded for security, along with auto-managed
timestamp fields. The slug is excluded because it serves as the URL
identifier and should not be casually changed.
"""
[docs]
class Meta:
model = Conference
fields = [
"name",
"start_date",
"end_date",
"timezone",
"venue",
"address",
"website_url",
"pretalx_event_slug",
"total_capacity",
"revenue_budget",
"target_attendance",
"grant_budget",
"is_active",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}),
"end_date": forms.DateInput(attrs={"type": "date"}),
}
[docs]
class KPITargetsForm(forms.ModelForm):
"""Form for editing per-conference KPI target thresholds."""
[docs]
class Meta:
model = KPITargets
fields = [
"target_conversion_rate",
"target_refund_rate",
"target_checkin_rate",
"target_fulfillment_rate",
"target_revenue_per_attendee",
"target_room_utilization",
]
[docs]
class SectionForm(forms.ModelForm):
"""Form for editing a conference section."""
[docs]
class Meta:
model = Section
fields = ["name", "start_date", "end_date", "order"]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}),
"end_date": forms.DateInput(attrs={"type": "date"}),
}
[docs]
def __init__(self, *args: object, conference: Conference | None = None, **kwargs: object) -> None:
"""Build date select choices from the conference date range."""
super().__init__(*args, **kwargs)
if conference and conference.start_date and conference.end_date:
day_choices = self._build_date_choices(conference.start_date, conference.end_date)
self.fields["start_date"] = forms.TypedChoiceField(
choices=[("", "---"), *day_choices],
coerce=datetime.date.fromisoformat,
label="Start date",
)
self.fields["end_date"] = forms.TypedChoiceField(
choices=[("", "---"), *day_choices],
coerce=datetime.date.fromisoformat,
label="End date",
)
if self.instance and self.instance.pk:
if self.instance.start_date:
self.initial["start_date"] = self.instance.start_date.isoformat()
if self.instance.end_date:
self.initial["end_date"] = self.instance.end_date.isoformat()
@staticmethod
def _build_date_choices(start: datetime.date, end: datetime.date) -> list[tuple[str, str]]:
"""Build a list of (iso_date, label) for each day in the range."""
choices: list[tuple[str, str]] = []
current = start
while current <= end:
label = current.strftime("%A, %B %-d, %Y")
choices.append((current.isoformat(), label))
current += datetime.timedelta(days=1)
return choices
[docs]
class RoomForm(forms.ModelForm):
"""Form for editing a room.
When the room has been synced from Pretalx, all fields are disabled
to prevent overwriting upstream data.
"""
[docs]
def __init__(self, *args: object, **kwargs: object) -> None:
"""Initialise the form and disable fields when synced from Pretalx."""
self.is_synced: bool = kwargs.pop("is_synced", False) # type: ignore[arg-type]
super().__init__(*args, **kwargs)
if self.is_synced:
for field_name in self.fields:
self.fields[field_name].disabled = True
[docs]
class TalkForm(forms.ModelForm):
"""Form for editing a talk.
Pretalx-synced fields are disabled when the record was last synced
from the upstream API.
"""
SYNCED_FIELDS: list[str] = [
"pretalx_code",
"title",
"abstract",
"description",
"submission_type",
"track",
"duration",
"state",
"speakers",
"room",
"slot_start",
"slot_end",
]
[docs]
class Meta:
model = Talk
fields = [
"pretalx_code",
"title",
"abstract",
"description",
"submission_type",
"track",
"duration",
"state",
"speakers",
"room",
"slot_start",
"slot_end",
]
[docs]
def __init__(self, *args: object, **kwargs: object) -> None:
"""Initialise the form and disable synced fields when locked by Pretalx."""
self.is_synced: bool = kwargs.pop("is_synced", False) # type: ignore[arg-type]
super().__init__(*args, **kwargs)
if self.is_synced:
for field_name in self.SYNCED_FIELDS:
if field_name in self.fields:
self.fields[field_name].disabled = True
[docs]
class ScheduleSlotForm(forms.ModelForm):
"""Form for editing a schedule slot.
Pretalx-synced fields are disabled when the slot has been synced
from the upstream Pretalx API.
"""
SYNCED_FIELDS: list[str] = [
"talk",
"title",
"room",
"start",
"end",
"slot_type",
]
[docs]
class Meta:
model = ScheduleSlot
fields = ["talk", "title", "room", "start", "end", "slot_type"]
[docs]
def __init__(self, *args: object, **kwargs: object) -> None:
"""Initialise the form and disable synced fields when locked by Pretalx."""
self.is_synced: bool = kwargs.pop("is_synced", False) # type: ignore[arg-type]
super().__init__(*args, **kwargs)
if self.is_synced:
for field_name in self.SYNCED_FIELDS:
if field_name in self.fields:
self.fields[field_name].disabled = True
[docs]
class SponsorForm(forms.ModelForm):
"""Form for editing a sponsor.
When the sponsor has an ``external_id`` (synced from the PSF API),
fields that come from the upstream API are disabled to prevent
overwriting synced data.
"""
SYNCED_FIELDS: list[str] = [
"name",
"level",
"website_url",
"logo_url",
"description",
]
[docs]
class Meta:
model = Sponsor
fields = [
"name",
"level",
"website_url",
"logo",
"logo_url",
"description",
"contact_name",
"contact_email",
"is_active",
]
[docs]
def __init__(self, *args: object, **kwargs: object) -> None:
"""Initialise the form and disable synced fields when locked by PSF sync."""
self.is_synced: bool = kwargs.pop("is_synced", False) # type: ignore[arg-type]
super().__init__(*args, **kwargs)
if self.is_synced:
for field_name in self.SYNCED_FIELDS:
if field_name in self.fields:
self.fields[field_name].disabled = True
[docs]
class ActivityForm(forms.ModelForm):
"""Form for editing a conference activity.
The ``slug`` field is excluded because it is auto-generated from
the activity name. The ``room`` field provides an optional link to
a Pretalx-synced room for venue assignment.
"""
[docs]
class Meta:
model = Activity
fields = [
"name",
"activity_type",
"description",
"room",
"location",
"pretalx_submission_type",
"start_time",
"end_time",
"max_participants",
"requires_ticket",
"external_url",
"is_active",
"organizers",
]
widgets = {
"start_time": forms.DateTimeInput(attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"),
"end_time": forms.DateTimeInput(attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"),
"organizers": forms.CheckboxSelectMultiple,
}
[docs]
class ReviewerMessageForm(forms.ModelForm):
"""Form for reviewers to send a message on a travel grant."""
[docs]
class Meta:
model = TravelGrantMessage
fields = ["message", "visible"]
widgets = {
"message": forms.Textarea(attrs={"rows": 3, "placeholder": "Write a message..."}),
}
labels = {
"visible": "Visible to applicant",
}
[docs]
class ReceiptFlagForm(forms.Form):
"""Form for flagging a receipt with a reason."""
reason = forms.CharField(
max_length=1024,
widget=forms.Textarea(attrs={"rows": 3, "placeholder": "Reason for flagging this receipt..."}),
)
[docs]
class DisbursementForm(forms.Form):
"""Form for marking a travel grant as disbursed."""
disbursed_amount = forms.DecimalField(
max_digits=10,
decimal_places=2,
help_text="Actual amount disbursed to the grantee.",
)
[docs]
class TicketTypeForm(forms.ModelForm):
"""Form for creating and editing ticket types.
Provides datetime-local widgets for availability windows and auto-populates
the slug from the ticket name on creation.
"""
[docs]
class Meta:
model = TicketType
fields = [
"name",
"slug",
"description",
"price",
"available_from",
"available_until",
"total_quantity",
"limit_per_user",
"requires_voucher",
"is_active",
"bulk_enabled",
"order",
]
widgets = {
"available_from": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"available_until": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class AddOnForm(forms.ModelForm):
"""Form for creating and editing add-ons.
Provides datetime-local widgets for availability windows.
"""
[docs]
class Meta:
model = AddOn
fields = [
"name",
"slug",
"description",
"price",
"available_from",
"available_until",
"total_quantity",
"is_active",
"bulk_enabled",
"order",
]
widgets = {
"available_from": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"available_until": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class VoucherForm(forms.ModelForm):
"""Form for creating and editing vouchers.
Uses checkbox widgets for the many-to-many ticket type and add-on fields
so organizers can quickly select applicable items.
"""
[docs]
class Meta:
model = Voucher
fields = [
"code",
"voucher_type",
"discount_value",
"max_uses",
"valid_from",
"valid_until",
"unlocks_hidden_tickets",
"is_active",
"applicable_ticket_types",
"applicable_addons",
]
widgets = {
"valid_from": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"valid_until": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"applicable_ticket_types": forms.CheckboxSelectMultiple,
"applicable_addons": forms.CheckboxSelectMultiple,
}
[docs]
class ManualPaymentForm(forms.Form):
"""Form for organizers to record a manual payment against an order.
Supports comp, credit, and manual payment methods. The note field
allows attaching context (e.g. check number, approval reference).
"""
amount = forms.DecimalField(
max_digits=10,
decimal_places=2,
help_text="Amount to record for this payment.",
)
method = forms.ChoiceField(
choices=[
(Payment.Method.COMP, "Complimentary"),
(Payment.Method.CREDIT, "Credit"),
(Payment.Method.MANUAL, "Manual"),
],
help_text="Payment method used.",
)
note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2, "placeholder": "Payment note (optional)..."}),
help_text="Optional note about this payment.",
)
[docs]
class TimeOrStockLimitConditionForm(forms.ModelForm):
"""Form for creating and editing time/stock limit conditions.
Provides datetime-local widgets for the time window and checkbox widgets
for the M2M product fields.
"""
[docs]
class Meta:
model = TimeOrStockLimitCondition
fields = [
"name",
"description",
"is_active",
"priority",
"discount_type",
"discount_value",
"max_quantity",
"applicable_ticket_types",
"applicable_addons",
"start_time",
"end_time",
"limit",
]
widgets = {
"start_time": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"end_time": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"applicable_ticket_types": forms.CheckboxSelectMultiple,
"applicable_addons": forms.CheckboxSelectMultiple,
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class SpeakerConditionForm(forms.ModelForm):
"""Form for creating and editing speaker conditions.
Controls whether primary speakers and/or copresenters qualify for the
configured discount.
"""
[docs]
class Meta:
model = SpeakerCondition
fields = [
"name",
"description",
"is_active",
"priority",
"discount_type",
"discount_value",
"max_quantity",
"applicable_ticket_types",
"applicable_addons",
"is_presenter",
"is_copresenter",
]
widgets = {
"applicable_ticket_types": forms.CheckboxSelectMultiple,
"applicable_addons": forms.CheckboxSelectMultiple,
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class GroupMemberConditionForm(forms.ModelForm):
"""Form for creating and editing group member conditions.
Uses checkbox widgets for the groups and product M2M fields.
"""
[docs]
class Meta:
model = GroupMemberCondition
fields = [
"name",
"description",
"is_active",
"priority",
"discount_type",
"discount_value",
"max_quantity",
"applicable_ticket_types",
"applicable_addons",
"groups",
]
widgets = {
"applicable_ticket_types": forms.CheckboxSelectMultiple,
"applicable_addons": forms.CheckboxSelectMultiple,
"groups": forms.CheckboxSelectMultiple,
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class IncludedProductConditionForm(forms.ModelForm):
"""Form for creating and editing included product conditions.
Uses checkbox widgets for both the enabling and target product M2M fields.
"""
[docs]
class Meta:
model = IncludedProductCondition
fields = [
"name",
"description",
"is_active",
"priority",
"discount_type",
"discount_value",
"max_quantity",
"applicable_ticket_types",
"applicable_addons",
"enabling_ticket_types",
]
widgets = {
"applicable_ticket_types": forms.CheckboxSelectMultiple,
"applicable_addons": forms.CheckboxSelectMultiple,
"enabling_ticket_types": forms.CheckboxSelectMultiple,
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class DiscountForProductForm(forms.ModelForm):
"""Form for creating and editing direct product discounts.
Provides datetime-local widgets for the time window and checkbox widgets
for the M2M product fields.
"""
[docs]
class Meta:
model = DiscountForProduct
fields = [
"name",
"description",
"is_active",
"priority",
"discount_type",
"discount_value",
"max_quantity",
"applicable_ticket_types",
"applicable_addons",
"start_time",
"end_time",
"limit",
]
widgets = {
"start_time": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"end_time": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"applicable_ticket_types": forms.CheckboxSelectMultiple,
"applicable_addons": forms.CheckboxSelectMultiple,
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class DiscountForCategoryForm(forms.ModelForm):
"""Form for creating and editing category-wide percentage discounts.
Provides datetime-local widgets for the time window and boolean toggles
for ticket/add-on category targeting.
"""
[docs]
class Meta:
model = DiscountForCategory
fields = [
"name",
"description",
"is_active",
"priority",
"percentage",
"apply_to_tickets",
"apply_to_addons",
"start_time",
"end_time",
"limit",
]
widgets = {
"start_time": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"end_time": forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
"description": forms.Textarea(attrs={"rows": 3}),
}
[docs]
class BadgeTemplateForm(forms.ModelForm):
"""Form for creating and editing badge templates.
Provides color input widgets for the color fields and a file input
for the optional logo upload.
"""
[docs]
class Meta:
model = BadgeTemplate
fields = [
"name",
"slug",
"is_default",
"width_mm",
"height_mm",
"show_name",
"show_email",
"show_company",
"show_ticket_type",
"show_qr_code",
"show_conference_name",
"ticket_banner_position",
"font_name",
"font_body",
"background_color",
"text_color",
"accent_color",
"logo",
"background_image",
]
widgets = {
"background_color": forms.TextInput(attrs={"type": "color"}),
"text_color": forms.TextInput(attrs={"type": "color"}),
"accent_color": forms.TextInput(attrs={"type": "color"}),
}
[docs]
class ExpenseForm(forms.ModelForm):
"""Form for creating and editing individual expenses."""
[docs]
class Meta:
model = Expense
fields = ["category", "description", "amount", "vendor", "date", "receipt_reference", "notes"]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
"date": forms.DateInput(attrs={"type": "date"}),
}
[docs]
def __init__(self, *args: object, **kwargs: object) -> None:
"""Initialise the form, scoping category choices to the conference."""
self.conference = kwargs.pop("conference", None)
super().__init__(*args, **kwargs)
if self.conference:
self.fields["category"].queryset = ExpenseCategory.objects.filter(conference=self.conference)