From 3fb70b97892ff90bc3344962c2b74802588e9317 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:10:32 -0600 Subject: [PATCH 1/9] feat: add pretalx override system for JIT talk management Co-Authored-By: Claude Opus 4.6 --- src/django_program/manage/forms_overrides.py | 92 ++++++++ .../django_program/manage/override_form.html | 51 +++++ .../django_program/manage/override_list.html | 80 +++++++ .../manage/submission_type_default_form.html | 51 +++++ .../manage/submission_type_default_list.html | 52 +++++ src/django_program/manage/urls.py | 2 + src/django_program/manage/urls_overrides.py | 25 +++ src/django_program/manage/views_overrides.py | 204 ++++++++++++++++++ ...0006_talkoverride_submissiontypedefault.py | 149 +++++++++++++ src/django_program/pretalx/models.py | 172 ++++++++++++++- src/django_program/pretalx/sync.py | 103 ++++++++- 11 files changed, 979 insertions(+), 2 deletions(-) create mode 100644 src/django_program/manage/forms_overrides.py create mode 100644 src/django_program/manage/templates/django_program/manage/override_form.html create mode 100644 src/django_program/manage/templates/django_program/manage/override_list.html create mode 100644 src/django_program/manage/templates/django_program/manage/submission_type_default_form.html create mode 100644 src/django_program/manage/templates/django_program/manage/submission_type_default_list.html create mode 100644 src/django_program/manage/urls_overrides.py create mode 100644 src/django_program/manage/views_overrides.py create mode 100644 src/django_program/pretalx/migrations/0006_talkoverride_submissiontypedefault.py diff --git a/src/django_program/manage/forms_overrides.py b/src/django_program/manage/forms_overrides.py new file mode 100644 index 0000000..60bda56 --- /dev/null +++ b/src/django_program/manage/forms_overrides.py @@ -0,0 +1,92 @@ +"""Model forms for Pretalx talk overrides and submission type defaults.""" + +from django import forms + +from django_program.pretalx.models import Room, SubmissionTypeDefault, Talk, TalkOverride + + +class TalkOverrideForm(forms.ModelForm): + """Form for creating or editing a talk override. + + Provides dropdowns for room and talk selection scoped to the current + conference. The ``talk`` field is only editable during creation; on + edit, it is rendered as disabled. + """ + + 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: + """Initialize with conference-scoped querysets. + + Args: + conference: The conference to scope talk/room choices to. + is_edit: When True, the talk field is rendered as disabled. + *args: Positional arguments passed to ModelForm. + **kwargs: Keyword arguments passed to ModelForm. + """ + super().__init__(*args, **kwargs) + if conference is not None: + self.fields["talk"].queryset = Talk.objects.filter(conference=conference) + self.fields["override_room"].queryset = Room.objects.filter(conference=conference) + + if is_edit: + self.fields["talk"].disabled = True + + +class SubmissionTypeDefaultForm(forms.ModelForm): + """Form for creating or editing submission type default assignments. + + Provides a text input for the submission type name and dropdowns for + room selection scoped to the current conference. + """ + + 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: + """Initialize with conference-scoped querysets. + + Args: + conference: The conference to scope room choices to. + *args: Positional arguments passed to ModelForm. + **kwargs: Keyword arguments passed to ModelForm. + """ + super().__init__(*args, **kwargs) + if conference is not None: + self.fields["default_room"].queryset = Room.objects.filter(conference=conference) diff --git a/src/django_program/manage/templates/django_program/manage/override_form.html b/src/django_program/manage/templates/django_program/manage/override_form.html new file mode 100644 index 0000000..475b793 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/override_form.html @@ -0,0 +1,51 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}{% if is_create %}Add Talk Override{% else %}Edit Talk Override{% endif %}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block page_title %} +

{% if is_create %}Add Talk Override{% else %}Edit Talk Override{% endif %}

+

{% if is_create %}Create a local override for a synced talk{% else %}Modify override settings{% endif %}

+{% endblock %} + +{% block content %} +
+ Override fields that are left blank will not modify the talk. + Only fields with values will be patched onto the synced data after each sync. +
+ +
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ + Cancel +
+
+
+{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/override_list.html b/src/django_program/manage/templates/django_program/manage/override_list.html new file mode 100644 index 0000000..f12b8c9 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/override_list.html @@ -0,0 +1,80 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Talk Overrides{% endblock %} + +{% block page_title %} +

Talk Overrides

+

Local patches applied on top of synced Pretalx data

+{% endblock %} + +{% block page_actions %} +Type Defaults +Add Override +{% endblock %} + +{% block content %} +{% if overrides %} + + + + + + + + + + + + + + {% for override in overrides %} + + + + + + + + + + {% endfor %} + +
TalkRoomStateCancelledRescheduledUpdatedActions
+ {{ override.talk.title }} + + {% if override.override_room %} + {{ override.override_room.name }} + {% else %} + — + {% endif %} + + {% if override.override_state %} + {{ override.override_state }} + {% else %} + — + {% endif %} + + {% if override.is_cancelled %} + Cancelled + {% else %} + — + {% endif %} + + {% if override.override_slot_start %} + {{ override.override_slot_start|date:"N j, H:i" }} + {% else %} + — + {% endif %} + {{ override.updated_at|date:"N j, H:i" }} + Edit +
+ +{% include "django_program/manage/_pagination.html" %} + +{% else %} +
+

No talk overrides configured for this conference.

+ Add Override +
+{% endif %} +{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/submission_type_default_form.html b/src/django_program/manage/templates/django_program/manage/submission_type_default_form.html new file mode 100644 index 0000000..beec9c7 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/submission_type_default_form.html @@ -0,0 +1,51 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}{% if is_create %}Add Type Default{% else %}Edit Type Default{% endif %}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block page_title %} +

{% if is_create %}Add Submission Type Default{% else %}Edit Submission Type Default{% endif %}

+

{% if is_create %}Assign default room and schedule for a submission type{% else %}Modify type default settings{% endif %}

+{% endblock %} + +{% block content %} +
+ Talks of the specified submission type that have no room assigned will + automatically receive these defaults after each Pretalx sync. +
+ +
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ + Cancel +
+
+
+{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/submission_type_default_list.html b/src/django_program/manage/templates/django_program/manage/submission_type_default_list.html new file mode 100644 index 0000000..4cc9789 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/submission_type_default_list.html @@ -0,0 +1,52 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Submission Type Defaults{% endblock %} + +{% block page_title %} +

Submission Type Defaults

+

Auto-assign rooms and times to unscheduled talks by submission type

+{% endblock %} + +{% block page_actions %} +Talk Overrides +Add Default +{% endblock %} + +{% block content %} +{% if type_defaults %} + + + + + + + + + + + + + {% for td in type_defaults %} + + + + + + + + + {% endfor %} + +
Submission TypeDefault RoomDateStart TimeEnd TimeActions
{{ td.submission_type }}{% if td.default_room %}{{ td.default_room.name }}{% else %}—{% endif %}{% if td.default_date %}{{ td.default_date|date:"N j, Y" }}{% else %}—{% endif %}{% if td.default_start_time %}{{ td.default_start_time|time:"H:i" }}{% else %}—{% endif %}{% if td.default_end_time %}{{ td.default_end_time|time:"H:i" }}{% else %}—{% endif %} + Edit +
+ +{% include "django_program/manage/_pagination.html" %} + +{% else %} +
+

No submission type defaults configured for this conference.

+ Add Default +
+{% endif %} +{% endblock %} 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..eb3e921 --- /dev/null +++ b/src/django_program/manage/urls_overrides.py @@ -0,0 +1,25 @@ +"""URL patterns for Pretalx talk 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 ( + SubmissionTypeDefaultCreateView, + SubmissionTypeDefaultEditView, + SubmissionTypeDefaultListView, + TalkOverrideCreateView, + TalkOverrideEditView, + TalkOverrideListView, +) + +urlpatterns = [ + 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"), + 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_overrides.py b/src/django_program/manage/views_overrides.py new file mode 100644 index 0000000..64173fe --- /dev/null +++ b/src/django_program/manage/views_overrides.py @@ -0,0 +1,204 @@ +"""Views for managing Pretalx talk overrides and submission type defaults.""" + +from typing import TYPE_CHECKING, Any + +from django.contrib import messages +from django.urls import reverse +from django.views.generic import CreateView, ListView, UpdateView + +from django_program.manage.forms_overrides import SubmissionTypeDefaultForm, TalkOverrideForm +from django_program.manage.views import ManagePermissionMixin +from django_program.pretalx.models import SubmissionTypeDefault, TalkOverride + +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpResponse + + +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]: + """Add active_nav to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "talks" + return context + + def get_queryset(self) -> QuerySet[TalkOverride]: + """Return overrides belonging to the current conference. + + Returns: + A queryset of TalkOverride instances with related talk and room data. + """ + 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]: + """Add active_nav and is_create to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "talks" + context["is_create"] = True + return context + + def get_form_kwargs(self) -> dict[str, Any]: + """Pass conference to the form for scoping querysets.""" + kwargs = super().get_form_kwargs() + kwargs["conference"] = self.conference + kwargs["is_edit"] = False + return kwargs + + def form_valid(self, form: TalkOverrideForm) -> HttpResponse: + """Assign the conference and created_by before saving.""" + 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: + """Redirect to the override list after creation.""" + 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]: + """Scope the queryset to the current conference. + + Returns: + A queryset of TalkOverride instances for this conference. + """ + return TalkOverride.objects.filter(conference=self.conference) + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add active_nav to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "talks" + context["is_create"] = False + return context + + def get_form_kwargs(self) -> dict[str, Any]: + """Pass conference and is_edit 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 and redirect.""" + messages.success(self.request, "Talk override updated.") + return super().form_valid(form) + + def get_success_url(self) -> str: + """Redirect to the override list after editing.""" + return reverse("manage:override-list", kwargs={"conference_slug": self.conference.slug}) + + +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]: + """Add active_nav to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "talks" + return context + + def get_queryset(self) -> QuerySet[SubmissionTypeDefault]: + """Return type defaults belonging to the current conference. + + Returns: + A queryset of SubmissionTypeDefault instances with related room data. + """ + 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]: + """Add active_nav and is_create to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "talks" + context["is_create"] = True + return context + + def get_form_kwargs(self) -> dict[str, Any]: + """Pass conference to the form for scoping querysets.""" + kwargs = super().get_form_kwargs() + kwargs["conference"] = self.conference + return kwargs + + def form_valid(self, form: SubmissionTypeDefaultForm) -> HttpResponse: + """Assign the conference before saving.""" + form.instance.conference = self.conference + messages.success(self.request, "Submission type default created.") + return super().form_valid(form) + + def get_success_url(self) -> str: + """Redirect to the type defaults list after creation.""" + 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]: + """Scope the queryset to the current conference. + + Returns: + A queryset of SubmissionTypeDefault instances for this conference. + """ + return SubmissionTypeDefault.objects.filter(conference=self.conference) + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add active_nav to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "talks" + context["is_create"] = False + return context + + def get_form_kwargs(self) -> dict[str, Any]: + """Pass conference to the form.""" + kwargs = super().get_form_kwargs() + kwargs["conference"] = self.conference + return kwargs + + def form_valid(self, form: SubmissionTypeDefaultForm) -> HttpResponse: + """Save the type default and redirect.""" + messages.success(self.request, "Submission type default updated.") + return super().form_valid(form) + + def get_success_url(self) -> str: + """Redirect to the type defaults list after editing.""" + return reverse("manage:type-default-list", kwargs={"conference_slug": self.conference.slug}) 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/models.py b/src/django_program/pretalx/models.py index 6445746..ff8fd55 100644 --- a/src/django_program/pretalx/models.py +++ b/src/django_program/pretalx/models.py @@ -1,4 +1,4 @@ -"""Speaker, Talk, Room, and ScheduleSlot models synced from Pretalx.""" +"""Speaker, Talk, Room, ScheduleSlot, and override models for Pretalx data.""" from django.conf import settings from django.db import models @@ -188,3 +188,173 @@ def display_title(self) -> str: if self.talk: return self.talk.title return self.title + + +class TalkOverride(models.Model): + """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 applied + after each sync to ensure local corrections persist. Fields left blank + (or ``None``) are not applied, preserving the synced value. + """ + + talk = models.OneToOneField( + Talk, + on_delete=models.CASCADE, + related_name="override", + ) + conference = models.ForeignKey( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="talk_overrides", + ) + 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'.", + ) + note = models.TextField( + blank=True, + default="", + help_text="Internal note explaining the reason for this override.", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_talk_overrides", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + def __str__(self) -> str: + return f"Override for {self.talk}" + + def apply(self) -> list[str]: + """Apply non-empty override fields onto the linked talk. + + Returns: + A list of field names that were changed on the talk. + """ + talk: Talk = self.talk # type: ignore[assignment] + changed: list[str] = [] + + if self.is_cancelled: + if talk.state != "cancelled": + talk.state = "cancelled" # type: ignore[assignment] + changed.append("state") + elif self.override_state and talk.state != self.override_state: + talk.state = self.override_state # type: ignore[assignment] + changed.append("state") + + if self.override_title and talk.title != self.override_title: + talk.title = self.override_title # type: ignore[assignment] + changed.append("title") + + if self.override_abstract and talk.abstract != self.override_abstract: + talk.abstract = self.override_abstract # type: ignore[assignment] + changed.append("abstract") + + if self.override_room is not None and talk.room_id != self.override_room_id: + talk.room = self.override_room # type: ignore[assignment] + changed.append("room") + + if self.override_slot_start is not None and talk.slot_start != self.override_slot_start: + talk.slot_start = self.override_slot_start # type: ignore[assignment] + changed.append("slot_start") + + if self.override_slot_end is not None and talk.slot_end != self.override_slot_end: + talk.slot_end = self.override_slot_end # type: ignore[assignment] + changed.append("slot_end") + + return changed + + +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..04a7fab 100644 --- a/src/django_program/pretalx/sync.py +++ b/src/django_program/pretalx/sync.py @@ -17,7 +17,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, TalkOverride 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 +675,96 @@ def _resolve_room(self, room_name: str) -> Room | None: return room return None + def apply_overrides(self) -> int: + """Apply all TalkOverride records for this conference onto their talks. + + Iterates through every override and patches the linked talk fields. + Only saves talks that actually changed. + + Returns: + The number of talks that were modified by overrides. + """ + overrides = TalkOverride.objects.filter(conference=self.conference).select_related("talk", "override_room") + to_update: list[Talk] = [] + all_fields: set[str] = set() + + for override in overrides: + changed_fields = override.apply() + if changed_fields: + to_update.append(override.talk) + all_fields.update(changed_fields) + + if to_update and all_fields: + Talk.objects.bulk_update(to_update, fields=list(all_fields), batch_size=500) + logger.info( + "Applied %d talk overrides for %s (fields: %s)", + len(to_update), + self.conference.slug, + ", ".join(sorted(all_fields)), + ) + return len(to_update) + + 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 + + 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=timezone.get_current_timezone(), + ) + 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=timezone.get_current_timezone(), + ) + 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 +772,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. + ``overrides_applied`` and ``type_defaults_applied`` are added + when overrides or type defaults modify any talks. """ schedule_count, unscheduled = self.sync_schedule(allow_large_deletions=allow_large_deletions) result: dict[str, int] = { @@ -692,6 +784,15 @@ def sync_all(self, *, allow_large_deletions: bool = False) -> dict[str, int]: } if unscheduled: result["unscheduled_talks"] = unscheduled + + overrides_applied = self.apply_overrides() + if overrides_applied: + result["overrides_applied"] = overrides_applied + + type_defaults_applied = self.apply_type_defaults() + if type_defaults_applied: + result["type_defaults_applied"] = type_defaults_applied + return result From 26c55791a122456d014b1dcb9c5e8ad3cfd9beac Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:29:52 -0600 Subject: [PATCH 2/9] fix: address PR review feedback and add tests for pretalx overrides Co-Authored-By: Claude Opus 4.6 --- src/django_program/pretalx/models.py | 17 + src/django_program/pretalx/sync.py | 11 +- tests/test_manage/test_override_views.py | 386 +++++++++++++++++++ tests/test_pretalx/test_overrides.py | 461 +++++++++++++++++++++++ 4 files changed, 872 insertions(+), 3 deletions(-) create mode 100644 tests/test_manage/test_override_views.py create mode 100644 tests/test_pretalx/test_overrides.py diff --git a/src/django_program/pretalx/models.py b/src/django_program/pretalx/models.py index ff8fd55..ec818af 100644 --- a/src/django_program/pretalx/models.py +++ b/src/django_program/pretalx/models.py @@ -1,6 +1,7 @@ """Speaker, Talk, Room, ScheduleSlot, and override models for Pretalx data.""" from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models @@ -269,6 +270,22 @@ class Meta: def __str__(self) -> str: return f"Override for {self.talk}" + def save(self, *args: object, **kwargs: object) -> None: + """Auto-set conference from the linked talk when not explicitly provided.""" + if self.talk_id and not self.conference_id: + self.conference_id = Talk.objects.filter(pk=self.talk_id).values_list("conference_id", flat=True).first() + super().save(*args, **kwargs) + + def clean(self) -> None: + """Validate that the linked talk belongs to the same conference.""" + super().clean() + if self.talk_id and self.conference_id: + talk_conference_id = Talk.objects.filter(pk=self.talk_id).values_list("conference_id", flat=True).first() + if talk_conference_id is not None and talk_conference_id != self.conference_id: + raise ValidationError( + {"talk": "The selected talk does not belong to this conference."}, + ) + def apply(self) -> list[str]: """Apply non-empty override fields onto the linked talk. diff --git a/src/django_program/pretalx/sync.py b/src/django_program/pretalx/sync.py index 04a7fab..747c7cb 100644 --- a/src/django_program/pretalx/sync.py +++ b/src/django_program/pretalx/sync.py @@ -7,6 +7,7 @@ """ import logging +import zoneinfo from datetime import datetime from typing import TYPE_CHECKING @@ -684,7 +685,10 @@ def apply_overrides(self) -> int: Returns: The number of talks that were modified by overrides. """ - overrides = TalkOverride.objects.filter(conference=self.conference).select_related("talk", "override_room") + overrides = TalkOverride.objects.filter( + conference=self.conference, + talk__conference=self.conference, + ).select_related("talk", "override_room") to_update: list[Talk] = [] all_fields: set[str] = set() @@ -717,6 +721,7 @@ def apply_type_defaults(self) -> int: if not defaults.exists(): return 0 + conf_tz = zoneinfo.ZoneInfo(str(self.conference.timezone)) to_update: list[Talk] = [] update_fields: set[str] = set() @@ -739,7 +744,7 @@ def apply_type_defaults(self) -> int: talk.slot_start = datetime.combine( type_default.default_date, type_default.default_start_time, - tzinfo=timezone.get_current_timezone(), + tzinfo=conf_tz, ) update_fields.add("slot_start") changed = True @@ -748,7 +753,7 @@ def apply_type_defaults(self) -> int: talk.slot_end = datetime.combine( type_default.default_date, type_default.default_end_time, - tzinfo=timezone.get_current_timezone(), + tzinfo=conf_tz, ) update_fields.add("slot_end") changed = True diff --git a/tests/test_manage/test_override_views.py b/tests/test_manage/test_override_views.py new file mode 100644 index 0000000..ecf3074 --- /dev/null +++ b/tests/test_manage/test_override_views.py @@ -0,0 +1,386 @@ +"""Tests for override management views (TalkOverride 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.pretalx.models import Room, SubmissionTypeDefault, Talk, TalkOverride + +# --------------------------------------------------------------------------- +# 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 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 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"] == "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"] == "talks" + + 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"] == "talks" + + 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_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 + + +# =========================================================================== +# 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"] == "talks" + 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"] == "talks" + + 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"] == "talks" + + 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_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/" diff --git a/tests/test_pretalx/test_overrides.py b/tests/test_pretalx/test_overrides.py new file mode 100644 index 0000000..e739e5f --- /dev/null +++ b/tests/test_pretalx/test_overrides.py @@ -0,0 +1,461 @@ +"""Tests for TalkOverride and SubmissionTypeDefault models 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, 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.apply() +# =========================================================================== + + +@pytest.mark.django_db +class TestTalkOverrideApply: + def test_apply_overrides_title(self): + conf = _make_conference(slug="apply-title") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Original") + override = TalkOverride.objects.create(talk=talk, conference=conf, override_title="New Title") + changed = override.apply() + assert "title" in changed + assert talk.title == "New Title" + + def test_apply_does_not_change_matching_title(self): + conf = _make_conference(slug="apply-same") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Same") + override = TalkOverride.objects.create(talk=talk, conference=conf, override_title="Same") + changed = override.apply() + assert "title" not in changed + + def test_apply_overrides_state(self): + conf = _make_conference(slug="apply-state") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="confirmed") + override = TalkOverride.objects.create(talk=talk, conference=conf, override_state="withdrawn") + changed = override.apply() + assert "state" in changed + assert talk.state == "withdrawn" + + def test_apply_is_cancelled_sets_state(self): + conf = _make_conference(slug="apply-cancel") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="confirmed") + override = TalkOverride.objects.create(talk=talk, conference=conf, is_cancelled=True) + changed = override.apply() + assert "state" in changed + assert talk.state == "cancelled" + + def test_apply_is_cancelled_no_op_when_already_cancelled(self): + conf = _make_conference(slug="apply-cancel-noop") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="cancelled") + override = TalkOverride.objects.create(talk=talk, conference=conf, is_cancelled=True) + changed = override.apply() + assert "state" not in changed + + def test_apply_overrides_abstract(self): + conf = _make_conference(slug="apply-abstract") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", abstract="old") + override = TalkOverride.objects.create(talk=talk, conference=conf, override_abstract="new abstract") + changed = override.apply() + assert "abstract" in changed + assert talk.abstract == "new abstract" + + def test_apply_overrides_room(self): + conf = _make_conference(slug="apply-room") + room = Room.objects.create(conference=conf, pretalx_id=1, name="Hall A") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T") + override = TalkOverride.objects.create(talk=talk, conference=conf, override_room=room) + changed = override.apply() + assert "room" in changed + assert talk.room == room + + def test_apply_overrides_slot_times(self): + conf = _make_conference(slug="apply-slot") + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T") + start = datetime(2027, 5, 1, 9, 0, tzinfo=UTC) + end = datetime(2027, 5, 1, 10, 0, tzinfo=UTC) + override = TalkOverride.objects.create( + talk=talk, + conference=conf, + override_slot_start=start, + override_slot_end=end, + ) + changed = override.apply() + assert "slot_start" in changed + assert "slot_end" in changed + assert talk.slot_start == start + assert talk.slot_end == end + + def test_apply_empty_override_changes_nothing(self): + conf = _make_conference(slug="apply-empty") + talk = Talk.objects.create( + conference=conf, + pretalx_code="T1", + title="T", + state="confirmed", + ) + override = TalkOverride.objects.create(talk=talk, conference=conf) + changed = override.apply() + assert changed == [] + + +# =========================================================================== +# 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_overrides() in sync service +# =========================================================================== + + +@pytest.mark.django_db +class TestApplyOverrides: + def test_apply_overrides_updates_talk(self, settings): + conf = _make_conference(slug="sync-override") + service = _make_service(conf, settings) + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Original", state="confirmed") + TalkOverride.objects.create( + talk=talk, + conference=conf, + override_title="Patched", + override_state="withdrawn", + ) + + count = service.apply_overrides() + + assert count == 1 + talk.refresh_from_db() + assert talk.title == "Patched" + assert talk.state == "withdrawn" + + def test_apply_overrides_skips_unchanged(self, settings): + conf = _make_conference(slug="sync-override-noop") + service = _make_service(conf, settings) + talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T") + TalkOverride.objects.create(talk=talk, conference=conf) + + count = service.apply_overrides() + assert count == 0 + + def test_apply_overrides_filters_by_talk_conference(self, settings): + """Overrides where talk.conference differs from override.conference are excluded.""" + conf_a = _make_conference(slug="sync-ov-a", pretalx_slug="ev-a") + conf_b = _make_conference(slug="sync-ov-b", pretalx_slug="ev-b") + talk = Talk.objects.create(conference=conf_a, pretalx_code="T1", title="T") + # Create override with mismatched conferences + TalkOverride.objects.create( + talk=talk, + conference=conf_b, + override_title="Should Not Apply", + ) + + service = _make_service(conf_b, settings) + count = service.apply_overrides() + assert count == 0 + + talk.refresh_from_db() + assert talk.title == "T" + + +# =========================================================================== +# 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 + + +# =========================================================================== +# sync_all includes overrides and type defaults +# =========================================================================== + + +@pytest.mark.django_db +class TestSyncAllIncludesOverrides: + def test_sync_all_applies_overrides_and_type_defaults(self, settings): + conf = _make_conference(slug="sync-all-ov") + service = _make_service(conf, settings) + + talk = Talk.objects.create( + conference=conf, + pretalx_code="T1", + title="Original", + state="confirmed", + ) + TalkOverride.objects.create( + talk=talk, + conference=conf, + override_title="Patched", + ) + + 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["overrides_applied"] == 1 + assert result["type_defaults_applied"] == 1 + + talk.refresh_from_db() + assert talk.title == "Patched" + + unscheduled.refresh_from_db() + assert unscheduled.room == room From 9eff6db80fd3b12ae234c6a1e4c20b8e6db7120a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 20:24:52 -0600 Subject: [PATCH 3/9] feat: expand override system to speakers, rooms, and sponsors --- src/django_program/manage/forms_overrides.py | 205 ++++++- .../templates/django_program/manage/base.html | 17 + .../django_program/manage/override_form.html | 78 ++- .../django_program/manage/room_list.html | 7 +- .../manage/room_override_form.html | 108 ++++ .../manage/room_override_list.html | 50 ++ .../django_program/manage/speaker_list.html | 8 + .../manage/speaker_override_form.html | 108 ++++ .../manage/speaker_override_list.html | 52 ++ .../django_program/manage/sponsor_list.html | 7 +- .../manage/sponsor_override_form.html | 108 ++++ .../manage/sponsor_override_list.html | 62 +++ .../django_program/manage/talk_detail.html | 54 ++ .../django_program/manage/talk_list.html | 7 +- src/django_program/manage/urls_overrides.py | 25 +- src/django_program/manage/views.py | 22 +- src/django_program/manage/views_overrides.py | 448 +++++++++++++-- src/django_program/pretalx/admin.py | 81 ++- ..._alter_talkoverride_conference_and_more.py | 154 ++++++ src/django_program/pretalx/models.py | 389 ++++++++++--- src/django_program/pretalx/sync.py | 42 +- src/django_program/sponsors/admin.py | 13 +- .../migrations/0004_sponsoroverride.py | 107 ++++ src/django_program/sponsors/models.py | 208 +++++++ tests/test_manage/test_override_views.py | 511 ++++++++++++++++-- tests/test_pretalx/test_overrides.py | 438 ++++++++++----- tests/test_sponsors/test_overrides.py | 323 +++++++++++ 27 files changed, 3241 insertions(+), 391 deletions(-) create mode 100644 src/django_program/manage/templates/django_program/manage/room_override_form.html create mode 100644 src/django_program/manage/templates/django_program/manage/room_override_list.html create mode 100644 src/django_program/manage/templates/django_program/manage/speaker_override_form.html create mode 100644 src/django_program/manage/templates/django_program/manage/speaker_override_list.html create mode 100644 src/django_program/manage/templates/django_program/manage/sponsor_override_form.html create mode 100644 src/django_program/manage/templates/django_program/manage/sponsor_override_list.html create mode 100644 src/django_program/pretalx/migrations/0007_alter_talkoverride_conference_and_more.py create mode 100644 src/django_program/sponsors/migrations/0004_sponsoroverride.py create mode 100644 tests/test_sponsors/test_overrides.py diff --git a/src/django_program/manage/forms_overrides.py b/src/django_program/manage/forms_overrides.py index 60bda56..f3345e4 100644 --- a/src/django_program/manage/forms_overrides.py +++ b/src/django_program/manage/forms_overrides.py @@ -1,17 +1,82 @@ -"""Model forms for Pretalx talk overrides and submission type defaults.""" +"""Model forms for Pretalx overrides and submission type defaults.""" from django import forms -from django_program.pretalx.models import Room, SubmissionTypeDefault, Talk, TalkOverride +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. + """Form for creating or editing a talk override.""" - Provides dropdowns for room and talk selection scoped to the current - conference. The ``talk`` field is only editable during creation; on - edit, it is rendered as disabled. - """ + talk = TalkChoiceField(queryset=Talk.objects.none()) class Meta: model = TalkOverride @@ -40,29 +105,119 @@ class Meta: } def __init__(self, *args: object, conference: object = None, is_edit: bool = False, **kwargs: object) -> None: - """Initialize with conference-scoped querysets. - - Args: - conference: The conference to scope talk/room choices to. - is_edit: When True, the talk field is rendered as disabled. - *args: Positional arguments passed to ModelForm. - **kwargs: Keyword arguments passed to ModelForm. - """ + """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) + 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 SubmissionTypeDefaultForm(forms.ModelForm): - """Form for creating or editing submission type default assignments. +class SpeakerOverrideForm(forms.ModelForm): + """Form for creating or editing a speaker override.""" - Provides a text input for the submission type name and dropdowns for - room selection scoped to the current conference. - """ + 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.NullBooleanSelect(), + } + + 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 + + +class SubmissionTypeDefaultForm(forms.ModelForm): + """Form for creating or editing submission type default assignments.""" class Meta: model = SubmissionTypeDefault @@ -80,13 +235,7 @@ class Meta: } def __init__(self, *args: object, conference: object = None, **kwargs: object) -> None: - """Initialize with conference-scoped querysets. - - Args: - conference: The conference to scope room choices to. - *args: Positional arguments passed to ModelForm. - **kwargs: Keyword arguments passed to ModelForm. - """ + """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) 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 %} +
    + +
    +
    +{% 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_overrides.py b/src/django_program/manage/urls_overrides.py index eb3e921..c155004 100644 --- a/src/django_program/manage/urls_overrides.py +++ b/src/django_program/manage/urls_overrides.py @@ -1,4 +1,4 @@ -"""URL patterns for Pretalx talk overrides and submission type defaults. +"""URL patterns for overrides and submission type defaults. Included from the main management URL config under the ``/overrides/`` prefix. @@ -7,6 +7,15 @@ from django.urls import path from django_program.manage.views_overrides import ( + RoomOverrideCreateView, + RoomOverrideEditView, + RoomOverrideListView, + SpeakerOverrideCreateView, + SpeakerOverrideEditView, + SpeakerOverrideListView, + SponsorOverrideCreateView, + SponsorOverrideEditView, + SponsorOverrideListView, SubmissionTypeDefaultCreateView, SubmissionTypeDefaultEditView, SubmissionTypeDefaultListView, @@ -16,9 +25,23 @@ ) 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..2e68b54 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -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") ) @@ -1112,10 +1117,13 @@ def get_queryset(self) -> QuerySet[Talk]: ) 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") + # Check if a TalkOverride already exists for this talk + override = TalkOverride.objects.filter(talk=self.object).first() + context["talk_override"] = override return context @@ -1362,7 +1370,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 index 64173fe..a44213b 100644 --- a/src/django_program/manage/views_overrides.py +++ b/src/django_program/manage/views_overrides.py @@ -1,20 +1,33 @@ -"""Views for managing Pretalx talk overrides and submission type defaults.""" +"""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 SubmissionTypeDefaultForm, TalkOverrideForm +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 SubmissionTypeDefault, TalkOverride +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.""" @@ -23,17 +36,14 @@ class TalkOverrideListView(ManagePermissionMixin, ListView): paginate_by = 50 def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add active_nav to the template context.""" + """Return template context with navigation state.""" context = super().get_context_data(**kwargs) - context["active_nav"] = "talks" + context["active_nav"] = "overrides" + context["active_override_tab"] = "talks" return context def get_queryset(self) -> QuerySet[TalkOverride]: - """Return overrides belonging to the current conference. - - Returns: - A queryset of TalkOverride instances with related talk and room data. - """ + """Return overrides filtered to the current conference.""" return ( TalkOverride.objects.filter(conference=self.conference) .select_related("talk", "override_room", "created_by") @@ -48,28 +58,37 @@ class TalkOverrideCreateView(ManagePermissionMixin, CreateView): form_class = TalkOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add active_nav and is_create to the template context.""" + """Return template context with navigation state.""" context = super().get_context_data(**kwargs) - context["active_nav"] = "talks" + 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 to the form for scoping querysets.""" + """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: - """Assign the conference and created_by before saving.""" + """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: - """Redirect to the override list after creation.""" + """Return the URL to redirect to after form submission.""" return reverse("manage:override-list", kwargs={"conference_slug": self.conference.slug}) @@ -80,37 +99,375 @@ class TalkOverrideEditView(ManagePermissionMixin, UpdateView): form_class = TalkOverrideForm def get_queryset(self) -> QuerySet[TalkOverride]: - """Scope the queryset to the current conference. - - Returns: - A queryset of TalkOverride instances for this conference. - """ + """Return overrides filtered to the current conference.""" return TalkOverride.objects.filter(conference=self.conference) def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add active_nav to the template context.""" + """Return template context with navigation state.""" context = super().get_context_data(**kwargs) - context["active_nav"] = "talks" + 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 is_edit to the form.""" + """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 and redirect.""" + """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 super().form_valid(form) + return redirect(self.get_success_url()) def get_success_url(self) -> str: - """Redirect to the override list after editing.""" + """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.""" @@ -119,17 +476,14 @@ class SubmissionTypeDefaultListView(ManagePermissionMixin, ListView): paginate_by = 50 def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add active_nav to the template context.""" + """Return template context with navigation state.""" context = super().get_context_data(**kwargs) - context["active_nav"] = "talks" + context["active_nav"] = "overrides" + context["active_override_tab"] = "type-defaults" return context def get_queryset(self) -> QuerySet[SubmissionTypeDefault]: - """Return type defaults belonging to the current conference. - - Returns: - A queryset of SubmissionTypeDefault instances with related room data. - """ + """Return overrides filtered to the current conference.""" return ( SubmissionTypeDefault.objects.filter(conference=self.conference) .select_related("default_room") @@ -144,26 +498,27 @@ class SubmissionTypeDefaultCreateView(ManagePermissionMixin, CreateView): form_class = SubmissionTypeDefaultForm def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add active_nav and is_create to the template context.""" + """Return template context with navigation state.""" context = super().get_context_data(**kwargs) - context["active_nav"] = "talks" + 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 to the form for scoping querysets.""" + """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: - """Assign the conference before saving.""" + """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: - """Redirect to the type defaults list after creation.""" + """Return the URL to redirect to after form submission.""" return reverse("manage:type-default-list", kwargs={"conference_slug": self.conference.slug}) @@ -174,31 +529,28 @@ class SubmissionTypeDefaultEditView(ManagePermissionMixin, UpdateView): form_class = SubmissionTypeDefaultForm def get_queryset(self) -> QuerySet[SubmissionTypeDefault]: - """Scope the queryset to the current conference. - - Returns: - A queryset of SubmissionTypeDefault instances for this conference. - """ + """Return overrides filtered to the current conference.""" return SubmissionTypeDefault.objects.filter(conference=self.conference) def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add active_nav to the template context.""" + """Return template context with navigation state.""" context = super().get_context_data(**kwargs) - context["active_nav"] = "talks" + 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 to the form.""" + """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 type default and redirect.""" + """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: - """Redirect to the type defaults list after editing.""" + """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/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/models.py b/src/django_program/pretalx/models.py index ec818af..600e85c 100644 --- a/src/django_program/pretalx/models.py +++ b/src/django_program/pretalx/models.py @@ -1,5 +1,10 @@ """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 @@ -39,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. @@ -76,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. @@ -124,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. @@ -191,25 +341,83 @@ def display_title(self) -> str: return self.title -class TalkOverride(models.Model): +class AbstractOverride(models.Model): + """Shared base for all override models. + + Provides common metadata fields (note, created_by, timestamps) and + the conference FK with auto-set-from-parent logic. + """ + + conference = models.ForeignKey( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="%(class)ss", + ) + note = models.TextField( + blank=True, + default="", + help_text="Internal note explaining the reason for this override.", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_%(class)ss", + ) + 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 applied - after each sync to ensure local corrections persist. Fields left blank - (or ``None``) are not applied, preserving the synced value. + 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" + talk = models.OneToOneField( Talk, on_delete=models.CASCADE, related_name="override", ) - conference = models.ForeignKey( - "program_conference.Conference", - on_delete=models.CASCADE, - related_name="talk_overrides", - ) override_room = models.ForeignKey( Room, on_delete=models.SET_NULL, @@ -249,81 +457,116 @@ class TalkOverride(models.Model): default=False, help_text="Mark this talk as cancelled. Overrides the state to 'cancelled'.", ) - note = models.TextField( + + 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" + + speaker = models.OneToOneField( + Speaker, + on_delete=models.CASCADE, + related_name="override", + ) + override_name = models.CharField( + max_length=300, blank=True, default="", - help_text="Internal note explaining the reason for this override.", + help_text="Override the speaker's display name.", ) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, + override_biography = models.TextField( blank=True, - related_name="created_talk_overrides", + 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.", ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["-updated_at"] def __str__(self) -> str: - return f"Override for {self.talk}" + return f"Override for {self.speaker}" - def save(self, *args: object, **kwargs: object) -> None: - """Auto-set conference from the linked talk when not explicitly provided.""" - if self.talk_id and not self.conference_id: - self.conference_id = Talk.objects.filter(pk=self.talk_id).values_list("conference_id", flat=True).first() - super().save(*args, **kwargs) + @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 + ) - def clean(self) -> None: - """Validate that the linked talk belongs to the same conference.""" - super().clean() - if self.talk_id and self.conference_id: - talk_conference_id = Talk.objects.filter(pk=self.talk_id).values_list("conference_id", flat=True).first() - if talk_conference_id is not None and talk_conference_id != self.conference_id: - raise ValidationError( - {"talk": "The selected talk does not belong to this conference."}, - ) - def apply(self) -> list[str]: - """Apply non-empty override fields onto the linked talk. +class RoomOverride(AbstractOverride): + """Local override applied on top of synced Pretalx room data. - Returns: - A list of field names that were changed on the talk. - """ - talk: Talk = self.talk # type: ignore[assignment] - changed: list[str] = [] - - if self.is_cancelled: - if talk.state != "cancelled": - talk.state = "cancelled" # type: ignore[assignment] - changed.append("state") - elif self.override_state and talk.state != self.override_state: - talk.state = self.override_state # type: ignore[assignment] - changed.append("state") - - if self.override_title and talk.title != self.override_title: - talk.title = self.override_title # type: ignore[assignment] - changed.append("title") - - if self.override_abstract and talk.abstract != self.override_abstract: - talk.abstract = self.override_abstract # type: ignore[assignment] - changed.append("abstract") - - if self.override_room is not None and talk.room_id != self.override_room_id: - talk.room = self.override_room # type: ignore[assignment] - changed.append("room") - - if self.override_slot_start is not None and talk.slot_start != self.override_slot_start: - talk.slot_start = self.override_slot_start # type: ignore[assignment] - changed.append("slot_start") - - if self.override_slot_end is not None and talk.slot_end != self.override_slot_end: - talk.slot_end = self.override_slot_end # type: ignore[assignment] - changed.append("slot_end") - - return changed + Allows conference organizers to patch individual fields of a synced room + without modifying the upstream Pretalx record. + """ + + _parent_field = "room" + + 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): diff --git a/src/django_program/pretalx/sync.py b/src/django_program/pretalx/sync.py index 747c7cb..82a1850 100644 --- a/src/django_program/pretalx/sync.py +++ b/src/django_program/pretalx/sync.py @@ -18,7 +18,7 @@ from django.utils import timezone from django.utils.text import slugify -from django_program.pretalx.models import Room, ScheduleSlot, Speaker, SubmissionTypeDefault, Talk, TalkOverride +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 @@ -676,38 +676,6 @@ def _resolve_room(self, room_name: str) -> Room | None: return room return None - def apply_overrides(self) -> int: - """Apply all TalkOverride records for this conference onto their talks. - - Iterates through every override and patches the linked talk fields. - Only saves talks that actually changed. - - Returns: - The number of talks that were modified by overrides. - """ - overrides = TalkOverride.objects.filter( - conference=self.conference, - talk__conference=self.conference, - ).select_related("talk", "override_room") - to_update: list[Talk] = [] - all_fields: set[str] = set() - - for override in overrides: - changed_fields = override.apply() - if changed_fields: - to_update.append(override.talk) - all_fields.update(changed_fields) - - if to_update and all_fields: - Talk.objects.bulk_update(to_update, fields=list(all_fields), batch_size=500) - logger.info( - "Applied %d talk overrides for %s (fields: %s)", - len(to_update), - self.conference.slug, - ", ".join(sorted(all_fields)), - ) - return len(to_update) - def apply_type_defaults(self) -> int: """Apply SubmissionTypeDefault records to unscheduled talks. @@ -777,8 +745,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. - ``overrides_applied`` and ``type_defaults_applied`` are added - when overrides or type defaults modify any talks. + ``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] = { @@ -790,10 +758,6 @@ def sync_all(self, *, allow_large_deletions: bool = False) -> dict[str, int]: if unscheduled: result["unscheduled_talks"] = unscheduled - overrides_applied = self.apply_overrides() - if overrides_applied: - result["overrides_applied"] = overrides_applied - type_defaults_applied = self.apply_type_defaults() if type_defaults_applied: result["type_defaults_applied"] = type_defaults_applied 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..97b88c8 100644 --- a/src/django_program/sponsors/models.py +++ b/src/django_program/sponsors/models.py @@ -1,5 +1,6 @@ """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 @@ -108,6 +109,213 @@ 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(models.Model): + """Local override applied on top of sponsor data. + + Allows conference organizers to patch individual fields of a sponsor + without modifying the original record. + """ + + sponsor = models.OneToOneField( + Sponsor, + on_delete=models.CASCADE, + related_name="override", + ) + conference = models.ForeignKey( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="sponsor_overrides", + ) + 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.", + ) + note = models.TextField( + blank=True, + default="", + help_text="Internal note explaining the reason for this override.", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_sponsor_overrides", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + def __str__(self) -> str: + return f"Override for {self.sponsor}" + + def save(self, *args: object, **kwargs: object) -> None: + """Auto-set conference from the linked sponsor when not explicitly provided.""" + if self.sponsor_id and not self.conference_id: + self.conference_id = ( + Sponsor.objects.filter(pk=self.sponsor_id).values_list("conference_id", flat=True).first() + ) + super().save(*args, **kwargs) + + def clean(self) -> None: + """Validate that the linked sponsor belongs to the same conference.""" + super().clean() + if self.sponsor_id and self.conference_id: + sponsor_conference_id = ( + Sponsor.objects.filter(pk=self.sponsor_id).values_list("conference_id", flat=True).first() + ) + if sponsor_conference_id is not None and sponsor_conference_id != self.conference_id: + raise ValidationError( + {"sponsor": "The selected sponsor does not belong to this conference."}, + ) + + @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 index ecf3074..bf05a66 100644 --- a/tests/test_manage/test_override_views.py +++ b/tests/test_manage/test_override_views.py @@ -1,4 +1,4 @@ -"""Tests for override management views (TalkOverride and SubmissionTypeDefault CRUD).""" +"""Tests for override management views (all override types and SubmissionTypeDefault CRUD).""" from datetime import date, timedelta @@ -9,7 +9,16 @@ from django.utils import timezone from django_program.conference.models import Conference -from django_program.pretalx.models import Room, SubmissionTypeDefault, Talk, TalkOverride +from django_program.pretalx.models import ( + Room, + RoomOverride, + Speaker, + SpeakerOverride, + SubmissionTypeDefault, + Talk, + TalkOverride, +) +from django_program.sponsors.models import Sponsor, SponsorLevel, SponsorOverride # --------------------------------------------------------------------------- # Fixtures @@ -59,6 +68,36 @@ def talk(conference, room): ) +@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( @@ -70,6 +109,39 @@ def talk_override(talk, conference, 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( @@ -110,7 +182,8 @@ def test_list_loads(self, authed_client, conference, talk_override): resp = authed_client.get(url) assert resp.status_code == 200 assert "overrides" in resp.context - assert resp.context["active_nav"] == "talks" + assert resp.context["active_nav"] == "overrides" + assert resp.context["active_override_tab"] == "talks" overrides = list(resp.context["overrides"]) assert len(overrides) == 1 @@ -143,7 +216,14 @@ def test_get_create_form(self, authed_client, conference, talk): resp = authed_client.get(url) assert resp.status_code == 200 assert resp.context["is_create"] is True - assert resp.context["active_nav"] == "talks" + 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}) @@ -186,23 +266,17 @@ 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, - }, + 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"] == "talks" + 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, - }, + kwargs={"conference_slug": conference.slug, "pk": talk_override.pk}, ) resp = authed_client.post( url, @@ -222,18 +296,364 @@ def test_post_updates_override(self, authed_client, conference, talk, talk_overr talk_override.refresh_from_db() assert talk_override.override_title == "Updated Title" - def test_edit_nonexistent_returns_404(self, authed_client, conference): + def test_edit_empty_deletes_override(self, authed_client, conference, talk, talk_override): url = reverse( "manage:override-edit", - kwargs={ - "conference_slug": conference.slug, - "pk": 99999, + 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 # =========================================================================== @@ -246,7 +666,7 @@ def test_list_loads(self, authed_client, conference, type_default): resp = authed_client.get(url) assert resp.status_code == 200 assert "type_defaults" in resp.context - assert resp.context["active_nav"] == "talks" + assert resp.context["active_nav"] == "overrides" defaults = list(resp.context["type_defaults"]) assert len(defaults) == 1 @@ -273,7 +693,7 @@ def test_get_create_form(self, authed_client, conference): resp = authed_client.get(url) assert resp.status_code == 200 assert resp.context["is_create"] is True - assert resp.context["active_nav"] == "talks" + 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}) @@ -311,23 +731,17 @@ 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, - }, + 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"] == "talks" + 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, - }, + kwargs={"conference_slug": conference.slug, "pk": type_default.pk}, ) resp = authed_client.post( url, @@ -346,10 +760,7 @@ def test_post_updates_default(self, authed_client, conference, type_default, roo def test_edit_nonexistent_returns_404(self, authed_client, conference): url = reverse( "manage:type-default-edit", - kwargs={ - "conference_slug": conference.slug, - "pk": 99999, - }, + kwargs={"conference_slug": conference.slug, "pk": 99999}, ) resp = authed_client.get(url) assert resp.status_code == 404 @@ -373,6 +784,42 @@ 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/" diff --git a/tests/test_pretalx/test_overrides.py b/tests/test_pretalx/test_overrides.py index e739e5f..2986b17 100644 --- a/tests/test_pretalx/test_overrides.py +++ b/tests/test_pretalx/test_overrides.py @@ -1,4 +1,4 @@ -"""Tests for TalkOverride and SubmissionTypeDefault models and sync integration.""" +"""Tests for override models, effective properties, and sync integration.""" import zoneinfo from datetime import UTC, date, datetime, time @@ -8,7 +8,15 @@ from django.core.exceptions import ValidationError from django_program.conference.models import Conference -from django_program.pretalx.models import Room, SubmissionTypeDefault, Talk, TalkOverride +from django_program.pretalx.models import ( + Room, + RoomOverride, + Speaker, + SpeakerOverride, + SubmissionTypeDefault, + Talk, + TalkOverride, +) from django_program.pretalx.sync import PretalxSyncService # --------------------------------------------------------------------------- @@ -117,162 +125,314 @@ def test_save_does_not_override_explicit_conference(self): # =========================================================================== -# TalkOverride.apply() +# TalkOverride.is_empty # =========================================================================== @pytest.mark.django_db -class TestTalkOverrideApply: - def test_apply_overrides_title(self): - conf = _make_conference(slug="apply-title") +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") - override = TalkOverride.objects.create(talk=talk, conference=conf, override_title="New Title") - changed = override.apply() - assert "title" in changed - assert talk.title == "New Title" - - def test_apply_does_not_change_matching_title(self): - conf = _make_conference(slug="apply-same") - talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Same") - override = TalkOverride.objects.create(talk=talk, conference=conf, override_title="Same") - changed = override.apply() - assert "title" not in changed - - def test_apply_overrides_state(self): - conf = _make_conference(slug="apply-state") + 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") - override = TalkOverride.objects.create(talk=talk, conference=conf, override_state="withdrawn") - changed = override.apply() - assert "state" in changed - assert talk.state == "withdrawn" + TalkOverride.objects.create(talk=talk, conference=conf, override_state="withdrawn") + assert talk.effective_state == "withdrawn" - def test_apply_is_cancelled_sets_state(self): - conf = _make_conference(slug="apply-cancel") + 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") - override = TalkOverride.objects.create(talk=talk, conference=conf, is_cancelled=True) - changed = override.apply() - assert "state" in changed - assert talk.state == "cancelled" - - def test_apply_is_cancelled_no_op_when_already_cancelled(self): - conf = _make_conference(slug="apply-cancel-noop") - talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T", state="cancelled") - override = TalkOverride.objects.create(talk=talk, conference=conf, is_cancelled=True) - changed = override.apply() - assert "state" not in changed - - def test_apply_overrides_abstract(self): - conf = _make_conference(slug="apply-abstract") + 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") - override = TalkOverride.objects.create(talk=talk, conference=conf, override_abstract="new abstract") - changed = override.apply() - assert "abstract" in changed - assert talk.abstract == "new abstract" - - def test_apply_overrides_room(self): - conf = _make_conference(slug="apply-room") - room = Room.objects.create(conference=conf, pretalx_id=1, name="Hall A") + 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") - override = TalkOverride.objects.create(talk=talk, conference=conf, override_room=room) - changed = override.apply() - assert "room" in changed - assert talk.room == room + 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_apply_overrides_slot_times(self): - conf = _make_conference(slug="apply-slot") + 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) - override = TalkOverride.objects.create( - talk=talk, - conference=conf, - override_slot_start=start, - override_slot_end=end, + 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" ) - changed = override.apply() - assert "slot_start" in changed - assert "slot_end" in changed - assert talk.slot_start == start - assert talk.slot_end == end - - def test_apply_empty_override_changes_nothing(self): - conf = _make_conference(slug="apply-empty") - talk = Talk.objects.create( - conference=conf, - pretalx_code="T1", - title="T", - state="confirmed", + SpeakerOverride.objects.create( + speaker=speaker, conference=conf, override_avatar_url="https://new.com/avatar.jpg" ) - override = TalkOverride.objects.create(talk=talk, conference=conf) - changed = override.apply() - assert changed == [] + 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" # =========================================================================== -# SubmissionTypeDefault.__str__ +# RoomOverride # =========================================================================== @pytest.mark.django_db -class TestSubmissionTypeDefaultStr: +class TestRoomOverride: 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'" + 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 # =========================================================================== -# apply_overrides() in sync service +# Room effective_* properties # =========================================================================== @pytest.mark.django_db -class TestApplyOverrides: - def test_apply_overrides_updates_talk(self, settings): - conf = _make_conference(slug="sync-override") - service = _make_service(conf, settings) - talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="Original", state="confirmed") - TalkOverride.objects.create( - talk=talk, - conference=conf, - override_title="Patched", - override_state="withdrawn", - ) +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" - count = service.apply_overrides() + 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" - assert count == 1 - talk.refresh_from_db() - assert talk.title == "Patched" - assert talk.state == "withdrawn" + 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_apply_overrides_skips_unchanged(self, settings): - conf = _make_conference(slug="sync-override-noop") - service = _make_service(conf, settings) - talk = Talk.objects.create(conference=conf, pretalx_code="T1", title="T") - TalkOverride.objects.create(talk=talk, conference=conf) + 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 - count = service.apply_overrides() - assert count == 0 + 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_apply_overrides_filters_by_talk_conference(self, settings): - """Overrides where talk.conference differs from override.conference are excluded.""" - conf_a = _make_conference(slug="sync-ov-a", pretalx_slug="ev-a") - conf_b = _make_conference(slug="sync-ov-b", pretalx_slug="ev-b") - talk = Talk.objects.create(conference=conf_a, pretalx_code="T1", title="T") - # Create override with mismatched conferences - TalkOverride.objects.create( - talk=talk, - conference=conf_b, - override_title="Should Not Apply", - ) + 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__ +# =========================================================================== - service = _make_service(conf_b, settings) - count = service.apply_overrides() - assert count == 0 - talk.refresh_from_db() - assert talk.title == "T" +@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'" # =========================================================================== @@ -408,27 +568,28 @@ def test_assigns_only_room_when_no_date(self, settings): # =========================================================================== -# sync_all includes overrides and type defaults +# AbstractOverride._get_parent_conference_id # =========================================================================== @pytest.mark.django_db -class TestSyncAllIncludesOverrides: - def test_sync_all_applies_overrides_and_type_defaults(self, settings): - conf = _make_conference(slug="sync-all-ov") - service = _make_service(conf, settings) +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 - talk = Talk.objects.create( - conference=conf, - pretalx_code="T1", - title="Original", - state="confirmed", - ) - TalkOverride.objects.create( - talk=talk, - conference=conf, - override_title="Patched", - ) + +# =========================================================================== +# 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, @@ -451,11 +612,8 @@ def test_sync_all_applies_overrides_and_type_defaults(self, settings): result = service.sync_all() - assert result["overrides_applied"] == 1 assert result["type_defaults_applied"] == 1 - - talk.refresh_from_db() - assert talk.title == "Patched" + 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..ef15ae6 --- /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 for 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 From b46fd3058b603381d42d9e3ea00be17e2555d9f1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 22:46:36 -0600 Subject: [PATCH 4/9] fix: address PR review feedback for timezone safety and CDN SRI Co-Authored-By: Claude Opus 4.6 --- .../django_program/manage/override_form.html | 4 +-- .../manage/room_override_form.html | 4 +-- .../manage/speaker_override_form.html | 4 +-- .../manage/sponsor_override_form.html | 4 +-- src/django_program/pretalx/sync.py | 12 +++++-- tests/test_pretalx/test_overrides.py | 35 +++++++++++++++++++ 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/django_program/manage/templates/django_program/manage/override_form.html b/src/django_program/manage/templates/django_program/manage/override_form.html index 11f9dac..8a07c7c 100644 --- a/src/django_program/manage/templates/django_program/manage/override_form.html +++ b/src/django_program/manage/templates/django_program/manage/override_form.html @@ -3,7 +3,7 @@ {% block title %}{% if is_create %}Add Talk Override{% else %}Edit Talk Override{% endif %}{% endblock %} {% block extra_head %} - +