diff --git a/src/django_program/conference/migrations/0005_merge_0004_add_feature_flags_0004_add_total_capacity.py b/src/django_program/conference/migrations/0005_merge_0004_add_feature_flags_0004_add_total_capacity.py
new file mode 100644
index 0000000..20bb29f
--- /dev/null
+++ b/src/django_program/conference/migrations/0005_merge_0004_add_feature_flags_0004_add_total_capacity.py
@@ -0,0 +1,12 @@
+# Generated by Django 5.2.11 on 2026-02-14 04:49
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("program_conference", "0004_add_feature_flags"),
+ ("program_conference", "0004_add_total_capacity"),
+ ]
+
+ operations = []
diff --git a/src/django_program/manage/forms_overrides.py b/src/django_program/manage/forms_overrides.py
new file mode 100644
index 0000000..b1115d8
--- /dev/null
+++ b/src/django_program/manage/forms_overrides.py
@@ -0,0 +1,285 @@
+"""Model forms for Pretalx overrides and submission type defaults."""
+
+from django import forms
+from django.core.exceptions import ValidationError
+
+from django_program.pretalx.models import (
+ Room,
+ RoomOverride,
+ Speaker,
+ SpeakerOverride,
+ SubmissionTypeDefault,
+ Talk,
+ TalkOverride,
+)
+from django_program.sponsors.models import Sponsor, SponsorLevel, SponsorOverride
+
+
+class TalkLabelMixin:
+ """Format Talk choices as 'Title [Type] (state)' for searchability."""
+
+ def label_from_instance(self, obj: Talk) -> str:
+ """Return a descriptive label for the talk option."""
+ parts: list[str] = [str(obj.title)]
+ if obj.submission_type:
+ parts.append(f"[{obj.submission_type}]")
+ if obj.state:
+ parts.append(f"({obj.state})")
+ return " ".join(parts)
+
+
+class TalkChoiceField(TalkLabelMixin, forms.ModelChoiceField):
+ """ModelChoiceField that renders Talk options with type and state."""
+
+
+class SpeakerLabelMixin:
+ """Format Speaker choices as 'Name (email)' for searchability."""
+
+ def label_from_instance(self, obj: Speaker) -> str:
+ """Return a descriptive label for the speaker option."""
+ parts: list[str] = [str(obj.name)]
+ if obj.email:
+ parts.append(f"({obj.email})")
+ return " ".join(parts)
+
+
+class SpeakerChoiceField(SpeakerLabelMixin, forms.ModelChoiceField):
+ """ModelChoiceField that renders Speaker options with email."""
+
+
+class RoomLabelMixin:
+ """Format Room choices as 'Name [capacity]' for searchability."""
+
+ def label_from_instance(self, obj: Room) -> str:
+ """Return a descriptive label for the room option."""
+ parts: list[str] = [str(obj.name)]
+ if obj.capacity:
+ parts.append(f"[{obj.capacity}]")
+ return " ".join(parts)
+
+
+class RoomChoiceField(RoomLabelMixin, forms.ModelChoiceField):
+ """ModelChoiceField that renders Room options with capacity."""
+
+
+class SponsorLabelMixin:
+ """Format Sponsor choices as 'Name (Level)' for searchability."""
+
+ def label_from_instance(self, obj: Sponsor) -> str:
+ """Return a descriptive label for the sponsor option."""
+ return f"{obj.name} ({obj.level.name})"
+
+
+class SponsorChoiceField(SponsorLabelMixin, forms.ModelChoiceField):
+ """ModelChoiceField that renders Sponsor options with level."""
+
+
+class TalkOverrideForm(forms.ModelForm):
+ """Form for creating or editing a talk override."""
+
+ talk = TalkChoiceField(queryset=Talk.objects.none())
+
+ class Meta:
+ model = TalkOverride
+ fields = [
+ "talk",
+ "override_room",
+ "override_title",
+ "override_state",
+ "override_slot_start",
+ "override_slot_end",
+ "override_abstract",
+ "is_cancelled",
+ "note",
+ ]
+ widgets = {
+ "override_slot_start": forms.DateTimeInput(
+ attrs={"type": "datetime-local"},
+ format="%Y-%m-%dT%H:%M",
+ ),
+ "override_slot_end": forms.DateTimeInput(
+ attrs={"type": "datetime-local"},
+ format="%Y-%m-%dT%H:%M",
+ ),
+ "override_abstract": forms.Textarea(attrs={"rows": 4}),
+ "note": forms.Textarea(attrs={"rows": 3}),
+ }
+
+ def __init__(self, *args: object, conference: object = None, is_edit: bool = False, **kwargs: object) -> None:
+ """Scope choice querysets to the given conference."""
+ super().__init__(*args, **kwargs)
+ if conference is not None:
+ self.fields["talk"].queryset = Talk.objects.filter(conference=conference).order_by(
+ "submission_type", "title"
+ )
+ self.fields["override_room"].queryset = Room.objects.filter(conference=conference)
+
+ if is_edit:
+ self.fields["talk"].disabled = True
+
+
+class SpeakerOverrideForm(forms.ModelForm):
+ """Form for creating or editing a speaker override."""
+
+ speaker = SpeakerChoiceField(queryset=Speaker.objects.none())
+
+ class Meta:
+ model = SpeakerOverride
+ fields = [
+ "speaker",
+ "override_name",
+ "override_biography",
+ "override_avatar_url",
+ "override_email",
+ "note",
+ ]
+ widgets = {
+ "override_biography": forms.Textarea(attrs={"rows": 4}),
+ "note": forms.Textarea(attrs={"rows": 3}),
+ }
+
+ def __init__(self, *args: object, conference: object = None, is_edit: bool = False, **kwargs: object) -> None:
+ """Scope choice querysets to the given conference."""
+ super().__init__(*args, **kwargs)
+ if conference is not None:
+ self.fields["speaker"].queryset = Speaker.objects.filter(conference=conference).order_by("name")
+
+ if is_edit:
+ self.fields["speaker"].disabled = True
+
+
+class RoomOverrideForm(forms.ModelForm):
+ """Form for creating or editing a room override."""
+
+ room = RoomChoiceField(queryset=Room.objects.none())
+
+ class Meta:
+ model = RoomOverride
+ fields = [
+ "room",
+ "override_name",
+ "override_description",
+ "override_capacity",
+ "note",
+ ]
+ widgets = {
+ "override_description": forms.Textarea(attrs={"rows": 4}),
+ "note": forms.Textarea(attrs={"rows": 3}),
+ }
+
+ def __init__(self, *args: object, conference: object = None, is_edit: bool = False, **kwargs: object) -> None:
+ """Scope choice querysets to the given conference."""
+ super().__init__(*args, **kwargs)
+ if conference is not None:
+ self.fields["room"].queryset = Room.objects.filter(conference=conference).order_by("position", "name")
+
+ if is_edit:
+ self.fields["room"].disabled = True
+
+
+class SponsorOverrideForm(forms.ModelForm):
+ """Form for creating or editing a sponsor override."""
+
+ sponsor = SponsorChoiceField(queryset=Sponsor.objects.none())
+
+ class Meta:
+ model = SponsorOverride
+ fields = [
+ "sponsor",
+ "override_name",
+ "override_description",
+ "override_website_url",
+ "override_logo_url",
+ "override_contact_name",
+ "override_contact_email",
+ "override_is_active",
+ "override_level",
+ "note",
+ ]
+ widgets = {
+ "override_description": forms.Textarea(attrs={"rows": 4}),
+ "note": forms.Textarea(attrs={"rows": 3}),
+ "override_is_active": forms.Select(
+ choices=((None, "Unknown"), (True, "Yes"), (False, "No")),
+ ),
+ }
+
+ def __init__(self, *args: object, conference: object = None, is_edit: bool = False, **kwargs: object) -> None:
+ """Scope choice querysets to the given conference."""
+ super().__init__(*args, **kwargs)
+ if conference is not None:
+ self.fields["sponsor"].queryset = (
+ Sponsor.objects.filter(conference=conference).select_related("level").order_by("name")
+ )
+ self.fields["override_level"].queryset = SponsorLevel.objects.filter(conference=conference).order_by(
+ "order"
+ )
+
+ if is_edit:
+ self.fields["sponsor"].disabled = True
+
+ def clean_override_is_active(self) -> bool | None:
+ """Convert Select widget string values to Python None/True/False."""
+ value = self.data.get("override_is_active", "")
+ if value == "" or value is None:
+ return None
+ if value == "True" or value is True:
+ return True
+ if value == "False" or value is False:
+ return False
+ return None
+
+
+class SubmissionTypeDefaultForm(forms.ModelForm):
+ """Form for creating or editing submission type default assignments."""
+
+ class Meta:
+ model = SubmissionTypeDefault
+ fields = [
+ "submission_type",
+ "default_room",
+ "default_date",
+ "default_start_time",
+ "default_end_time",
+ ]
+ widgets = {
+ "default_date": forms.DateInput(attrs={"type": "date"}),
+ "default_start_time": forms.TimeInput(attrs={"type": "time"}),
+ "default_end_time": forms.TimeInput(attrs={"type": "time"}),
+ }
+
+ def __init__(self, *args: object, conference: object = None, **kwargs: object) -> None:
+ """Scope choice querysets to the given conference."""
+ super().__init__(*args, **kwargs)
+ if conference is not None:
+ self.fields["default_room"].queryset = Room.objects.filter(conference=conference)
+
+ def clean(self) -> dict[str, object]:
+ """Validate time field consistency.
+
+ Ensures that:
+ - ``default_date`` is required when either time field is set (times
+ without a date are silently ignored by ``apply_type_defaults``).
+ - ``default_start_time`` and ``default_end_time`` must be provided as
+ a pair or not at all (partial time ranges are invalid).
+ """
+ cleaned = super().clean()
+ start_time = cleaned.get("default_start_time")
+ end_time = cleaned.get("default_end_time")
+ has_date = cleaned.get("default_date") is not None
+
+ if (start_time or end_time) and not has_date:
+ raise ValidationError(
+ {"default_date": "A date is required when start or end time is set."},
+ )
+
+ if bool(start_time) != bool(end_time):
+ msg = "Both start and end time are required when either is set."
+ errors: dict[str, str] = {}
+ if not start_time:
+ errors["default_start_time"] = msg
+ if not end_time:
+ errors["default_end_time"] = msg
+ raise ValidationError(errors)
+
+ return cleaned
diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html
index acd0bf4..39a2192 100644
--- a/src/django_program/manage/templates/django_program/manage/base.html
+++ b/src/django_program/manage/templates/django_program/manage/base.html
@@ -1045,6 +1045,23 @@
Schedule
+
+
+
+
+{% if talk_override %}
+
+
+
Active Override
+
+ {% if talk_override.override_room %}
+
+
Room Override
+
{{ talk_override.override_room.name }}
+
+ {% endif %}
+ {% if talk_override.override_title %}
+
+
Title Override
+
{{ talk_override.override_title }}
+
+ {% endif %}
+ {% if talk_override.override_state %}
+
+
State Override
+
{{ talk_override.override_state }}
+
+ {% endif %}
+ {% if talk_override.is_cancelled %}
+
+ {% endif %}
+ {% if talk_override.override_slot_start %}
+
+
Start Override
+
{{ talk_override.override_slot_start|date:"N j, H:i" }}
+
+ {% endif %}
+ {% if talk_override.note %}
+
+
Note
+
{{ talk_override.note }}
+
+ {% endif %}
+
+
+
+
+{% endif %}
+
{% if talk.abstract %}
diff --git a/src/django_program/manage/templates/django_program/manage/talk_list.html b/src/django_program/manage/templates/django_program/manage/talk_list.html
index f56b08c..1bde1f6 100644
--- a/src/django_program/manage/templates/django_program/manage/talk_list.html
+++ b/src/django_program/manage/templates/django_program/manage/talk_list.html
@@ -75,8 +75,13 @@
{% if current_type %}{{ current_type }}{% else %}Talks{% endif %}
Unscheduled
{% endif %}
-
+ |
Edit
+ {% if talk.override %}
+ Override
+ {% else %}
+ + Override
+ {% endif %}
|
{% endfor %}
diff --git a/src/django_program/manage/urls.py b/src/django_program/manage/urls.py
index 311405a..d4fbca2 100644
--- a/src/django_program/manage/urls.py
+++ b/src/django_program/manage/urls.py
@@ -199,4 +199,6 @@
path("
/vouchers/bulk/", include("django_program.manage.urls_vouchers")),
# --- Financial Dashboard ---
path("/financial/", include("django_program.manage.urls_financial")),
+ # --- Pretalx Overrides ---
+ path("/overrides/", include("django_program.manage.urls_overrides")),
]
diff --git a/src/django_program/manage/urls_overrides.py b/src/django_program/manage/urls_overrides.py
new file mode 100644
index 0000000..c155004
--- /dev/null
+++ b/src/django_program/manage/urls_overrides.py
@@ -0,0 +1,48 @@
+"""URL patterns for overrides and submission type defaults.
+
+Included from the main management URL config under the
+``/overrides/`` prefix.
+"""
+
+from django.urls import path
+
+from django_program.manage.views_overrides import (
+ RoomOverrideCreateView,
+ RoomOverrideEditView,
+ RoomOverrideListView,
+ SpeakerOverrideCreateView,
+ SpeakerOverrideEditView,
+ SpeakerOverrideListView,
+ SponsorOverrideCreateView,
+ SponsorOverrideEditView,
+ SponsorOverrideListView,
+ SubmissionTypeDefaultCreateView,
+ SubmissionTypeDefaultEditView,
+ SubmissionTypeDefaultListView,
+ TalkOverrideCreateView,
+ TalkOverrideEditView,
+ TalkOverrideListView,
+)
+
+urlpatterns = [
+ # Talk overrides
+ path("talks/", TalkOverrideListView.as_view(), name="override-list"),
+ path("talks/add/", TalkOverrideCreateView.as_view(), name="override-add"),
+ path("talks//edit/", TalkOverrideEditView.as_view(), name="override-edit"),
+ # Speaker overrides
+ path("speakers/", SpeakerOverrideListView.as_view(), name="speaker-override-list"),
+ path("speakers/add/", SpeakerOverrideCreateView.as_view(), name="speaker-override-add"),
+ path("speakers//edit/", SpeakerOverrideEditView.as_view(), name="speaker-override-edit"),
+ # Room overrides
+ path("rooms/", RoomOverrideListView.as_view(), name="room-override-list"),
+ path("rooms/add/", RoomOverrideCreateView.as_view(), name="room-override-add"),
+ path("rooms//edit/", RoomOverrideEditView.as_view(), name="room-override-edit"),
+ # Sponsor overrides
+ path("sponsors/", SponsorOverrideListView.as_view(), name="sponsor-override-list"),
+ path("sponsors/add/", SponsorOverrideCreateView.as_view(), name="sponsor-override-add"),
+ path("sponsors//edit/", SponsorOverrideEditView.as_view(), name="sponsor-override-edit"),
+ # Submission type defaults
+ path("type-defaults/", SubmissionTypeDefaultListView.as_view(), name="type-default-list"),
+ path("type-defaults/add/", SubmissionTypeDefaultCreateView.as_view(), name="type-default-add"),
+ path("type-defaults//edit/", SubmissionTypeDefaultEditView.as_view(), name="type-default-edit"),
+]
diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py
index 63a1127..cf3e8a2 100644
--- a/src/django_program/manage/views.py
+++ b/src/django_program/manage/views.py
@@ -20,7 +20,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import models, transaction
-from django.db.models import Case, Count, F, Q, QuerySet, Sum, Value, When
+from django.db.models import Case, Count, F, Prefetch, Q, QuerySet, Sum, Value, When
from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
@@ -50,7 +50,7 @@
TravelGrantForm,
VoucherForm,
)
-from django_program.pretalx.models import Room, ScheduleSlot, Speaker, Talk
+from django_program.pretalx.models import Room, ScheduleSlot, Speaker, Talk, TalkOverride
from django_program.pretalx.sync import PretalxSyncService
from django_program.programs.models import Activity, ActivitySignup, Receipt, TravelGrant, TravelGrantMessage
from django_program.registration.models import AddOn, Order, Payment, TicketType, Voucher
@@ -866,7 +866,7 @@ def get_queryset(self) -> QuerySet[Room]:
Returns:
A queryset of Room instances ordered by position.
"""
- return Room.objects.filter(conference=self.conference).order_by("position", "name")
+ return Room.objects.filter(conference=self.conference).select_related("override").order_by("position", "name")
class RoomEditView(ManagePermissionMixin, UpdateView):
@@ -975,7 +975,11 @@ def get_queryset(self) -> QuerySet[Speaker]:
Returns:
A queryset of Speaker instances for this conference.
"""
- qs = Speaker.objects.filter(conference=self.conference).annotate(talk_count=Count("talks", distinct=True))
+ qs = (
+ Speaker.objects.filter(conference=self.conference)
+ .select_related("override")
+ .annotate(talk_count=Count("talks", distinct=True))
+ )
query = self.request.GET.get("q", "").strip()
if query:
qs = qs.filter(Q(name__icontains=query) | Q(email__icontains=query))
@@ -1003,6 +1007,7 @@ def get_queryset(self) -> QuerySet[Speaker]:
"""Scope speaker lookup to the current conference and preload talks."""
return (
Speaker.objects.filter(conference=self.conference)
+ .select_related("override")
.prefetch_related("talks")
.annotate(talk_count=Count("talks", distinct=True))
)
@@ -1057,7 +1062,7 @@ def get_queryset(self) -> QuerySet[Talk]:
"""
qs = (
Talk.objects.filter(conference=self.conference)
- .select_related("room")
+ .select_related("room", "override")
.prefetch_related("speakers")
.order_by("slot_start", "title")
)
@@ -1107,15 +1112,25 @@ def get_queryset(self) -> QuerySet[Talk]:
"""Scope talk lookup to conference and preload related speaker/room data."""
return (
Talk.objects.filter(conference=self.conference)
- .select_related("room")
- .prefetch_related("speakers", "schedule_slots")
+ .select_related("room", "override")
+ .prefetch_related(
+ "speakers",
+ Prefetch(
+ "schedule_slots",
+ queryset=ScheduleSlot.objects.select_related("room").order_by("start"),
+ ),
+ )
)
def get_context_data(self, **kwargs: object) -> dict[str, object]:
- """Add active nav and ordered schedule slots for this talk."""
+ """Add active nav, schedule slots, and override info for this talk."""
context = super().get_context_data(**kwargs)
context["active_nav"] = "talks"
- context["talk_slots"] = self.object.schedule_slots.select_related("room").order_by("start")
+ context["talk_slots"] = self.object.schedule_slots.all()
+ try:
+ context["talk_override"] = self.object.override
+ except TalkOverride.DoesNotExist:
+ context["talk_override"] = None
return context
@@ -1362,7 +1377,9 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]:
def get_queryset(self) -> QuerySet[Sponsor]:
"""Return sponsors for the current conference."""
return (
- Sponsor.objects.filter(conference=self.conference).select_related("level").order_by("level__order", "name")
+ Sponsor.objects.filter(conference=self.conference)
+ .select_related("level", "override")
+ .order_by("level__order", "name")
)
diff --git a/src/django_program/manage/views_overrides.py b/src/django_program/manage/views_overrides.py
new file mode 100644
index 0000000..a44213b
--- /dev/null
+++ b/src/django_program/manage/views_overrides.py
@@ -0,0 +1,556 @@
+"""Views for managing Pretalx overrides and submission type defaults."""
+
+from typing import TYPE_CHECKING, Any
+
+from django.contrib import messages
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.views.generic import CreateView, ListView, UpdateView
+
+from django_program.manage.forms_overrides import (
+ RoomOverrideForm,
+ SpeakerOverrideForm,
+ SponsorOverrideForm,
+ SubmissionTypeDefaultForm,
+ TalkOverrideForm,
+)
+from django_program.manage.views import ManagePermissionMixin
+from django_program.pretalx.models import RoomOverride, SpeakerOverride, SubmissionTypeDefault, TalkOverride
+from django_program.sponsors.models import SponsorOverride
+
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+ from django.http import HttpResponse
+
+
+# ===========================================================================
+# Talk Overrides
+# ===========================================================================
+
+
+class TalkOverrideListView(ManagePermissionMixin, ListView):
+ """List all talk overrides for the current conference."""
+
+ template_name = "django_program/manage/override_list.html"
+ context_object_name = "overrides"
+ paginate_by = 50
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "talks"
+ return context
+
+ def get_queryset(self) -> QuerySet[TalkOverride]:
+ """Return overrides filtered to the current conference."""
+ return (
+ TalkOverride.objects.filter(conference=self.conference)
+ .select_related("talk", "override_room", "created_by")
+ .order_by("-updated_at")
+ )
+
+
+class TalkOverrideCreateView(ManagePermissionMixin, CreateView):
+ """Create a new talk override for the current conference."""
+
+ template_name = "django_program/manage/override_form.html"
+ form_class = TalkOverrideForm
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "talks"
+ context["is_create"] = True
+ return context
+
+ def get_initial(self) -> dict[str, Any]:
+ """Pre-populate the form from query parameters."""
+ initial = super().get_initial()
+ talk_pk = self.request.GET.get("talk")
+ if talk_pk:
+ initial["talk"] = talk_pk
+ return initial
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = False
+ return kwargs
+
+ def form_valid(self, form: TalkOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ form.instance.conference = self.conference
+ form.instance.created_by = self.request.user
+ messages.success(self.request, "Talk override created.")
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+class TalkOverrideEditView(ManagePermissionMixin, UpdateView):
+ """Edit an existing talk override."""
+
+ template_name = "django_program/manage/override_form.html"
+ form_class = TalkOverrideForm
+
+ def get_queryset(self) -> QuerySet[TalkOverride]:
+ """Return overrides filtered to the current conference."""
+ return TalkOverride.objects.filter(conference=self.conference)
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "talks"
+ context["is_create"] = False
+ return context
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = True
+ return kwargs
+
+ def form_valid(self, form: TalkOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ override = form.save(commit=False)
+ if override.is_empty:
+ override.delete()
+ messages.success(self.request, "Override removed (all fields were empty).")
+ return redirect(self.get_success_url())
+ override.save()
+ messages.success(self.request, "Talk override updated.")
+ return redirect(self.get_success_url())
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+# ===========================================================================
+# Speaker Overrides
+# ===========================================================================
+
+
+class SpeakerOverrideListView(ManagePermissionMixin, ListView):
+ """List all speaker overrides for the current conference."""
+
+ template_name = "django_program/manage/speaker_override_list.html"
+ context_object_name = "overrides"
+ paginate_by = 50
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "speakers"
+ return context
+
+ def get_queryset(self) -> QuerySet[SpeakerOverride]:
+ """Return overrides filtered to the current conference."""
+ return (
+ SpeakerOverride.objects.filter(conference=self.conference)
+ .select_related("speaker", "created_by")
+ .order_by("-updated_at")
+ )
+
+
+class SpeakerOverrideCreateView(ManagePermissionMixin, CreateView):
+ """Create a new speaker override."""
+
+ template_name = "django_program/manage/speaker_override_form.html"
+ form_class = SpeakerOverrideForm
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "speakers"
+ context["is_create"] = True
+ return context
+
+ def get_initial(self) -> dict[str, Any]:
+ """Pre-populate the form from query parameters."""
+ initial = super().get_initial()
+ speaker_pk = self.request.GET.get("speaker")
+ if speaker_pk:
+ initial["speaker"] = speaker_pk
+ return initial
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = False
+ return kwargs
+
+ def form_valid(self, form: SpeakerOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ form.instance.conference = self.conference
+ form.instance.created_by = self.request.user
+ messages.success(self.request, "Speaker override created.")
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:speaker-override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+class SpeakerOverrideEditView(ManagePermissionMixin, UpdateView):
+ """Edit an existing speaker override."""
+
+ template_name = "django_program/manage/speaker_override_form.html"
+ form_class = SpeakerOverrideForm
+
+ def get_queryset(self) -> QuerySet[SpeakerOverride]:
+ """Return overrides filtered to the current conference."""
+ return SpeakerOverride.objects.filter(conference=self.conference)
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "speakers"
+ context["is_create"] = False
+ return context
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = True
+ return kwargs
+
+ def form_valid(self, form: SpeakerOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ override = form.save(commit=False)
+ if override.is_empty:
+ override.delete()
+ messages.success(self.request, "Override removed (all fields were empty).")
+ return redirect(self.get_success_url())
+ override.save()
+ messages.success(self.request, "Speaker override updated.")
+ return redirect(self.get_success_url())
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:speaker-override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+# ===========================================================================
+# Room Overrides
+# ===========================================================================
+
+
+class RoomOverrideListView(ManagePermissionMixin, ListView):
+ """List all room overrides for the current conference."""
+
+ template_name = "django_program/manage/room_override_list.html"
+ context_object_name = "overrides"
+ paginate_by = 50
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "rooms"
+ return context
+
+ def get_queryset(self) -> QuerySet[RoomOverride]:
+ """Return overrides filtered to the current conference."""
+ return (
+ RoomOverride.objects.filter(conference=self.conference)
+ .select_related("room", "created_by")
+ .order_by("-updated_at")
+ )
+
+
+class RoomOverrideCreateView(ManagePermissionMixin, CreateView):
+ """Create a new room override."""
+
+ template_name = "django_program/manage/room_override_form.html"
+ form_class = RoomOverrideForm
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "rooms"
+ context["is_create"] = True
+ return context
+
+ def get_initial(self) -> dict[str, Any]:
+ """Pre-populate the form from query parameters."""
+ initial = super().get_initial()
+ room_pk = self.request.GET.get("room")
+ if room_pk:
+ initial["room"] = room_pk
+ return initial
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = False
+ return kwargs
+
+ def form_valid(self, form: RoomOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ form.instance.conference = self.conference
+ form.instance.created_by = self.request.user
+ messages.success(self.request, "Room override created.")
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:room-override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+class RoomOverrideEditView(ManagePermissionMixin, UpdateView):
+ """Edit an existing room override."""
+
+ template_name = "django_program/manage/room_override_form.html"
+ form_class = RoomOverrideForm
+
+ def get_queryset(self) -> QuerySet[RoomOverride]:
+ """Return overrides filtered to the current conference."""
+ return RoomOverride.objects.filter(conference=self.conference)
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "rooms"
+ context["is_create"] = False
+ return context
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = True
+ return kwargs
+
+ def form_valid(self, form: RoomOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ override = form.save(commit=False)
+ if override.is_empty:
+ override.delete()
+ messages.success(self.request, "Override removed (all fields were empty).")
+ return redirect(self.get_success_url())
+ override.save()
+ messages.success(self.request, "Room override updated.")
+ return redirect(self.get_success_url())
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:room-override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+# ===========================================================================
+# Sponsor Overrides
+# ===========================================================================
+
+
+class SponsorOverrideListView(ManagePermissionMixin, ListView):
+ """List all sponsor overrides for the current conference."""
+
+ template_name = "django_program/manage/sponsor_override_list.html"
+ context_object_name = "overrides"
+ paginate_by = 50
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "sponsors"
+ return context
+
+ def get_queryset(self) -> QuerySet[SponsorOverride]:
+ """Return overrides filtered to the current conference."""
+ return (
+ SponsorOverride.objects.filter(conference=self.conference)
+ .select_related("sponsor", "sponsor__level", "override_level", "created_by")
+ .order_by("-updated_at")
+ )
+
+
+class SponsorOverrideCreateView(ManagePermissionMixin, CreateView):
+ """Create a new sponsor override."""
+
+ template_name = "django_program/manage/sponsor_override_form.html"
+ form_class = SponsorOverrideForm
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "sponsors"
+ context["is_create"] = True
+ return context
+
+ def get_initial(self) -> dict[str, Any]:
+ """Pre-populate the form from query parameters."""
+ initial = super().get_initial()
+ sponsor_pk = self.request.GET.get("sponsor")
+ if sponsor_pk:
+ initial["sponsor"] = sponsor_pk
+ return initial
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = False
+ return kwargs
+
+ def form_valid(self, form: SponsorOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ form.instance.conference = self.conference
+ form.instance.created_by = self.request.user
+ messages.success(self.request, "Sponsor override created.")
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:sponsor-override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+class SponsorOverrideEditView(ManagePermissionMixin, UpdateView):
+ """Edit an existing sponsor override."""
+
+ template_name = "django_program/manage/sponsor_override_form.html"
+ form_class = SponsorOverrideForm
+
+ def get_queryset(self) -> QuerySet[SponsorOverride]:
+ """Return overrides filtered to the current conference."""
+ return SponsorOverride.objects.filter(conference=self.conference)
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "sponsors"
+ context["is_create"] = False
+ return context
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ kwargs["is_edit"] = True
+ return kwargs
+
+ def form_valid(self, form: SponsorOverrideForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ override = form.save(commit=False)
+ if override.is_empty:
+ override.delete()
+ messages.success(self.request, "Override removed (all fields were empty).")
+ return redirect(self.get_success_url())
+ override.save()
+ messages.success(self.request, "Sponsor override updated.")
+ return redirect(self.get_success_url())
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:sponsor-override-list", kwargs={"conference_slug": self.conference.slug})
+
+
+# ===========================================================================
+# Submission Type Defaults
+# ===========================================================================
+
+
+class SubmissionTypeDefaultListView(ManagePermissionMixin, ListView):
+ """List all submission type defaults for the current conference."""
+
+ template_name = "django_program/manage/submission_type_default_list.html"
+ context_object_name = "type_defaults"
+ paginate_by = 50
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "type-defaults"
+ return context
+
+ def get_queryset(self) -> QuerySet[SubmissionTypeDefault]:
+ """Return overrides filtered to the current conference."""
+ return (
+ SubmissionTypeDefault.objects.filter(conference=self.conference)
+ .select_related("default_room")
+ .order_by("submission_type")
+ )
+
+
+class SubmissionTypeDefaultCreateView(ManagePermissionMixin, CreateView):
+ """Create a new submission type default for the current conference."""
+
+ template_name = "django_program/manage/submission_type_default_form.html"
+ form_class = SubmissionTypeDefaultForm
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "type-defaults"
+ context["is_create"] = True
+ return context
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ return kwargs
+
+ def form_valid(self, form: SubmissionTypeDefaultForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ form.instance.conference = self.conference
+ messages.success(self.request, "Submission type default created.")
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:type-default-list", kwargs={"conference_slug": self.conference.slug})
+
+
+class SubmissionTypeDefaultEditView(ManagePermissionMixin, UpdateView):
+ """Edit an existing submission type default."""
+
+ template_name = "django_program/manage/submission_type_default_form.html"
+ form_class = SubmissionTypeDefaultForm
+
+ def get_queryset(self) -> QuerySet[SubmissionTypeDefault]:
+ """Return overrides filtered to the current conference."""
+ return SubmissionTypeDefault.objects.filter(conference=self.conference)
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ """Return template context with navigation state."""
+ context = super().get_context_data(**kwargs)
+ context["active_nav"] = "overrides"
+ context["active_override_tab"] = "type-defaults"
+ context["is_create"] = False
+ return context
+
+ def get_form_kwargs(self) -> dict[str, Any]:
+ """Pass conference and edit mode to the form."""
+ kwargs = super().get_form_kwargs()
+ kwargs["conference"] = self.conference
+ return kwargs
+
+ def form_valid(self, form: SubmissionTypeDefaultForm) -> HttpResponse:
+ """Save the override with conference and user context."""
+ messages.success(self.request, "Submission type default updated.")
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ """Return the URL to redirect to after form submission."""
+ return reverse("manage:type-default-list", kwargs={"conference_slug": self.conference.slug})
diff --git a/src/django_program/pretalx/admin.py b/src/django_program/pretalx/admin.py
index 9cc568e..f6ab74c 100644
--- a/src/django_program/pretalx/admin.py
+++ b/src/django_program/pretalx/admin.py
@@ -2,7 +2,16 @@
from django.contrib import admin
-from django_program.pretalx.models import Room, ScheduleSlot, Speaker, Talk
+from django_program.pretalx.models import (
+ Room,
+ RoomOverride,
+ ScheduleSlot,
+ Speaker,
+ SpeakerOverride,
+ SubmissionTypeDefault,
+ Talk,
+ TalkOverride,
+)
@admin.register(Room)
@@ -17,12 +26,7 @@ class RoomAdmin(admin.ModelAdmin):
@admin.register(Speaker)
class SpeakerAdmin(admin.ModelAdmin):
- """Admin interface for managing speakers synced from Pretalx.
-
- Speaker records are primarily created and updated by the Pretalx sync
- process. The ``pretalx_code`` and sync timestamps are read-only to
- prevent accidental edits that would break the sync link.
- """
+ """Admin interface for managing speakers synced from Pretalx."""
list_display = ("name", "conference", "pretalx_code", "email", "user", "synced_at")
list_filter = ("conference",)
@@ -33,11 +37,7 @@ class SpeakerAdmin(admin.ModelAdmin):
@admin.register(Talk)
class TalkAdmin(admin.ModelAdmin):
- """Admin interface for managing talks synced from Pretalx.
-
- Talks are synced from the Pretalx submissions API. Filtering by submission
- type, track, and state allows quick navigation of large schedules.
- """
+ """Admin interface for managing talks synced from Pretalx."""
list_display = ("title", "conference", "submission_type", "track", "state", "room", "slot_start")
list_filter = ("conference", "submission_type", "track", "state")
@@ -49,12 +49,7 @@ class TalkAdmin(admin.ModelAdmin):
@admin.register(ScheduleSlot)
class ScheduleSlotAdmin(admin.ModelAdmin):
- """Admin interface for managing schedule slots.
-
- Slots represent the full conference timetable including talks, breaks,
- and social events. The ``display_title`` column shows the linked talk
- title when available, falling back to the slot's own title.
- """
+ """Admin interface for managing schedule slots."""
list_display = ("display_title", "conference", "room", "start", "end", "slot_type")
list_filter = ("conference", "slot_type")
@@ -66,3 +61,53 @@ class ScheduleSlotAdmin(admin.ModelAdmin):
def display_title(self, obj: ScheduleSlot) -> str:
"""Return the talk title when linked, otherwise the slot title."""
return obj.display_title
+
+
+@admin.register(TalkOverride)
+class TalkOverrideAdmin(admin.ModelAdmin):
+ """Admin interface for managing talk overrides."""
+
+ list_display = ("talk", "conference", "override_room", "override_state", "is_cancelled", "updated_at")
+ list_filter = ("conference", "is_cancelled")
+ search_fields = ("talk__title", "note")
+ raw_id_fields = ("talk", "override_room", "created_by")
+ readonly_fields = ("created_at", "updated_at")
+
+
+@admin.register(SpeakerOverride)
+class SpeakerOverrideAdmin(admin.ModelAdmin):
+ """Admin interface for managing speaker overrides."""
+
+ list_display = ("speaker", "conference", "override_name", "override_email", "updated_at")
+ list_filter = ("conference",)
+ search_fields = ("speaker__name", "override_name", "note")
+ raw_id_fields = ("speaker", "created_by")
+ readonly_fields = ("created_at", "updated_at")
+
+
+@admin.register(RoomOverride)
+class RoomOverrideAdmin(admin.ModelAdmin):
+ """Admin interface for managing room overrides."""
+
+ list_display = ("room", "conference", "override_name", "override_capacity", "updated_at")
+ list_filter = ("conference",)
+ search_fields = ("room__name", "override_name", "note")
+ raw_id_fields = ("room", "created_by")
+ readonly_fields = ("created_at", "updated_at")
+
+
+@admin.register(SubmissionTypeDefault)
+class SubmissionTypeDefaultAdmin(admin.ModelAdmin):
+ """Admin interface for submission type defaults."""
+
+ list_display = (
+ "submission_type",
+ "conference",
+ "default_room",
+ "default_date",
+ "default_start_time",
+ "default_end_time",
+ )
+ list_filter = ("conference",)
+ search_fields = ("submission_type",)
+ raw_id_fields = ("default_room",)
diff --git a/src/django_program/pretalx/migrations/0006_talkoverride_submissiontypedefault.py b/src/django_program/pretalx/migrations/0006_talkoverride_submissiontypedefault.py
new file mode 100644
index 0000000..e0a44ab
--- /dev/null
+++ b/src/django_program/pretalx/migrations/0006_talkoverride_submissiontypedefault.py
@@ -0,0 +1,149 @@
+# Generated by Django 5.2.11 on 2026-02-14 01:07
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("program_conference", "0003_conference_address"),
+ ("program_pretalx", "0005_alter_room_unique_together_alter_room_pretalx_id_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TalkOverride",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "override_title",
+ models.CharField(blank=True, default="", help_text="Override the talk title.", max_length=500),
+ ),
+ (
+ "override_state",
+ models.CharField(
+ blank=True,
+ default="",
+ help_text="Override the talk state (e.g. confirmed, withdrawn).",
+ max_length=50,
+ ),
+ ),
+ (
+ "override_slot_start",
+ models.DateTimeField(blank=True, help_text="Override the scheduled start time.", null=True),
+ ),
+ (
+ "override_slot_end",
+ models.DateTimeField(blank=True, help_text="Override the scheduled end time.", null=True),
+ ),
+ (
+ "override_abstract",
+ models.TextField(blank=True, default="", help_text="Override the talk abstract."),
+ ),
+ (
+ "is_cancelled",
+ models.BooleanField(
+ default=False, help_text="Mark this talk as cancelled. Overrides the state to 'cancelled'."
+ ),
+ ),
+ (
+ "note",
+ models.TextField(
+ blank=True, default="", help_text="Internal note explaining the reason for this override."
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "conference",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="talk_overrides",
+ to="program_conference.conference",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_talk_overrides",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "override_room",
+ models.ForeignKey(
+ blank=True,
+ help_text="Override the room assignment for this talk.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="talk_overrides",
+ to="program_pretalx.room",
+ ),
+ ),
+ (
+ "talk",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE, related_name="override", to="program_pretalx.talk"
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-updated_at"],
+ },
+ ),
+ migrations.CreateModel(
+ name="SubmissionTypeDefault",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "submission_type",
+ models.CharField(
+ help_text="The Pretalx submission type name to match (e.g. 'Poster', 'Tutorial').",
+ max_length=200,
+ ),
+ ),
+ (
+ "default_date",
+ models.DateField(blank=True, help_text="Default date for talks of this type.", null=True),
+ ),
+ (
+ "default_start_time",
+ models.TimeField(blank=True, help_text="Default start time for talks of this type.", null=True),
+ ),
+ (
+ "default_end_time",
+ models.TimeField(blank=True, help_text="Default end time for talks of this type.", null=True),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "conference",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="submission_type_defaults",
+ to="program_conference.conference",
+ ),
+ ),
+ (
+ "default_room",
+ models.ForeignKey(
+ blank=True,
+ help_text="Default room for talks of this type.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="submission_type_defaults",
+ to="program_pretalx.room",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["submission_type"],
+ "unique_together": {("conference", "submission_type")},
+ },
+ ),
+ ]
diff --git a/src/django_program/pretalx/migrations/0007_alter_talkoverride_conference_and_more.py b/src/django_program/pretalx/migrations/0007_alter_talkoverride_conference_and_more.py
new file mode 100644
index 0000000..8b940e7
--- /dev/null
+++ b/src/django_program/pretalx/migrations/0007_alter_talkoverride_conference_and_more.py
@@ -0,0 +1,154 @@
+# Generated by Django 5.2.11 on 2026-02-14 02:05
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("program_conference", "0003_conference_address"),
+ ("program_pretalx", "0006_talkoverride_submissiontypedefault"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="talkoverride",
+ name="conference",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)ss",
+ to="program_conference.conference",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="talkoverride",
+ name="created_by",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_%(class)ss",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.CreateModel(
+ name="RoomOverride",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "note",
+ models.TextField(
+ blank=True, default="", help_text="Internal note explaining the reason for this override."
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "override_name",
+ models.CharField(blank=True, default="", help_text="Override the room name.", max_length=300),
+ ),
+ (
+ "override_description",
+ models.TextField(blank=True, default="", help_text="Override the room description."),
+ ),
+ (
+ "override_capacity",
+ models.PositiveIntegerField(blank=True, help_text="Override the room capacity.", null=True),
+ ),
+ (
+ "conference",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)ss",
+ to="program_conference.conference",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_%(class)ss",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "room",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE, related_name="override", to="program_pretalx.room"
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-updated_at"],
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="SpeakerOverride",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "note",
+ models.TextField(
+ blank=True, default="", help_text="Internal note explaining the reason for this override."
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "override_name",
+ models.CharField(
+ blank=True, default="", help_text="Override the speaker's display name.", max_length=300
+ ),
+ ),
+ (
+ "override_biography",
+ models.TextField(blank=True, default="", help_text="Override the speaker biography."),
+ ),
+ (
+ "override_avatar_url",
+ models.URLField(blank=True, default="", help_text="Override the speaker avatar URL."),
+ ),
+ (
+ "override_email",
+ models.EmailField(
+ blank=True, default="", help_text="Override the speaker contact email.", max_length=254
+ ),
+ ),
+ (
+ "conference",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)ss",
+ to="program_conference.conference",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_%(class)ss",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "speaker",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="override",
+ to="program_pretalx.speaker",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-updated_at"],
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/src/django_program/pretalx/migrations/0008_alter_roomoverride_conference_and_more.py b/src/django_program/pretalx/migrations/0008_alter_roomoverride_conference_and_more.py
new file mode 100644
index 0000000..47a79c6
--- /dev/null
+++ b/src/django_program/pretalx/migrations/0008_alter_roomoverride_conference_and_more.py
@@ -0,0 +1,76 @@
+# Generated by Django 5.2.11 on 2026-02-14 05:09
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("program_conference", "0005_merge_0004_add_feature_flags_0004_add_total_capacity"),
+ ("program_pretalx", "0007_alter_talkoverride_conference_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="roomoverride",
+ name="conference",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="room_overrides",
+ to="program_conference.conference",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="roomoverride",
+ name="created_by",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_room_overrides",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="speakeroverride",
+ name="conference",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="speaker_overrides",
+ to="program_conference.conference",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="speakeroverride",
+ name="created_by",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_speaker_overrides",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="talkoverride",
+ name="conference",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="talk_overrides",
+ to="program_conference.conference",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="talkoverride",
+ name="created_by",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_talk_overrides",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/src/django_program/pretalx/models.py b/src/django_program/pretalx/models.py
index 6445746..13c39d5 100644
--- a/src/django_program/pretalx/models.py
+++ b/src/django_program/pretalx/models.py
@@ -1,6 +1,12 @@
-"""Speaker, Talk, Room, and ScheduleSlot models synced from Pretalx."""
+"""Speaker, Talk, Room, ScheduleSlot, and override models for Pretalx data."""
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from datetime import datetime
from django.conf import settings
+from django.core.exceptions import ValidationError
from django.db import models
@@ -38,6 +44,39 @@ class Meta:
def __str__(self) -> str:
return self.name
+ @property
+ def effective_name(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_name:
+ return o.override_name
+ except RoomOverride.DoesNotExist:
+ pass
+ return self.name
+
+ @property
+ def effective_description(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_description:
+ return o.override_description
+ except RoomOverride.DoesNotExist:
+ pass
+ return self.description
+
+ @property
+ def effective_capacity(self) -> int | None:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_capacity is not None:
+ return o.override_capacity
+ except RoomOverride.DoesNotExist:
+ pass
+ return self.capacity
+
class Speaker(models.Model):
"""A speaker profile synced from the Pretalx API.
@@ -75,6 +114,50 @@ class Meta:
def __str__(self) -> str:
return self.name
+ @property
+ def effective_name(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_name:
+ return o.override_name
+ except SpeakerOverride.DoesNotExist:
+ pass
+ return self.name
+
+ @property
+ def effective_biography(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_biography:
+ return o.override_biography
+ except SpeakerOverride.DoesNotExist:
+ pass
+ return self.biography
+
+ @property
+ def effective_avatar_url(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_avatar_url:
+ return o.override_avatar_url
+ except SpeakerOverride.DoesNotExist:
+ pass
+ return self.avatar_url
+
+ @property
+ def effective_email(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_email:
+ return o.override_email
+ except SpeakerOverride.DoesNotExist:
+ pass
+ return self.email
+
class Talk(models.Model):
"""A talk submission synced from the Pretalx API.
@@ -123,6 +206,74 @@ class Meta:
def __str__(self) -> str:
return self.title
+ @property
+ def effective_title(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_title:
+ return o.override_title
+ except TalkOverride.DoesNotExist:
+ pass
+ return self.title
+
+ @property
+ def effective_state(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.is_cancelled:
+ return "cancelled"
+ if o.override_state:
+ return o.override_state
+ except TalkOverride.DoesNotExist:
+ pass
+ return self.state
+
+ @property
+ def effective_abstract(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_abstract:
+ return o.override_abstract
+ except TalkOverride.DoesNotExist:
+ pass
+ return self.abstract
+
+ @property
+ def effective_room(self) -> Room | None:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_room_id:
+ return o.override_room
+ except TalkOverride.DoesNotExist:
+ pass
+ return self.room
+
+ @property
+ def effective_slot_start(self) -> datetime | None:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_slot_start is not None:
+ return o.override_slot_start
+ except TalkOverride.DoesNotExist:
+ pass
+ return self.slot_start
+
+ @property
+ def effective_slot_end(self) -> datetime | None:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_slot_end is not None:
+ return o.override_slot_end
+ except TalkOverride.DoesNotExist:
+ pass
+ return self.slot_end
+
class ScheduleSlot(models.Model):
"""A time slot in the conference schedule.
@@ -188,3 +339,307 @@ def display_title(self) -> str:
if self.talk:
return self.talk.title
return self.title
+
+
+class AbstractOverride(models.Model):
+ """Shared base for all override models.
+
+ Provides common metadata fields (note, timestamps) and
+ auto-set-from-parent logic. Concrete subclasses define their own
+ ``conference`` and ``created_by`` ForeignKeys with explicit related names.
+ """
+
+ note = models.TextField(
+ blank=True,
+ default="",
+ help_text="Internal note explaining the reason for this override.",
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
+ ordering = ["-updated_at"]
+
+ # Subclasses must define ``_parent_field`` (e.g. "talk", "speaker").
+ _parent_field: str = ""
+
+ def save(self, *args: object, **kwargs: object) -> None:
+ """Auto-set conference from the linked parent when not explicitly provided."""
+ parent_id = getattr(self, f"{self._parent_field}_id", None)
+ if parent_id and not self.conference_id:
+ self.conference_id = self._get_parent_conference_id()
+ super().save(*args, **kwargs)
+
+ def clean(self) -> None:
+ """Validate that the linked parent belongs to the same conference."""
+ super().clean()
+ parent_id = getattr(self, f"{self._parent_field}_id", None)
+ if parent_id and self.conference_id:
+ parent_conf_id = self._get_parent_conference_id()
+ if parent_conf_id is not None and parent_conf_id != self.conference_id:
+ raise ValidationError(
+ {self._parent_field: f"The selected {self._parent_field} does not belong to this conference."},
+ )
+
+ def _get_parent_conference_id(self) -> int | None:
+ """Look up the conference_id from the parent entity."""
+ parent_id = getattr(self, f"{self._parent_field}_id", None)
+ if parent_id is None:
+ return None
+ parent_model = type(self)._meta.get_field(self._parent_field).related_model # noqa: SLF001
+ return parent_model.objects.filter(pk=parent_id).values_list("conference_id", flat=True).first()
+
+
+class TalkOverride(AbstractOverride):
+ """Local override applied on top of synced Pretalx talk data.
+
+ Allows conference organizers to patch individual fields of a synced talk
+ without modifying the upstream Pretalx record. Overrides are resolved
+ at the view/template layer via ``effective_*`` properties on Talk.
+ Fields left blank (or ``None``) are not applied, preserving the synced value.
+ """
+
+ _parent_field = "talk"
+
+ conference = models.ForeignKey(
+ "program_conference.Conference",
+ on_delete=models.CASCADE,
+ related_name="talk_overrides",
+ )
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="created_talk_overrides",
+ )
+ talk = models.OneToOneField(
+ Talk,
+ on_delete=models.CASCADE,
+ related_name="override",
+ )
+ override_room = models.ForeignKey(
+ Room,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="talk_overrides",
+ help_text="Override the room assignment for this talk.",
+ )
+ override_title = models.CharField(
+ max_length=500,
+ blank=True,
+ default="",
+ help_text="Override the talk title.",
+ )
+ override_state = models.CharField(
+ max_length=50,
+ blank=True,
+ default="",
+ help_text="Override the talk state (e.g. confirmed, withdrawn).",
+ )
+ override_slot_start = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="Override the scheduled start time.",
+ )
+ override_slot_end = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="Override the scheduled end time.",
+ )
+ override_abstract = models.TextField(
+ blank=True,
+ default="",
+ help_text="Override the talk abstract.",
+ )
+ is_cancelled = models.BooleanField(
+ default=False,
+ help_text="Mark this talk as cancelled. Overrides the state to 'cancelled'.",
+ )
+
+ def __str__(self) -> str:
+ return f"Override for {self.talk}"
+
+ @property
+ def is_empty(self) -> bool:
+ """Return True when no override fields carry a value.
+
+ An override with only a note (but no actual field overrides) is
+ considered empty and should be cleaned up.
+ """
+ return (
+ not self.override_room_id
+ and not self.override_title
+ and not self.override_state
+ and self.override_slot_start is None
+ and self.override_slot_end is None
+ and not self.override_abstract
+ and not self.is_cancelled
+ )
+
+
+class SpeakerOverride(AbstractOverride):
+ """Local override applied on top of synced Pretalx speaker data.
+
+ Allows conference organizers to patch individual fields of a synced speaker
+ without modifying the upstream Pretalx record.
+ """
+
+ _parent_field = "speaker"
+
+ conference = models.ForeignKey(
+ "program_conference.Conference",
+ on_delete=models.CASCADE,
+ related_name="speaker_overrides",
+ )
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="created_speaker_overrides",
+ )
+ speaker = models.OneToOneField(
+ Speaker,
+ on_delete=models.CASCADE,
+ related_name="override",
+ )
+ override_name = models.CharField(
+ max_length=300,
+ blank=True,
+ default="",
+ help_text="Override the speaker's display name.",
+ )
+ override_biography = models.TextField(
+ blank=True,
+ default="",
+ help_text="Override the speaker biography.",
+ )
+ override_avatar_url = models.URLField(
+ blank=True,
+ default="",
+ help_text="Override the speaker avatar URL.",
+ )
+ override_email = models.EmailField(
+ blank=True,
+ default="",
+ help_text="Override the speaker contact email.",
+ )
+
+ def __str__(self) -> str:
+ return f"Override for {self.speaker}"
+
+ @property
+ def is_empty(self) -> bool:
+ """Return True when no override fields carry a value."""
+ return (
+ not self.override_name
+ and not self.override_biography
+ and not self.override_avatar_url
+ and not self.override_email
+ )
+
+
+class RoomOverride(AbstractOverride):
+ """Local override applied on top of synced Pretalx room data.
+
+ Allows conference organizers to patch individual fields of a synced room
+ without modifying the upstream Pretalx record.
+ """
+
+ _parent_field = "room"
+
+ conference = models.ForeignKey(
+ "program_conference.Conference",
+ on_delete=models.CASCADE,
+ related_name="room_overrides",
+ )
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="created_room_overrides",
+ )
+ room = models.OneToOneField(
+ Room,
+ on_delete=models.CASCADE,
+ related_name="override",
+ )
+ override_name = models.CharField(
+ max_length=300,
+ blank=True,
+ default="",
+ help_text="Override the room name.",
+ )
+ override_description = models.TextField(
+ blank=True,
+ default="",
+ help_text="Override the room description.",
+ )
+ override_capacity = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ help_text="Override the room capacity.",
+ )
+
+ def __str__(self) -> str:
+ return f"Override for {self.room}"
+
+ @property
+ def is_empty(self) -> bool:
+ """Return True when no override fields carry a value."""
+ return not self.override_name and not self.override_description and self.override_capacity is None
+
+
+class SubmissionTypeDefault(models.Model):
+ """Default room and time-slot assignment for a Pretalx submission type.
+
+ When talks of a given ``submission_type`` (e.g. "Poster") have no room or
+ schedule assigned by Pretalx, these defaults are applied automatically
+ after each sync.
+ """
+
+ conference = models.ForeignKey(
+ "program_conference.Conference",
+ on_delete=models.CASCADE,
+ related_name="submission_type_defaults",
+ )
+ submission_type = models.CharField(
+ max_length=200,
+ help_text="The Pretalx submission type name to match (e.g. 'Poster', 'Tutorial').",
+ )
+ default_room = models.ForeignKey(
+ Room,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="submission_type_defaults",
+ help_text="Default room for talks of this type.",
+ )
+ default_date = models.DateField(
+ null=True,
+ blank=True,
+ help_text="Default date for talks of this type.",
+ )
+ default_start_time = models.TimeField(
+ null=True,
+ blank=True,
+ help_text="Default start time for talks of this type.",
+ )
+ default_end_time = models.TimeField(
+ null=True,
+ blank=True,
+ help_text="Default end time for talks of this type.",
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["submission_type"]
+ unique_together = [("conference", "submission_type")]
+
+ def __str__(self) -> str:
+ return f"Defaults for '{self.submission_type}'"
diff --git a/src/django_program/pretalx/sync.py b/src/django_program/pretalx/sync.py
index 9f3bebc..db1bd02 100644
--- a/src/django_program/pretalx/sync.py
+++ b/src/django_program/pretalx/sync.py
@@ -7,7 +7,8 @@
"""
import logging
-from datetime import datetime
+import zoneinfo
+from datetime import UTC, datetime
from typing import TYPE_CHECKING
from django.contrib.auth import get_user_model
@@ -17,7 +18,7 @@
from django.utils import timezone
from django.utils.text import slugify
-from django_program.pretalx.models import Room, ScheduleSlot, Speaker, Talk
+from django_program.pretalx.models import Room, ScheduleSlot, Speaker, SubmissionTypeDefault, Talk
from django_program.pretalx.profiles import resolve_pretalx_profile
from django_program.programs.models import Activity
from django_program.settings import get_config
@@ -675,6 +676,76 @@ def _resolve_room(self, room_name: str) -> Room | None:
return room
return None
+ def apply_type_defaults(self) -> int:
+ """Apply SubmissionTypeDefault records to unscheduled talks.
+
+ For each configured submission type default, finds talks of that type
+ that have no room assigned and applies the default room and time slot.
+
+ Returns:
+ The number of talks that were modified by type defaults.
+ """
+ defaults = SubmissionTypeDefault.objects.filter(conference=self.conference).select_related("default_room")
+ if not defaults.exists():
+ return 0
+
+ try:
+ conf_tz = zoneinfo.ZoneInfo(str(self.conference.timezone))
+ except (zoneinfo.ZoneInfoNotFoundError, KeyError): # fmt: skip
+ logger.warning(
+ "Invalid timezone '%s' for conference %s; falling back to UTC for type defaults.",
+ self.conference.timezone,
+ self.conference.slug,
+ )
+ conf_tz = UTC
+ to_update: list[Talk] = []
+ update_fields: set[str] = set()
+
+ for type_default in defaults:
+ talks = Talk.objects.filter(
+ conference=self.conference,
+ submission_type=type_default.submission_type,
+ room__isnull=True,
+ )
+
+ for talk in talks:
+ changed = False
+
+ if type_default.default_room is not None:
+ talk.room = type_default.default_room
+ update_fields.add("room")
+ changed = True
+
+ if type_default.default_date and type_default.default_start_time and talk.slot_start is None:
+ talk.slot_start = datetime.combine(
+ type_default.default_date,
+ type_default.default_start_time,
+ tzinfo=conf_tz,
+ )
+ update_fields.add("slot_start")
+ changed = True
+
+ if type_default.default_date and type_default.default_end_time and talk.slot_end is None:
+ talk.slot_end = datetime.combine(
+ type_default.default_date,
+ type_default.default_end_time,
+ tzinfo=conf_tz,
+ )
+ update_fields.add("slot_end")
+ changed = True
+
+ if changed:
+ to_update.append(talk)
+
+ if to_update and update_fields:
+ Talk.objects.bulk_update(to_update, fields=list(update_fields), batch_size=500)
+ logger.info(
+ "Applied type defaults to %d talks for %s",
+ len(to_update),
+ self.conference.slug,
+ )
+ return len(to_update)
+
def sync_all(self, *, allow_large_deletions: bool = False) -> dict[str, int]:
"""Run all sync operations in dependency order.
@@ -682,6 +753,8 @@ def sync_all(self, *, allow_large_deletions: bool = False) -> dict[str, int]:
A mapping of entity type to the number synced. The
``schedule_slots`` key contains only the synced count;
``unscheduled_talks`` is added when any talks lack a slot.
+ ``type_defaults_applied`` is added when type defaults modify
+ any talks.
"""
schedule_count, unscheduled = self.sync_schedule(allow_large_deletions=allow_large_deletions)
result: dict[str, int] = {
@@ -692,6 +765,11 @@ def sync_all(self, *, allow_large_deletions: bool = False) -> dict[str, int]:
}
if unscheduled:
result["unscheduled_talks"] = unscheduled
+
+ type_defaults_applied = self.apply_type_defaults()
+ if type_defaults_applied:
+ result["type_defaults_applied"] = type_defaults_applied
+
return result
diff --git a/src/django_program/sponsors/admin.py b/src/django_program/sponsors/admin.py
index 4e7b375..8254023 100644
--- a/src/django_program/sponsors/admin.py
+++ b/src/django_program/sponsors/admin.py
@@ -2,7 +2,7 @@
from django.contrib import admin
-from django_program.sponsors.models import Sponsor, SponsorBenefit, SponsorLevel
+from django_program.sponsors.models import Sponsor, SponsorBenefit, SponsorLevel, SponsorOverride
class SponsorBenefitInline(admin.TabularInline):
@@ -30,3 +30,14 @@ class SponsorAdmin(admin.ModelAdmin):
list_filter = ("conference", "level", "is_active")
search_fields = ("name", "slug", "contact_name", "contact_email")
inlines = (SponsorBenefitInline,)
+
+
+@admin.register(SponsorOverride)
+class SponsorOverrideAdmin(admin.ModelAdmin):
+ """Admin interface for managing sponsor overrides."""
+
+ list_display = ("sponsor", "conference", "override_name", "override_is_active", "updated_at")
+ list_filter = ("conference",)
+ search_fields = ("sponsor__name", "override_name", "note")
+ raw_id_fields = ("sponsor", "override_level", "created_by")
+ readonly_fields = ("created_at", "updated_at")
diff --git a/src/django_program/sponsors/migrations/0004_sponsoroverride.py b/src/django_program/sponsors/migrations/0004_sponsoroverride.py
new file mode 100644
index 0000000..255b020
--- /dev/null
+++ b/src/django_program/sponsors/migrations/0004_sponsoroverride.py
@@ -0,0 +1,107 @@
+# Generated by Django 5.2.11 on 2026-02-14 02:05
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("program_conference", "0003_conference_address"),
+ ("program_sponsors", "0003_add_external_id_and_logo_url"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SponsorOverride",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "override_name",
+ models.CharField(blank=True, default="", help_text="Override the sponsor name.", max_length=200),
+ ),
+ (
+ "override_description",
+ models.TextField(blank=True, default="", help_text="Override the sponsor description."),
+ ),
+ (
+ "override_website_url",
+ models.URLField(blank=True, default="", help_text="Override the sponsor website URL."),
+ ),
+ (
+ "override_logo_url",
+ models.URLField(blank=True, default="", help_text="Override the sponsor logo URL."),
+ ),
+ (
+ "override_contact_name",
+ models.CharField(
+ blank=True, default="", help_text="Override the sponsor contact name.", max_length=200
+ ),
+ ),
+ (
+ "override_contact_email",
+ models.EmailField(
+ blank=True, default="", help_text="Override the sponsor contact email.", max_length=254
+ ),
+ ),
+ (
+ "override_is_active",
+ models.BooleanField(
+ blank=True,
+ default=None,
+ help_text="Override the sponsor active status. Leave blank for no override.",
+ null=True,
+ ),
+ ),
+ (
+ "note",
+ models.TextField(
+ blank=True, default="", help_text="Internal note explaining the reason for this override."
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "conference",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="sponsor_overrides",
+ to="program_conference.conference",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_sponsor_overrides",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "override_level",
+ models.ForeignKey(
+ blank=True,
+ help_text="Override the sponsor level.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="sponsor_overrides",
+ to="program_sponsors.sponsorlevel",
+ ),
+ ),
+ (
+ "sponsor",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="override",
+ to="program_sponsors.sponsor",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-updated_at"],
+ },
+ ),
+ ]
diff --git a/src/django_program/sponsors/models.py b/src/django_program/sponsors/models.py
index bf5d7d2..1e07b4e 100644
--- a/src/django_program/sponsors/models.py
+++ b/src/django_program/sponsors/models.py
@@ -1,9 +1,12 @@
"""Sponsor level, sponsor, and benefit models for django-program."""
+from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
+from django_program.pretalx.models import AbstractOverride
+
class SponsorLevel(models.Model):
"""A sponsorship tier for a conference.
@@ -108,6 +111,187 @@ def clean(self) -> None:
msg = "Sponsor level must belong to the same conference as the sponsor."
raise ValidationError({"level": msg})
+ @property
+ def effective_name(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_name:
+ return o.override_name
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.name
+
+ @property
+ def effective_description(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_description:
+ return o.override_description
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.description
+
+ @property
+ def effective_website_url(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_website_url:
+ return o.override_website_url
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.website_url
+
+ @property
+ def effective_logo_url(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_logo_url:
+ return o.override_logo_url
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.logo_url
+
+ @property
+ def effective_contact_name(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_contact_name:
+ return o.override_contact_name
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.contact_name
+
+ @property
+ def effective_contact_email(self) -> str:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_contact_email:
+ return o.override_contact_email
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.contact_email
+
+ @property
+ def effective_is_active(self) -> bool:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_is_active is not None:
+ return o.override_is_active
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.is_active
+
+ @property
+ def effective_level(self) -> SponsorLevel:
+ """Return the overridden value if set, otherwise the synced value."""
+ try:
+ o = self.override
+ if o.override_level_id:
+ return o.override_level
+ except SponsorOverride.DoesNotExist:
+ pass
+ return self.level
+
+
+class SponsorOverride(AbstractOverride):
+ """Local override applied on top of sponsor data.
+
+ Allows conference organizers to patch individual fields of a sponsor
+ without modifying the original record. Inherits ``save()``/``clean()``
+ conference auto-set and validation from ``AbstractOverride``.
+ """
+
+ _parent_field = "sponsor"
+
+ conference = models.ForeignKey(
+ "program_conference.Conference",
+ on_delete=models.CASCADE,
+ related_name="sponsor_overrides",
+ )
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="created_sponsor_overrides",
+ )
+ sponsor = models.OneToOneField(
+ Sponsor,
+ on_delete=models.CASCADE,
+ related_name="override",
+ )
+ override_name = models.CharField(
+ max_length=200,
+ blank=True,
+ default="",
+ help_text="Override the sponsor name.",
+ )
+ override_description = models.TextField(
+ blank=True,
+ default="",
+ help_text="Override the sponsor description.",
+ )
+ override_website_url = models.URLField(
+ blank=True,
+ default="",
+ help_text="Override the sponsor website URL.",
+ )
+ override_logo_url = models.URLField(
+ blank=True,
+ default="",
+ help_text="Override the sponsor logo URL.",
+ )
+ override_contact_name = models.CharField(
+ max_length=200,
+ blank=True,
+ default="",
+ help_text="Override the sponsor contact name.",
+ )
+ override_contact_email = models.EmailField(
+ blank=True,
+ default="",
+ help_text="Override the sponsor contact email.",
+ )
+ override_is_active = models.BooleanField(
+ null=True,
+ blank=True,
+ default=None,
+ help_text="Override the sponsor active status. Leave blank for no override.",
+ )
+ override_level = models.ForeignKey(
+ SponsorLevel,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="sponsor_overrides",
+ help_text="Override the sponsor level.",
+ )
+
+ def __str__(self) -> str:
+ level_name = self.sponsor.level.name if self.sponsor.level_id else "Unknown"
+ return f"Override: {self.sponsor.name} ({level_name})"
+
+ @property
+ def is_empty(self) -> bool:
+ """Return True when no override fields carry a value."""
+ return (
+ not self.override_name
+ and not self.override_description
+ and not self.override_website_url
+ and not self.override_logo_url
+ and not self.override_contact_name
+ and not self.override_contact_email
+ and self.override_is_active is None
+ and not self.override_level_id
+ )
+
class SponsorBenefit(models.Model):
"""A specific benefit tracked for a sponsor.
diff --git a/tests/test_manage/test_override_views.py b/tests/test_manage/test_override_views.py
new file mode 100644
index 0000000..86ff65c
--- /dev/null
+++ b/tests/test_manage/test_override_views.py
@@ -0,0 +1,1040 @@
+"""Tests for override management views (all override types and SubmissionTypeDefault CRUD)."""
+
+from datetime import date, timedelta
+
+import pytest
+from django.contrib.auth.models import User
+from django.test import Client
+from django.urls import reverse
+from django.utils import timezone
+
+from django_program.conference.models import Conference
+from django_program.manage.forms_overrides import SponsorOverrideForm, SubmissionTypeDefaultForm
+from django_program.pretalx.models import (
+ Room,
+ RoomOverride,
+ Speaker,
+ SpeakerOverride,
+ SubmissionTypeDefault,
+ Talk,
+ TalkOverride,
+)
+from django_program.sponsors.models import Sponsor, SponsorLevel, SponsorOverride
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def superuser(db):
+ return User.objects.create_superuser(username="admin", password="password", email="admin@test.com")
+
+
+@pytest.fixture
+def regular_user(db):
+ return User.objects.create_user(username="regular", password="password", email="regular@test.com")
+
+
+@pytest.fixture
+def conference(db):
+ return Conference.objects.create(
+ name="Test Conf",
+ slug="test-conf",
+ start_date=date(2027, 5, 1),
+ end_date=date(2027, 5, 3),
+ timezone="UTC",
+ pretalx_event_slug="test-event",
+ is_active=True,
+ )
+
+
+@pytest.fixture
+def room(conference):
+ return Room.objects.create(conference=conference, pretalx_id=1, name="Main Hall", capacity=500, position=1)
+
+
+@pytest.fixture
+def talk(conference, room):
+ return Talk.objects.create(
+ conference=conference,
+ pretalx_code="TALK1",
+ title="My Great Talk",
+ abstract="An abstract.",
+ submission_type="Talk",
+ state="confirmed",
+ room=room,
+ slot_start=timezone.now(),
+ slot_end=timezone.now() + timedelta(hours=1),
+ )
+
+
+@pytest.fixture
+def speaker(conference):
+ return Speaker.objects.create(
+ conference=conference,
+ pretalx_code="SPK1",
+ name="Alice Speaker",
+ email="alice@test.com",
+ )
+
+
+@pytest.fixture
+def sponsor_level(conference):
+ return SponsorLevel.objects.create(
+ conference=conference,
+ name="Gold",
+ slug="gold",
+ cost=5000,
+ )
+
+
+@pytest.fixture
+def sponsor(conference, sponsor_level):
+ return Sponsor.objects.create(
+ conference=conference,
+ level=sponsor_level,
+ name="Acme Corp",
+ slug="acme-corp",
+ )
+
+
+@pytest.fixture
+def talk_override(talk, conference, superuser):
+ return TalkOverride.objects.create(
+ talk=talk,
+ conference=conference,
+ override_title="Overridden Title",
+ note="Test override",
+ created_by=superuser,
+ )
+
+
+@pytest.fixture
+def speaker_override(speaker, conference, superuser):
+ return SpeakerOverride.objects.create(
+ speaker=speaker,
+ conference=conference,
+ override_name="Bob Speaker",
+ note="Test speaker override",
+ created_by=superuser,
+ )
+
+
+@pytest.fixture
+def room_override(room, conference, superuser):
+ return RoomOverride.objects.create(
+ room=room,
+ conference=conference,
+ override_name="Grand Hall",
+ note="Test room override",
+ created_by=superuser,
+ )
+
+
+@pytest.fixture
+def sponsor_override(sponsor, conference, superuser):
+ return SponsorOverride.objects.create(
+ sponsor=sponsor,
+ conference=conference,
+ override_name="Acme Inc",
+ note="Test sponsor override",
+ created_by=superuser,
+ )
+
+
+@pytest.fixture
+def type_default(conference, room):
+ return SubmissionTypeDefault.objects.create(
+ conference=conference,
+ submission_type="Poster",
+ default_room=room,
+ )
+
+
+@pytest.fixture
+def authed_client(superuser):
+ c = Client()
+ c.login(username="admin", password="password")
+ return c
+
+
+@pytest.fixture
+def anon_client():
+ return Client()
+
+
+@pytest.fixture
+def regular_client(regular_user):
+ c = Client()
+ c.login(username="regular", password="password")
+ return c
+
+
+# ===========================================================================
+# TalkOverride List View
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideListView:
+ def test_list_loads(self, authed_client, conference, talk_override):
+ url = reverse("manage:override-list", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert "overrides" in resp.context
+ assert resp.context["active_nav"] == "overrides"
+ assert resp.context["active_override_tab"] == "talks"
+ overrides = list(resp.context["overrides"])
+ assert len(overrides) == 1
+
+ def test_list_empty(self, authed_client, conference):
+ url = reverse("manage:override-list", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert list(resp.context["overrides"]) == []
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:override-list", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+ def test_regular_user_forbidden(self, regular_client, conference):
+ url = reverse("manage:override-list", kwargs={"conference_slug": conference.slug})
+ resp = regular_client.get(url)
+ assert resp.status_code == 403
+
+
+# ===========================================================================
+# TalkOverride Create View
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideCreateView:
+ def test_get_create_form(self, authed_client, conference, talk):
+ url = reverse("manage:override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+ assert resp.context["active_nav"] == "overrides"
+
+ def test_get_create_form_with_talk_prepopulated(self, authed_client, conference, talk):
+ url = reverse("manage:override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url, {"talk": talk.pk})
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+ assert resp.context["form"].initial.get("talk") == str(talk.pk)
+
+ def test_post_creates_override(self, authed_client, conference, talk, superuser):
+ url = reverse("manage:override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.post(
+ url,
+ {
+ "talk": talk.pk,
+ "override_title": "New Title",
+ "override_state": "",
+ "override_abstract": "",
+ "override_room": "",
+ "override_slot_start": "",
+ "override_slot_end": "",
+ "is_cancelled": False,
+ "note": "created via test",
+ },
+ )
+ assert resp.status_code == 302
+ expected_url = reverse("manage:override-list", kwargs={"conference_slug": conference.slug})
+ assert resp.url == expected_url
+
+ override = TalkOverride.objects.get(talk=talk)
+ assert override.override_title == "New Title"
+ assert override.conference == conference
+ assert override.created_by == superuser
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:override-add", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+
+# ===========================================================================
+# TalkOverride Edit View
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideEditView:
+ def test_get_edit_form(self, authed_client, conference, talk_override):
+ url = reverse(
+ "manage:override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": talk_override.pk},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is False
+ assert resp.context["active_nav"] == "overrides"
+
+ def test_post_updates_override(self, authed_client, conference, talk, talk_override):
+ url = reverse(
+ "manage:override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": talk_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "talk": talk.pk,
+ "override_title": "Updated Title",
+ "override_state": "",
+ "override_abstract": "",
+ "override_room": "",
+ "override_slot_start": "",
+ "override_slot_end": "",
+ "is_cancelled": False,
+ "note": "updated",
+ },
+ )
+ assert resp.status_code == 302
+ talk_override.refresh_from_db()
+ assert talk_override.override_title == "Updated Title"
+
+ def test_edit_empty_deletes_override(self, authed_client, conference, talk, talk_override):
+ url = reverse(
+ "manage:override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": talk_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "talk": talk.pk,
+ "override_title": "",
+ "override_state": "",
+ "override_abstract": "",
+ "override_room": "",
+ "override_slot_start": "",
+ "override_slot_end": "",
+ "is_cancelled": False,
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ assert not TalkOverride.objects.filter(pk=talk_override.pk).exists()
+
+ def test_edit_nonexistent_returns_404(self, authed_client, conference):
+ url = reverse(
+ "manage:override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": 99999},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 404
+
+
+# ===========================================================================
+# SpeakerOverride Views
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSpeakerOverrideListView:
+ def test_list_loads(self, authed_client, conference, speaker_override):
+ url = reverse("manage:speaker-override-list", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["active_override_tab"] == "speakers"
+ assert len(list(resp.context["overrides"])) == 1
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:speaker-override-list", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+
+@pytest.mark.django_db
+class TestSpeakerOverrideCreateView:
+ def test_get_create_form(self, authed_client, conference, speaker):
+ url = reverse("manage:speaker-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+
+ def test_get_create_form_with_speaker_prepopulated(self, authed_client, conference, speaker):
+ url = reverse("manage:speaker-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url, {"speaker": speaker.pk})
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+ assert resp.context["form"].initial.get("speaker") == str(speaker.pk)
+
+ def test_post_creates_override(self, authed_client, conference, speaker, superuser):
+ url = reverse("manage:speaker-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.post(
+ url,
+ {
+ "speaker": speaker.pk,
+ "override_name": "New Name",
+ "override_biography": "",
+ "override_avatar_url": "",
+ "override_email": "",
+ "note": "test",
+ },
+ )
+ assert resp.status_code == 302
+ override = SpeakerOverride.objects.get(speaker=speaker)
+ assert override.override_name == "New Name"
+ assert override.created_by == superuser
+
+
+@pytest.mark.django_db
+class TestSpeakerOverrideEditView:
+ def test_get_edit_form(self, authed_client, conference, speaker_override):
+ url = reverse(
+ "manage:speaker-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": speaker_override.pk},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is False
+ assert resp.context["active_override_tab"] == "speakers"
+
+ def test_post_updates(self, authed_client, conference, speaker, speaker_override):
+ url = reverse(
+ "manage:speaker-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": speaker_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "speaker": speaker.pk,
+ "override_name": "Updated Name",
+ "override_biography": "",
+ "override_avatar_url": "",
+ "override_email": "",
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ speaker_override.refresh_from_db()
+ assert speaker_override.override_name == "Updated Name"
+
+ def test_edit_empty_deletes(self, authed_client, conference, speaker, speaker_override):
+ url = reverse(
+ "manage:speaker-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": speaker_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "speaker": speaker.pk,
+ "override_name": "",
+ "override_biography": "",
+ "override_avatar_url": "",
+ "override_email": "",
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ assert not SpeakerOverride.objects.filter(pk=speaker_override.pk).exists()
+
+
+# ===========================================================================
+# RoomOverride Views
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestRoomOverrideListView:
+ def test_list_loads(self, authed_client, conference, room_override):
+ url = reverse("manage:room-override-list", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["active_override_tab"] == "rooms"
+ assert len(list(resp.context["overrides"])) == 1
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:room-override-list", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+
+@pytest.mark.django_db
+class TestRoomOverrideCreateView:
+ def test_get_create_form(self, authed_client, conference, room):
+ url = reverse("manage:room-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+
+ def test_get_create_form_with_room_prepopulated(self, authed_client, conference, room):
+ url = reverse("manage:room-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url, {"room": room.pk})
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+ assert resp.context["form"].initial.get("room") == str(room.pk)
+
+ def test_post_creates_override(self, authed_client, conference, room, superuser):
+ url = reverse("manage:room-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.post(
+ url,
+ {
+ "room": room.pk,
+ "override_name": "New Room Name",
+ "override_description": "",
+ "override_capacity": "",
+ "note": "test",
+ },
+ )
+ assert resp.status_code == 302
+ override = RoomOverride.objects.get(room=room)
+ assert override.override_name == "New Room Name"
+ assert override.created_by == superuser
+
+
+@pytest.mark.django_db
+class TestRoomOverrideEditView:
+ def test_get_edit_form(self, authed_client, conference, room_override):
+ url = reverse(
+ "manage:room-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": room_override.pk},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is False
+ assert resp.context["active_override_tab"] == "rooms"
+
+ def test_post_updates(self, authed_client, conference, room, room_override):
+ url = reverse(
+ "manage:room-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": room_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "room": room.pk,
+ "override_name": "Updated Room",
+ "override_description": "",
+ "override_capacity": "",
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ room_override.refresh_from_db()
+ assert room_override.override_name == "Updated Room"
+
+ def test_edit_empty_deletes(self, authed_client, conference, room, room_override):
+ url = reverse(
+ "manage:room-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": room_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "room": room.pk,
+ "override_name": "",
+ "override_description": "",
+ "override_capacity": "",
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ assert not RoomOverride.objects.filter(pk=room_override.pk).exists()
+
+
+# ===========================================================================
+# SponsorOverride Views
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideListView:
+ def test_list_loads(self, authed_client, conference, sponsor_override):
+ url = reverse("manage:sponsor-override-list", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["active_override_tab"] == "sponsors"
+ assert len(list(resp.context["overrides"])) == 1
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:sponsor-override-list", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideCreateView:
+ def test_get_create_form(self, authed_client, conference, sponsor):
+ url = reverse("manage:sponsor-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+
+ def test_get_create_form_with_sponsor_prepopulated(self, authed_client, conference, sponsor):
+ url = reverse("manage:sponsor-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url, {"sponsor": sponsor.pk})
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+ assert resp.context["form"].initial.get("sponsor") == str(sponsor.pk)
+
+ def test_post_creates_override(self, authed_client, conference, sponsor, superuser):
+ url = reverse("manage:sponsor-override-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.post(
+ url,
+ {
+ "sponsor": sponsor.pk,
+ "override_name": "Acme Inc",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "",
+ "override_level": "",
+ "note": "test",
+ },
+ )
+ assert resp.status_code == 302
+ override = SponsorOverride.objects.get(sponsor=sponsor)
+ assert override.override_name == "Acme Inc"
+ assert override.created_by == superuser
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideEditView:
+ def test_get_edit_form(self, authed_client, conference, sponsor_override):
+ url = reverse(
+ "manage:sponsor-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": sponsor_override.pk},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is False
+ assert resp.context["active_override_tab"] == "sponsors"
+
+ def test_post_updates(self, authed_client, conference, sponsor, sponsor_override):
+ url = reverse(
+ "manage:sponsor-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": sponsor_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "sponsor": sponsor.pk,
+ "override_name": "Updated Corp",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "",
+ "override_level": "",
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ sponsor_override.refresh_from_db()
+ assert sponsor_override.override_name == "Updated Corp"
+
+ def test_edit_empty_deletes(self, authed_client, conference, sponsor, sponsor_override):
+ url = reverse(
+ "manage:sponsor-override-edit",
+ kwargs={"conference_slug": conference.slug, "pk": sponsor_override.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "sponsor": sponsor.pk,
+ "override_name": "",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "",
+ "override_level": "",
+ "note": "",
+ },
+ )
+ assert resp.status_code == 302
+ assert not SponsorOverride.objects.filter(pk=sponsor_override.pk).exists()
+
+
+# ===========================================================================
+# SubmissionTypeDefault List View
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSubmissionTypeDefaultListView:
+ def test_list_loads(self, authed_client, conference, type_default):
+ url = reverse("manage:type-default-list", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert "type_defaults" in resp.context
+ assert resp.context["active_nav"] == "overrides"
+ defaults = list(resp.context["type_defaults"])
+ assert len(defaults) == 1
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:type-default-list", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+ def test_regular_user_forbidden(self, regular_client, conference):
+ url = reverse("manage:type-default-list", kwargs={"conference_slug": conference.slug})
+ resp = regular_client.get(url)
+ assert resp.status_code == 403
+
+
+# ===========================================================================
+# SubmissionTypeDefault Create View
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSubmissionTypeDefaultCreateView:
+ def test_get_create_form(self, authed_client, conference):
+ url = reverse("manage:type-default-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is True
+ assert resp.context["active_nav"] == "overrides"
+
+ def test_post_creates_default(self, authed_client, conference, room):
+ url = reverse("manage:type-default-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.post(
+ url,
+ {
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "2027-05-01",
+ "default_start_time": "09:00",
+ "default_end_time": "17:00",
+ },
+ )
+ assert resp.status_code == 302
+ expected_url = reverse("manage:type-default-list", kwargs={"conference_slug": conference.slug})
+ assert resp.url == expected_url
+
+ td = SubmissionTypeDefault.objects.get(conference=conference, submission_type="Tutorial")
+ assert td.default_room == room
+ assert td.conference == conference
+
+ def test_anonymous_redirect(self, anon_client, conference):
+ url = reverse("manage:type-default-add", kwargs={"conference_slug": conference.slug})
+ resp = anon_client.get(url)
+ assert resp.status_code == 302
+
+
+# ===========================================================================
+# SubmissionTypeDefault Edit View
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSubmissionTypeDefaultEditView:
+ def test_get_edit_form(self, authed_client, conference, type_default):
+ url = reverse(
+ "manage:type-default-edit",
+ kwargs={"conference_slug": conference.slug, "pk": type_default.pk},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 200
+ assert resp.context["is_create"] is False
+ assert resp.context["active_nav"] == "overrides"
+
+ def test_post_updates_default(self, authed_client, conference, type_default, room):
+ url = reverse(
+ "manage:type-default-edit",
+ kwargs={"conference_slug": conference.slug, "pk": type_default.pk},
+ )
+ resp = authed_client.post(
+ url,
+ {
+ "submission_type": "Workshop",
+ "default_room": room.pk,
+ "default_date": "",
+ "default_start_time": "",
+ "default_end_time": "",
+ },
+ )
+ assert resp.status_code == 302
+ type_default.refresh_from_db()
+ assert type_default.submission_type == "Workshop"
+
+ def test_edit_nonexistent_returns_404(self, authed_client, conference):
+ url = reverse(
+ "manage:type-default-edit",
+ kwargs={"conference_slug": conference.slug, "pk": 99999},
+ )
+ resp = authed_client.get(url)
+ assert resp.status_code == 404
+
+
+# ===========================================================================
+# URL Resolution Tests
+# ===========================================================================
+
+
+class TestOverrideURLResolution:
+ def test_override_list_url(self):
+ url = reverse("manage:override-list", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/talks/"
+
+ def test_override_add_url(self):
+ url = reverse("manage:override-add", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/talks/add/"
+
+ def test_override_edit_url(self):
+ url = reverse("manage:override-edit", kwargs={"conference_slug": "test-conf", "pk": 1})
+ assert url == "/manage/test-conf/overrides/talks/1/edit/"
+
+ def test_speaker_override_list_url(self):
+ url = reverse("manage:speaker-override-list", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/speakers/"
+
+ def test_speaker_override_add_url(self):
+ url = reverse("manage:speaker-override-add", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/speakers/add/"
+
+ def test_speaker_override_edit_url(self):
+ url = reverse("manage:speaker-override-edit", kwargs={"conference_slug": "test-conf", "pk": 1})
+ assert url == "/manage/test-conf/overrides/speakers/1/edit/"
+
+ def test_room_override_list_url(self):
+ url = reverse("manage:room-override-list", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/rooms/"
+
+ def test_room_override_add_url(self):
+ url = reverse("manage:room-override-add", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/rooms/add/"
+
+ def test_room_override_edit_url(self):
+ url = reverse("manage:room-override-edit", kwargs={"conference_slug": "test-conf", "pk": 1})
+ assert url == "/manage/test-conf/overrides/rooms/1/edit/"
+
+ def test_sponsor_override_list_url(self):
+ url = reverse("manage:sponsor-override-list", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/sponsors/"
+
+ def test_sponsor_override_add_url(self):
+ url = reverse("manage:sponsor-override-add", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/sponsors/add/"
+
+ def test_sponsor_override_edit_url(self):
+ url = reverse("manage:sponsor-override-edit", kwargs={"conference_slug": "test-conf", "pk": 1})
+ assert url == "/manage/test-conf/overrides/sponsors/1/edit/"
+
+ def test_type_default_list_url(self):
+ url = reverse("manage:type-default-list", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/type-defaults/"
+
+ def test_type_default_add_url(self):
+ url = reverse("manage:type-default-add", kwargs={"conference_slug": "test-conf"})
+ assert url == "/manage/test-conf/overrides/type-defaults/add/"
+
+ def test_type_default_edit_url(self):
+ url = reverse("manage:type-default-edit", kwargs={"conference_slug": "test-conf", "pk": 1})
+ assert url == "/manage/test-conf/overrides/type-defaults/1/edit/"
+
+
+# ===========================================================================
+# SubmissionTypeDefaultForm Validation
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSubmissionTypeDefaultFormValidation:
+ """Validate that SubmissionTypeDefaultForm.clean() enforces time/date consistency."""
+
+ def test_valid_with_all_time_fields(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "2027-05-01",
+ "default_start_time": "09:00",
+ "default_end_time": "17:00",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+
+ def test_valid_with_no_time_fields(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "",
+ "default_start_time": "",
+ "default_end_time": "",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+
+ def test_valid_with_date_only(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "2027-05-01",
+ "default_start_time": "",
+ "default_end_time": "",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+
+ def test_rejects_start_time_without_date(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "",
+ "default_start_time": "09:00",
+ "default_end_time": "17:00",
+ },
+ conference=conference,
+ )
+ assert not form.is_valid()
+ assert "default_date" in form.errors
+
+ def test_rejects_end_time_without_date(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "",
+ "default_start_time": "",
+ "default_end_time": "17:00",
+ },
+ conference=conference,
+ )
+ assert not form.is_valid()
+ assert "default_date" in form.errors
+
+ def test_rejects_start_time_without_end_time(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "2027-05-01",
+ "default_start_time": "09:00",
+ "default_end_time": "",
+ },
+ conference=conference,
+ )
+ assert not form.is_valid()
+ assert "default_end_time" in form.errors
+
+ def test_rejects_end_time_without_start_time(self, conference, room):
+ form = SubmissionTypeDefaultForm(
+ data={
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "2027-05-01",
+ "default_start_time": "",
+ "default_end_time": "17:00",
+ },
+ conference=conference,
+ )
+ assert not form.is_valid()
+ assert "default_start_time" in form.errors
+
+ def test_post_rejects_times_without_date_via_view(self, authed_client, conference, room):
+ url = reverse("manage:type-default-add", kwargs={"conference_slug": conference.slug})
+ resp = authed_client.post(
+ url,
+ {
+ "submission_type": "Tutorial",
+ "default_room": room.pk,
+ "default_date": "",
+ "default_start_time": "09:00",
+ "default_end_time": "17:00",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.context["form"].errors
+
+
+# ===========================================================================
+# SponsorOverrideForm.clean_override_is_active
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideFormIsActive:
+ """Validate that override_is_active converts string widget values correctly."""
+
+ def test_empty_string_becomes_none(self, conference, sponsor):
+ form = SponsorOverrideForm(
+ data={
+ "sponsor": sponsor.pk,
+ "override_name": "",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "",
+ "override_level": "",
+ "note": "",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+ assert form.cleaned_data["override_is_active"] is None
+
+ def test_true_string_becomes_true(self, conference, sponsor):
+ form = SponsorOverrideForm(
+ data={
+ "sponsor": sponsor.pk,
+ "override_name": "",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "True",
+ "override_level": "",
+ "note": "",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+ assert form.cleaned_data["override_is_active"] is True
+
+ def test_false_string_becomes_false(self, conference, sponsor):
+ form = SponsorOverrideForm(
+ data={
+ "sponsor": sponsor.pk,
+ "override_name": "",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "False",
+ "override_level": "",
+ "note": "",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+ assert form.cleaned_data["override_is_active"] is False
+
+ def test_unexpected_value_becomes_none(self, conference, sponsor):
+ form = SponsorOverrideForm(
+ data={
+ "sponsor": sponsor.pk,
+ "override_name": "",
+ "override_description": "",
+ "override_website_url": "",
+ "override_logo_url": "",
+ "override_contact_name": "",
+ "override_contact_email": "",
+ "override_is_active": "maybe",
+ "override_level": "",
+ "note": "",
+ },
+ conference=conference,
+ )
+ assert form.is_valid(), form.errors
+ assert form.cleaned_data["override_is_active"] is None
diff --git a/tests/test_pretalx/test_overrides.py b/tests/test_pretalx/test_overrides.py
new file mode 100644
index 0000000..17d4e5a
--- /dev/null
+++ b/tests/test_pretalx/test_overrides.py
@@ -0,0 +1,654 @@
+"""Tests for override models, effective properties, and sync integration."""
+
+import zoneinfo
+from datetime import UTC, date, datetime, time
+from unittest.mock import MagicMock
+
+import pytest
+from django.core.exceptions import ValidationError
+
+from django_program.conference.models import Conference
+from django_program.pretalx.models import (
+ Room,
+ RoomOverride,
+ Speaker,
+ SpeakerOverride,
+ SubmissionTypeDefault,
+ Talk,
+ TalkOverride,
+)
+from django_program.pretalx.sync import PretalxSyncService
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_PRETALX_SETTINGS = {
+ "pretalx": {"base_url": "https://pretalx.example.com", "token": "tok"},
+}
+
+
+def _make_conference(slug="override-conf", pretalx_slug="override-event", **overrides):
+ """Create and return a Conference with sensible defaults."""
+ defaults = {
+ "name": "Override Conf",
+ "slug": slug,
+ "start_date": date(2027, 5, 1),
+ "end_date": date(2027, 5, 3),
+ "timezone": "US/Eastern",
+ "pretalx_event_slug": pretalx_slug,
+ }
+ defaults.update(overrides)
+ return Conference.objects.create(**defaults)
+
+
+def _make_service(conference, settings):
+ """Build a PretalxSyncService with mocked client dependencies."""
+ settings.DJANGO_PROGRAM = _PRETALX_SETTINGS
+ service = PretalxSyncService(conference)
+ service._rooms = {}
+ service._room_names = {}
+ service._submission_types = {}
+ service._tracks = {}
+ service._tags = {}
+ return service
+
+
+# ===========================================================================
+# TalkOverride.__str__
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideStr:
+ def test_str(self):
+ conf = _make_conference(slug="str-conf")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="My Talk")
+ override = TalkOverride.objects.create(talk=talk, conference=conf)
+ assert str(override) == "Override for My Talk"
+
+
+# ===========================================================================
+# TalkOverride.clean()
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideClean:
+ def test_clean_valid_same_conference(self):
+ conf = _make_conference(slug="clean-ok")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Talk A")
+ override = TalkOverride(talk=talk, conference=conf)
+ override.clean()
+
+ def test_clean_rejects_mismatched_conference(self):
+ conf_a = _make_conference(slug="clean-a", pretalx_slug="event-a")
+ conf_b = _make_conference(slug="clean-b", pretalx_slug="event-b")
+ talk = Talk.objects.create(conference=conf_a, pretalx_code="T1", title="Talk A")
+ override = TalkOverride(talk=talk, conference=conf_b)
+ with pytest.raises(ValidationError, match="does not belong to this conference"):
+ override.clean()
+
+ def test_clean_skips_when_no_talk(self):
+ conf = _make_conference(slug="clean-notalk")
+ override = TalkOverride(conference=conf)
+ override.clean()
+
+ def test_clean_skips_when_no_conference(self):
+ conf = _make_conference(slug="clean-noconf")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Talk A")
+ override = TalkOverride(talk=talk)
+ override.clean()
+
+
+# ===========================================================================
+# TalkOverride.save() auto-set conference
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideSave:
+ def test_save_auto_sets_conference_from_talk(self):
+ conf = _make_conference(slug="save-auto")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Talk A")
+ override = TalkOverride(talk=talk)
+ override.save()
+ assert override.conference_id == conf.pk
+
+ def test_save_does_not_override_explicit_conference(self):
+ conf_a = _make_conference(slug="save-explicit-a", pretalx_slug="ev-a")
+ conf_b = _make_conference(slug="save-explicit-b", pretalx_slug="ev-b")
+ talk = Talk.objects.create(conference=conf_a, pretalx_code="T1", title="Talk A")
+ override = TalkOverride(talk=talk, conference=conf_b)
+ override.save()
+ assert override.conference_id == conf_b.pk
+
+
+# ===========================================================================
+# TalkOverride.is_empty
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkOverrideIsEmpty:
+ def test_empty_override(self):
+ conf = _make_conference(slug="empty-ov")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T")
+ override = TalkOverride.objects.create(talk=talk, conference=conf)
+ assert override.is_empty is True
+
+ def test_non_empty_override(self):
+ conf = _make_conference(slug="notempty-ov")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T")
+ override = TalkOverride.objects.create(talk=talk, conference=conf, override_title="New")
+ assert override.is_empty is False
+
+
+# ===========================================================================
+# Talk effective_* properties
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestTalkEffectiveProperties:
+ def test_effective_title_no_override(self):
+ conf = _make_conference(slug="eff-title-none")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Original")
+ assert talk.effective_title == "Original"
+
+ def test_effective_title_with_override(self):
+ conf = _make_conference(slug="eff-title-ov")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Original")
+ TalkOverride.objects.create(talk=talk, conference=conf, override_title="Overridden")
+ assert talk.effective_title == "Overridden"
+
+ def test_effective_title_blank_override_falls_back(self):
+ conf = _make_conference(slug="eff-title-blank")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Original")
+ TalkOverride.objects.create(talk=talk, conference=conf, override_title="")
+ assert talk.effective_title == "Original"
+
+ def test_effective_state_cancelled(self):
+ conf = _make_conference(slug="eff-state-cancel")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="confirmed")
+ TalkOverride.objects.create(talk=talk, conference=conf, is_cancelled=True)
+ assert talk.effective_state == "cancelled"
+
+ def test_effective_state_override(self):
+ conf = _make_conference(slug="eff-state-ov")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="confirmed")
+ TalkOverride.objects.create(talk=talk, conference=conf, override_state="withdrawn")
+ assert talk.effective_state == "withdrawn"
+
+ def test_effective_state_no_override(self):
+ conf = _make_conference(slug="eff-state-none")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="confirmed")
+ assert talk.effective_state == "confirmed"
+
+ def test_effective_room_override(self):
+ conf = _make_conference(slug="eff-room")
+ room_a = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ room_b = Room.objects.create(conference=conf, pretalx_id=2, name="Room B")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", room=room_a)
+ TalkOverride.objects.create(talk=talk, conference=conf, override_room=room_b)
+ assert talk.effective_room == room_b
+
+ def test_effective_room_no_override(self):
+ conf = _make_conference(slug="eff-room-none")
+ room_a = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", room=room_a)
+ assert talk.effective_room == room_a
+
+ def test_effective_abstract_override(self):
+ conf = _make_conference(slug="eff-abstract")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", abstract="old")
+ TalkOverride.objects.create(talk=talk, conference=conf, override_abstract="new abstract")
+ assert talk.effective_abstract == "new abstract"
+
+ def test_effective_abstract_no_override(self):
+ conf = _make_conference(slug="eff-abstract-none")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", abstract="old")
+ assert talk.effective_abstract == "old"
+
+ def test_effective_slot_start_override(self):
+ conf = _make_conference(slug="eff-slot")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T")
+ start = datetime(2027, 5, 1, 9, 0, tzinfo=UTC)
+ TalkOverride.objects.create(talk=talk, conference=conf, override_slot_start=start)
+ assert talk.effective_slot_start == start
+
+ def test_effective_slot_start_no_override(self):
+ conf = _make_conference(slug="eff-slot-start-none")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T")
+ start = datetime(2027, 5, 1, 9, 0, tzinfo=UTC)
+ talk.slot_start = start
+ talk.save()
+ assert talk.effective_slot_start == start
+
+ def test_effective_slot_end_with_override(self):
+ conf = _make_conference(slug="eff-slot-end")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T")
+ end = datetime(2027, 5, 1, 10, 0, tzinfo=UTC)
+ TalkOverride.objects.create(talk=talk, conference=conf, override_slot_end=end)
+ assert talk.effective_slot_end == end
+
+ def test_effective_slot_end_no_override(self):
+ conf = _make_conference(slug="eff-slot-end-none")
+ talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T")
+ end = datetime(2027, 5, 1, 10, 0, tzinfo=UTC)
+ talk.slot_end = end
+ talk.save()
+ assert talk.effective_slot_end == end
+
+
+# ===========================================================================
+# SpeakerOverride
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSpeakerOverride:
+ def test_str(self):
+ conf = _make_conference(slug="spk-str")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice")
+ override = SpeakerOverride.objects.create(speaker=speaker, conference=conf)
+ assert str(override) == "Override for Alice"
+
+ def test_save_auto_sets_conference(self):
+ conf = _make_conference(slug="spk-save")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice")
+ override = SpeakerOverride(speaker=speaker)
+ override.save()
+ assert override.conference_id == conf.pk
+
+ def test_clean_rejects_mismatched_conference(self):
+ conf_a = _make_conference(slug="spk-clean-a", pretalx_slug="ev-a")
+ conf_b = _make_conference(slug="spk-clean-b", pretalx_slug="ev-b")
+ speaker = Speaker.objects.create(conference=conf_a, pretalx_code="S1", name="Alice")
+ override = SpeakerOverride(speaker=speaker, conference=conf_b)
+ with pytest.raises(ValidationError, match="does not belong to this conference"):
+ override.clean()
+
+ def test_is_empty_true(self):
+ conf = _make_conference(slug="spk-empty")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice")
+ override = SpeakerOverride.objects.create(speaker=speaker, conference=conf)
+ assert override.is_empty is True
+
+ def test_is_empty_false(self):
+ conf = _make_conference(slug="spk-notempty")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice")
+ override = SpeakerOverride.objects.create(speaker=speaker, conference=conf, override_name="Bob")
+ assert override.is_empty is False
+
+
+# ===========================================================================
+# Speaker effective_* properties
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSpeakerEffectiveProperties:
+ def test_effective_name_no_override(self):
+ conf = _make_conference(slug="spk-eff-none")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice")
+ assert speaker.effective_name == "Alice"
+
+ def test_effective_name_with_override(self):
+ conf = _make_conference(slug="spk-eff-ov")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice")
+ SpeakerOverride.objects.create(speaker=speaker, conference=conf, override_name="Bob")
+ assert speaker.effective_name == "Bob"
+
+ def test_effective_biography_with_override(self):
+ conf = _make_conference(slug="spk-eff-bio")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice", biography="old bio")
+ SpeakerOverride.objects.create(speaker=speaker, conference=conf, override_biography="new bio")
+ assert speaker.effective_biography == "new bio"
+
+ def test_effective_biography_no_override(self):
+ conf = _make_conference(slug="spk-eff-bio-none")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice", biography="old bio")
+ assert speaker.effective_biography == "old bio"
+
+ def test_effective_avatar_url_with_override(self):
+ conf = _make_conference(slug="spk-eff-avatar")
+ speaker = Speaker.objects.create(
+ conference=conf, pretalx_code="S1", name="Alice", avatar_url="https://old.com/avatar.jpg"
+ )
+ SpeakerOverride.objects.create(
+ speaker=speaker, conference=conf, override_avatar_url="https://new.com/avatar.jpg"
+ )
+ assert speaker.effective_avatar_url == "https://new.com/avatar.jpg"
+
+ def test_effective_avatar_url_no_override(self):
+ conf = _make_conference(slug="spk-eff-avatar-none")
+ speaker = Speaker.objects.create(
+ conference=conf, pretalx_code="S1", name="Alice", avatar_url="https://old.com/avatar.jpg"
+ )
+ assert speaker.effective_avatar_url == "https://old.com/avatar.jpg"
+
+ def test_effective_email_with_override(self):
+ conf = _make_conference(slug="spk-eff-email")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice", email="old@test.com")
+ SpeakerOverride.objects.create(speaker=speaker, conference=conf, override_email="new@test.com")
+ assert speaker.effective_email == "new@test.com"
+
+ def test_effective_email_no_override(self):
+ conf = _make_conference(slug="spk-eff-email-none")
+ speaker = Speaker.objects.create(conference=conf, pretalx_code="S1", name="Alice", email="old@test.com")
+ assert speaker.effective_email == "old@test.com"
+
+
+# ===========================================================================
+# RoomOverride
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestRoomOverride:
+ def test_str(self):
+ conf = _make_conference(slug="rm-str")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Main Hall")
+ override = RoomOverride.objects.create(room=room, conference=conf)
+ assert str(override) == "Override for Main Hall"
+
+ def test_save_auto_sets_conference(self):
+ conf = _make_conference(slug="rm-save")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Main Hall")
+ override = RoomOverride(room=room)
+ override.save()
+ assert override.conference_id == conf.pk
+
+ def test_clean_rejects_mismatched_conference(self):
+ conf_a = _make_conference(slug="rm-clean-a", pretalx_slug="ev-a")
+ conf_b = _make_conference(slug="rm-clean-b", pretalx_slug="ev-b")
+ room = Room.objects.create(conference=conf_a, pretalx_id=1, name="Room A")
+ override = RoomOverride(room=room, conference=conf_b)
+ with pytest.raises(ValidationError, match="does not belong to this conference"):
+ override.clean()
+
+ def test_is_empty_true(self):
+ conf = _make_conference(slug="rm-empty")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ override = RoomOverride.objects.create(room=room, conference=conf)
+ assert override.is_empty is True
+
+ def test_is_empty_false(self):
+ conf = _make_conference(slug="rm-notempty")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ override = RoomOverride.objects.create(room=room, conference=conf, override_name="Room B")
+ assert override.is_empty is False
+
+
+# ===========================================================================
+# Room effective_* properties
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestRoomEffectiveProperties:
+ def test_effective_name_no_override(self):
+ conf = _make_conference(slug="rm-eff-none")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ assert room.effective_name == "Room A"
+
+ def test_effective_name_with_override(self):
+ conf = _make_conference(slug="rm-eff-ov")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ RoomOverride.objects.create(room=room, conference=conf, override_name="Room B")
+ assert room.effective_name == "Room B"
+
+ def test_effective_capacity_with_override(self):
+ conf = _make_conference(slug="rm-eff-cap")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A", capacity=100)
+ RoomOverride.objects.create(room=room, conference=conf, override_capacity=200)
+ assert room.effective_capacity == 200
+
+ def test_effective_capacity_no_override(self):
+ conf = _make_conference(slug="rm-eff-cap-none")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A", capacity=100)
+ assert room.effective_capacity == 100
+
+ def test_effective_description_with_override(self):
+ conf = _make_conference(slug="rm-eff-desc")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A", description="old")
+ RoomOverride.objects.create(room=room, conference=conf, override_description="new desc")
+ assert room.effective_description == "new desc"
+
+ def test_effective_description_no_override(self):
+ conf = _make_conference(slug="rm-eff-desc-none")
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A", description="old")
+ assert room.effective_description == "old"
+
+
+# ===========================================================================
+# SubmissionTypeDefault.__str__
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSubmissionTypeDefaultStr:
+ def test_str(self):
+ conf = _make_conference(slug="std-str")
+ std = SubmissionTypeDefault.objects.create(conference=conf, submission_type="Poster")
+ assert str(std) == "Defaults for 'Poster'"
+
+
+# ===========================================================================
+# apply_type_defaults() in sync service
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestApplyTypeDefaults:
+ def test_assigns_room_to_unscheduled_talk(self, settings):
+ conf = _make_conference(slug="td-room")
+ service = _make_service(conf, settings)
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Poster Hall")
+ Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="Poster",
+ submission_type="Poster",
+ )
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ default_room=room,
+ )
+
+ count = service.apply_type_defaults()
+
+ assert count == 1
+ talk = Talk.objects.get(pretalx_code="P1")
+ assert talk.room == room
+
+ def test_assigns_slot_times_to_unscheduled_talk(self, settings):
+ conf = _make_conference(slug="td-time", timezone="US/Eastern")
+ service = _make_service(conf, settings)
+ Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="Poster",
+ submission_type="Poster",
+ )
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ default_date=date(2027, 5, 1),
+ default_start_time=time(9, 0),
+ default_end_time=time(17, 0),
+ )
+
+ count = service.apply_type_defaults()
+
+ assert count == 1
+ talk = Talk.objects.get(pretalx_code="P1")
+ assert talk.slot_start is not None
+ assert talk.slot_end is not None
+ eastern = zoneinfo.ZoneInfo("US/Eastern")
+ assert talk.slot_start.tzinfo is not None
+ expected_start = datetime.combine(date(2027, 5, 1), time(9, 0), tzinfo=eastern)
+ assert talk.slot_start == expected_start
+
+ def test_skips_talks_with_room_assigned(self, settings):
+ conf = _make_conference(slug="td-skip")
+ service = _make_service(conf, settings)
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Room A")
+ Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="Poster",
+ submission_type="Poster",
+ room=room,
+ )
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ )
+
+ count = service.apply_type_defaults()
+ assert count == 0
+
+ def test_returns_zero_when_no_defaults(self, settings):
+ conf = _make_conference(slug="td-none")
+ service = _make_service(conf, settings)
+ count = service.apply_type_defaults()
+ assert count == 0
+
+ def test_skips_slot_start_when_already_set(self, settings):
+ conf = _make_conference(slug="td-start-set")
+ service = _make_service(conf, settings)
+ existing_start = datetime(2027, 5, 1, 8, 0, tzinfo=UTC)
+ Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="Poster",
+ submission_type="Poster",
+ slot_start=existing_start,
+ )
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ default_date=date(2027, 5, 1),
+ default_start_time=time(9, 0),
+ default_end_time=time(17, 0),
+ )
+
+ count = service.apply_type_defaults()
+ assert count == 1
+ talk = Talk.objects.get(pretalx_code="P1")
+ assert talk.slot_start == existing_start
+ assert talk.slot_end is not None
+
+ def test_assigns_only_room_when_no_date(self, settings):
+ conf = _make_conference(slug="td-nodate")
+ service = _make_service(conf, settings)
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="R")
+ Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="P",
+ submission_type="Poster",
+ )
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ default_room=room,
+ default_start_time=time(9, 0),
+ default_end_time=time(17, 0),
+ )
+
+ count = service.apply_type_defaults()
+ assert count == 1
+ talk = Talk.objects.get(pretalx_code="P1")
+ assert talk.room == room
+ assert talk.slot_start is None
+
+
+# ===========================================================================
+# apply_type_defaults() timezone fallback
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestApplyTypeDefaultsTimezoneFallback:
+ def test_invalid_timezone_falls_back_to_utc(self, settings):
+ conf = _make_conference(slug="td-badtz", timezone="Not/A/Timezone")
+ service = _make_service(conf, settings)
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="R")
+ Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="P",
+ submission_type="Poster",
+ )
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ default_room=room,
+ default_date=date(2027, 5, 1),
+ default_start_time=time(9, 0),
+ default_end_time=time(17, 0),
+ )
+
+ count = service.apply_type_defaults()
+
+ assert count == 1
+ talk = Talk.objects.get(pretalx_code="P1")
+ assert talk.room == room
+ assert talk.slot_start is not None
+ assert talk.slot_start == datetime(2027, 5, 1, 9, 0, tzinfo=UTC)
+
+
+# ===========================================================================
+# AbstractOverride._get_parent_conference_id
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestAbstractOverrideGetParentConferenceId:
+ def test_get_parent_conference_id_returns_none_when_no_parent(self):
+ conf = _make_conference(slug="parent-none")
+ override = TalkOverride(conference=conf)
+ assert override._get_parent_conference_id() is None
+
+
+# ===========================================================================
+# sync_all includes type defaults (overrides no longer applied in sync)
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSyncAllIncludesTypeDefaults:
+ def test_sync_all_applies_type_defaults(self, settings):
+ conf = _make_conference(slug="sync-all-td")
+ service = _make_service(conf, settings)
+
+ unscheduled = Talk.objects.create(
+ conference=conf,
+ pretalx_code="P1",
+ title="Poster",
+ submission_type="Poster",
+ )
+ room = Room.objects.create(conference=conf, pretalx_id=1, name="Hall")
+ SubmissionTypeDefault.objects.create(
+ conference=conf,
+ submission_type="Poster",
+ default_room=room,
+ )
+
+ # Mock all sync methods since they need real API calls
+ service.sync_rooms = MagicMock(return_value=0)
+ service.sync_speakers = MagicMock(return_value=0)
+ service.sync_talks = MagicMock(return_value=0)
+ service.sync_schedule = MagicMock(return_value=(0, 0))
+
+ result = service.sync_all()
+
+ assert result["type_defaults_applied"] == 1
+ assert "overrides_applied" not in result
+
+ unscheduled.refresh_from_db()
+ assert unscheduled.room == room
diff --git a/tests/test_sponsors/test_overrides.py b/tests/test_sponsors/test_overrides.py
new file mode 100644
index 0000000..d2032e5
--- /dev/null
+++ b/tests/test_sponsors/test_overrides.py
@@ -0,0 +1,323 @@
+"""Tests for sponsor override models and effective properties."""
+
+from datetime import date
+
+import pytest
+from django.core.exceptions import ValidationError
+
+from django_program.conference.models import Conference
+from django_program.sponsors.models import Sponsor, SponsorLevel, SponsorOverride
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_conference(slug="sponsor-conf", **overrides):
+ """Create and return a Conference with sensible defaults."""
+ defaults = {
+ "name": "Sponsor Conf",
+ "slug": slug,
+ "start_date": date(2027, 5, 1),
+ "end_date": date(2027, 5, 3),
+ "timezone": "US/Eastern",
+ }
+ defaults.update(overrides)
+ return Conference.objects.create(**defaults)
+
+
+def _make_level(conference, name="Gold", **overrides):
+ """Create and return a SponsorLevel."""
+ defaults = {
+ "name": name,
+ "cost": 5000.00,
+ }
+ defaults.update(overrides)
+ return SponsorLevel.objects.create(conference=conference, **defaults)
+
+
+def _make_sponsor(conference, level, name="Acme Corp", **overrides):
+ """Create and return a Sponsor."""
+ defaults = {
+ "name": name,
+ }
+ defaults.update(overrides)
+ return Sponsor.objects.create(conference=conference, level=level, **defaults)
+
+
+# ===========================================================================
+# SponsorOverride.__str__
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideStr:
+ def test_str(self):
+ conf = _make_conference(slug="sponsor-str")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(sponsor=sponsor, conference=conf)
+ assert str(override) == "Override: Acme Corp (Gold)"
+
+
+# ===========================================================================
+# SponsorOverride.save() auto-set conference
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideSave:
+ def test_save_auto_sets_conference_from_sponsor(self):
+ conf = _make_conference(slug="sponsor-save-auto")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride(sponsor=sponsor)
+ override.save()
+ assert override.conference_id == conf.pk
+
+ def test_save_does_not_override_explicit_conference(self):
+ conf_a = _make_conference(slug="sponsor-save-a")
+ conf_b = _make_conference(slug="sponsor-save-b")
+ level_a = _make_level(conf_a)
+ sponsor = _make_sponsor(conf_a, level_a, "Acme Corp")
+ override = SponsorOverride(sponsor=sponsor, conference=conf_b)
+ override.save()
+ assert override.conference_id == conf_b.pk
+
+
+# ===========================================================================
+# SponsorOverride.clean()
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideClean:
+ def test_clean_valid_same_conference(self):
+ conf = _make_conference(slug="sponsor-clean-ok")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride(sponsor=sponsor, conference=conf)
+ override.clean()
+
+ def test_clean_rejects_mismatched_conference(self):
+ conf_a = _make_conference(slug="sponsor-clean-a")
+ conf_b = _make_conference(slug="sponsor-clean-b")
+ level_a = _make_level(conf_a)
+ sponsor = _make_sponsor(conf_a, level_a, "Acme Corp")
+ override = SponsorOverride(sponsor=sponsor, conference=conf_b)
+ with pytest.raises(ValidationError, match="does not belong to this conference"):
+ override.clean()
+
+ def test_clean_skips_when_no_sponsor(self):
+ conf = _make_conference(slug="sponsor-clean-nosponsor")
+ override = SponsorOverride(conference=conf)
+ override.clean()
+
+ def test_clean_skips_when_no_conference(self):
+ conf = _make_conference(slug="sponsor-clean-noconf")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride(sponsor=sponsor)
+ override.clean()
+
+
+# ===========================================================================
+# SponsorOverride.is_empty
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorOverrideIsEmpty:
+ def test_empty_override(self):
+ conf = _make_conference(slug="sponsor-empty-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(sponsor=sponsor, conference=conf)
+ assert override.is_empty is True
+
+ def test_non_empty_override_name(self):
+ conf = _make_conference(slug="sponsor-notempty-name")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_name="New Name")
+ assert override.is_empty is False
+
+ def test_non_empty_override_description(self):
+ conf = _make_conference(slug="sponsor-notempty-desc")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(
+ sponsor=sponsor, conference=conf, override_description="New description"
+ )
+ assert override.is_empty is False
+
+ def test_non_empty_override_website_url(self):
+ conf = _make_conference(slug="sponsor-notempty-url")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(
+ sponsor=sponsor, conference=conf, override_website_url="https://new.com"
+ )
+ assert override.is_empty is False
+
+ def test_non_empty_override_logo_url(self):
+ conf = _make_conference(slug="sponsor-notempty-logo")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(
+ sponsor=sponsor, conference=conf, override_logo_url="https://new.com/logo.png"
+ )
+ assert override.is_empty is False
+
+ def test_non_empty_override_contact_name(self):
+ conf = _make_conference(slug="sponsor-notempty-contact")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_contact_name="Jane Doe")
+ assert override.is_empty is False
+
+ def test_non_empty_override_contact_email(self):
+ conf = _make_conference(slug="sponsor-notempty-email")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(
+ sponsor=sponsor, conference=conf, override_contact_email="jane@example.com"
+ )
+ assert override.is_empty is False
+
+ def test_non_empty_override_is_active(self):
+ conf = _make_conference(slug="sponsor-notempty-active")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ override = SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_is_active=False)
+ assert override.is_empty is False
+
+ def test_non_empty_override_level(self):
+ conf = _make_conference(slug="sponsor-notempty-level")
+ level_a = _make_level(conf, name="Gold")
+ level_b = _make_level(conf, name="Silver")
+ sponsor = _make_sponsor(conf, level_a, "Acme Corp")
+ override = SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_level=level_b)
+ assert override.is_empty is False
+
+
+# ===========================================================================
+# Sponsor effective_* properties
+# ===========================================================================
+
+
+@pytest.mark.django_db
+class TestSponsorEffectiveProperties:
+ def test_effective_name_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-name-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ assert sponsor.effective_name == "Acme Corp"
+
+ def test_effective_name_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-name-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp")
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_name="New Acme Corp")
+ assert sponsor.effective_name == "New Acme Corp"
+
+ def test_effective_description_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-desc-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", description="Original description")
+ assert sponsor.effective_description == "Original description"
+
+ def test_effective_description_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-desc-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", description="Original description")
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_description="New description")
+ assert sponsor.effective_description == "New description"
+
+ def test_effective_website_url_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-url-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", website_url="https://acme.com")
+ assert sponsor.effective_website_url == "https://acme.com"
+
+ def test_effective_website_url_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-url-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", website_url="https://acme.com")
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_website_url="https://newacme.com")
+ assert sponsor.effective_website_url == "https://newacme.com"
+
+ def test_effective_logo_url_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-logo-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", logo_url="https://acme.com/logo.png")
+ assert sponsor.effective_logo_url == "https://acme.com/logo.png"
+
+ def test_effective_logo_url_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-logo-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", logo_url="https://acme.com/logo.png")
+ SponsorOverride.objects.create(
+ sponsor=sponsor, conference=conf, override_logo_url="https://newacme.com/logo.png"
+ )
+ assert sponsor.effective_logo_url == "https://newacme.com/logo.png"
+
+ def test_effective_contact_name_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-contact-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", contact_name="John Doe")
+ assert sponsor.effective_contact_name == "John Doe"
+
+ def test_effective_contact_name_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-contact-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", contact_name="John Doe")
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_contact_name="Jane Doe")
+ assert sponsor.effective_contact_name == "Jane Doe"
+
+ def test_effective_contact_email_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-email-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", contact_email="john@acme.com")
+ assert sponsor.effective_contact_email == "john@acme.com"
+
+ def test_effective_contact_email_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-email-ov")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", contact_email="john@acme.com")
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_contact_email="jane@acme.com")
+ assert sponsor.effective_contact_email == "jane@acme.com"
+
+ def test_effective_is_active_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-active-none")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", is_active=True)
+ assert sponsor.effective_is_active is True
+
+ def test_effective_is_active_with_override_false(self):
+ conf = _make_conference(slug="sponsor-eff-active-ov-false")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", is_active=True)
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_is_active=False)
+ assert sponsor.effective_is_active is False
+
+ def test_effective_is_active_with_override_true(self):
+ conf = _make_conference(slug="sponsor-eff-active-ov-true")
+ level = _make_level(conf)
+ sponsor = _make_sponsor(conf, level, "Acme Corp", is_active=False)
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_is_active=True)
+ assert sponsor.effective_is_active is True
+
+ def test_effective_level_no_override(self):
+ conf = _make_conference(slug="sponsor-eff-level-none")
+ level_a = _make_level(conf, name="Gold")
+ sponsor = _make_sponsor(conf, level_a, "Acme Corp")
+ assert sponsor.effective_level == level_a
+
+ def test_effective_level_with_override(self):
+ conf = _make_conference(slug="sponsor-eff-level-ov")
+ level_a = _make_level(conf, name="Gold")
+ level_b = _make_level(conf, name="Silver")
+ sponsor = _make_sponsor(conf, level_a, "Acme Corp")
+ SponsorOverride.objects.create(sponsor=sponsor, conference=conf, override_level=level_b)
+ assert sponsor.effective_level == level_b