From 61e0d1edaca11b15e67585092cd604fec98dced0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Feb 2026 19:46:35 -0600 Subject: [PATCH 1/4] feat: add sponsors app with models, admin, signals, views, and manage UI Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 1 + src/django_program/manage/forms.py | 27 +++ .../templates/django_program/manage/base.html | 15 ++ .../django_program/manage/dashboard.html | 6 + .../django_program/manage/sponsor_edit.html | 73 +++++++ .../manage/sponsor_level_edit.html | 45 ++++ .../manage/sponsor_level_list.html | 50 +++++ .../django_program/manage/sponsor_list.html | 52 +++++ src/django_program/manage/urls.py | 20 ++ src/django_program/manage/views.py | 158 ++++++++++++++ src/django_program/sponsors/admin.py | 34 ++++ src/django_program/sponsors/apps.py | 4 + .../sponsors/migrations/0001_initial.py | 151 ++++++++++++++ .../sponsors/migrations/__init__.py | 0 src/django_program/sponsors/models.py | 105 ++++++++++ src/django_program/sponsors/signals.py | 46 +++++ .../sponsors/sponsor_detail.html | 29 +++ .../django_program/sponsors/sponsor_list.html | 23 +++ src/django_program/sponsors/urls.py | 20 ++ src/django_program/sponsors/views.py | 82 ++++++++ tests/test_manage/test_sponsor_views.py | 192 ++++++++++++++++++ tests/test_sponsors/test_models.py | 59 ++++++ tests/test_sponsors/test_signals.py | 117 +++++++++++ tests/test_sponsors/test_views.py | 99 +++++++++ tests/urls.py | 1 + uv.lock | 35 ++++ 26 files changed, 1444 insertions(+) create mode 100644 src/django_program/manage/templates/django_program/manage/sponsor_edit.html create mode 100644 src/django_program/manage/templates/django_program/manage/sponsor_level_edit.html create mode 100644 src/django_program/manage/templates/django_program/manage/sponsor_level_list.html create mode 100644 src/django_program/manage/templates/django_program/manage/sponsor_list.html create mode 100644 src/django_program/sponsors/admin.py create mode 100644 src/django_program/sponsors/migrations/0001_initial.py create mode 100644 src/django_program/sponsors/migrations/__init__.py create mode 100644 src/django_program/sponsors/models.py create mode 100644 src/django_program/sponsors/signals.py create mode 100644 src/django_program/sponsors/templates/django_program/sponsors/sponsor_detail.html create mode 100644 src/django_program/sponsors/templates/django_program/sponsors/sponsor_list.html create mode 100644 src/django_program/sponsors/urls.py create mode 100644 src/django_program/sponsors/views.py create mode 100644 tests/test_manage/test_sponsor_views.py create mode 100644 tests/test_sponsors/test_models.py create mode 100644 tests/test_sponsors/test_signals.py create mode 100644 tests/test_sponsors/test_views.py diff --git a/pyproject.toml b/pyproject.toml index cb72736..af5531e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ classifiers = [ dependencies = [ "django>=5.2", "django-fernet-encrypted-fields>=0.3.1", + "pillow>=12.1.1", "pretalx-client>=0.1.0", "stripe>=12.0.0", ] diff --git a/src/django_program/manage/forms.py b/src/django_program/manage/forms.py index 3cdf13f..0fc7c56 100644 --- a/src/django_program/manage/forms.py +++ b/src/django_program/manage/forms.py @@ -11,6 +11,7 @@ from django_program.conference.models import Conference, Section from django_program.pretalx.models import Room, ScheduleSlot, Talk +from django_program.sponsors.models import Sponsor, SponsorLevel class ImportFromPretalxForm(forms.Form): @@ -187,3 +188,29 @@ def __init__(self, *args: object, **kwargs: object) -> None: for field_name in self.SYNCED_FIELDS: if field_name in self.fields: self.fields[field_name].disabled = True + + +class SponsorLevelForm(forms.ModelForm): + """Form for editing a sponsor level.""" + + class Meta: + model = SponsorLevel + fields = ["name", "slug", "cost", "description", "benefits_summary", "comp_ticket_count", "order"] + + +class SponsorForm(forms.ModelForm): + """Form for editing a sponsor.""" + + class Meta: + model = Sponsor + fields = [ + "name", + "slug", + "level", + "website_url", + "logo", + "description", + "contact_name", + "contact_email", + "is_active", + ] 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 9671b92..2c827dc 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -1041,6 +1041,21 @@ + {% endif %} diff --git a/src/django_program/manage/templates/django_program/manage/dashboard.html b/src/django_program/manage/templates/django_program/manage/dashboard.html index 2e74d9b..84379e2 100644 --- a/src/django_program/manage/templates/django_program/manage/dashboard.html +++ b/src/django_program/manage/templates/django_program/manage/dashboard.html @@ -97,6 +97,12 @@

{{ conference.name }}

Sections
+ {% if stats.unscheduled_talks %}
diff --git a/src/django_program/manage/templates/django_program/manage/sponsor_edit.html b/src/django_program/manage/templates/django_program/manage/sponsor_edit.html new file mode 100644 index 0000000..232cac8 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/sponsor_edit.html @@ -0,0 +1,73 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}{% if is_create %}Add Sponsor{% else %}Edit Sponsor{% endif %}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block page_title %} +

{% if is_create %}Add Sponsor{% else %}Edit Sponsor: {{ sponsor.name }}{% endif %}

+{% endblock %} + +{% block content %} +
+
+ {% 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 +
+
+
+ +{% if not is_create and benefits %} +

Benefits

+ + + + + + + + + + {% for benefit in benefits %} + + + + + + {% endfor %} + +
BenefitStatusNotes
{{ benefit.name }} + {% if benefit.is_complete %} + Complete + {% else %} + Pending + {% endif %} + {{ benefit.notes|default:"--" }}
+{% endif %} +{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/sponsor_level_edit.html b/src/django_program/manage/templates/django_program/manage/sponsor_level_edit.html new file mode 100644 index 0000000..4eab9c5 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/sponsor_level_edit.html @@ -0,0 +1,45 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}{% if is_create %}Add Sponsor Level{% else %}Edit Sponsor Level{% endif %}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block page_title %} +

{% if is_create %}Add Sponsor Level{% else %}Edit Level: {{ level.name }}{% endif %}

+{% endblock %} + +{% block content %} +
+
+ {% 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/sponsor_level_list.html b/src/django_program/manage/templates/django_program/manage/sponsor_level_list.html new file mode 100644 index 0000000..7247d59 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/sponsor_level_list.html @@ -0,0 +1,50 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Sponsor Levels{% endblock %} + +{% block page_title %} +

Sponsor Levels

+

Sponsorship tiers and pricing

+{% endblock %} + +{% block page_actions %} +Add Level +{% endblock %} + +{% block content %} +{% if levels %} + + + + + + + + + + + + + + {% for level in levels %} + + + + + + + + + + {% endfor %} + +
NameSlugCostComp TicketsOrderSponsorsActions
{{ level.name }}{{ level.slug }}${{ level.cost }}{{ level.comp_ticket_count }}{{ level.order }}{{ level.sponsors.count }} + Edit +
+{% else %} +
+

No sponsor levels defined for this conference.

+

Add First Level

+
+{% endif %} +{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/sponsor_list.html b/src/django_program/manage/templates/django_program/manage/sponsor_list.html new file mode 100644 index 0000000..e91486d --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/sponsor_list.html @@ -0,0 +1,52 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Sponsors{% endblock %} + +{% block page_title %} +

Sponsors

+

Conference sponsors and their benefits

+{% endblock %} + +{% block page_actions %} +Add Sponsor +{% endblock %} + +{% block content %} +{% if sponsors %} + + + + + + + + + + + + {% for sponsor in sponsors %} + + + + + + + + {% endfor %} + +
NameLevelContactStatusActions
{{ sponsor.name }}{{ sponsor.level.name }}{{ sponsor.contact_name|default:"--" }} + {% if sponsor.is_active %} + Active + {% else %} + Inactive + {% endif %} + + Edit +
+{% else %} +
+

No sponsors for this conference yet.

+

Add First Sponsor

+
+{% endif %} +{% endblock %} diff --git a/src/django_program/manage/urls.py b/src/django_program/manage/urls.py index a3ad5ec..7a2e633 100644 --- a/src/django_program/manage/urls.py +++ b/src/django_program/manage/urls.py @@ -26,6 +26,12 @@ SectionListView, SpeakerDetailView, SpeakerListView, + SponsorCreateView, + SponsorEditView, + SponsorLevelCreateView, + SponsorLevelEditView, + SponsorLevelListView, + SponsorManageListView, SyncPretalxStreamView, SyncPretalxView, TalkDetailView, @@ -74,4 +80,18 @@ ScheduleSlotEditView.as_view(), name="slot-edit", ), + path("/sponsor-levels/", SponsorLevelListView.as_view(), name="sponsor-level-list"), + path("/sponsor-levels/add/", SponsorLevelCreateView.as_view(), name="sponsor-level-add"), + path( + "/sponsor-levels//edit/", + SponsorLevelEditView.as_view(), + name="sponsor-level-edit", + ), + path("/sponsors/", SponsorManageListView.as_view(), name="sponsor-manage-list"), + path("/sponsors/add/", SponsorCreateView.as_view(), name="sponsor-add"), + path( + "/sponsors//edit/", + SponsorEditView.as_view(), + name="sponsor-edit", + ), ] diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index cda7b2f..2ba3fb8 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -34,11 +34,14 @@ RoomForm, ScheduleSlotForm, SectionForm, + SponsorForm, + SponsorLevelForm, TalkForm, ) from django_program.pretalx.models import Room, ScheduleSlot, Speaker, Talk from django_program.pretalx.sync import PretalxSyncService from django_program.settings import get_config +from django_program.sponsors.models import Sponsor, SponsorLevel from pretalx_client.adapters.normalization import localized as _localized from pretalx_client.client import PretalxClient @@ -602,6 +605,8 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: "schedule_slots": ScheduleSlot.objects.filter(conference=conference).count(), "sections": Section.objects.filter(conference=conference).count(), "unscheduled_talks": Talk.objects.filter(conference=conference, slot_start__isnull=True).count(), + "sponsors": Sponsor.objects.filter(conference=conference).count(), + "sponsor_levels": SponsorLevel.objects.filter(conference=conference).count(), } return context @@ -1171,6 +1176,159 @@ def form_valid(self, form: ScheduleSlotForm) -> HttpResponse: return super().form_valid(form) +class SponsorLevelListView(ManagePermissionMixin, ListView): + """List sponsor levels for the current conference.""" + + template_name = "django_program/manage/sponsor_level_list.html" + context_object_name = "levels" + 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"] = "sponsor-levels" + return context + + def get_queryset(self) -> QuerySet[SponsorLevel]: + """Return sponsor levels for the current conference.""" + return SponsorLevel.objects.filter(conference=self.conference).order_by("order", "name") + + +class SponsorLevelEditView(ManagePermissionMixin, UpdateView): + """Edit a sponsor level.""" + + template_name = "django_program/manage/sponsor_level_edit.html" + form_class = SponsorLevelForm + context_object_name = "level" + + 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"] = "sponsor-levels" + return context + + def get_queryset(self) -> QuerySet[SponsorLevel]: + """Scope to the current conference.""" + return SponsorLevel.objects.filter(conference=self.conference) + + def get_success_url(self) -> str: + """Redirect to the sponsor level list.""" + return reverse("manage:sponsor-level-list", kwargs={"conference_slug": self.conference.slug}) + + def form_valid(self, form: SponsorLevelForm) -> HttpResponse: + """Save and flash success.""" + messages.success(self.request, "Sponsor level updated successfully.") + return super().form_valid(form) + + +class SponsorLevelCreateView(ManagePermissionMixin, CreateView): + """Create a new sponsor level.""" + + template_name = "django_program/manage/sponsor_level_edit.html" + form_class = SponsorLevelForm + + 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"] = "sponsor-levels" + context["is_create"] = True + return context + + def form_valid(self, form: SponsorLevelForm) -> HttpResponse: + """Assign the conference before saving.""" + form.instance.conference = self.conference + messages.success(self.request, "Sponsor level created successfully.") + return super().form_valid(form) + + def get_success_url(self) -> str: + """Redirect to the sponsor level list.""" + return reverse("manage:sponsor-level-list", kwargs={"conference_slug": self.conference.slug}) + + +class SponsorManageListView(ManagePermissionMixin, ListView): + """List sponsors for the current conference.""" + + template_name = "django_program/manage/sponsor_list.html" + context_object_name = "sponsors" + 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"] = "sponsors" + return context + + 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") + ) + + +class SponsorEditView(ManagePermissionMixin, UpdateView): + """Edit a sponsor.""" + + template_name = "django_program/manage/sponsor_edit.html" + form_class = SponsorForm + context_object_name = "sponsor" + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add ``active_nav`` and benefits to the template context.""" + context = super().get_context_data(**kwargs) + context["active_nav"] = "sponsors" + context["benefits"] = self.object.benefits.all() + return context + + def get_queryset(self) -> QuerySet[Sponsor]: + """Scope to the current conference.""" + return Sponsor.objects.filter(conference=self.conference).select_related("level") + + def get_form(self, form_class: type[SponsorForm] | None = None) -> SponsorForm: + """Scope the level queryset to the current conference.""" + form = super().get_form(form_class) + form.fields["level"].queryset = SponsorLevel.objects.filter(conference=self.conference) + return form + + def get_success_url(self) -> str: + """Redirect to the sponsor list.""" + return reverse("manage:sponsor-manage-list", kwargs={"conference_slug": self.conference.slug}) + + def form_valid(self, form: SponsorForm) -> HttpResponse: + """Save and flash success.""" + messages.success(self.request, "Sponsor updated successfully.") + return super().form_valid(form) + + +class SponsorCreateView(ManagePermissionMixin, CreateView): + """Create a new sponsor.""" + + template_name = "django_program/manage/sponsor_edit.html" + form_class = SponsorForm + + 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"] = "sponsors" + context["is_create"] = True + return context + + def get_form(self, form_class: type[SponsorForm] | None = None) -> SponsorForm: + """Scope the level queryset to the current conference.""" + form = super().get_form(form_class) + form.fields["level"].queryset = SponsorLevel.objects.filter(conference=self.conference) + return form + + def form_valid(self, form: SponsorForm) -> HttpResponse: + """Assign the conference before saving.""" + form.instance.conference = self.conference + messages.success(self.request, "Sponsor created successfully.") + return super().form_valid(form) + + def get_success_url(self) -> str: + """Redirect to the sponsor list.""" + return reverse("manage:sponsor-manage-list", kwargs={"conference_slug": self.conference.slug}) + + class SyncPretalxView(ManagePermissionMixin, View): """Trigger a Pretalx sync for the current conference. diff --git a/src/django_program/sponsors/admin.py b/src/django_program/sponsors/admin.py new file mode 100644 index 0000000..5c212e2 --- /dev/null +++ b/src/django_program/sponsors/admin.py @@ -0,0 +1,34 @@ +"""Django admin configuration for the sponsors app.""" + +from django.contrib import admin + +from django_program.sponsors.models import Sponsor, SponsorBenefit, SponsorLevel + + +class SponsorBenefitInline(admin.TabularInline): + """Inline editor for sponsor benefits within the sponsor admin.""" + + model = SponsorBenefit + extra = 1 + fields = ("name", "description", "is_complete", "notes") + + +@admin.register(SponsorLevel) +class SponsorLevelAdmin(admin.ModelAdmin): + """Admin interface for managing sponsor levels.""" + + list_display = ("name", "conference", "cost", "comp_ticket_count", "order") + list_filter = ("conference",) + search_fields = ("name", "slug") + prepopulated_fields = {"slug": ("name",)} + + +@admin.register(Sponsor) +class SponsorAdmin(admin.ModelAdmin): + """Admin interface for managing sponsors with inline benefits.""" + + list_display = ("name", "conference", "level", "is_active") + list_filter = ("conference", "level", "is_active") + search_fields = ("name", "slug", "contact_name", "contact_email") + prepopulated_fields = {"slug": ("name",)} + inlines = (SponsorBenefitInline,) diff --git a/src/django_program/sponsors/apps.py b/src/django_program/sponsors/apps.py index 9296b78..0108128 100644 --- a/src/django_program/sponsors/apps.py +++ b/src/django_program/sponsors/apps.py @@ -10,3 +10,7 @@ class DjangoProgramSponsorsConfig(AppConfig): name = "django_program.sponsors" label = "program_sponsors" verbose_name = "Sponsors" + + def ready(self) -> None: + """Import signal handlers on app startup.""" + import django_program.sponsors.signals # noqa: F401, PLC0415 diff --git a/src/django_program/sponsors/migrations/0001_initial.py b/src/django_program/sponsors/migrations/0001_initial.py new file mode 100644 index 0000000..b1b30a1 --- /dev/null +++ b/src/django_program/sponsors/migrations/0001_initial.py @@ -0,0 +1,151 @@ +# Generated by Django 5.2.11 on 2026-02-12 01:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("program_conference", "0002_encrypt_stripe_fields"), + ] + + operations = [ + migrations.CreateModel( + name="Sponsor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("slug", models.SlugField(max_length=200)), + ("website_url", models.URLField(blank=True, default="")), + ( + "logo", + models.ImageField(blank=True, default="", upload_to="sponsors/logos/"), + ), + ("description", models.TextField(blank=True, default="")), + ( + "contact_name", + models.CharField(blank=True, default="", max_length=200), + ), + ( + "contact_email", + models.EmailField(blank=True, default="", max_length=254), + ), + ("is_active", models.BooleanField(default=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="sponsors", + to="program_conference.conference", + ), + ), + ], + options={ + "ordering": ["level__order", "name"], + }, + ), + migrations.CreateModel( + name="SponsorBenefit", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True, default="")), + ("is_complete", models.BooleanField(default=False)), + ("notes", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "sponsor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="benefits", + to="program_sponsors.sponsor", + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="SponsorLevel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("slug", models.SlugField(max_length=200)), + ("cost", models.DecimalField(decimal_places=2, max_digits=10)), + ("description", models.TextField(blank=True, default="")), + ( + "benefits_summary", + models.TextField( + blank=True, + default="", + help_text="Plain text summary of benefits for this level.", + ), + ), + ( + "comp_ticket_count", + models.PositiveIntegerField( + default=0, + help_text="Number of complimentary tickets to auto-generate for sponsors at this level.", + ), + ), + ("order", models.PositiveIntegerField(default=0)), + ("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_levels", + to="program_conference.conference", + ), + ), + ], + options={ + "ordering": ["order", "name"], + "unique_together": {("conference", "slug")}, + }, + ), + migrations.AddField( + model_name="sponsor", + name="level", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sponsors", + to="program_sponsors.sponsorlevel", + ), + ), + migrations.AlterUniqueTogether( + name="sponsor", + unique_together={("conference", "slug")}, + ), + ] diff --git a/src/django_program/sponsors/migrations/__init__.py b/src/django_program/sponsors/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_program/sponsors/models.py b/src/django_program/sponsors/models.py new file mode 100644 index 0000000..3cb3ea2 --- /dev/null +++ b/src/django_program/sponsors/models.py @@ -0,0 +1,105 @@ +"""Sponsor level, sponsor, and benefit models for django-program.""" + +from django.db import models + + +class SponsorLevel(models.Model): + """A sponsorship tier for a conference. + + Defines a named tier (e.g. "Gold", "Silver", "Bronze") with pricing, + a description of included benefits, and the number of complimentary + tickets that sponsors at this level receive automatically. + """ + + conference = models.ForeignKey( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="sponsor_levels", + ) + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=200) + cost = models.DecimalField(max_digits=10, decimal_places=2) + description = models.TextField(blank=True, default="") + benefits_summary = models.TextField( + blank=True, + default="", + help_text="Plain text summary of benefits for this level.", + ) + comp_ticket_count = models.PositiveIntegerField( + default=0, + help_text="Number of complimentary tickets to auto-generate for sponsors at this level.", + ) + order = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order", "name"] + unique_together = [("conference", "slug")] + + def __str__(self) -> str: + return f"{self.name} ({self.conference.slug})" + + +class Sponsor(models.Model): + """A sponsoring organization for a conference. + + Represents a company or organization that has purchased a sponsorship + package. Each sponsor belongs to a single level and conference, with + contact details and branding assets for the conference website. + """ + + conference = models.ForeignKey( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="sponsors", + ) + level = models.ForeignKey( + SponsorLevel, + on_delete=models.CASCADE, + related_name="sponsors", + ) + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=200) + website_url = models.URLField(blank=True, default="") + logo = models.ImageField(upload_to="sponsors/logos/", blank=True, default="") + description = models.TextField(blank=True, default="") + contact_name = models.CharField(max_length=200, blank=True, default="") + contact_email = models.EmailField(blank=True, default="") + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["level__order", "name"] + unique_together = [("conference", "slug")] + + def __str__(self) -> str: + return f"{self.name} ({self.level.name})" + + +class SponsorBenefit(models.Model): + """A specific benefit tracked for a sponsor. + + Tracks individual deliverables owed to a sponsor as part of their + sponsorship package (e.g. "Logo on website", "Booth space"). The + ``is_complete`` flag marks whether the benefit has been fulfilled. + """ + + sponsor = models.ForeignKey( + Sponsor, + on_delete=models.CASCADE, + related_name="benefits", + ) + name = models.CharField(max_length=200) + description = models.TextField(blank=True, default="") + is_complete = models.BooleanField(default=False) + notes = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return f"{self.name} - {self.sponsor.name}" diff --git a/src/django_program/sponsors/signals.py b/src/django_program/sponsors/signals.py new file mode 100644 index 0000000..e76c1d1 --- /dev/null +++ b/src/django_program/sponsors/signals.py @@ -0,0 +1,46 @@ +"""Auto-voucher generation signal for the sponsors app.""" + +from django.db.models.signals import post_save + +from django_program.registration.models import Voucher +from django_program.sponsors.models import Sponsor + + +def generate_comp_vouchers(sender: object, instance: Sponsor, created: bool, **kwargs: object) -> None: # noqa: ARG001, FBT001 + """Create complimentary vouchers when a new sponsor is saved. + + Generates one ``Voucher`` per complimentary ticket defined on the + sponsor's level. Each voucher is a single-use, 100 % comp code that + also unlocks hidden ticket types. ``bulk_create`` with + ``ignore_conflicts=True`` makes the operation idempotent. + + Args: + sender: The model class that sent the signal. + instance: The ``Sponsor`` instance that was saved. + created: ``True`` when the instance was just inserted. + **kwargs: Additional keyword arguments passed by the signal. + """ + if not created: + return + + comp_ticket_count: int = instance.level.comp_ticket_count + if comp_ticket_count <= 0: + return + + vouchers = [ + Voucher( + conference=instance.conference, + code=f"SPONSOR-{instance.slug.upper()}-{i + 1}", + voucher_type=Voucher.VoucherType.COMP, + discount_value=0, + max_uses=1, + unlocks_hidden_tickets=True, + is_active=True, + ) + for i in range(comp_ticket_count) + ] + + Voucher.objects.bulk_create(vouchers, ignore_conflicts=True) + + +post_save.connect(generate_comp_vouchers, sender=Sponsor) diff --git a/src/django_program/sponsors/templates/django_program/sponsors/sponsor_detail.html b/src/django_program/sponsors/templates/django_program/sponsors/sponsor_detail.html new file mode 100644 index 0000000..9933aef --- /dev/null +++ b/src/django_program/sponsors/templates/django_program/sponsors/sponsor_detail.html @@ -0,0 +1,29 @@ +{% block title %}{{ sponsor.name }}{% endblock %} + +{% block content %} +Back to sponsors + +
+

{{ sponsor.name }}

+

{{ sponsor.level.name }} Sponsor

+ + {% if sponsor.logo %}{{ sponsor.name }}{% endif %} + + {% if sponsor.description %} +
{{ sponsor.description|linebreaks }}
+ {% endif %} + + {% if sponsor.website_url %} +

{{ sponsor.website_url }}

+ {% endif %} + + {% if benefits %} +

Benefits

+
    + {% for benefit in benefits %} +
  • {{ benefit.name }}{% if benefit.is_complete %} (completed){% endif %}
  • + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/src/django_program/sponsors/templates/django_program/sponsors/sponsor_list.html b/src/django_program/sponsors/templates/django_program/sponsors/sponsor_list.html new file mode 100644 index 0000000..40659e9 --- /dev/null +++ b/src/django_program/sponsors/templates/django_program/sponsors/sponsor_list.html @@ -0,0 +1,23 @@ +{% block title %}Sponsors{% endblock %} + +{% block content %} +

Sponsors

+ +{% for level in levels %} +
+

{{ level.name }}

+ +
+{% empty %} +

No sponsors yet.

+{% endfor %} +{% endblock %} diff --git a/src/django_program/sponsors/urls.py b/src/django_program/sponsors/urls.py new file mode 100644 index 0000000..59a464b --- /dev/null +++ b/src/django_program/sponsors/urls.py @@ -0,0 +1,20 @@ +"""URL configuration for the sponsors app. + +Provides sponsor listing and detail endpoints scoped to a conference slug. +Mount these under a conference-scoped prefix in the host project:: + + urlpatterns = [ + path("/sponsors/", include("django_program.sponsors.urls")), + ] +""" + +from django.urls import path + +from django_program.sponsors.views import SponsorDetailView, SponsorListView + +app_name = "sponsors" + +urlpatterns = [ + path("", SponsorListView.as_view(), name="sponsor-list"), + path("/", SponsorDetailView.as_view(), name="sponsor-detail"), +] diff --git a/src/django_program/sponsors/views.py b/src/django_program/sponsors/views.py new file mode 100644 index 0000000..9ecbd8a --- /dev/null +++ b/src/django_program/sponsors/views.py @@ -0,0 +1,82 @@ +"""Views for the sponsors app. + +Provides sponsor listing and detail views scoped to a conference +via the ``conference_slug`` URL kwarg. +""" + +from typing import TYPE_CHECKING + +from django.shortcuts import get_object_or_404 +from django.views.generic import DetailView, ListView + +from django_program.pretalx.views import ConferenceMixin +from django_program.sponsors.models import Sponsor, SponsorLevel + +if TYPE_CHECKING: + from django.db.models import QuerySet + + +class SponsorListView(ConferenceMixin, ListView): + """List view of all active sponsors for a conference, grouped by level.""" + + template_name = "django_program/sponsors/sponsor_list.html" + context_object_name = "sponsors" + + def get_queryset(self) -> QuerySet[Sponsor]: + """Return active sponsors for the current conference. + + Returns: + A queryset of active Sponsor instances ordered by level and name. + """ + return ( + Sponsor.objects.filter(conference=self.conference, is_active=True) + .select_related("level") + .order_by("level__order", "name") + ) + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add sponsor levels to the template context. + + Returns: + Context dict containing ``conference``, ``sponsors``, and ``levels``. + """ + context = super().get_context_data(**kwargs) + context["levels"] = ( + SponsorLevel.objects.filter(conference=self.conference, sponsors__is_active=True) + .distinct() + .order_by("order") + ) + return context + + +class SponsorDetailView(ConferenceMixin, DetailView): + """Detail view for a single sponsor.""" + + template_name = "django_program/sponsors/sponsor_detail.html" + context_object_name = "sponsor" + + def get_object(self, queryset: QuerySet[Sponsor] | None = None) -> Sponsor: # noqa: ARG002 + """Look up the sponsor by conference and slug. + + Returns: + The matched Sponsor instance. + + Raises: + Http404: If no active sponsor matches the conference and slug. + """ + return get_object_or_404( + Sponsor.objects.select_related("level"), + conference=self.conference, + slug=self.kwargs["slug"], + is_active=True, + ) + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add benefits to the template context. + + Returns: + Context dict containing ``conference``, ``sponsor``, and ``benefits``. + """ + context = super().get_context_data(**kwargs) + context["benefits"] = self.object.benefits.all() + return context diff --git a/tests/test_manage/test_sponsor_views.py b/tests/test_manage/test_sponsor_views.py new file mode 100644 index 0000000..e84abc9 --- /dev/null +++ b/tests/test_manage/test_sponsor_views.py @@ -0,0 +1,192 @@ +"""Tests for sponsor management views in the manage app.""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.contrib.auth.models import User +from django.test import Client +from django.urls import reverse + +from django_program.conference.models import Conference +from django_program.sponsors.models import Sponsor, SponsorBenefit, SponsorLevel + + +@pytest.fixture +def superuser(db): + return User.objects.create_superuser(username="admin", password="password", email="admin@test.com") + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="SponsorMgmt Conf", + slug="sponsor-mgmt", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + is_active=True, + ) + + +@pytest.fixture +def level(conference): + return SponsorLevel.objects.create( + conference=conference, + name="Gold", + slug="gold", + cost=Decimal("5000.00"), + comp_ticket_count=0, + ) + + +@pytest.fixture +def sponsor(conference, level): + return Sponsor.objects.create( + conference=conference, + level=level, + name="TestCorp", + slug="testcorp", + is_active=True, + ) + + +@pytest.fixture +def authed_client(client: Client, superuser): + client.force_login(superuser) + return client + + +# ---- Dashboard includes sponsors stat ---- + + +@pytest.mark.django_db +def test_dashboard_includes_sponsor_stats(authed_client: Client, conference, sponsor): + url = reverse("manage:dashboard", kwargs={"conference_slug": conference.slug}) + response = authed_client.get(url) + assert response.status_code == 200 + assert response.context["stats"]["sponsors"] == 1 + + +# ---- SponsorLevel views ---- + + +@pytest.mark.django_db +def test_sponsor_level_list(authed_client: Client, conference, level): + url = reverse("manage:sponsor-level-list", kwargs={"conference_slug": conference.slug}) + response = authed_client.get(url) + assert response.status_code == 200 + assert level in response.context["levels"] + + +@pytest.mark.django_db +def test_sponsor_level_create(authed_client: Client, conference): + url = reverse("manage:sponsor-level-add", kwargs={"conference_slug": conference.slug}) + response = authed_client.post( + url, + { + "name": "Silver", + "slug": "silver", + "cost": "2000.00", + "comp_ticket_count": "2", + "order": "1", + }, + ) + assert response.status_code == 302 + assert SponsorLevel.objects.filter(conference=conference, slug="silver").exists() + + +@pytest.mark.django_db +def test_sponsor_level_create_get(authed_client: Client, conference): + url = reverse("manage:sponsor-level-add", kwargs={"conference_slug": conference.slug}) + response = authed_client.get(url) + assert response.status_code == 200 + assert response.context["is_create"] is True + + +@pytest.mark.django_db +def test_sponsor_level_edit(authed_client: Client, conference, level): + url = reverse("manage:sponsor-level-edit", kwargs={"conference_slug": conference.slug, "pk": level.pk}) + response = authed_client.post( + url, + { + "name": "Platinum", + "slug": "gold", + "cost": "10000.00", + "comp_ticket_count": "5", + "order": "0", + }, + ) + assert response.status_code == 302 + level.refresh_from_db() + assert level.name == "Platinum" + + +@pytest.mark.django_db +def test_sponsor_level_edit_get(authed_client: Client, conference, level): + url = reverse("manage:sponsor-level-edit", kwargs={"conference_slug": conference.slug, "pk": level.pk}) + response = authed_client.get(url) + assert response.status_code == 200 + assert response.context["level"] == level + + +# ---- Sponsor views ---- + + +@pytest.mark.django_db +def test_sponsor_manage_list(authed_client: Client, conference, sponsor): + url = reverse("manage:sponsor-manage-list", kwargs={"conference_slug": conference.slug}) + response = authed_client.get(url) + assert response.status_code == 200 + assert sponsor in response.context["sponsors"] + + +@pytest.mark.django_db +def test_sponsor_create(authed_client: Client, conference, level): + url = reverse("manage:sponsor-add", kwargs={"conference_slug": conference.slug}) + response = authed_client.post( + url, + { + "name": "NewCorp", + "slug": "newcorp", + "level": level.pk, + "is_active": "on", + }, + ) + assert response.status_code == 302 + assert Sponsor.objects.filter(conference=conference, slug="newcorp").exists() + + +@pytest.mark.django_db +def test_sponsor_create_get(authed_client: Client, conference, level): + url = reverse("manage:sponsor-add", kwargs={"conference_slug": conference.slug}) + response = authed_client.get(url) + assert response.status_code == 200 + assert response.context["is_create"] is True + + +@pytest.mark.django_db +def test_sponsor_edit(authed_client: Client, conference, sponsor, level): + url = reverse("manage:sponsor-edit", kwargs={"conference_slug": conference.slug, "pk": sponsor.pk}) + response = authed_client.post( + url, + { + "name": "UpdatedCorp", + "slug": "testcorp", + "level": level.pk, + "is_active": "on", + }, + ) + assert response.status_code == 302 + sponsor.refresh_from_db() + assert sponsor.name == "UpdatedCorp" + + +@pytest.mark.django_db +def test_sponsor_edit_get_with_benefits(authed_client: Client, conference, sponsor): + benefit = SponsorBenefit.objects.create(sponsor=sponsor, name="Logo on site") + url = reverse("manage:sponsor-edit", kwargs={"conference_slug": conference.slug, "pk": sponsor.pk}) + response = authed_client.get(url) + assert response.status_code == 200 + assert response.context["sponsor"] == sponsor + assert benefit in response.context["benefits"] diff --git a/tests/test_sponsors/test_models.py b/tests/test_sponsors/test_models.py new file mode 100644 index 0000000..aebbb8f --- /dev/null +++ b/tests/test_sponsors/test_models.py @@ -0,0 +1,59 @@ +"""Tests for sponsors models.""" + +from datetime import date +from decimal import Decimal + +import pytest + +from django_program.conference.models import Conference +from django_program.sponsors.models import Sponsor, SponsorBenefit, SponsorLevel + + +@pytest.fixture +def conference() -> Conference: + return Conference.objects.create( + name="SponsorCon", + slug="sponsorcon", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + ) + + +@pytest.fixture +def level(conference: Conference) -> SponsorLevel: + return SponsorLevel.objects.create( + conference=conference, + name="Gold", + slug="gold", + cost=Decimal("5000.00"), + ) + + +@pytest.fixture +def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: + return Sponsor.objects.create( + conference=conference, + level=level, + name="Acme Corp", + slug="acme-corp", + ) + + +@pytest.mark.django_db +def test_sponsor_level_str(level: SponsorLevel): + assert str(level) == "Gold (sponsorcon)" + + +@pytest.mark.django_db +def test_sponsor_str(sponsor: Sponsor): + assert str(sponsor) == "Acme Corp (Gold)" + + +@pytest.mark.django_db +def test_sponsor_benefit_str(sponsor: Sponsor): + benefit = SponsorBenefit.objects.create( + sponsor=sponsor, + name="Logo on website", + ) + assert str(benefit) == "Logo on website - Acme Corp" diff --git a/tests/test_sponsors/test_signals.py b/tests/test_sponsors/test_signals.py new file mode 100644 index 0000000..2e2fa21 --- /dev/null +++ b/tests/test_sponsors/test_signals.py @@ -0,0 +1,117 @@ +"""Tests for sponsors auto-voucher generation signal.""" + +from datetime import date +from decimal import Decimal + +import pytest + +from django_program.conference.models import Conference +from django_program.registration.models import Voucher +from django_program.sponsors.models import Sponsor, SponsorLevel + + +@pytest.fixture +def conference() -> Conference: + return Conference.objects.create( + name="SignalCon", + slug="signalcon", + start_date=date(2027, 7, 1), + end_date=date(2027, 7, 3), + timezone="UTC", + ) + + +@pytest.mark.django_db +def test_comp_vouchers_created_on_sponsor_create(conference: Conference): + level = SponsorLevel.objects.create( + conference=conference, + name="Platinum", + slug="platinum", + cost=Decimal("10000.00"), + comp_ticket_count=3, + ) + + Sponsor.objects.create( + conference=conference, + level=level, + name="BigCo", + slug="bigco", + ) + + vouchers = Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-BIGCO-") + assert vouchers.count() == 3 + for v in vouchers: + assert v.voucher_type == Voucher.VoucherType.COMP + assert v.max_uses == 1 + assert v.unlocks_hidden_tickets is True + assert v.is_active is True + + +@pytest.mark.django_db +def test_no_vouchers_when_comp_count_zero(conference: Conference): + level = SponsorLevel.objects.create( + conference=conference, + name="Bronze", + slug="bronze", + cost=Decimal("500.00"), + comp_ticket_count=0, + ) + + Sponsor.objects.create( + conference=conference, + level=level, + name="SmallCo", + slug="smallco", + ) + + assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-SMALLCO-").count() == 0 + + +@pytest.mark.django_db +def test_no_vouchers_on_sponsor_update(conference: Conference): + level = SponsorLevel.objects.create( + conference=conference, + name="Silver", + slug="silver", + cost=Decimal("2000.00"), + comp_ticket_count=2, + ) + + sponsor = Sponsor.objects.create( + conference=conference, + level=level, + name="MidCo", + slug="midco", + ) + assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-MIDCO-").count() == 2 + + sponsor.name = "MidCo Updated" + sponsor.save() + assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-MIDCO-").count() == 2 + + +@pytest.mark.django_db +def test_bulk_create_ignore_conflicts_is_idempotent(conference: Conference): + level = SponsorLevel.objects.create( + conference=conference, + name="Gold", + slug="gold", + cost=Decimal("5000.00"), + comp_ticket_count=2, + ) + + Voucher.objects.create( + conference=conference, + code="SPONSOR-PRECO-1", + voucher_type=Voucher.VoucherType.COMP, + max_uses=1, + ) + + Sponsor.objects.create( + conference=conference, + level=level, + name="PreCo", + slug="preco", + ) + + assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-PRECO-").count() == 2 diff --git a/tests/test_sponsors/test_views.py b/tests/test_sponsors/test_views.py new file mode 100644 index 0000000..6774507 --- /dev/null +++ b/tests/test_sponsors/test_views.py @@ -0,0 +1,99 @@ +"""Tests for sponsors views.""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.test import Client + +from django_program.conference.models import Conference +from django_program.sponsors.models import Sponsor, SponsorBenefit, SponsorLevel + + +@pytest.fixture +def conference() -> Conference: + return Conference.objects.create( + name="ViewCon", + slug="viewcon", + start_date=date(2027, 8, 1), + end_date=date(2027, 8, 3), + timezone="UTC", + ) + + +@pytest.fixture +def level(conference: Conference) -> SponsorLevel: + return SponsorLevel.objects.create( + conference=conference, + name="Gold", + slug="gold", + cost=Decimal("5000.00"), + comp_ticket_count=0, + ) + + +@pytest.fixture +def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: + return Sponsor.objects.create( + conference=conference, + level=level, + name="TestCo", + slug="testco", + is_active=True, + ) + + +@pytest.mark.django_db +def test_sponsor_list_view(client: Client, conference: Conference, sponsor: Sponsor): + response = client.get(f"/{conference.slug}/sponsors/") + assert response.status_code == 200 + assert sponsor in response.context["sponsors"] + assert sponsor.level in response.context["levels"] + + +@pytest.mark.django_db +def test_sponsor_list_excludes_inactive(client: Client, conference: Conference, level: SponsorLevel): + Sponsor.objects.create( + conference=conference, + level=level, + name="InactiveCo", + slug="inactiveco", + is_active=False, + ) + response = client.get(f"/{conference.slug}/sponsors/") + assert response.status_code == 200 + assert list(response.context["sponsors"]) == [] + + +@pytest.mark.django_db +def test_sponsor_detail_view(client: Client, conference: Conference, sponsor: Sponsor): + benefit = SponsorBenefit.objects.create(sponsor=sponsor, name="Logo placement") + response = client.get(f"/{conference.slug}/sponsors/{sponsor.slug}/") + assert response.status_code == 200 + assert response.context["sponsor"] == sponsor + assert benefit in response.context["benefits"] + + +@pytest.mark.django_db +def test_sponsor_detail_404_for_inactive(client: Client, conference: Conference, level: SponsorLevel): + inactive = Sponsor.objects.create( + conference=conference, + level=level, + name="GoneCo", + slug="goneco", + is_active=False, + ) + response = client.get(f"/{conference.slug}/sponsors/{inactive.slug}/") + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_sponsor_detail_404_for_wrong_conference(client: Client, conference: Conference, sponsor: Sponsor): + other = Conference.objects.create( + name="OtherCon", + slug="othercon", + start_date=date(2027, 9, 1), + end_date=date(2027, 9, 3), + ) + response = client.get(f"/{other.slug}/sponsors/{sponsor.slug}/") + assert response.status_code == 404 diff --git a/tests/urls.py b/tests/urls.py index 99f4cfe..0bc8abf 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -7,4 +7,5 @@ path("admin/", admin.site.urls), path("manage/", include("django_program.manage.urls")), path("/program/", include("django_program.pretalx.urls")), + path("/sponsors/", include("django_program.sponsors.urls")), ] diff --git a/uv.lock b/uv.lock index 885de50..1d0d7aa 100644 --- a/uv.lock +++ b/uv.lock @@ -271,6 +271,7 @@ source = { editable = "." } dependencies = [ { name = "django" }, { name = "django-fernet-encrypted-fields" }, + { name = "pillow" }, { name = "pretalx-client" }, { name = "stripe" }, ] @@ -326,6 +327,7 @@ test = [ requires-dist = [ { name = "django", specifier = ">=5.2" }, { name = "django-fernet-encrypted-fields", specifier = ">=0.3.1" }, + { name = "pillow", specifier = ">=12.1.1" }, { name = "pretalx-client", editable = "packages/pretalx-client" }, { name = "stripe", specifier = ">=12.0.0" }, ] @@ -584,6 +586,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" From 16fbb9ee0172348364f7b2c5692402ff64e6c386 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Feb 2026 19:55:35 -0600 Subject: [PATCH 2/4] fix: auto-generate sponsor slugs from name Co-Authored-By: Claude Opus 4.6 --- src/django_program/manage/forms.py | 3 +-- src/django_program/sponsors/admin.py | 2 -- ...er_sponsor_slug_alter_sponsorlevel_slug.py | 22 +++++++++++++++++++ src/django_program/sponsors/models.py | 17 ++++++++++++-- tests/test_manage/test_sponsor_views.py | 6 ----- tests/test_sponsors/test_models.py | 2 -- tests/test_sponsors/test_signals.py | 8 ------- tests/test_sponsors/test_views.py | 4 ---- 8 files changed, 38 insertions(+), 26 deletions(-) create mode 100644 src/django_program/sponsors/migrations/0002_alter_sponsor_slug_alter_sponsorlevel_slug.py diff --git a/src/django_program/manage/forms.py b/src/django_program/manage/forms.py index 0fc7c56..36e8a6e 100644 --- a/src/django_program/manage/forms.py +++ b/src/django_program/manage/forms.py @@ -195,7 +195,7 @@ class SponsorLevelForm(forms.ModelForm): class Meta: model = SponsorLevel - fields = ["name", "slug", "cost", "description", "benefits_summary", "comp_ticket_count", "order"] + fields = ["name", "cost", "description", "benefits_summary", "comp_ticket_count", "order"] class SponsorForm(forms.ModelForm): @@ -205,7 +205,6 @@ class Meta: model = Sponsor fields = [ "name", - "slug", "level", "website_url", "logo", diff --git a/src/django_program/sponsors/admin.py b/src/django_program/sponsors/admin.py index 5c212e2..4e7b375 100644 --- a/src/django_program/sponsors/admin.py +++ b/src/django_program/sponsors/admin.py @@ -20,7 +20,6 @@ class SponsorLevelAdmin(admin.ModelAdmin): list_display = ("name", "conference", "cost", "comp_ticket_count", "order") list_filter = ("conference",) search_fields = ("name", "slug") - prepopulated_fields = {"slug": ("name",)} @admin.register(Sponsor) @@ -30,5 +29,4 @@ class SponsorAdmin(admin.ModelAdmin): list_display = ("name", "conference", "level", "is_active") list_filter = ("conference", "level", "is_active") search_fields = ("name", "slug", "contact_name", "contact_email") - prepopulated_fields = {"slug": ("name",)} inlines = (SponsorBenefitInline,) diff --git a/src/django_program/sponsors/migrations/0002_alter_sponsor_slug_alter_sponsorlevel_slug.py b/src/django_program/sponsors/migrations/0002_alter_sponsor_slug_alter_sponsorlevel_slug.py new file mode 100644 index 0000000..2bc840f --- /dev/null +++ b/src/django_program/sponsors/migrations/0002_alter_sponsor_slug_alter_sponsorlevel_slug.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.11 on 2026-02-12 01:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program_sponsors", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="sponsor", + name="slug", + field=models.SlugField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name="sponsorlevel", + name="slug", + field=models.SlugField(blank=True, max_length=200), + ), + ] diff --git a/src/django_program/sponsors/models.py b/src/django_program/sponsors/models.py index 3cb3ea2..c094bac 100644 --- a/src/django_program/sponsors/models.py +++ b/src/django_program/sponsors/models.py @@ -1,6 +1,7 @@ """Sponsor level, sponsor, and benefit models for django-program.""" from django.db import models +from django.utils.text import slugify class SponsorLevel(models.Model): @@ -17,7 +18,7 @@ class SponsorLevel(models.Model): related_name="sponsor_levels", ) name = models.CharField(max_length=200) - slug = models.SlugField(max_length=200) + slug = models.SlugField(max_length=200, blank=True) cost = models.DecimalField(max_digits=10, decimal_places=2) description = models.TextField(blank=True, default="") benefits_summary = models.TextField( @@ -40,6 +41,12 @@ class Meta: def __str__(self) -> str: return f"{self.name} ({self.conference.slug})" + def save(self, *args: object, **kwargs: object) -> None: + """Auto-generate slug from name if not set.""" + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + class Sponsor(models.Model): """A sponsoring organization for a conference. @@ -60,7 +67,7 @@ class Sponsor(models.Model): related_name="sponsors", ) name = models.CharField(max_length=200) - slug = models.SlugField(max_length=200) + slug = models.SlugField(max_length=200, blank=True) website_url = models.URLField(blank=True, default="") logo = models.ImageField(upload_to="sponsors/logos/", blank=True, default="") description = models.TextField(blank=True, default="") @@ -77,6 +84,12 @@ class Meta: def __str__(self) -> str: return f"{self.name} ({self.level.name})" + def save(self, *args: object, **kwargs: object) -> None: + """Auto-generate slug from name if not set.""" + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + class SponsorBenefit(models.Model): """A specific benefit tracked for a sponsor. diff --git a/tests/test_manage/test_sponsor_views.py b/tests/test_manage/test_sponsor_views.py index e84abc9..d8e0a3c 100644 --- a/tests/test_manage/test_sponsor_views.py +++ b/tests/test_manage/test_sponsor_views.py @@ -34,7 +34,6 @@ def level(conference): return SponsorLevel.objects.create( conference=conference, name="Gold", - slug="gold", cost=Decimal("5000.00"), comp_ticket_count=0, ) @@ -46,7 +45,6 @@ def sponsor(conference, level): conference=conference, level=level, name="TestCorp", - slug="testcorp", is_active=True, ) @@ -86,7 +84,6 @@ def test_sponsor_level_create(authed_client: Client, conference): url, { "name": "Silver", - "slug": "silver", "cost": "2000.00", "comp_ticket_count": "2", "order": "1", @@ -111,7 +108,6 @@ def test_sponsor_level_edit(authed_client: Client, conference, level): url, { "name": "Platinum", - "slug": "gold", "cost": "10000.00", "comp_ticket_count": "5", "order": "0", @@ -148,7 +144,6 @@ def test_sponsor_create(authed_client: Client, conference, level): url, { "name": "NewCorp", - "slug": "newcorp", "level": level.pk, "is_active": "on", }, @@ -172,7 +167,6 @@ def test_sponsor_edit(authed_client: Client, conference, sponsor, level): url, { "name": "UpdatedCorp", - "slug": "testcorp", "level": level.pk, "is_active": "on", }, diff --git a/tests/test_sponsors/test_models.py b/tests/test_sponsors/test_models.py index aebbb8f..e10d9e2 100644 --- a/tests/test_sponsors/test_models.py +++ b/tests/test_sponsors/test_models.py @@ -25,7 +25,6 @@ def level(conference: Conference) -> SponsorLevel: return SponsorLevel.objects.create( conference=conference, name="Gold", - slug="gold", cost=Decimal("5000.00"), ) @@ -36,7 +35,6 @@ def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: conference=conference, level=level, name="Acme Corp", - slug="acme-corp", ) diff --git a/tests/test_sponsors/test_signals.py b/tests/test_sponsors/test_signals.py index 2e2fa21..1f66599 100644 --- a/tests/test_sponsors/test_signals.py +++ b/tests/test_sponsors/test_signals.py @@ -26,7 +26,6 @@ def test_comp_vouchers_created_on_sponsor_create(conference: Conference): level = SponsorLevel.objects.create( conference=conference, name="Platinum", - slug="platinum", cost=Decimal("10000.00"), comp_ticket_count=3, ) @@ -35,7 +34,6 @@ def test_comp_vouchers_created_on_sponsor_create(conference: Conference): conference=conference, level=level, name="BigCo", - slug="bigco", ) vouchers = Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-BIGCO-") @@ -52,7 +50,6 @@ def test_no_vouchers_when_comp_count_zero(conference: Conference): level = SponsorLevel.objects.create( conference=conference, name="Bronze", - slug="bronze", cost=Decimal("500.00"), comp_ticket_count=0, ) @@ -61,7 +58,6 @@ def test_no_vouchers_when_comp_count_zero(conference: Conference): conference=conference, level=level, name="SmallCo", - slug="smallco", ) assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-SMALLCO-").count() == 0 @@ -72,7 +68,6 @@ def test_no_vouchers_on_sponsor_update(conference: Conference): level = SponsorLevel.objects.create( conference=conference, name="Silver", - slug="silver", cost=Decimal("2000.00"), comp_ticket_count=2, ) @@ -81,7 +76,6 @@ def test_no_vouchers_on_sponsor_update(conference: Conference): conference=conference, level=level, name="MidCo", - slug="midco", ) assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-MIDCO-").count() == 2 @@ -95,7 +89,6 @@ def test_bulk_create_ignore_conflicts_is_idempotent(conference: Conference): level = SponsorLevel.objects.create( conference=conference, name="Gold", - slug="gold", cost=Decimal("5000.00"), comp_ticket_count=2, ) @@ -111,7 +104,6 @@ def test_bulk_create_ignore_conflicts_is_idempotent(conference: Conference): conference=conference, level=level, name="PreCo", - slug="preco", ) assert Voucher.objects.filter(conference=conference, code__startswith="SPONSOR-PRECO-").count() == 2 diff --git a/tests/test_sponsors/test_views.py b/tests/test_sponsors/test_views.py index 6774507..9257fd6 100644 --- a/tests/test_sponsors/test_views.py +++ b/tests/test_sponsors/test_views.py @@ -26,7 +26,6 @@ def level(conference: Conference) -> SponsorLevel: return SponsorLevel.objects.create( conference=conference, name="Gold", - slug="gold", cost=Decimal("5000.00"), comp_ticket_count=0, ) @@ -38,7 +37,6 @@ def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: conference=conference, level=level, name="TestCo", - slug="testco", is_active=True, ) @@ -57,7 +55,6 @@ def test_sponsor_list_excludes_inactive(client: Client, conference: Conference, conference=conference, level=level, name="InactiveCo", - slug="inactiveco", is_active=False, ) response = client.get(f"/{conference.slug}/sponsors/") @@ -80,7 +77,6 @@ def test_sponsor_detail_404_for_inactive(client: Client, conference: Conference, conference=conference, level=level, name="GoneCo", - slug="goneco", is_active=False, ) response = client.get(f"/{conference.slug}/sponsors/{inactive.slug}/") From f9e407672a44ecaef0a0ab1318a88412c9a8edcb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Feb 2026 20:25:48 -0600 Subject: [PATCH 3/4] fix: address PR #21 review comments - Truncate voucher codes to fit 100-char max_length (slug can be 200) - Add dispatch_uid to sponsor post_save signal connection - Replace limit_choices_to F() with clean() for cross-model validation - Fix N+1 query: annotate SponsorLevelListView with Count('sponsors') - Public templates extend base.html and use {% regroup %} for O(n) - Add PSF sponsor sync: config, profiles, sync service, mgmt command - Lock synced fields in sponsor edit view - Wire PSF_SPONSOR_API_TOKEN in examples/settings.py Co-Authored-By: Claude Opus 4.6 --- examples/settings.py | 3 + src/django_program/manage/forms.py | 25 ++++- .../django_program/manage/dashboard.html | 13 +++ .../django_program/manage/sponsor_edit.html | 15 ++- .../manage/sponsor_level_list.html | 2 +- src/django_program/manage/urls.py | 2 + src/django_program/manage/views.py | 69 ++++++++++++- src/django_program/settings.py | 17 ++++ src/django_program/sponsors/models.py | 18 ++++ src/django_program/sponsors/signals.py | 31 +++--- .../sponsors/sponsor_detail.html | 10 +- .../django_program/sponsors/sponsor_list.html | 25 +++-- tests/settings.py | 7 ++ tests/test_manage/test_sponsor_views.py | 96 +++++++++++++++++++ tests/test_sponsors/test_models.py | 34 +++++++ 15 files changed, 336 insertions(+), 31 deletions(-) diff --git a/examples/settings.py b/examples/settings.py index 739044a..6f4bba8 100644 --- a/examples/settings.py +++ b/examples/settings.py @@ -79,4 +79,7 @@ "base_url": os.environ.get("PRETALX_BASE_URL", "https://pretalx.com"), "token": os.environ.get("PRETALX_TOKEN", ""), }, + "psf_sponsors": { + "token": os.environ.get("PSF_SPONSOR_API_TOKEN", ""), + }, } diff --git a/src/django_program/manage/forms.py b/src/django_program/manage/forms.py index 36e8a6e..5f28d36 100644 --- a/src/django_program/manage/forms.py +++ b/src/django_program/manage/forms.py @@ -199,7 +199,20 @@ class Meta: class SponsorForm(forms.ModelForm): - """Form for editing a sponsor.""" + """Form for editing a sponsor. + + When the sponsor has an ``external_id`` (synced from the PSF API), + fields that come from the upstream API are disabled to prevent + overwriting synced data. + """ + + SYNCED_FIELDS: list[str] = [ + "name", + "level", + "website_url", + "logo_url", + "description", + ] class Meta: model = Sponsor @@ -208,8 +221,18 @@ class Meta: "level", "website_url", "logo", + "logo_url", "description", "contact_name", "contact_email", "is_active", ] + + def __init__(self, *args: object, **kwargs: object) -> None: + """Initialise the form and disable synced fields when locked by PSF sync.""" + self.is_synced: bool = kwargs.pop("is_synced", False) # type: ignore[arg-type] + super().__init__(*args, **kwargs) + if self.is_synced: + for field_name in self.SYNCED_FIELDS: + if field_name in self.fields: + self.fields[field_name].disabled = True diff --git a/src/django_program/manage/templates/django_program/manage/dashboard.html b/src/django_program/manage/templates/django_program/manage/dashboard.html index 84379e2..01a39d9 100644 --- a/src/django_program/manage/templates/django_program/manage/dashboard.html +++ b/src/django_program/manage/templates/django_program/manage/dashboard.html @@ -192,6 +192,19 @@

Pretalx Sync

{% endif %} +{% if has_psf_sponsor_sync %} +

PSF Sponsor Sync

+
+

+ Pull sponsor data from the Python Software Foundation sponsorship API. +

+
+ {% csrf_token %} + +
+
+{% endif %} + {% if conference.pretalx_event_slug %}