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 %} +
    +
    Cancelled
    +
    Yes
    +
    + {% 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 %} +
    +
    + Edit Override +
    +
    +
    +{% 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