Source code for django_program.programs.forms
"""Forms for the programs app."""
import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from django import forms
from django_program.conference.models import Section
from django_program.programs.models import PaymentInfo, Receipt, TravelGrant, TravelGrantMessage
from django_program.programs.utils import get_conference_days
from django_program.settings import get_config
if TYPE_CHECKING:
from django_program.conference.models import Conference
[docs]
class TravelGrantApplicationForm(forms.ModelForm):
"""Public-facing form for applying for a travel grant.
Mirrors PyCon US's travel grant application flow. Collects
request type, travel plan breakdowns (airfare + lodging), applicant
profile, and community involvement. The ``conference`` and ``user``
fields are set by the view, not the form.
"""
days_attending = forms.MultipleChoiceField(
choices=[],
widget=forms.CheckboxSelectMultiple,
required=False,
label="Which days do you plan to attend?",
)
[docs]
class Meta:
model = TravelGrant
fields = [
"request_type",
"application_type",
"travel_from",
"international",
"first_time",
"days_attending",
"travel_plans_airfare_description",
"travel_plans_airfare_amount",
"travel_plans_lodging_description",
"travel_plans_lodging_amount",
"requested_amount",
"sharing_expenses",
"traveling_with",
"experience_level",
"occupation",
"involvement",
"reason",
]
widgets = {
"request_type": forms.RadioSelect,
"application_type": forms.Select,
"experience_level": forms.Select,
"first_time": forms.Select(
choices=[(None, "---"), (True, "Yes"), (False, "No")],
),
"international": forms.CheckboxInput,
"sharing_expenses": forms.CheckboxInput,
"travel_plans_airfare_amount": forms.NumberInput(
attrs={"step": "0.01", "min": "0", "class": "amount-input"},
),
"travel_plans_lodging_amount": forms.NumberInput(
attrs={"step": "0.01", "min": "0", "class": "amount-input"},
),
"requested_amount": forms.NumberInput(
attrs={"step": "0.01", "min": "0.01"},
),
"travel_plans_airfare_description": forms.TextInput(
attrs={"placeholder": "e.g. Round-trip flight from Chicago, IL to Pittsburgh, PA"},
),
"travel_plans_lodging_description": forms.TextInput(
attrs={"placeholder": "e.g. 4 nights at the conference hotel"},
),
"reason": forms.Textarea(attrs={"rows": 4}),
"involvement": forms.Textarea(attrs={"rows": 3}),
"occupation": forms.Textarea(attrs={"rows": 2}),
"traveling_with": forms.Textarea(attrs={"rows": 2}),
}
labels = {
"request_type": "What are you requesting?",
"application_type": "Application type",
"travel_from": "Traveling from",
"international": "International travel",
"first_time": "First time attending this conference?",
"days_attending": "Which days do you plan to attend?",
"travel_plans_airfare_description": "Airfare details",
"travel_plans_airfare_amount": "Airfare amount (USD)",
"travel_plans_lodging_description": "Lodging details",
"travel_plans_lodging_amount": "Lodging amount (USD)",
"requested_amount": "Total amount requested (USD)",
"sharing_expenses": "Sharing travel expenses with another applicant?",
"traveling_with": "Who are you traveling with?",
"experience_level": "Python experience level",
"occupation": "Current occupation or situation",
"involvement": "Community involvement",
"reason": "Why do you need a travel grant?",
}
[docs]
def __init__(self, *args: object, conference: Conference | None = None, **kwargs: object) -> None:
"""Set required fields, placeholders, and dynamic day choices."""
super().__init__(*args, **kwargs)
self.fields["experience_level"].required = True
self.fields["occupation"].required = True
self.fields["involvement"].required = True
self.fields["travel_from"].widget.attrs["placeholder"] = "City, State/Country"
# Prefer schedule-derived day choices (richer labels from actual Pretalx
# data), falling back to section-based choices when no schedule exists.
if conference:
schedule_choices = get_conference_days(conference)
if schedule_choices:
self.fields["days_attending"].choices = schedule_choices
elif conference.start_date and conference.end_date:
day_choices = self._build_day_choices(conference)
self.fields["days_attending"].choices = day_choices
# Pre-populate days_attending checkboxes from comma-separated model value
if self.instance and self.instance.pk and self.instance.days_attending:
self.initial["days_attending"] = [d.strip() for d in self.instance.days_attending.split(",") if d.strip()]
[docs]
def clean_days_attending(self) -> str:
"""Serialize checkbox selections to comma-separated string for storage."""
values = self.cleaned_data.get("days_attending", [])
return ",".join(values)
[docs]
def clean_requested_amount(self) -> Decimal:
"""Validate that the requested amount is positive."""
amount = self.cleaned_data["requested_amount"]
if amount is not None and amount <= 0:
msg = "Requested amount must be greater than zero."
raise forms.ValidationError(msg)
return amount
[docs]
def clean(self) -> dict[str, object]:
"""Cross-field validation matching PyCon's patterns."""
cleaned = super().clean()
request_type = cleaned.get("request_type")
amount = cleaned.get("requested_amount", Decimal(0))
if request_type == TravelGrant.RequestType.TICKET_AND_GRANT:
if amount is not None and amount < 1:
self.add_error(
"requested_amount",
"You must specify an amount when requesting a travel grant. "
'If you only need a ticket, select "In-Person Ticket Only".',
)
max_amount = get_config().max_grant_amount
if amount is not None and amount > max_amount:
self.add_error(
"requested_amount",
f"Total requested amount must be less than ${max_amount:,} USD.",
)
airfare_amount = cleaned.get("travel_plans_airfare_amount", Decimal(0))
airfare_desc = cleaned.get("travel_plans_airfare_description", "")
if airfare_amount and not airfare_desc:
self.add_error(
"travel_plans_airfare_description",
"Please describe your airfare plans when requesting airfare funds.",
)
lodging_amount = cleaned.get("travel_plans_lodging_amount", Decimal(0))
lodging_desc = cleaned.get("travel_plans_lodging_description", "")
if lodging_amount and not lodging_desc:
self.add_error(
"travel_plans_lodging_description",
"Please describe your lodging plans when requesting lodging funds.",
)
return cleaned
@staticmethod
def _build_day_choices(conference: Conference) -> list[tuple[str, str]]:
"""Build labeled day choices from conference sections.
Uses sections (Tutorials, Talks, Sprints, etc.) to label each day.
Falls back to generic "Day N" labels for days not covered by any section.
"""
sections = list(Section.objects.filter(conference=conference).order_by("order", "start_date"))
# Map each date to its section
date_section: dict[datetime.date, tuple[str, int]] = {}
for section in sections:
day_num = 1
current = section.start_date
while current <= section.end_date:
if current not in date_section:
date_section[current] = (str(section.name), day_num)
day_num += 1
current += datetime.timedelta(days=1)
day_choices: list[tuple[str, str]] = []
current = conference.start_date
generic_num = 1
while current <= conference.end_date:
date_str = current.strftime("%A, %B %-d")
if current in date_section:
section_name, section_day = date_section[current]
label = f"{section_name} Day {section_day} — {date_str}"
else:
label = f"Day {generic_num} — {date_str}"
day_choices.append((current.isoformat(), label))
current += datetime.timedelta(days=1)
generic_num += 1
return day_choices
[docs]
class ReceiptForm(forms.ModelForm):
"""Form for uploading an expense receipt."""
[docs]
class Meta:
model = Receipt
fields = ["receipt_type", "date", "amount", "description", "receipt_file"]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}),
"amount": forms.NumberInput(attrs={"step": "0.01", "min": "0.01"}),
"description": forms.TextInput(attrs={"placeholder": "Brief description of expense"}),
}
[docs]
def clean_receipt_file(self) -> object:
"""Enforce a 10 MB file size limit."""
f = self.cleaned_data.get("receipt_file")
if f and hasattr(f, "size") and f.size > 10 * 1024 * 1024:
msg = "File size must be under 10 MB."
raise forms.ValidationError(msg)
return f
[docs]
class PaymentInfoForm(forms.ModelForm):
"""Form for submitting payment details for reimbursement."""
[docs]
class Meta:
model = PaymentInfo
fields = [
"payment_method",
"legal_name",
"address_street",
"address_city",
"address_state",
"address_zip",
"address_country",
"paypal_email",
"zelle_email",
"wise_email",
"bank_name",
"bank_account_number",
"bank_routing_number",
"bank_holder_name",
"bank_holder_address",
"bank_address",
"bank_additional",
]
widgets = {
"bank_holder_address": forms.Textarea(attrs={"rows": 2}),
"bank_address": forms.Textarea(attrs={"rows": 2}),
"bank_additional": forms.Textarea(attrs={"rows": 2, "placeholder": "Sort code, IBAN, SWIFT/BIC, etc."}),
}
[docs]
def clean(self) -> dict[str, object]:
"""Validate that method-specific fields are provided."""
cleaned = super().clean()
method = cleaned.get("payment_method")
if method == PaymentInfo.PaymentMethod.PAYPAL and not cleaned.get("paypal_email"):
self.add_error("paypal_email", "PayPal email is required for PayPal payments.")
elif method == PaymentInfo.PaymentMethod.ZELLE and not cleaned.get("zelle_email"):
self.add_error("zelle_email", "Zelle email is required for Zelle payments.")
elif method == PaymentInfo.PaymentMethod.WISE and not cleaned.get("wise_email"):
self.add_error("wise_email", "Wise email is required for Wise payments.")
elif method in (PaymentInfo.PaymentMethod.ACH, PaymentInfo.PaymentMethod.WIRE):
if not cleaned.get("bank_name"):
self.add_error("bank_name", "Bank name is required for bank transfers.")
if not cleaned.get("bank_account_number"):
self.add_error("bank_account_number", "Account number is required for bank transfers.")
if not cleaned.get("bank_routing_number"):
self.add_error("bank_routing_number", "Routing number is required for bank transfers.")
return cleaned