diff --git a/apps/sponsors/manage/__init__.py b/apps/sponsors/manage/__init__.py new file mode 100644 index 000000000..097f07522 --- /dev/null +++ b/apps/sponsors/manage/__init__.py @@ -0,0 +1 @@ +"""Sponsor management UI — locked down to Sponsorship Admin group and staff.""" diff --git a/apps/sponsors/manage/forms.py b/apps/sponsors/manage/forms.py new file mode 100644 index 000000000..50b6fdaa7 --- /dev/null +++ b/apps/sponsors/manage/forms.py @@ -0,0 +1,734 @@ +"""Forms for the sponsor management UI.""" + +import contextlib + +from django import forms +from django.utils import timezone + +from apps.sponsors.models import ( + SPONSOR_TEMPLATE_HELP_TEXT, + EmailTargetableConfiguration, + LegalClause, + LogoPlacementConfiguration, + ProvidedFileAssetConfiguration, + ProvidedTextAssetConfiguration, + RequiredImgAssetConfiguration, + RequiredResponseAssetConfiguration, + RequiredTextAssetConfiguration, + Sponsor, + SponsorContact, + SponsorEmailNotificationTemplate, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + SponsorshipPackage, + SponsorshipProgram, + TieredBenefitConfiguration, +) + + +def year_choices(): + """Return year choices for select widgets. Current year + 2 years forward, plus historical years with data.""" + current = timezone.now().year + return [(y, str(y)) for y in range(current + 2, 2021, -1)] + + +class SponsorshipBenefitManageForm(forms.ModelForm): + """Form for creating and editing sponsorship benefits.""" + + class Meta: + """Meta options.""" + + model = SponsorshipBenefit + fields = [ + "name", + "description", + "program", + "packages", + "package_only", + "new", + "unavailable", + "standalone", + "internal_description", + "internal_value", + "capacity", + "soft_capacity", + "year", + ] + widgets = { + "name": forms.TextInput( + attrs={"style": "width:100%;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + "description": forms.Textarea( + attrs={ + "rows": 3, + "style": "width:100%;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;resize:vertical;", + } + ), + "internal_description": forms.Textarea( + attrs={ + "rows": 3, + "style": "width:100%;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;resize:vertical;", + } + ), + "packages": forms.CheckboxSelectMultiple(), + "year": forms.Select( + attrs={"style": "padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + } + + def __init__(self, *args, **kwargs): + """Initialize form with year choices and package filtering.""" + super().__init__(*args, **kwargs) + self.fields["year"].widget = forms.Select( + choices=[("", "---"), *year_choices()], + attrs={"style": "padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"}, + ) + # Filter packages to benefit's year, or initial year, or current year + filter_year = None + if self.instance and self.instance.year: + filter_year = self.instance.year + elif self.initial.get("year"): + filter_year = self.initial["year"] + else: + with contextlib.suppress(SponsorshipCurrentYear.DoesNotExist): + filter_year = SponsorshipCurrentYear.get_year() + if filter_year: + self.fields["packages"].queryset = SponsorshipPackage.objects.filter(year=filter_year).order_by( + "-sponsorship_amount" + ) + + +class SponsorshipPackageManageForm(forms.ModelForm): + """Form for creating and editing sponsorship packages.""" + + class Meta: + """Meta options.""" + + model = SponsorshipPackage + fields = [ + "name", + "slug", + "sponsorship_amount", + "advertise", + "logo_dimension", + "year", + "allow_a_la_carte", + ] + widgets = { + "name": forms.TextInput( + attrs={"style": "width:100%;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + "slug": forms.TextInput( + attrs={"style": "width:100%;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + "sponsorship_amount": forms.NumberInput( + attrs={"style": "width:200px;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + "logo_dimension": forms.NumberInput( + attrs={"style": "width:120px;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + } + + def __init__(self, *args, **kwargs): + """Initialize form with year choices.""" + super().__init__(*args, **kwargs) + self.fields["year"].widget = forms.Select( + choices=[("", "---"), *year_choices()], + attrs={"style": "padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"}, + ) + + +class CloneYearForm(forms.Form): + """Form for cloning benefits and packages from one year to another.""" + + source_year = forms.ChoiceField( + label="Copy from year", + widget=forms.Select( + attrs={"style": "padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;min-width:120px;"} + ), + ) + target_year = forms.IntegerField( + label="Copy to year", + widget=forms.NumberInput( + attrs={"style": "width:120px;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + ) + clone_packages = forms.BooleanField( + label="Clone packages (tiers)", + required=False, + initial=True, + ) + clone_benefits = forms.BooleanField( + label="Clone benefits", + required=False, + initial=True, + ) + + def __init__(self, *args, **kwargs): + """Initialize form with source year choices.""" + super().__init__(*args, **kwargs) + # Populate source years from existing data + benefit_years = SponsorshipBenefit.objects.values_list("year", flat=True).distinct().order_by("-year") + self.fields["source_year"].choices = [(y, str(y)) for y in benefit_years if y] + + def clean_target_year(self): + """Validate target year is in acceptable range.""" + year = self.cleaned_data["target_year"] + current = timezone.now().year + if year < current: + msg = f"Target year must be {current} or later." + raise forms.ValidationError(msg) + return year + + def clean(self): + """Validate source and target years are different.""" + cleaned = super().clean() + source = cleaned.get("source_year") + target = cleaned.get("target_year") + if source and target and int(source) == target: + msg = "Source and target years must be different." + raise forms.ValidationError(msg) + return cleaned + + +class BenefitFilterForm(forms.Form): + """Form for filtering benefits in the list view.""" + + year = forms.ChoiceField(required=False) + program = forms.ModelChoiceField( + queryset=SponsorshipProgram.objects.all(), + required=False, + empty_label="All programs", + ) + package = forms.ModelChoiceField( + queryset=SponsorshipPackage.objects.none(), + required=False, + empty_label="All packages", + ) + + def __init__(self, *args, **kwargs): + """Initialize form with year and package choices.""" + selected_year = kwargs.pop("selected_year", None) + super().__init__(*args, **kwargs) + benefit_years = SponsorshipBenefit.objects.values_list("year", flat=True).distinct().order_by("-year") + self.fields["year"].choices = [("", "All years")] + [(y, str(y)) for y in benefit_years if y] + if selected_year: + self.fields["package"].queryset = SponsorshipPackage.objects.filter(year=selected_year) + else: + self.fields["package"].queryset = SponsorshipPackage.objects.all() + + +class CurrentYearForm(forms.ModelForm): + """Form for updating the active sponsorship year.""" + + class Meta: + """Meta options.""" + + model = SponsorshipCurrentYear + fields = ["year"] + widgets = { + "year": forms.NumberInput( + attrs={"style": "width:120px;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;"} + ), + } + + +INPUT_STYLE = ( + "width:100%;padding:8px 12px;border:1px solid #ccc;border-radius:4px;font-size:14px;box-sizing:border-box;" +) + + +class SponsorshipApproveForm(forms.ModelForm): + """Form for approving a sponsorship — sets dates, fee, package.""" + + start_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + ) + end_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + ) + renewal = forms.BooleanField( + required=False, + help_text="Use renewal contract template instead of new sponsorship template.", + ) + + class Meta: + """Meta options.""" + + model = Sponsorship + fields = ["start_date", "end_date", "package", "sponsorship_fee", "renewal"] + widgets = { + "package": forms.Select(attrs={"style": INPUT_STYLE}), + "sponsorship_fee": forms.NumberInput(attrs={"style": INPUT_STYLE}), + } + + def __init__(self, *args, **kwargs): + """Initialize form with year-filtered packages.""" + super().__init__(*args, **kwargs) + # Filter packages to the sponsorship's year + if self.instance and self.instance.year: + self.fields["package"].queryset = SponsorshipPackage.objects.filter(year=self.instance.year).order_by( + "-sponsorship_amount" + ) + + def clean(self): + """Validate that end date is after start date.""" + cleaned = super().clean() + start = cleaned.get("start_date") + end = cleaned.get("end_date") + if start and end and end <= start: + msg = "End date must be after start date." + raise forms.ValidationError(msg) + return cleaned + + +class SponsorshipApproveSignedForm(SponsorshipApproveForm): + """Form for approving a sponsorship with an already-signed contract.""" + + signed_contract = forms.FileField( + label="Signed contract document", + help_text="Upload the final version of the signed contract (PDF or DOCX).", + widget=forms.ClearableFileInput(attrs={"style": INPUT_STYLE, "accept": ".pdf,.docx"}), + ) + + +class SponsorshipEditForm(forms.ModelForm): + """Form for editing sponsorship details (package, fee, year).""" + + class Meta: + """Meta options.""" + + model = Sponsorship + fields = ["package", "sponsorship_fee", "year"] + widgets = { + "package": forms.Select(attrs={"style": INPUT_STYLE}), + "sponsorship_fee": forms.NumberInput(attrs={"style": INPUT_STYLE}), + "year": forms.NumberInput(attrs={"style": "width:120px;" + INPUT_STYLE}), + } + + def __init__(self, *args, **kwargs): + """Initialize form with year-filtered packages.""" + super().__init__(*args, **kwargs) + # Filter packages to the sponsorship's year + if self.instance and self.instance.year: + self.fields["package"].queryset = SponsorshipPackage.objects.filter(year=self.instance.year).order_by( + "-sponsorship_amount" + ) + + +class SponsorEditForm(forms.ModelForm): + """Form for editing sponsor company info.""" + + class Meta: + """Meta options.""" + + model = Sponsor + fields = [ + "name", + "description", + "landing_page_url", + "primary_phone", + "mailing_address_line_1", + "mailing_address_line_2", + "city", + "state", + "postal_code", + "country", + ] + widgets = { + "name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "description": forms.Textarea(attrs={"rows": 3, "style": INPUT_STYLE + "resize:vertical;"}), + "landing_page_url": forms.URLInput(attrs={"style": INPUT_STYLE}), + "primary_phone": forms.TextInput(attrs={"style": INPUT_STYLE}), + "mailing_address_line_1": forms.TextInput(attrs={"style": INPUT_STYLE}), + "mailing_address_line_2": forms.TextInput(attrs={"style": INPUT_STYLE}), + "city": forms.TextInput(attrs={"style": INPUT_STYLE}), + "state": forms.TextInput(attrs={"style": INPUT_STYLE}), + "postal_code": forms.TextInput(attrs={"style": INPUT_STYLE}), + "country": forms.Select(attrs={"style": INPUT_STYLE}), + } + + +class SponsorshipFilterForm(forms.Form): + """Filter form for the sponsorship list.""" + + STATUS_CHOICES = [("", "All statuses"), *Sponsorship.STATUS_CHOICES] + status = forms.ChoiceField(choices=STATUS_CHOICES, required=False) + year = forms.ChoiceField(required=False) + search = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + "placeholder": "Search sponsor name...", + "style": "padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;width:200px;", + }, + ), + ) + + def __init__(self, *args, **kwargs): + """Initialize form with year choices from existing sponsorships.""" + super().__init__(*args, **kwargs) + years = Sponsorship.objects.values_list("year", flat=True).distinct().order_by("-year") + self.fields["year"].choices = [("", "All years")] + [(y, str(y)) for y in years if y] + + +class BenefitChoiceField(forms.ModelChoiceField): + """ModelChoiceField that shows 'Program > Benefit Name' without year.""" + + def label_from_instance(self, obj): + """Return label as 'Program > Benefit Name'.""" + return f"{obj.program.name} > {obj.name}" + + +class AddBenefitToSponsorshipForm(forms.Form): + """Form for adding a benefit to a sponsorship.""" + + benefit = BenefitChoiceField( + queryset=SponsorshipBenefit.objects.none(), + widget=forms.Select(attrs={"style": INPUT_STYLE}), + label="Benefit to add", + ) + + def __init__(self, *args, sponsorship=None, **kwargs): + """Initialize form with benefits filtered by sponsorship year.""" + super().__init__(*args, **kwargs) + if sponsorship and sponsorship.year: + # Show benefits for this year that aren't already on the sponsorship + existing_ids = sponsorship.benefits.values_list("sponsorship_benefit_id", flat=True) + self.fields["benefit"].queryset = ( + SponsorshipBenefit.objects.filter(year=sponsorship.year) + .exclude(pk__in=existing_ids) + .select_related("program") + .order_by("program__order", "order") + ) + + +class ExecuteContractForm(forms.Form): + """Form for uploading a signed contract document.""" + + signed_document = forms.FileField( + label="Signed contract document", + help_text="Upload the signed contract (PDF or DOCX).", + widget=forms.ClearableFileInput(attrs={"style": INPUT_STYLE, "accept": ".pdf,.docx"}), + ) + + +class SendSponsorshipNotificationManageForm(forms.Form): + """Form for sending email notifications to sponsorship contacts from the manage UI.""" + + contact_types = forms.MultipleChoiceField( + choices=SponsorContact.CONTACT_TYPES, + required=True, + widget=forms.CheckboxSelectMultiple, + label="Send to contact types", + ) + notification = forms.ModelChoiceField( + queryset=SponsorEmailNotificationTemplate.objects.all(), + help_text="Select an existing notification template, or write custom content below.", + required=False, + label="Template", + ) + subject = forms.CharField( + max_length=140, + required=False, + widget=forms.TextInput(attrs={"style": INPUT_STYLE, "placeholder": "Custom email subject"}), + ) + content = forms.CharField( + widget=forms.Textarea( + attrs={"rows": 8, "style": INPUT_STYLE + "resize:vertical;", "placeholder": "Custom email content"} + ), + required=False, + help_text=SPONSOR_TEMPLATE_HELP_TEXT, + ) + + def clean(self): + """Validate that either a notification template or custom content is provided, not both.""" + cleaned_data = super().clean() + notification = cleaned_data.get("notification") + subject = cleaned_data.get("subject", "").strip() + content = cleaned_data.get("content", "").strip() + custom_notification = subject or content + + if not (notification or custom_notification): + msg = "You must select a template or provide custom subject and content." + raise forms.ValidationError(msg) + if notification and custom_notification: + msg = "Select a template or use custom content, not both." + raise forms.ValidationError(msg) + + return cleaned_data + + def get_notification(self): + """Return the selected template or build one from custom fields.""" + default_notification = SponsorEmailNotificationTemplate( + content=self.cleaned_data["content"], + subject=self.cleaned_data["subject"], + ) + return self.cleaned_data.get("notification") or default_notification + + +class NotificationTemplateForm(forms.ModelForm): + """Form for creating and editing SponsorEmailNotificationTemplate instances.""" + + class Meta: + """Meta options.""" + + model = SponsorEmailNotificationTemplate + fields = ["internal_name", "subject", "content"] + widgets = { + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "subject": forms.TextInput(attrs={"style": INPUT_STYLE}), + "content": forms.Textarea(attrs={"rows": 12, "style": INPUT_STYLE + "resize:vertical;"}), + } + help_texts = { + "content": SPONSOR_TEMPLATE_HELP_TEXT, + } + + +class SponsorContactForm(forms.ModelForm): + """Form for adding/editing a sponsor contact.""" + + class Meta: + """Meta options.""" + + model = SponsorContact + fields = ["name", "email", "phone", "primary", "administrative", "accounting", "manager"] + widgets = { + "name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "email": forms.EmailInput(attrs={"style": INPUT_STYLE}), + "phone": forms.TextInput(attrs={"style": INPUT_STYLE}), + } + + +# ── Benefit Feature Configuration Forms ── + + +class LogoPlacementConfigForm(forms.ModelForm): + """Form for LogoPlacementConfiguration.""" + + class Meta: + """Meta options.""" + + model = LogoPlacementConfiguration + fields = ["publisher", "logo_place", "link_to_sponsors_page", "describe_as_sponsor"] + widgets = { + "publisher": forms.Select(attrs={"style": INPUT_STYLE}), + "logo_place": forms.Select(attrs={"style": INPUT_STYLE}), + } + + +class TieredBenefitConfigForm(forms.ModelForm): + """Form for TieredBenefitConfiguration.""" + + class Meta: + """Meta options.""" + + model = TieredBenefitConfiguration + fields = ["package", "quantity", "display_label"] + widgets = { + "package": forms.Select(attrs={"style": INPUT_STYLE}), + "quantity": forms.NumberInput(attrs={"style": INPUT_STYLE}), + "display_label": forms.TextInput(attrs={"style": INPUT_STYLE}), + } + + +class EmailTargetableConfigForm(forms.ModelForm): + """Form for EmailTargetableConfiguration (no extra fields).""" + + class Meta: + """Meta options.""" + + model = EmailTargetableConfiguration + fields = [] + + +class RequiredImgAssetConfigForm(forms.ModelForm): + """Form for RequiredImgAssetConfiguration.""" + + class Meta: + """Meta options.""" + + model = RequiredImgAssetConfiguration + fields = [ + "related_to", + "internal_name", + "label", + "help_text", + "due_date", + "min_width", + "max_width", + "min_height", + "max_height", + ] + widgets = { + "related_to": forms.Select(attrs={"style": INPUT_STYLE}), + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "label": forms.TextInput(attrs={"style": INPUT_STYLE}), + "help_text": forms.TextInput(attrs={"style": INPUT_STYLE}), + "due_date": forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + "min_width": forms.NumberInput(attrs={"style": INPUT_STYLE}), + "max_width": forms.NumberInput(attrs={"style": INPUT_STYLE}), + "min_height": forms.NumberInput(attrs={"style": INPUT_STYLE}), + "max_height": forms.NumberInput(attrs={"style": INPUT_STYLE}), + } + + +class RequiredTextAssetConfigForm(forms.ModelForm): + """Form for RequiredTextAssetConfiguration.""" + + class Meta: + """Meta options.""" + + model = RequiredTextAssetConfiguration + fields = ["related_to", "internal_name", "label", "help_text", "due_date", "max_length"] + widgets = { + "related_to": forms.Select(attrs={"style": INPUT_STYLE}), + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "label": forms.TextInput(attrs={"style": INPUT_STYLE}), + "help_text": forms.TextInput(attrs={"style": INPUT_STYLE}), + "due_date": forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + "max_length": forms.NumberInput(attrs={"style": INPUT_STYLE}), + } + + +class RequiredResponseAssetConfigForm(forms.ModelForm): + """Form for RequiredResponseAssetConfiguration.""" + + class Meta: + """Meta options.""" + + model = RequiredResponseAssetConfiguration + fields = ["related_to", "internal_name", "label", "help_text", "due_date"] + widgets = { + "related_to": forms.Select(attrs={"style": INPUT_STYLE}), + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "label": forms.TextInput(attrs={"style": INPUT_STYLE}), + "help_text": forms.TextInput(attrs={"style": INPUT_STYLE}), + "due_date": forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + } + + +class ProvidedTextAssetConfigForm(forms.ModelForm): + """Form for ProvidedTextAssetConfiguration.""" + + class Meta: + """Meta options.""" + + model = ProvidedTextAssetConfiguration + fields = ["related_to", "internal_name", "label", "help_text", "shared"] + widgets = { + "related_to": forms.Select(attrs={"style": INPUT_STYLE}), + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "label": forms.TextInput(attrs={"style": INPUT_STYLE}), + "help_text": forms.TextInput(attrs={"style": INPUT_STYLE}), + } + + +class ProvidedFileAssetConfigForm(forms.ModelForm): + """Form for ProvidedFileAssetConfiguration.""" + + class Meta: + """Meta options.""" + + model = ProvidedFileAssetConfiguration + fields = ["related_to", "internal_name", "label", "help_text", "shared"] + widgets = { + "related_to": forms.Select(attrs={"style": INPUT_STYLE}), + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "label": forms.TextInput(attrs={"style": INPUT_STYLE}), + "help_text": forms.TextInput(attrs={"style": INPUT_STYLE}), + } + + +class ComposerSponsorForm(forms.ModelForm): + """Form for creating a new sponsor inline within the composer wizard.""" + + class Meta: + """Meta options.""" + + model = Sponsor + fields = ["name", "description", "primary_phone", "city", "country"] + widgets = { + "name": forms.TextInput(attrs={"style": INPUT_STYLE, "placeholder": "Company name"}), + "description": forms.Textarea( + attrs={"rows": 3, "style": INPUT_STYLE + "resize:vertical;", "placeholder": "Brief description"} + ), + "primary_phone": forms.TextInput(attrs={"style": INPUT_STYLE, "placeholder": "Phone number"}), + "city": forms.TextInput(attrs={"style": INPUT_STYLE, "placeholder": "City"}), + "country": forms.Select(attrs={"style": INPUT_STYLE}), + } + + +class ComposerTermsForm(forms.Form): + """Form for setting sponsorship terms in the composer wizard.""" + + fee = forms.IntegerField( + min_value=0, + widget=forms.NumberInput(attrs={"style": INPUT_STYLE, "placeholder": "Sponsorship fee in USD"}), + label="Sponsorship Fee (USD)", + ) + start_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + label="Start Date", + ) + end_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date", "style": INPUT_STYLE}), + label="End Date", + ) + renewal = forms.BooleanField( + required=False, + label="Renewal", + help_text="Use renewal contract template instead of new sponsorship template.", + ) + notes = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={"rows": 4, "style": INPUT_STYLE + "resize:vertical;", "placeholder": "Internal notes..."} + ), + label="Notes", + ) + + def clean(self): + """Validate that end date is after start date.""" + cleaned = super().clean() + start = cleaned.get("start_date") + end = cleaned.get("end_date") + if start and end and end <= start: + msg = "End date must be after start date." + raise forms.ValidationError(msg) + return cleaned + + +class LegalClauseForm(forms.ModelForm): + """Form for creating and editing legal clauses.""" + + class Meta: + """Meta options.""" + + model = LegalClause + fields = ["internal_name", "clause", "notes"] + widgets = { + "internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}), + "clause": forms.Textarea(attrs={"rows": 6, "style": INPUT_STYLE + "resize:vertical;"}), + "notes": forms.Textarea( + attrs={"rows": 3, "style": INPUT_STYLE + "resize:vertical;", "placeholder": "Internal notes..."} + ), + } + + +# Dispatcher mapping config type slugs to (model, form) tuples +CONFIG_TYPES = { + "logo_placement": (LogoPlacementConfiguration, LogoPlacementConfigForm, "Logo Placement"), + "tiered_benefit": (TieredBenefitConfiguration, TieredBenefitConfigForm, "Tiered Benefit"), + "email_targetable": (EmailTargetableConfiguration, EmailTargetableConfigForm, "Email Targetable"), + "required_image": (RequiredImgAssetConfiguration, RequiredImgAssetConfigForm, "Required Image Asset"), + "required_text": (RequiredTextAssetConfiguration, RequiredTextAssetConfigForm, "Required Text Asset"), + "required_response": ( + RequiredResponseAssetConfiguration, + RequiredResponseAssetConfigForm, + "Required Response Asset", + ), + "provided_text": (ProvidedTextAssetConfiguration, ProvidedTextAssetConfigForm, "Provided Text Asset"), + "provided_file": (ProvidedFileAssetConfiguration, ProvidedFileAssetConfigForm, "Provided File Asset"), +} diff --git a/apps/sponsors/manage/tests.py b/apps/sponsors/manage/tests.py new file mode 100644 index 000000000..ae7cdfb8a --- /dev/null +++ b/apps/sponsors/manage/tests.py @@ -0,0 +1,2626 @@ +"""Tests for the sponsor management UI views.""" + +import csv +import datetime +import io +from unittest import mock + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.http import HttpResponse +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from apps.sponsors.models import ( + Contract, + LegalClause, + Sponsor, + SponsorBenefit, + SponsorContact, + SponsorEmailNotificationTemplate, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + SponsorshipPackage, + SponsorshipProgram, + TextAsset, +) + + +@override_settings(LOGIN_URL="/accounts/login/") +class SponsorManageTestBase(TestCase): + """Base test class with common setup for sponsor management tests.""" + + @classmethod + def setUpTestData(cls): + cls.group = Group.objects.create(name="Sponsorship Admin") + cls.program = SponsorshipProgram.objects.create(name="Foundation", order=0) + cls.year = timezone.now().year + + # Update or create current year singleton + current_year = SponsorshipCurrentYear.objects.first() + if current_year: + current_year.year = cls.year + current_year.save() + else: + SponsorshipCurrentYear.objects.create(year=cls.year) + + # Create a package + cls.package = SponsorshipPackage.objects.create( + name="Visionary", + slug="visionary", + sponsorship_amount=150000, + year=cls.year, + advertise=True, + ) + + # Create a benefit + cls.benefit = SponsorshipBenefit.objects.create( + name="Logo on python.org", + program=cls.program, + year=cls.year, + internal_value=1000, + ) + cls.benefit.packages.add(cls.package) + + def setUp(self): + self.staff_user = get_user_model().objects.create_user("staff", "staff@example.com", "pass", is_staff=True) + self.group_user = get_user_model().objects.create_user("groupuser", "group@example.com", "pass") + self.group_user.groups.add(self.group) + self.anon_user = get_user_model().objects.create_user("anon", "anon@example.com", "pass") + + +class AccessControlTests(SponsorManageTestBase): + """Test that views are properly locked down.""" + + def test_anonymous_redirected_to_login(self): + self.client.logout() + response = self.client.get(reverse("manage_dashboard")) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_non_group_user_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_dashboard")) + self.assertEqual(response.status_code, 403) + + def test_staff_user_allowed(self): + self.client.login(username="staff", password="pass") + response = self.client.get(reverse("manage_dashboard")) + self.assertEqual(response.status_code, 200) + + def test_group_user_allowed(self): + self.client.login(username="groupuser", password="pass") + response = self.client.get(reverse("manage_dashboard")) + self.assertEqual(response.status_code, 200) + + +class DashboardViewTests(SponsorManageTestBase): + """Test dashboard view.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_dashboard_loads(self): + response = self.client.get(reverse("manage_dashboard")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Sponsor") + + def test_dashboard_shows_year_data(self): + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Foundation") + self.assertContains(response, "Logo on python.org") + + def test_dashboard_defaults_to_current_year(self): + response = self.client.get(reverse("manage_dashboard")) + self.assertEqual(response.context["selected_year"], self.year) + + +class BenefitViewTests(SponsorManageTestBase): + """Test benefit CRUD views.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_benefit_list(self): + response = self.client.get(reverse("manage_benefit_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logo on python.org") + + def test_benefit_list_filter_by_year(self): + response = self.client.get(reverse("manage_benefit_list") + f"?year={self.year}") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logo on python.org") + + def test_benefit_list_filter_empty(self): + response = self.client.get(reverse("manage_benefit_list") + "?year=2099") + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "Logo on python.org") + + def test_benefit_create_get(self): + response = self.client.get(reverse("manage_benefit_create")) + self.assertEqual(response.status_code, 200) + + def test_benefit_create_post(self): + response = self.client.post( + reverse("manage_benefit_create"), + { + "name": "New Test Benefit", + "program": self.program.pk, + "year": self.year, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(SponsorshipBenefit.objects.filter(name="New Test Benefit").exists()) + + def test_benefit_edit_get(self): + response = self.client.get(reverse("manage_benefit_edit", args=[self.benefit.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logo on python.org") + + def test_benefit_edit_post(self): + response = self.client.post( + reverse("manage_benefit_edit", args=[self.benefit.pk]), + { + "name": "Updated Benefit Name", + "program": self.program.pk, + "year": self.year, + }, + ) + self.assertEqual(response.status_code, 302) + self.benefit.refresh_from_db() + self.assertEqual(self.benefit.name, "Updated Benefit Name") + + def test_benefit_delete_get(self): + response = self.client.get(reverse("manage_benefit_delete", args=[self.benefit.pk])) + self.assertEqual(response.status_code, 200) + + def test_benefit_delete_post(self): + pk = self.benefit.pk + response = self.client.post(reverse("manage_benefit_delete", args=[pk])) + self.assertEqual(response.status_code, 302) + self.assertFalse(SponsorshipBenefit.objects.filter(pk=pk).exists()) + + +class PackageViewTests(SponsorManageTestBase): + """Test package CRUD views.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_package_list(self): + response = self.client.get(reverse("manage_packages")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Visionary") + + def test_package_create_get(self): + response = self.client.get(reverse("manage_package_create")) + self.assertEqual(response.status_code, 200) + + def test_package_create_post(self): + response = self.client.post( + reverse("manage_package_create"), + { + "name": "Diamond", + "slug": "diamond", + "sponsorship_amount": 200000, + "logo_dimension": 200, + "year": self.year, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(SponsorshipPackage.objects.filter(slug="diamond").exists()) + + def test_package_edit_get(self): + response = self.client.get(reverse("manage_package_edit", args=[self.package.pk])) + self.assertEqual(response.status_code, 200) + + def test_package_delete_get(self): + response = self.client.get(reverse("manage_package_delete", args=[self.package.pk])) + self.assertEqual(response.status_code, 200) + + +class CloneYearViewTests(SponsorManageTestBase): + """Test clone year wizard.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_clone_page_loads(self): + response = self.client.get(reverse("manage_clone_year")) + self.assertEqual(response.status_code, 200) + + def test_clone_year(self): + target_year = self.year + 1 + response = self.client.post( + reverse("manage_clone_year"), + { + "source_year": str(self.year), + "target_year": target_year, + "clone_packages": True, + "clone_benefits": True, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(SponsorshipPackage.objects.filter(year=target_year, slug="visionary").exists()) + self.assertTrue(SponsorshipBenefit.objects.filter(year=target_year, name="Logo on python.org").exists()) + + def test_clone_same_year_rejected(self): + response = self.client.post( + reverse("manage_clone_year"), + { + "source_year": str(self.year), + "target_year": self.year, + "clone_packages": True, + "clone_benefits": True, + }, + ) + self.assertEqual(response.status_code, 200) # Re-renders form with errors + self.assertContains(response, "must be different") + + +class CurrentYearViewTests(SponsorManageTestBase): + """Test current year update view.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_current_year_page_loads(self): + response = self.client.get(reverse("manage_current_year")) + self.assertEqual(response.status_code, 200) + + def test_update_current_year(self): + response = self.client.post(reverse("manage_current_year"), {"year": 2025}) + self.assertEqual(response.status_code, 302) + self.assertEqual(SponsorshipCurrentYear.objects.first().year, 2025) + + +class SponsorshipReviewTestBase(SponsorManageTestBase): + """Base for sponsorship review tests with a sponsor and sponsorship.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.sponsor = Sponsor.objects.create(name="Acme Corp") + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + # Create a fresh sponsorship per test (status changes are destructive) + self.sponsorship = Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=150000, + year=self.year, + status=Sponsorship.APPLIED, + ) + + +class SponsorshipListViewTests(SponsorshipReviewTestBase): + """Test sponsorship list view.""" + + def test_list_loads(self): + response = self.client.get(reverse("manage_sponsorships")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + + def test_list_excludes_rejected_by_default(self): + self.sponsorship.status = Sponsorship.REJECTED + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorships")) + self.assertNotContains(response, "Acme Corp") + + def test_list_filter_by_status(self): + response = self.client.get(reverse("manage_sponsorships") + "?status=applied") + self.assertContains(response, "Acme Corp") + + def test_list_filter_by_year(self): + response = self.client.get(reverse("manage_sponsorships") + f"?year={self.year}") + self.assertContains(response, "Acme Corp") + + def test_list_search_by_name(self): + response = self.client.get(reverse("manage_sponsorships") + "?search=Acme") + self.assertContains(response, "Acme Corp") + response = self.client.get(reverse("manage_sponsorships") + "?search=Nonexistent") + self.assertNotContains(response, "Acme Corp") + + +class SponsorshipDetailViewTests(SponsorshipReviewTestBase): + """Test sponsorship detail view.""" + + def test_detail_loads(self): + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + + def test_detail_shows_approve_button_for_applied(self): + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertTrue(response.context["can_approve"]) + + def test_detail_shows_reject_button_for_applied(self): + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertTrue(response.context["can_reject"]) + + +class SponsorshipApproveViewTests(SponsorshipReviewTestBase): + """Test approval workflow.""" + + def test_approve_form_loads(self): + response = self.client.get(reverse("manage_sponsorship_approve", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Approve Sponsorship") + + def test_approve_sponsorship(self): + response = self.client.post( + reverse("manage_sponsorship_approve", args=[self.sponsorship.pk]), + { + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "package": self.package.pk, + "sponsorship_fee": 150000, + }, + ) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.APPROVED) + + def test_approve_bad_dates_rejected(self): + response = self.client.post( + reverse("manage_sponsorship_approve", args=[self.sponsorship.pk]), + { + "start_date": "2024-12-31", + "end_date": "2024-01-01", + "package": self.package.pk, + "sponsorship_fee": 150000, + }, + ) + self.assertEqual(response.status_code, 200) # Re-renders form + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.APPLIED) + + +class SponsorshipRejectViewTests(SponsorshipReviewTestBase): + """Test rejection workflow.""" + + def test_reject_notify_redirects_to_notify_page(self): + """Default reject action redirects to notify page with prefill.""" + response = self.client.post( + reverse("manage_sponsorship_reject", args=[self.sponsorship.pk]), + {"action": "reject_notify"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("notify", response.url) + self.assertIn("prefill=rejection", response.url) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.REJECTED) + + def test_reject_silent_no_redirect_to_notify(self): + """Silent reject goes back to detail page, not notify.""" + response = self.client.post( + reverse("manage_sponsorship_reject", args=[self.sponsorship.pk]), + {"action": "reject_silent"}, + ) + self.assertEqual(response.status_code, 302) + self.assertNotIn("notify", response.url) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.REJECTED) + + def test_reject_default_action_is_notify(self): + """Without explicit action, defaults to reject_notify.""" + response = self.client.post(reverse("manage_sponsorship_reject", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("prefill=rejection", response.url) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.REJECTED) + + def test_notify_page_prefilled_for_rejection(self): + """Notify page pre-fills subject and content for rejection.""" + self.sponsorship.reject() + self.sponsorship.save() + response = self.client.get( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]) + "?prefill=rejection" + ) + self.assertEqual(response.status_code, 200) + form = response.context["form"] + self.assertIn("Sponsorship Application Update", form.initial.get("subject", "")) + self.assertIn("unable to move forward", form.initial.get("content", "")) + + +class SponsorshipRollbackViewTests(SponsorshipReviewTestBase): + """Test rollback workflow.""" + + def test_rollback_approved_to_applied(self): + # First approve it + self.sponsorship.approve( + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 12, 31), + ) + self.sponsorship.save() + # Then rollback + response = self.client.post(reverse("manage_sponsorship_rollback", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.APPLIED) + + +class SponsorshipLockToggleViewTests(SponsorshipReviewTestBase): + """Test lock/unlock toggle.""" + + def test_lock_sponsorship(self): + response = self.client.post( + reverse("manage_sponsorship_lock", args=[self.sponsorship.pk]), + {"action": "lock"}, + ) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertTrue(self.sponsorship.locked) + + def test_unlock_sponsorship(self): + self.sponsorship.locked = True + self.sponsorship.save(update_fields=["locked"]) + response = self.client.post( + reverse("manage_sponsorship_lock", args=[self.sponsorship.pk]), + {"action": "unlock"}, + ) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertFalse(self.sponsorship.locked) + + +class SponsorshipBenefitManagementTests(SponsorshipReviewTestBase): + """Test adding/removing benefits on sponsorships.""" + + def test_add_benefit(self): + response = self.client.post( + reverse("manage_sponsorship_add_benefit", args=[self.sponsorship.pk]), + {"benefit": self.benefit.pk}, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue( + SponsorBenefit.objects.filter(sponsorship=self.sponsorship, sponsorship_benefit=self.benefit).exists() + ) + + def test_remove_benefit(self): + sb = SponsorBenefit.new_copy(self.benefit, sponsorship=self.sponsorship) + response = self.client.post( + reverse("manage_sponsorship_remove_benefit", args=[self.sponsorship.pk, sb.pk]), + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(SponsorBenefit.objects.filter(pk=sb.pk).exists()) + + def test_cannot_add_when_locked(self): + self.sponsorship.locked = True + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.save() + response = self.client.post( + reverse("manage_sponsorship_add_benefit", args=[self.sponsorship.pk]), + {"benefit": self.benefit.pk}, + ) + self.assertEqual(response.status_code, 302) + self.assertFalse( + SponsorBenefit.objects.filter(sponsorship=self.sponsorship, sponsorship_benefit=self.benefit).exists() + ) + + +class SponsorshipEditViewTests(SponsorshipReviewTestBase): + """Test sponsorship and sponsor edit views.""" + + def test_sponsorship_edit_loads(self): + response = self.client.get(reverse("manage_sponsorship_edit", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + + def test_sponsorship_edit_filters_packages_by_year(self): + # Create a package in a different year + SponsorshipPackage.objects.create(name="Other", slug="other", sponsorship_amount=1, year=2099) + response = self.client.get(reverse("manage_sponsorship_edit", args=[self.sponsorship.pk])) + form = response.context["form"] + pkg_years = set(form.fields["package"].queryset.values_list("year", flat=True)) + self.assertEqual(pkg_years, {self.year}) + + def test_sponsor_edit_loads(self): + response = self.client.get( + reverse("manage_sponsor_edit", args=[self.sponsor.pk]) + f"?from_sponsorship={self.sponsorship.pk}" + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + + def test_sponsor_edit_post(self): + response = self.client.post( + reverse("manage_sponsor_edit", args=[self.sponsor.pk]), + { + "name": "Acme Corp Updated", + "description": "Updated desc", + "primary_phone": "555-0000", + "mailing_address_line_1": "123 Main St", + "city": "Springfield", + "postal_code": "62701", + "country": "US", + }, + ) + self.assertEqual(response.status_code, 302) + self.sponsor.refresh_from_db() + self.assertEqual(self.sponsor.name, "Acme Corp Updated") + + +class ContractViewTests(SponsorshipReviewTestBase): + """Test contract management views.""" + + def _approve_sponsorship(self): + self.sponsorship.approve( + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 12, 31), + ) + self.sponsorship.save() + return Contract.new(self.sponsorship) + + def test_send_contract_page_loads(self): + self._approve_sponsorship() + response = self.client.get(reverse("manage_contract_send", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Send Contract") + + def test_execute_contract_page_loads(self): + self._approve_sponsorship() + response = self.client.get(reverse("manage_contract_execute", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Execute Contract") + + def test_nullify_contract_requires_correct_status(self): + contract = self._approve_sponsorship() + # Draft contracts can't be nullified + response = self.client.post(reverse("manage_contract_nullify", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + contract.refresh_from_db() + self.assertEqual(contract.status, Contract.DRAFT) # unchanged + + +class ContractRegenerateViewTests(SponsorshipReviewTestBase): + """Test contract regeneration workflow.""" + + def _approve_sponsorship(self): + self.sponsorship.approve( + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 12, 31), + ) + self.sponsorship.save() + return Contract.new(self.sponsorship) + + def test_regenerate_creates_new_contract_and_preserves_old(self): + old_contract = self._approve_sponsorship() + old_pk = old_contract.pk + response = self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + # Old contract is detached and outdated + old_contract.refresh_from_db() + self.assertIsNone(old_contract.sponsorship) + self.assertEqual(old_contract.status, Contract.OUTDATED) + # New contract exists on the sponsorship + self.sponsorship.refresh_from_db() + new_contract = self.sponsorship.contract + self.assertNotEqual(new_contract.pk, old_pk) + self.assertEqual(new_contract.status, Contract.DRAFT) + + def test_regenerate_without_existing_contract_creates_new(self): + self.sponsorship.approve( + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 12, 31), + ) + self.sponsorship.save() + # No contract exists yet + response = self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.contract.status, Contract.DRAFT) + + def test_regenerate_requires_auth(self): + self.client.logout() + response = self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_regenerate_non_group_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 403) + + def test_historical_contracts_in_detail_context(self): + old_contract = self._approve_sponsorship() + # Regenerate to create history + self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + historical = response.context["historical_contracts"] + self.assertEqual(historical.count(), 1) + self.assertEqual(historical.first().pk, old_contract.pk) + + def test_multiple_regenerations_preserve_all(self): + self._approve_sponsorship() + # Regenerate twice + self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk])) + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + historical = response.context["historical_contracts"] + self.assertEqual(historical.count(), 2) + for hc in historical: + self.assertEqual(hc.status, Contract.OUTDATED) + self.assertIsNone(hc.sponsorship) + + def test_regenerate_success_message(self): + self._approve_sponsorship() + response = self.client.post(reverse("manage_contract_regenerate", args=[self.sponsorship.pk]), follow=True) + self.assertContains(response, "New contract draft created") + self.assertContains(response, "Previous contract preserved as outdated") + + +class SponsorshipNotifyViewTests(SponsorshipReviewTestBase): + """Test notification sending from sponsorship detail.""" + + def test_notify_page_loads(self): + response = self.client.get(reverse("manage_sponsorship_notify", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Send Notification") + self.assertContains(response, self.sponsor.name) + + def test_notify_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_sponsorship_notify", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_notify_non_group_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_sponsorship_notify", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 403) + + def test_notify_preview_with_custom_content(self): + SponsorContact.objects.create( + sponsor=self.sponsor, name="Test Contact", email="test@example.com", phone="555-0001", primary=True + ) + response = self.client.post( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "subject": "Test Subject", + "content": "Hello {{ sponsor_name }}", + "preview": "1", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Email Preview") + self.assertContains(response, "Test Subject") + + def test_notify_preview_without_contacts_no_preview(self): + """Preview returns None email_preview when no contacts match.""" + response = self.client.post( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "subject": "Test Subject", + "content": "Hello", + "preview": "1", + }, + ) + self.assertEqual(response.status_code, 200) + # No contacts, so email_preview is None + self.assertIsNone(response.context["email_preview"]) + + def test_notify_confirm_sends(self): + SponsorContact.objects.create( + sponsor=self.sponsor, name="Test Contact", email="test@example.com", phone="555-0001", primary=True + ) + response = self.client.post( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "subject": "Test Subject", + "content": "Hello {{ sponsor_name }}", + "confirm": "1", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn( + reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]), + response.url, + ) + + def test_notify_empty_form_shows_errors(self): + response = self.client.post( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]), + { + "confirm": "1", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This field is required") + + def test_notify_both_template_and_custom_rejected(self): + tpl = SponsorEmailNotificationTemplate.objects.create(internal_name="Test TPL", subject="Subj", content="Body") + response = self.client.post( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "notification": tpl.pk, + "subject": "Also custom", + "content": "Also custom body", + "confirm": "1", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a template or use custom content") + + def test_notify_with_template_sends(self): + tpl = SponsorEmailNotificationTemplate.objects.create( + internal_name="Welcome", subject="Welcome {{ sponsor_name }}", content="Hello!" + ) + SponsorContact.objects.create( + sponsor=self.sponsor, name="Contact", email="c@example.com", phone="555", primary=True + ) + response = self.client.post( + reverse("manage_sponsorship_notify", args=[self.sponsorship.pk]), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "notification": tpl.pk, + "confirm": "1", + }, + ) + self.assertEqual(response.status_code, 302) + + +class SponsorshipDetailAssetTests(SponsorshipReviewTestBase): + """Test that the sponsorship detail view includes asset data.""" + + def test_detail_context_has_asset_fields(self): + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertIn("required_assets", response.context) + self.assertIn("assets_submitted", response.context) + self.assertIn("assets_total", response.context) + self.assertEqual(response.context["assets_total"], 0) + self.assertEqual(response.context["assets_submitted"], 0) + + +class NotificationTemplateListViewTests(SponsorManageTestBase): + """Test notification template list view.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_list_loads_empty(self): + response = self.client.get(reverse("manage_notification_templates")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Notification Templates") + + def test_list_shows_templates(self): + SponsorEmailNotificationTemplate.objects.create( + internal_name="Welcome Email", subject="Welcome", content="Hello" + ) + response = self.client.get(reverse("manage_notification_templates")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Welcome Email") + + def test_list_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_notification_templates")) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + +class NotificationTemplateCreateViewTests(SponsorManageTestBase): + """Test notification template creation.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_create_form_loads(self): + response = self.client.get(reverse("manage_notification_template_create")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Create Notification Template") + + def test_create_template(self): + response = self.client.post( + reverse("manage_notification_template_create"), + { + "internal_name": "New Template", + "subject": "Hello {{ sponsor_name }}", + "content": "Dear {{ sponsor_name }}, your level is {{ sponsorship_level }}.", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(SponsorEmailNotificationTemplate.objects.filter(internal_name="New Template").exists()) + + def test_create_missing_fields_rejected(self): + response = self.client.post( + reverse("manage_notification_template_create"), + {"internal_name": ""}, + ) + self.assertEqual(response.status_code, 200) # Re-renders form + + +class NotificationTemplateUpdateViewTests(SponsorManageTestBase): + """Test notification template editing.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.template = SponsorEmailNotificationTemplate.objects.create( + internal_name="Editable", subject="Subject", content="Content" + ) + + def test_edit_form_loads(self): + response = self.client.get(reverse("manage_notification_template_edit", args=[self.template.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Editable") + + def test_edit_template(self): + response = self.client.post( + reverse("manage_notification_template_edit", args=[self.template.pk]), + { + "internal_name": "Updated Name", + "subject": "Updated Subject", + "content": "Updated Content", + }, + ) + self.assertEqual(response.status_code, 302) + self.template.refresh_from_db() + self.assertEqual(self.template.internal_name, "Updated Name") + self.assertEqual(self.template.subject, "Updated Subject") + + +class NotificationTemplateDeleteViewTests(SponsorManageTestBase): + """Test notification template deletion.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.template = SponsorEmailNotificationTemplate.objects.create( + internal_name="ToDelete", subject="Subject", content="Content" + ) + + def test_delete_confirm_loads(self): + response = self.client.get(reverse("manage_notification_template_delete", args=[self.template.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "ToDelete") + + def test_delete_template(self): + pk = self.template.pk + response = self.client.post(reverse("manage_notification_template_delete", args=[pk])) + self.assertEqual(response.status_code, 302) + self.assertFalse(SponsorEmailNotificationTemplate.objects.filter(pk=pk).exists()) + + def test_delete_requires_auth(self): + self.client.logout() + response = self.client.post(reverse("manage_notification_template_delete", args=[self.template.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + # Template still exists + self.assertTrue(SponsorEmailNotificationTemplate.objects.filter(pk=self.template.pk).exists()) + + +class SponsorshipExportViewTests(SponsorshipReviewTestBase): + """Test CSV export of sponsorships.""" + + def _parse_csv(self, response): + """Parse a CSV response into a list of dicts.""" + content = response.content.decode("utf-8") + reader = csv.DictReader(io.StringIO(content)) + return list(reader) + + def test_export_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_sponsorship_export")) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_export_non_group_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_sponsorship_export")) + self.assertEqual(response.status_code, 403) + + def test_export_csv_content_type(self): + response = self.client.get(reverse("manage_sponsorship_export")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertIn("attachment", response["Content-Disposition"]) + self.assertIn("sponsorships.csv", response["Content-Disposition"]) + + def test_export_csv_has_header_row(self): + response = self.client.get(reverse("manage_sponsorship_export")) + rows = self._parse_csv(response) + # Should have at least one data row (from setUp sponsorship) + self.assertGreaterEqual(len(rows), 1) + self.assertIn("Sponsor Name", rows[0]) + self.assertIn("Package", rows[0]) + self.assertIn("Fee", rows[0]) + + def test_export_csv_contains_sponsorship_data(self): + response = self.client.get(reverse("manage_sponsorship_export")) + rows = self._parse_csv(response) + names = [r["Sponsor Name"] for r in rows] + self.assertIn("Acme Corp", names) + + def test_export_csv_filter_by_status(self): + response = self.client.get(reverse("manage_sponsorship_export") + "?status=applied") + rows = self._parse_csv(response) + names = [r["Sponsor Name"] for r in rows] + self.assertIn("Acme Corp", names) + + # Rejected should be excluded by default + self.sponsorship.status = Sponsorship.REJECTED + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorship_export")) + rows = self._parse_csv(response) + names = [r["Sponsor Name"] for r in rows] + self.assertNotIn("Acme Corp", names) + + def test_export_csv_filter_by_year(self): + response = self.client.get(reverse("manage_sponsorship_export") + f"?year={self.year}") + rows = self._parse_csv(response) + names = [r["Sponsor Name"] for r in rows] + self.assertIn("Acme Corp", names) + + response = self.client.get(reverse("manage_sponsorship_export") + "?year=2099") + rows = self._parse_csv(response) + self.assertEqual(len(rows), 0) + + def test_export_csv_filter_by_search(self): + response = self.client.get(reverse("manage_sponsorship_export") + "?search=Acme") + rows = self._parse_csv(response) + self.assertEqual(len(rows), 1) + + response = self.client.get(reverse("manage_sponsorship_export") + "?search=Nonexistent") + rows = self._parse_csv(response) + self.assertEqual(len(rows), 0) + + def test_export_csv_includes_primary_contact(self): + SponsorContact.objects.create( + sponsor=self.sponsor, name="Jane Doe", email="jane@acme.com", phone="555-1234", primary=True + ) + response = self.client.get(reverse("manage_sponsorship_export")) + rows = self._parse_csv(response) + acme_row = next(r for r in rows if r["Sponsor Name"] == "Acme Corp") + self.assertEqual(acme_row["Primary Contact Name"], "Jane Doe") + self.assertEqual(acme_row["Primary Contact Email"], "jane@acme.com") + + def test_export_post_selected_ids(self): + response = self.client.post( + reverse("manage_sponsorship_export"), + {"selected_ids": [self.sponsorship.pk]}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + rows = self._parse_csv(response) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["Sponsor Name"], "Acme Corp") + + def test_export_post_no_ids_falls_back_to_filters(self): + response = self.client.post( + reverse("manage_sponsorship_export"), + {"status": "applied"}, + ) + self.assertEqual(response.status_code, 200) + rows = self._parse_csv(response) + names = [r["Sponsor Name"] for r in rows] + self.assertIn("Acme Corp", names) + + +class BulkActionDispatchViewTests(SponsorshipReviewTestBase): + """Test bulk action dispatch from sponsorship list.""" + + def test_bulk_action_requires_auth(self): + self.client.logout() + response = self.client.post(reverse("manage_bulk_action"), {"action": "export_csv"}) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_bulk_export_csv(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "export_csv", "selected_ids": [self.sponsorship.pk]}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + content = response.content.decode("utf-8") + self.assertIn("Acme Corp", content) + + def test_bulk_export_no_selection_warns(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "export_csv"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_sponsorships"), response.url) + + def test_bulk_send_notification_redirects(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "send_notification", "selected_ids": [self.sponsorship.pk]}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_bulk_notify"), response.url) + # Check session was set + session = self.client.session + self.assertEqual(session["bulk_notify_ids"], [str(self.sponsorship.pk)]) + + def test_bulk_send_notification_no_selection_warns(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "send_notification"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_sponsorships"), response.url) + + def test_unknown_action_redirects(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "unknown", "selected_ids": [self.sponsorship.pk]}, + ) + self.assertEqual(response.status_code, 302) + + def test_no_action_selected_redirects(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "", "selected_ids": [self.sponsorship.pk]}, + ) + self.assertEqual(response.status_code, 302) + + +class BulkNotifyViewTests(SponsorshipReviewTestBase): + """Test bulk notification view.""" + + def _set_session_ids(self): + """Store sponsorship IDs in the session for bulk notify.""" + session = self.client.session + session["bulk_notify_ids"] = [str(self.sponsorship.pk)] + session.save() + + def test_bulk_notify_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_bulk_notify")) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_bulk_notify_no_ids_redirects(self): + response = self.client.get(reverse("manage_bulk_notify")) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_sponsorships"), response.url) + + def test_bulk_notify_page_loads(self): + self._set_session_ids() + response = self.client.get(reverse("manage_bulk_notify")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Bulk Notification") + self.assertContains(response, "Acme Corp") + + def test_bulk_notify_preview(self): + self._set_session_ids() + SponsorContact.objects.create( + sponsor=self.sponsor, name="Contact", email="c@example.com", phone="555", primary=True + ) + response = self.client.post( + reverse("manage_bulk_notify"), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "subject": "Test Subject", + "content": "Hello {{ sponsor_name }}", + "preview": "1", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Email Preview") + + def test_bulk_notify_confirm_sends(self): + self._set_session_ids() + SponsorContact.objects.create( + sponsor=self.sponsor, name="Contact", email="c@example.com", phone="555", primary=True + ) + response = self.client.post( + reverse("manage_bulk_notify"), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "subject": "Test Subject", + "content": "Hello {{ sponsor_name }}", + "confirm": "1", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_sponsorships"), response.url) + # Session should be cleared + self.assertNotIn("bulk_notify_ids", self.client.session) + + def test_bulk_notify_post_no_ids_redirects(self): + response = self.client.post( + reverse("manage_bulk_notify"), + { + "contact_types": [SponsorContact.PRIMARY_CONTACT], + "subject": "Test", + "content": "Hello", + "confirm": "1", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_sponsorships"), response.url) + + def test_bulk_notify_empty_form_shows_errors(self): + self._set_session_ids() + response = self.client.post( + reverse("manage_bulk_notify"), + {"confirm": "1"}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This field is required") + + +class SponsorshipListBulkUITests(SponsorshipReviewTestBase): + """Test that the sponsorship list page includes bulk action UI elements.""" + + def test_list_has_checkboxes(self): + response = self.client.get(reverse("manage_sponsorships")) + self.assertContains(response, 'id="select-all"') + self.assertContains(response, 'class="row-select"') + + def test_list_has_bulk_action_form(self): + response = self.client.get(reverse("manage_sponsorships")) + self.assertContains(response, 'id="bulk-action-form"') + self.assertContains(response, "Bulk action") + self.assertContains(response, "export_csv") + self.assertContains(response, "send_notification") + + def test_list_has_export_csv_button(self): + response = self.client.get(reverse("manage_sponsorships")) + self.assertContains(response, "Export CSV") + + def test_list_has_export_assets_option(self): + response = self.client.get(reverse("manage_sponsorships")) + self.assertContains(response, "export_assets") + self.assertContains(response, "Export Assets ZIP") + + +class SponsorshipApproveSignedViewTests(SponsorshipReviewTestBase): + """Test approve-with-signed-contract workflow.""" + + def test_approve_signed_form_loads(self): + response = self.client.get(reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Approve with Signed Contract") + + def test_approve_signed_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_approve_signed_non_group_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 403) + + def test_approve_signed_sponsorship(self): + from django.core.files.uploadedfile import SimpleUploadedFile + + signed_doc = SimpleUploadedFile("signed.pdf", b"fake-pdf-content", content_type="application/pdf") + response = self.client.post( + reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk]), + { + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "package": self.package.pk, + "sponsorship_fee": 150000, + "signed_contract": signed_doc, + }, + ) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.FINALIZED) + # Contract should exist and be executed + contract = self.sponsorship.contract + self.assertEqual(contract.status, Contract.EXECUTED) + + def test_approve_signed_bad_dates_rejected(self): + from django.core.files.uploadedfile import SimpleUploadedFile + + signed_doc = SimpleUploadedFile("signed.pdf", b"fake-pdf-content", content_type="application/pdf") + response = self.client.post( + reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk]), + { + "start_date": "2024-12-31", + "end_date": "2024-01-01", + "package": self.package.pk, + "sponsorship_fee": 150000, + "signed_contract": signed_doc, + }, + ) + self.assertEqual(response.status_code, 200) # Re-renders form with errors + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.APPLIED) + + def test_approve_signed_missing_file_rejected(self): + response = self.client.post( + reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk]), + { + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "package": self.package.pk, + "sponsorship_fee": 150000, + }, + ) + self.assertEqual(response.status_code, 200) # Re-renders form with errors + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.APPLIED) + + def test_approve_signed_shows_on_detail_for_applied(self): + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertContains(response, "Approve with Signed Contract") + self.assertContains(response, reverse("manage_sponsorship_approve_signed", args=[self.sponsorship.pk])) + + def test_approve_signed_hidden_for_approved(self): + self.sponsorship.approve( + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 12, 31), + ) + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertNotContains(response, "Approve with Signed Contract") + + +class AssetExportViewTests(SponsorshipReviewTestBase): + """Test asset export as ZIP.""" + + def test_export_assets_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_sponsorship_export_assets", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_export_assets_non_group_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_sponsorship_export_assets", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 403) + + def test_export_assets_no_assets_redirects(self): + response = self.client.get(reverse("manage_sponsorship_export_assets", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn( + reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]), + response.url, + ) + + def test_bulk_export_assets_no_selection_warns(self): + response = self.client.post( + reverse("manage_bulk_action"), + {"action": "export_assets"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("manage_sponsorships"), response.url) + + +class BenefitFeatureConfigViewTests(SponsorManageTestBase): + """Test benefit feature configuration CRUD views.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + # ── Display on benefit edit page ── + + def test_benefit_edit_shows_config_section(self): + response = self.client.get(reverse("manage_benefit_edit", args=[self.benefit.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Feature Configurations") + self.assertContains(response, "Add Configuration") + + def test_benefit_edit_shows_existing_configs(self): + from apps.sponsors.models import LogoPlacementConfiguration + + LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.get(reverse("manage_benefit_edit", args=[self.benefit.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logo") + self.assertContains(response, "Sidebar") + + # ── Add config ── + + def test_add_config_get(self): + response = self.client.get(reverse("manage_benefit_config_add", args=[self.benefit.pk, "logo_placement"])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logo Placement") + self.assertContains(response, "Add Configuration") + + def test_add_config_post_logo_placement(self): + from apps.sponsors.models import LogoPlacementConfiguration + + response = self.client.post( + reverse("manage_benefit_config_add", args=[self.benefit.pk, "logo_placement"]), + { + "publisher": "psf", + "logo_place": "sidebar", + "link_to_sponsors_page": False, + "describe_as_sponsor": False, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(LogoPlacementConfiguration.objects.filter(benefit=self.benefit, publisher="psf").exists()) + + def test_add_config_post_email_targetable(self): + from apps.sponsors.models import EmailTargetableConfiguration + + response = self.client.post( + reverse("manage_benefit_config_add", args=[self.benefit.pk, "email_targetable"]), + {}, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(EmailTargetableConfiguration.objects.filter(benefit=self.benefit).exists()) + + def test_add_config_post_tiered_benefit(self): + from apps.sponsors.models import TieredBenefitConfiguration + + response = self.client.post( + reverse("manage_benefit_config_add", args=[self.benefit.pk, "tiered_benefit"]), + { + "package": self.package.pk, + "quantity": 5, + "display_label": "", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(TieredBenefitConfiguration.objects.filter(benefit=self.benefit, quantity=5).exists()) + + def test_add_config_invalid_type_redirects(self): + response = self.client.get(reverse("manage_benefit_config_add", args=[self.benefit.pk, "nonexistent"])) + self.assertEqual(response.status_code, 302) + + def test_add_config_invalid_data_rerenders(self): + response = self.client.post( + reverse("manage_benefit_config_add", args=[self.benefit.pk, "logo_placement"]), + { + "publisher": "", + "logo_place": "", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This field is required") + + # ── Edit config ── + + def test_edit_config_get(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.get(reverse("manage_benefit_config_edit", args=[cfg.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Edit") + self.assertContains(response, "Logo Placement") + + def test_edit_config_post(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.post( + reverse("manage_benefit_config_edit", args=[cfg.pk]), + { + "publisher": "pycon", + "logo_place": "footer", + "link_to_sponsors_page": True, + "describe_as_sponsor": False, + }, + ) + self.assertEqual(response.status_code, 302) + cfg.refresh_from_db() + self.assertEqual(cfg.publisher, "pycon") + self.assertEqual(cfg.logo_place, "footer") + self.assertTrue(cfg.link_to_sponsors_page) + + def test_edit_config_invalid_data_rerenders(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.post( + reverse("manage_benefit_config_edit", args=[cfg.pk]), + { + "publisher": "", + "logo_place": "", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This field is required") + + # ── Delete config ── + + def test_delete_config(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.post(reverse("manage_benefit_config_delete", args=[cfg.pk])) + self.assertEqual(response.status_code, 302) + self.assertFalse(LogoPlacementConfiguration.objects.filter(pk=cfg.pk).exists()) + + def test_delete_config_redirects_to_benefit_edit(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.post(reverse("manage_benefit_config_delete", args=[cfg.pk])) + self.assertRedirects( + response, + reverse("manage_benefit_edit", args=[self.benefit.pk]), + fetch_redirect_response=False, + ) + + def test_delete_config_get_not_allowed(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create( + benefit=self.benefit, + publisher="psf", + logo_place="sidebar", + ) + response = self.client.get(reverse("manage_benefit_config_delete", args=[cfg.pk])) + self.assertEqual(response.status_code, 405) + + # ── Access control ── + + def test_add_config_requires_auth(self): + self.client.logout() + response = self.client.get(reverse("manage_benefit_config_add", args=[self.benefit.pk, "logo_placement"])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_add_config_non_group_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_benefit_config_add", args=[self.benefit.pk, "logo_placement"])) + self.assertEqual(response.status_code, 403) + + def test_edit_config_requires_auth(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create(benefit=self.benefit, publisher="psf", logo_place="sidebar") + self.client.logout() + response = self.client.get(reverse("manage_benefit_config_edit", args=[cfg.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_delete_config_requires_auth(self): + from apps.sponsors.models import LogoPlacementConfiguration + + cfg = LogoPlacementConfiguration.objects.create(benefit=self.benefit, publisher="psf", logo_place="sidebar") + self.client.logout() + response = self.client.post(reverse("manage_benefit_config_delete", args=[cfg.pk])) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + +class ComposerAccessTests(SponsorManageTestBase): + """Test that the composer view is properly access-controlled.""" + + def test_anonymous_redirected(self): + self.client.logout() + response = self.client.get(reverse("manage_composer")) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_non_group_user_denied(self): + self.client.login(username="anon", password="pass") + response = self.client.get(reverse("manage_composer")) + self.assertEqual(response.status_code, 403) + + def test_staff_user_allowed(self): + self.client.login(username="staff", password="pass") + response = self.client.get(reverse("manage_composer")) + self.assertEqual(response.status_code, 200) + + def test_group_user_allowed(self): + self.client.login(username="groupuser", password="pass") + response = self.client.get(reverse("manage_composer")) + self.assertEqual(response.status_code, 200) + + +class ComposerStep1Tests(SponsorManageTestBase): + """Test step 1 — sponsor selection.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.sponsor = Sponsor.objects.create( + name="Acme Corp", + description="Test sponsor", + primary_phone="555-1234", + city="Portland", + country="US", + web_logo="test_logo.png", + ) + + def test_step1_renders(self): + response = self.client.get(reverse("manage_composer") + "?step=1") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a Sponsor") + + def test_step1_search(self): + response = self.client.get(reverse("manage_composer") + "?step=1&q=Acme") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + + def test_step1_search_no_results(self): + response = self.client.get(reverse("manage_composer") + "?step=1&q=ZZZNonexistent") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No sponsors found") + + def test_step1_select_sponsor(self): + response = self.client.post( + reverse("manage_composer") + "?step=1", + {"action": "select_sponsor", "sponsor_id": self.sponsor.pk}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=2", response.url) + self.assertEqual(self.client.session["composer"]["sponsor_id"], self.sponsor.pk) + + def test_step1_create_sponsor(self): + response = self.client.post( + reverse("manage_composer") + "?step=1", + { + "action": "create_sponsor", + "name": "New Co", + "description": "A new company", + "primary_phone": "555-9999", + "city": "Seattle", + "country": "US", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=2", response.url) + new_sponsor = Sponsor.objects.get(name="New Co") + self.assertEqual(self.client.session["composer"]["sponsor_id"], new_sponsor.pk) + + def test_step1_create_sponsor_invalid(self): + response = self.client.post( + reverse("manage_composer") + "?step=1", + {"action": "create_sponsor", "name": ""}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This field is required") + + +class ComposerStep2Tests(SponsorManageTestBase): + """Test step 2 — package selection.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.sponsor = Sponsor.objects.create( + name="Acme Corp", + description="Test sponsor", + primary_phone="555-1234", + city="Portland", + country="US", + web_logo="test_logo.png", + ) + # Set up session for step 2 + session = self.client.session + session["composer"] = {"sponsor_id": self.sponsor.pk} + session.save() + + def test_step2_renders(self): + response = self.client.get(reverse("manage_composer") + "?step=2") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Choose a Base Package") + self.assertContains(response, "Visionary") + + def test_step2_select_package(self): + response = self.client.post( + reverse("manage_composer") + "?step=2", + {"package_id": self.package.pk, "year": self.year}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=3", response.url) + data = self.client.session["composer"] + self.assertEqual(data["package_id"], self.package.pk) + + def test_step2_select_custom(self): + response = self.client.post( + reverse("manage_composer") + "?step=2", + {"package_id": "custom", "year": self.year}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=3", response.url) + data = self.client.session["composer"] + self.assertIsNone(data["package_id"]) + self.assertTrue(data["custom_package"]) + + def test_step2_no_selection(self): + response = self.client.post( + reverse("manage_composer") + "?step=2", + {"package_id": "", "year": self.year}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=2", response.url) + + +class ComposerStep3Tests(SponsorManageTestBase): + """Test step 3 — benefit customization.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.sponsor = Sponsor.objects.create( + name="Acme Corp", + description="Test sponsor", + primary_phone="555-1234", + city="Portland", + country="US", + web_logo="test_logo.png", + ) + session = self.client.session + session["composer"] = { + "sponsor_id": self.sponsor.pk, + "package_id": self.package.pk, + "year": self.year, + "benefit_ids": [self.benefit.pk], + } + session.save() + + def test_step3_renders(self): + response = self.client.get(reverse("manage_composer") + "?step=3") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Customize Benefits") + + def test_step3_submit_benefits(self): + response = self.client.post( + reverse("manage_composer") + "?step=3", + {"benefit_ids": [self.benefit.pk]}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=4", response.url) + data = self.client.session["composer"] + self.assertEqual(data["benefit_ids"], [self.benefit.pk]) + + +class ComposerStep4Tests(SponsorManageTestBase): + """Test step 4 — terms setting.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.sponsor = Sponsor.objects.create( + name="Acme Corp", + description="Test sponsor", + primary_phone="555-1234", + city="Portland", + country="US", + web_logo="test_logo.png", + ) + session = self.client.session + session["composer"] = { + "sponsor_id": self.sponsor.pk, + "package_id": self.package.pk, + "year": self.year, + "benefit_ids": [self.benefit.pk], + } + session.save() + + def test_step4_renders(self): + response = self.client.get(reverse("manage_composer") + "?step=4") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Set Sponsorship Terms") + + def test_step4_submit_terms(self): + response = self.client.post( + reverse("manage_composer") + "?step=4", + { + "fee": "150000", + "start_date": "2024-01-01", + "end_date": "2025-01-01", + "renewal": "", + "notes": "Test notes", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=5", response.url) + data = self.client.session["composer"] + self.assertEqual(data["fee"], 150000) + self.assertEqual(data["start_date"], "2024-01-01") + + def test_step4_invalid_dates(self): + response = self.client.post( + reverse("manage_composer") + "?step=4", + { + "fee": "150000", + "start_date": "2025-01-01", + "end_date": "2024-01-01", + "renewal": "", + "notes": "", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "End date must be after start date") + + def test_step4_fee_prefilled_from_package(self): + response = self.client.get(reverse("manage_composer") + "?step=4") + self.assertContains(response, "150000") + + +class ComposerStep5Tests(SponsorManageTestBase): + """Test step 5 — review and create sponsorship + draft contract.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.sponsor = Sponsor.objects.create( + name="Acme Corp", + description="Test sponsor", + primary_phone="555-1234", + city="Portland", + country="US", + web_logo="test_logo.png", + ) + SponsorContact.objects.create( + sponsor=self.sponsor, + name="Jane Doe", + email="jane@acme.com", + phone="555-0000", + primary=True, + ) + session = self.client.session + session["composer"] = { + "sponsor_id": self.sponsor.pk, + "package_id": self.package.pk, + "year": self.year, + "benefit_ids": [self.benefit.pk], + "fee": 150000, + "start_date": "2024-01-01", + "end_date": "2025-01-01", + "renewal": False, + "notes": "Some notes", + } + session.save() + + def test_step5_renders(self): + response = self.client.get(reverse("manage_composer") + "?step=5") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Sponsorship Summary") + self.assertContains(response, "Acme Corp") + self.assertContains(response, "Visionary") + + def test_step5_shows_create_button(self): + response = self.client.get(reverse("manage_composer") + "?step=5") + self.assertContains(response, "Create Sponsorship") + self.assertContains(response, "Draft Contract") + + def test_step5_creates_sponsorship_and_contract(self): + response = self.client.post(reverse("manage_composer") + "?step=5") + self.assertEqual(response.status_code, 302) + self.assertIn("step=6", response.url) + + # Sponsorship created + sponsorship = Sponsorship.objects.get(sponsor=self.sponsor) + self.assertEqual(sponsorship.sponsorship_fee, 150000) + self.assertEqual(sponsorship.year, self.year) + self.assertEqual(sponsorship.package, self.package) + self.assertEqual(sponsorship.benefits.count(), 1) + self.assertEqual(sponsorship.benefits.first().name, "Logo on python.org") + + # Contract created + contract = Contract.objects.get(sponsorship=sponsorship) + self.assertEqual(contract.status, Contract.DRAFT) + self.assertIn("Acme Corp", contract.sponsor_info) + + # Session has both IDs + data = self.client.session["composer"] + self.assertEqual(data["sponsorship_id"], sponsorship.pk) + self.assertEqual(data["contract_id"], contract.pk) + + def test_step5_duplicate_guard(self): + # Create first sponsorship + self.client.post(reverse("manage_composer") + "?step=5") + + # Reset session to try again (but keep sponsorship_id out so step 5 is accessible) + session = self.client.session + session["composer"] = { + "sponsor_id": self.sponsor.pk, + "package_id": self.package.pk, + "year": self.year, + "benefit_ids": [self.benefit.pk], + "fee": 150000, + "start_date": "2024-01-01", + "end_date": "2025-01-01", + "renewal": False, + } + session.save() + + # Should fail with duplicate guard + response = self.client.post(reverse("manage_composer") + "?step=5") + self.assertEqual(response.status_code, 302) + self.assertIn("step=5", response.url) + + +class ComposerStep6Tests(SponsorManageTestBase): + """Test step 6 — contract editor and send page.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.sponsor = Sponsor.objects.create( + name="Acme Corp", + description="Test sponsor", + primary_phone="555-1234", + city="Portland", + country="US", + web_logo="test_logo.png", + ) + SponsorContact.objects.create( + sponsor=self.sponsor, + name="Jane Doe", + email="jane@acme.com", + phone="555-0000", + primary=True, + ) + # Create sponsorship and contract directly + self.sponsorship = Sponsorship.objects.create( + submited_by=self.staff_user, + sponsor=self.sponsor, + level_name="Visionary", + package=self.package, + sponsorship_fee=150000, + for_modified_package=True, + year=self.year, + start_date="2024-01-01", + end_date="2025-01-01", + ) + SponsorBenefit.new_copy(self.benefit, sponsorship=self.sponsorship) + self.contract = Contract.new(self.sponsorship) + + session = self.client.session + session["composer"] = { + "sponsor_id": self.sponsor.pk, + "package_id": self.package.pk, + "year": self.year, + "benefit_ids": [self.benefit.pk], + "fee": 150000, + "start_date": "2024-01-01", + "end_date": "2025-01-01", + "renewal": False, + "notes": "Some notes", + "sponsorship_id": self.sponsorship.pk, + "contract_id": self.contract.pk, + } + session.save() + + def test_step6_renders(self): + response = self.client.get(reverse("manage_composer") + "?step=6") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Contract Editor") + self.assertContains(response, "Acme Corp") + + def test_step6_shows_available_clauses(self): + """Step 6 shows insert buttons for managed legal clauses.""" + clause = LegalClause.objects.create( + internal_name="Trademark", + clause="Sponsor may use the Python trademark.", + ) + response = self.client.get(reverse("manage_composer") + "?step=6") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Insert clause") + self.assertContains(response, "Trademark") + self.assertContains(response, clause.clause) + clause.delete() + + def test_step6_save_contract(self): + response = self.client.post( + reverse("manage_composer") + "?step=6", + { + "action": "save_contract", + "sponsor_info": "Updated Acme Info", + "sponsor_contact": "Updated Contact", + "benefits_list": "- Updated benefit", + "legal_clauses": "[^1]: Updated clause", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=6", response.url) + self.contract.refresh_from_db() + self.assertEqual(self.contract.sponsor_info, "Updated Acme Info") + self.assertEqual(self.contract.sponsor_contact, "Updated Contact") + self.assertEqual(self.contract.benefits_list.raw, "- Updated benefit") + self.assertEqual(self.contract.legal_clauses.raw, "[^1]: Updated clause") + + @mock.patch("apps.sponsors.contracts.render_contract_to_pdf_response") + def test_step6_download_pdf(self, mock_render): + mock_render.return_value = HttpResponse(b"fake-pdf", content_type="application/pdf") + response = self.client.post( + reverse("manage_composer") + "?step=6", + {"action": "download_pdf"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/pdf") + mock_render.assert_called_once() + + @mock.patch("apps.sponsors.contracts.render_contract_to_docx_response") + def test_step6_download_docx(self, mock_render): + mock_render.return_value = HttpResponse( + b"fake-docx", content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + response = self.client.post( + reverse("manage_composer") + "?step=6", + {"action": "download_docx"}, + ) + self.assertEqual(response.status_code, 200) + mock_render.assert_called_once() + + @mock.patch("apps.sponsors.contracts.render_contract_to_docx_file") + @mock.patch("apps.sponsors.contracts.render_contract_to_pdf_file") + def test_step6_send_proposal(self, mock_pdf, mock_docx): + mock_pdf.return_value = b"fake-pdf-bytes" + mock_docx.return_value = b"fake-docx-bytes" + + response = self.client.post( + reverse("manage_composer") + "?step=6", + { + "action": "send_proposal", + "email_subject": "Test Subject", + "email_body": "Test body", + }, + ) + self.assertEqual(response.status_code, 302) + # Should redirect to sponsorship detail + self.assertIn(str(self.sponsorship.pk), response.url) + + from django.core.mail import outbox + + self.assertEqual(len(outbox), 1) + self.assertIn("jane@acme.com", outbox[0].to) + self.assertEqual(outbox[0].subject, "Test Subject") + # Should have attachments + self.assertEqual(len(outbox[0].attachments), 2) + + # Contract should be awaiting signature + self.contract.refresh_from_db() + self.assertEqual(self.contract.status, Contract.AWAITING_SIGNATURE) + + # Session should be cleared + self.assertNotIn("composer", self.client.session) + + def test_step6_send_proposal_no_contacts(self): + SponsorContact.objects.filter(sponsor=self.sponsor).delete() + response = self.client.post( + reverse("manage_composer") + "?step=6", + {"action": "send_proposal"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=6", response.url) + + def test_step6_send_internal(self): + response = self.client.post( + reverse("manage_composer") + "?step=6", + {"action": "send_internal", "internal_email": "reviewer@python.org"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=6", response.url) + from django.core.mail import outbox + + self.assertEqual(len(outbox), 1) + self.assertIn("Acme Corp", outbox[0].subject) + self.assertIn("reviewer@python.org", outbox[0].to) + + def test_step6_send_internal_no_email(self): + response = self.client.post( + reverse("manage_composer") + "?step=6", + {"action": "send_internal", "internal_email": ""}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("step=6", response.url) + + def test_step6_finish(self): + response = self.client.post( + reverse("manage_composer") + "?step=6", + {"action": "finish"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn(str(self.sponsorship.pk), response.url) + # Session should be cleared + self.assertNotIn("composer", self.client.session) + + def test_step6_not_accessible_without_sponsorship(self): + session = self.client.session + del session["composer"]["sponsorship_id"] + del session["composer"]["contract_id"] + session.save() + response = self.client.get(reverse("manage_composer") + "?step=6") + self.assertEqual(response.status_code, 200) + # Should be clamped back to step 5 + self.assertContains(response, "Sponsorship Summary") + + +class ComposerNavigationTests(SponsorManageTestBase): + """Test wizard navigation constraints.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_cannot_skip_to_step2_without_sponsor(self): + response = self.client.get(reverse("manage_composer") + "?step=2") + self.assertEqual(response.status_code, 200) + # Should be rendered as step 1 + self.assertContains(response, "Select a Sponsor") + + def test_cannot_skip_to_step5_without_data(self): + response = self.client.get(reverse("manage_composer") + "?step=5") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a Sponsor") + + def test_cannot_skip_to_step6_without_sponsorship(self): + response = self.client.get(reverse("manage_composer") + "?step=6") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a Sponsor") + + def test_invalid_step_defaults_to_1(self): + response = self.client.get(reverse("manage_composer") + "?step=abc") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a Sponsor") + + +class DashboardExpiringSoonTests(SponsorManageTestBase): + """Test dashboard expiring/expired sponsorship sections.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.sponsor = Sponsor.objects.create(name="Expiring Corp") + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_expiring_soon_shown_on_dashboard(self): + """Finalized sponsorship ending within 90 days appears in Expiring Soon.""" + today = timezone.now().date() + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=self.year, + status=Sponsorship.FINALIZED, + start_date=today - datetime.timedelta(days=300), + end_date=today + datetime.timedelta(days=30), + ) + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Expiring Soon") + self.assertContains(response, "Expiring Corp") + + def test_expiring_far_future_not_shown(self): + """Finalized sponsorship ending more than 90 days out is not in Expiring Soon.""" + today = timezone.now().date() + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=self.year, + status=Sponsorship.FINALIZED, + start_date=today - datetime.timedelta(days=100), + end_date=today + datetime.timedelta(days=200), + ) + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertNotContains(response, "Expiring Soon") + + def test_recently_expired_shown_on_dashboard(self): + """Finalized sponsorship with past end_date appears in Recently Expired.""" + today = timezone.now().date() + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=self.year, + status=Sponsorship.FINALIZED, + start_date=today - datetime.timedelta(days=400), + end_date=today - datetime.timedelta(days=10), + ) + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Recently Expired") + self.assertContains(response, "Expiring Corp") + + def test_overlapped_expired_not_shown(self): + """Expired sponsorship with overlapped_by set is excluded from Recently Expired.""" + today = timezone.now().date() + renewal = Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=self.year, + status=Sponsorship.FINALIZED, + start_date=today - datetime.timedelta(days=30), + end_date=today + datetime.timedelta(days=335), + ) + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=self.year, + status=Sponsorship.FINALIZED, + start_date=today - datetime.timedelta(days=400), + end_date=today - datetime.timedelta(days=10), + overlapped_by=renewal, + ) + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertNotContains(response, "Recently Expired") + + def test_applied_sponsorship_not_in_expiring(self): + """Only finalized sponsorships appear in expiring sections.""" + today = timezone.now().date() + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=self.year, + status=Sponsorship.APPLIED, + start_date=today - datetime.timedelta(days=300), + end_date=today + datetime.timedelta(days=10), + ) + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertNotContains(response, "Expiring Soon") + + def test_expiring_shown_cross_year(self): + """Expiring sponsorships from a prior year show on the current year dashboard.""" + today = timezone.now().date() + prior_year = self.year - 1 + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + sponsorship_fee=100000, + year=prior_year, + status=Sponsorship.FINALIZED, + start_date=today - datetime.timedelta(days=300), + end_date=today + datetime.timedelta(days=30), + ) + response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}") + self.assertContains(response, "Expiring Soon") + self.assertContains(response, "Expiring Corp") + + +class SponsorshipDetailRenewalTests(SponsorshipReviewTestBase): + """Test renewal-related features on sponsorship detail view.""" + + def test_create_renewal_button_shown_for_finalized(self): + """Finalized sponsorships show the + Renewal button.""" + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.save(update_fields=["status"]) + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertContains(response, "+ Renewal") + self.assertContains(response, "renewal=1") + + def test_create_renewal_button_hidden_for_applied(self): + """Applied sponsorships do not show the + Renewal button.""" + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertNotContains(response, "+ Renewal") + + def test_expiring_soon_tag_shown(self): + """Finalized sponsorship with end_date within 90 days shows Expiring Soon tag.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=300) + self.sponsorship.end_date = today + datetime.timedelta(days=30) + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertContains(response, "Expiring Soon") + + def test_expired_tag_shown(self): + """Finalized sponsorship with end_date in past shows Expired tag.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=400) + self.sponsorship.end_date = today - datetime.timedelta(days=10) + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertContains(response, "Expired") + + def test_no_expiry_tag_when_not_near_end(self): + """Finalized sponsorship with distant end_date shows no expiry tags.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=100) + self.sponsorship.end_date = today + datetime.timedelta(days=200) + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertNotContains(response, "Expiring Soon") + self.assertNotContains(response, ">Expired<") + + +class SponsorshipListExpiryTagTests(SponsorshipReviewTestBase): + """Test expiry tags on sponsorship list view.""" + + def test_expired_tag_shown_in_list(self): + """Expired finalized sponsorship shows 'Expired' tag in list.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=400) + self.sponsorship.end_date = today - datetime.timedelta(days=10) + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorships") + "?status=finalized") + self.assertContains(response, "Expired") + + def test_days_left_tag_shown_in_list(self): + """Expiring finalized sponsorship shows days-left tag in list.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=300) + self.sponsorship.end_date = today + datetime.timedelta(days=20) + self.sponsorship.save() + response = self.client.get(reverse("manage_sponsorships") + "?status=finalized") + self.assertContains(response, "d left") + + +class ComposerRenewalPreFillTests(SponsorManageTestBase): + """Test that composer pre-fills renewal flag from query param.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.sponsor = Sponsor.objects.create(name="Renewing Corp") + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_renewal_flag_stored_in_session(self): + """Starting composer with renewal=1 stores renewal in session.""" + self.client.get(reverse("manage_composer") + f"?new=1&sponsor_id={self.sponsor.pk}&renewal=1") + session_data = self.client.session.get("composer", {}) + self.assertTrue(session_data.get("renewal")) + + def test_renewal_flag_not_stored_without_param(self): + """Starting composer without renewal param does not store renewal.""" + self.client.get(reverse("manage_composer") + f"?new=1&sponsor_id={self.sponsor.pk}") + session_data = self.client.session.get("composer", {}) + self.assertNotIn("renewal", session_data) + + +class BenefitSyncViewTests(SponsorshipReviewTestBase): + """Test benefit sync to active sponsorships.""" + + def setUp(self): + super().setUp() + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=100) + self.sponsorship.end_date = today + datetime.timedelta(days=265) + self.sponsorship.save() + # Create SponsorBenefit linking sponsorship to benefit template + self.sponsor_benefit = SponsorBenefit.objects.create( + sponsorship=self.sponsorship, + sponsorship_benefit=self.benefit, + name=self.benefit.name, + description=self.benefit.description, + program=self.benefit.program, + benefit_internal_value=self.benefit.internal_value, + ) + + def test_sync_page_loads(self): + """Sync page shows eligible sponsorships.""" + response = self.client.get(reverse("manage_benefit_sync", args=[self.benefit.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + self.assertContains(response, "Sync Benefit to Sponsorships") + + def test_sync_updates_sponsor_benefit(self): + """Posting sync updates the SponsorBenefit with latest template data.""" + # Change the benefit template + self.benefit.name = "Updated Logo Benefit" + self.benefit.internal_value = 5000 + self.benefit.save() + # Sync + response = self.client.post( + reverse("manage_benefit_sync", args=[self.benefit.pk]), + {"sponsorship_ids": [self.sponsorship.pk]}, + ) + self.assertEqual(response.status_code, 302) + self.sponsor_benefit.refresh_from_db() + self.assertEqual(self.sponsor_benefit.name, "Updated Logo Benefit") + self.assertEqual(self.sponsor_benefit.benefit_internal_value, 5000) + + def test_sync_excludes_rejected(self): + """Rejected sponsorships are not shown on the sync page.""" + self.sponsorship.status = Sponsorship.REJECTED + self.sponsorship.save(update_fields=["status"]) + response = self.client.get(reverse("manage_benefit_sync", args=[self.benefit.pk])) + self.assertNotContains(response, "Acme Corp") + + def test_sync_excludes_expired(self): + """Expired sponsorships are not shown on the sync page.""" + today = timezone.now().date() + self.sponsorship.end_date = today - datetime.timedelta(days=10) + self.sponsorship.save(update_fields=["end_date"]) + response = self.client.get(reverse("manage_benefit_sync", args=[self.benefit.pk])) + self.assertNotContains(response, "Acme Corp") + + def test_sync_no_selection_warns(self): + """Posting with no selections shows a warning.""" + response = self.client.post( + reverse("manage_benefit_sync", args=[self.benefit.pk]), + {}, + ) + self.assertEqual(response.status_code, 302) + + def test_sync_button_shown_on_benefit_edit(self): + """Benefit edit page shows Sync button when sponsorships exist.""" + response = self.client.get(reverse("manage_benefit_edit", args=[self.benefit.pk])) + self.assertContains(response, "Sync to Sponsorships") + + +class LegalClauseViewTests(SponsorManageTestBase): + """Test legal clause CRUD views.""" + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + self.clause = LegalClause.objects.create( + internal_name="Trademark Usage", + clause="Sponsor may use the Python trademark per PSF guidelines.", + notes="Standard clause", + ) + + def test_list_loads(self): + response = self.client.get(reverse("manage_legal_clauses")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Trademark Usage") + + def test_create_get(self): + response = self.client.get(reverse("manage_legal_clause_create")) + self.assertEqual(response.status_code, 200) + + def test_create_post(self): + response = self.client.post( + reverse("manage_legal_clause_create"), + {"internal_name": "New Clause", "clause": "Some legal text.", "notes": ""}, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(LegalClause.objects.filter(internal_name="New Clause").exists()) + + def test_edit_get(self): + response = self.client.get(reverse("manage_legal_clause_edit", args=[self.clause.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Trademark Usage") + + def test_edit_post(self): + response = self.client.post( + reverse("manage_legal_clause_edit", args=[self.clause.pk]), + {"internal_name": "Updated Name", "clause": "Updated text.", "notes": ""}, + ) + self.assertEqual(response.status_code, 302) + self.clause.refresh_from_db() + self.assertEqual(self.clause.internal_name, "Updated Name") + + def test_delete_get(self): + response = self.client.get(reverse("manage_legal_clause_delete", args=[self.clause.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Trademark Usage") + + def test_delete_post(self): + response = self.client.post(reverse("manage_legal_clause_delete", args=[self.clause.pk])) + self.assertEqual(response.status_code, 302) + self.assertFalse(LegalClause.objects.filter(pk=self.clause.pk).exists()) + + def test_move_up(self): + clause2 = LegalClause.objects.create(internal_name="Second Clause", clause="Text.") + self.client.post( + reverse("manage_legal_clause_move", args=[clause2.pk]), + {"direction": "up"}, + ) + clause2.refresh_from_db() + self.clause.refresh_from_db() + self.assertLess(clause2.order, self.clause.order) + + def test_move_down(self): + clause2 = LegalClause.objects.create(internal_name="Second Clause", clause="Text.") + self.client.post( + reverse("manage_legal_clause_move", args=[self.clause.pk]), + {"direction": "down"}, + ) + self.clause.refresh_from_db() + clause2.refresh_from_db() + self.assertGreater(self.clause.order, clause2.order) + + def test_nav_has_legal_clauses_link(self): + response = self.client.get(reverse("manage_dashboard")) + self.assertContains(response, "Legal Clauses") + + +class AssetBrowserViewTests(SponsorshipReviewTestBase): + """Test asset browser view.""" + + def _create_text_asset(self, content_object, internal_name, text=""): + """Helper to create a TextAsset via generic relation.""" + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(content_object) + return TextAsset.objects.create( + content_type=ct, + object_id=content_object.pk, + internal_name=internal_name, + text=text, + ) + + def test_browser_loads(self): + response = self.client.get(reverse("manage_assets")) + self.assertEqual(response.status_code, 200) + + def test_browser_shows_assets(self): + self._create_text_asset(self.sponsor, "company_bio", text="Acme makes things") + response = self.client.get(reverse("manage_assets")) + self.assertContains(response, "company_bio") + self.assertContains(response, "Acme Corp") + + def test_filter_by_value_with(self): + self._create_text_asset(self.sponsor, "filled_asset", text="Has value") + self._create_text_asset(self.sponsor, "empty_asset", text="") + response = self.client.get(reverse("manage_assets") + "?value=with") + self.assertContains(response, "filled_asset") + self.assertNotContains(response, "empty_asset") + + def test_filter_by_value_without(self): + self._create_text_asset(self.sponsor, "filled_asset", text="Has value") + self._create_text_asset(self.sponsor, "empty_asset", text="") + response = self.client.get(reverse("manage_assets") + "?value=without") + self.assertNotContains(response, "filled_asset") + self.assertContains(response, "empty_asset") + + def test_filter_by_search(self): + self._create_text_asset(self.sponsor, "logo_2025", text="logo") + self._create_text_asset(self.sponsor, "bio_text", text="bio") + response = self.client.get(reverse("manage_assets") + "?search=logo") + self.assertContains(response, "logo_2025") + self.assertNotContains(response, "bio_text") + + def test_excludes_expired_sponsorship_assets(self): + """Assets from expired sponsorships are hidden.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=400) + self.sponsorship.end_date = today - datetime.timedelta(days=10) + self.sponsorship.save() + self._create_text_asset(self.sponsorship, "old_asset", text="stale") + response = self.client.get(reverse("manage_assets")) + self.assertNotContains(response, "old_asset") + + def test_shows_active_sponsorship_assets(self): + """Assets from active sponsorships are shown.""" + today = timezone.now().date() + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.start_date = today - datetime.timedelta(days=100) + self.sponsorship.end_date = today + datetime.timedelta(days=265) + self.sponsorship.save() + self._create_text_asset(self.sponsorship, "active_asset", text="current") + response = self.client.get(reverse("manage_assets")) + self.assertContains(response, "active_asset") + + def test_nav_has_assets_link(self): + response = self.client.get(reverse("manage_dashboard")) + self.assertContains(response, "Assets") + + +class SponsorListViewTests(SponsorManageTestBase): + """Test sponsor directory view.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.sponsor = Sponsor.objects.create(name="Acme Corp", city="Portland", country="US") + cls.sponsor2 = Sponsor.objects.create(name="Beta Inc", city="London", country="GB") + + def setUp(self): + super().setUp() + self.client.login(username="staff", password="pass") + + def test_list_loads(self): + response = self.client.get(reverse("manage_sponsors")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + self.assertContains(response, "Beta Inc") + + def test_search(self): + response = self.client.get(reverse("manage_sponsors") + "?search=Acme") + self.assertContains(response, "Acme Corp") + self.assertNotContains(response, "Beta Inc") + + def test_shows_sponsorship_count(self): + Sponsorship.objects.create( + sponsor=self.sponsor, + submited_by=self.staff_user, + package=self.package, + year=self.year, + status=Sponsorship.APPLIED, + ) + response = self.client.get(reverse("manage_sponsors")) + self.assertEqual(response.status_code, 200) + + def test_nav_has_sponsors_link(self): + response = self.client.get(reverse("manage_dashboard")) + self.assertContains(response, "Sponsors") + + +class FinancesViewTests(SponsorshipReviewTestBase): + """Test revenue report view.""" + + def test_report_loads(self): + response = self.client.get(reverse("manage_finances")) + self.assertEqual(response.status_code, 200) + + def test_report_shows_revenue(self): + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.save(update_fields=["status"]) + response = self.client.get(reverse("manage_finances") + f"?year={self.year}") + self.assertContains(response, "150,000") + self.assertContains(response, "Acme Corp") + + def test_report_excludes_applied(self): + """Applied sponsorships are not counted in revenue.""" + response = self.client.get(reverse("manage_finances") + f"?year={self.year}") + self.assertNotContains(response, "Acme Corp") + + def test_year_over_year(self): + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.save(update_fields=["status"]) + response = self.client.get(reverse("manage_finances") + f"?year={self.year}") + self.assertContains(response, str(self.year)) + + def test_dashboard_revenue_links_to_report(self): + response = self.client.get(reverse("manage_dashboard")) + self.assertContains(response, "/sponsors/manage/finances/") + + +class SponsorshipDetailFinancialTests(SponsorshipReviewTestBase): + """Test financial breakdown on sponsorship detail page.""" + + def test_financial_breakdown_shown(self): + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.sponsorship_fee = 150000 + self.sponsorship.save() + from apps.sponsors.models import SponsorBenefit + + SponsorBenefit.objects.create( + sponsorship=self.sponsorship, + sponsorship_benefit=self.benefit, + name="Logo", + program=self.program, + benefit_internal_value=5000, + ) + response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk])) + self.assertContains(response, "Financial Breakdown") + self.assertContains(response, "Foundation") diff --git a/apps/sponsors/manage/urls.py b/apps/sponsors/manage/urls.py new file mode 100644 index 000000000..50603c121 --- /dev/null +++ b/apps/sponsors/manage/urls.py @@ -0,0 +1,146 @@ +"""URL configuration for the sponsor management UI.""" + +from django.urls import path + +from apps.sponsors.manage import views + +urlpatterns = [ + # Dashboard + path("", views.ManageDashboardView.as_view(), name="manage_dashboard"), + # Benefits + path("benefits/", views.BenefitListView.as_view(), name="manage_benefit_list"), + path("benefits/new/", views.BenefitCreateView.as_view(), name="manage_benefit_create"), + path("benefits//edit/", views.BenefitUpdateView.as_view(), name="manage_benefit_edit"), + path("benefits//delete/", views.BenefitDeleteView.as_view(), name="manage_benefit_delete"), + path("benefits//sync/", views.BenefitSyncView.as_view(), name="manage_benefit_sync"), + # Benefit feature configurations + path( + "benefits//add-config//", + views.BenefitConfigAddView.as_view(), + name="manage_benefit_config_add", + ), + path("benefit-configs//edit/", views.BenefitConfigEditView.as_view(), name="manage_benefit_config_edit"), + path( + "benefit-configs//delete/", views.BenefitConfigDeleteView.as_view(), name="manage_benefit_config_delete" + ), + # Finances + path("finances/", views.FinancesView.as_view(), name="manage_finances"), + # Asset browser + path("assets/", views.AssetBrowserView.as_view(), name="manage_assets"), + # Legal clauses + path("legal-clauses/", views.LegalClauseListView.as_view(), name="manage_legal_clauses"), + path("legal-clauses/new/", views.LegalClauseCreateView.as_view(), name="manage_legal_clause_create"), + path("legal-clauses//edit/", views.LegalClauseUpdateView.as_view(), name="manage_legal_clause_edit"), + path("legal-clauses//delete/", views.LegalClauseDeleteView.as_view(), name="manage_legal_clause_delete"), + path("legal-clauses//move/", views.LegalClauseMoveView.as_view(), name="manage_legal_clause_move"), + # Packages + path("packages/", views.PackageListView.as_view(), name="manage_packages"), + path("packages/new/", views.PackageCreateView.as_view(), name="manage_package_create"), + path("packages//edit/", views.PackageUpdateView.as_view(), name="manage_package_edit"), + path("packages//delete/", views.PackageDeleteView.as_view(), name="manage_package_delete"), + # Clone year + path("clone/", views.CloneYearView.as_view(), name="manage_clone_year"), + # Active year + path("current-year/", views.CurrentYearUpdateView.as_view(), name="manage_current_year"), + # Sponsorship review + path("sponsorships/", views.SponsorshipListView.as_view(), name="manage_sponsorships"), + path("sponsorships/export/", views.SponsorshipExportView.as_view(), name="manage_sponsorship_export"), + path("sponsorships/bulk-action/", views.BulkActionDispatchView.as_view(), name="manage_bulk_action"), + path("sponsorships/bulk-notify/", views.BulkNotifyView.as_view(), name="manage_bulk_notify"), + path("sponsorships//", views.SponsorshipDetailView.as_view(), name="manage_sponsorship_detail"), + path("sponsorships//approve/", views.SponsorshipApproveView.as_view(), name="manage_sponsorship_approve"), + path( + "sponsorships//approve-signed/", + views.SponsorshipApproveSignedView.as_view(), + name="manage_sponsorship_approve_signed", + ), + path("sponsorships//reject/", views.SponsorshipRejectView.as_view(), name="manage_sponsorship_reject"), + path( + "sponsorships//rollback/", views.SponsorshipRollbackView.as_view(), name="manage_sponsorship_rollback" + ), + path("sponsorships//lock/", views.SponsorshipLockToggleView.as_view(), name="manage_sponsorship_lock"), + path("sponsorships//edit/", views.SponsorshipEditView.as_view(), name="manage_sponsorship_edit"), + # Benefits on sponsorship + path( + "sponsorships//add-benefit/", + views.SponsorshipAddBenefitView.as_view(), + name="manage_sponsorship_add_benefit", + ), + path( + "sponsorships//remove-benefit//", + views.SponsorshipRemoveBenefitView.as_view(), + name="manage_sponsorship_remove_benefit", + ), + # Contract actions (keyed by sponsorship pk) + path( + "sponsorships//contract/preview/", + views.ContractPreviewView.as_view(), + name="manage_contract_preview", + ), + path("sponsorships//contract/send/", views.ContractSendView.as_view(), name="manage_contract_send"), + path( + "sponsorships//contract/execute/", views.ContractExecuteView.as_view(), name="manage_contract_execute" + ), + path( + "sponsorships//contract/nullify/", views.ContractNullifyView.as_view(), name="manage_contract_nullify" + ), + path( + "sponsorships//contract/redraft/", views.ContractRedraftView.as_view(), name="manage_contract_redraft" + ), + path( + "sponsorships//contract/regenerate/", + views.ContractRegenerateView.as_view(), + name="manage_contract_regenerate", + ), + # Asset export + path( + "sponsorships//export-assets/", + views.AssetExportView.as_view(), + name="manage_sponsorship_export_assets", + ), + # Sponsor directory + create/edit + path("sponsors/", views.SponsorListView.as_view(), name="manage_sponsors"), + path("sponsors/new/", views.SponsorCreateView.as_view(), name="manage_sponsor_create"), + path("sponsors//edit/", views.SponsorEditView.as_view(), name="manage_sponsor_edit"), + # Sponsor contacts + path( + "sponsors//contacts/new/", + views.SponsorContactCreateView.as_view(), + name="manage_contact_create", + ), + path("contacts//edit/", views.SponsorContactUpdateView.as_view(), name="manage_contact_edit"), + path("contacts//delete/", views.SponsorContactDeleteView.as_view(), name="manage_contact_delete"), + # Sponsorship notifications + path( + "sponsorships//notify/", + views.SponsorshipNotifyView.as_view(), + name="manage_sponsorship_notify", + ), + # Composer wizard + path("composer/", views.ComposerView.as_view(), name="manage_composer"), + path( + "composer/contract-preview/", + views.ComposerContractPreviewView.as_view(), + name="manage_composer_contract_preview", + ), + # Notification template CRUD + history + path("notifications/", views.NotificationTemplateListView.as_view(), name="manage_notification_templates"), + path("notifications/history/", views.NotificationHistoryView.as_view(), name="manage_notification_history"), + path( + "notifications/new/", + views.NotificationTemplateCreateView.as_view(), + name="manage_notification_template_create", + ), + path( + "notifications//edit/", + views.NotificationTemplateUpdateView.as_view(), + name="manage_notification_template_edit", + ), + path( + "notifications//delete/", + views.NotificationTemplateDeleteView.as_view(), + name="manage_notification_template_delete", + ), + # Guide + path("guide/", views.GuideView.as_view(), name="manage_guide"), +] diff --git a/apps/sponsors/manage/views.py b/apps/sponsors/manage/views.py new file mode 100644 index 000000000..902b9b73f --- /dev/null +++ b/apps/sponsors/manage/views.py @@ -0,0 +1,2928 @@ +"""Views for the sponsor management UI. + +Locked down to users in the 'Sponsorship Admin' group (or staff/superuser). +""" + +import contextlib +import csv +import datetime +import io +import zipfile +from tempfile import NamedTemporaryFile + +from django.conf import settings +from django.contrib import messages +from django.db import transaction +from django.db.models import Count, Q, Sum +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone as tz +from django.views import View +from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, TemplateView, UpdateView + +from apps.sponsors import use_cases +from apps.sponsors.exceptions import InvalidStatusError +from apps.sponsors.manage.forms import ( + CONFIG_TYPES, + AddBenefitToSponsorshipForm, + BenefitFilterForm, + CloneYearForm, + ComposerSponsorForm, + ComposerTermsForm, + CurrentYearForm, + ExecuteContractForm, + LegalClauseForm, + NotificationTemplateForm, + SendSponsorshipNotificationManageForm, + SponsorContactForm, + SponsorEditForm, + SponsorshipApproveForm, + SponsorshipApproveSignedForm, + SponsorshipBenefitManageForm, + SponsorshipEditForm, + SponsorshipFilterForm, + SponsorshipPackageManageForm, +) +from apps.sponsors.models import ( + BenefitFeature, + BenefitFeatureConfiguration, + Contract, + GenericAsset, + LegalClause, + Sponsor, + SponsorBenefit, + SponsorContact, + SponsorEmailNotificationTemplate, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + SponsorshipNotificationLog, + SponsorshipPackage, + SponsorshipProgram, +) +from pydotorg.mixins import GroupRequiredMixin, LoginRequiredMixin + + +class SponsorshipAdminRequiredMixin(LoginRequiredMixin, GroupRequiredMixin): + """Require user to be in 'Sponsorship Admin' group or be staff.""" + + group_required = "Sponsorship Admin" + raise_exception = True + + def check_membership(self, group): + """Allow staff users in addition to group members.""" + if self.request.user.is_staff: + return True + return super().check_membership(group) + + +class ManageDashboardView(SponsorshipAdminRequiredMixin, TemplateView): + """Dashboard showing sponsorship configuration overview by year.""" + + template_name = "sponsors/manage/dashboard.html" + + def get_context_data(self, **kwargs): + """Return dashboard context with year stats and program breakdowns.""" + context = super().get_context_data(**kwargs) + + # Get all years with benefits + years = SponsorshipBenefit.objects.values_list("year", flat=True).distinct().order_by("-year") + years = [y for y in years if y] + + current_year = None + with contextlib.suppress(SponsorshipCurrentYear.DoesNotExist): + current_year = SponsorshipCurrentYear.get_year() + + selected_year = self.request.GET.get("year") + if selected_year: + selected_year = int(selected_year) + elif current_year: + selected_year = current_year + elif years: + selected_year = years[0] + + # Stats for the selected year + year_benefits = ( + SponsorshipBenefit.objects.filter(year=selected_year) + if selected_year + else SponsorshipBenefit.objects.none() + ) + year_packages = ( + SponsorshipPackage.objects.filter(year=selected_year) + if selected_year + else SponsorshipPackage.objects.none() + ) + + # Benefits grouped by program + programs = SponsorshipProgram.objects.all().order_by("order") + program_stats = [] + for program in programs: + benefits = year_benefits.filter(program=program) + if benefits.exists(): + program_stats.append( + { + "program": program, + "count": benefits.count(), + "unavailable": benefits.filter(unavailable=True).count(), + "new": benefits.filter(new=True).count(), + "total_value": benefits.aggregate(total=Sum("internal_value"))["total"] or 0, + "benefits": benefits.order_by("order"), + } + ) + + # Sponsorship stats for this year + year_sponsorships = ( + Sponsorship.objects.filter(year=selected_year) if selected_year else Sponsorship.objects.none() + ) + count_applied = year_sponsorships.filter(status=Sponsorship.APPLIED).count() + count_approved = year_sponsorships.filter(status=Sponsorship.APPROVED).count() + count_finalized = year_sponsorships.filter(status=Sponsorship.FINALIZED).count() + count_rejected = year_sponsorships.filter(status=Sponsorship.REJECTED).count() + + # Action-needed lists + needs_review = ( + year_sponsorships.filter(status=Sponsorship.APPLIED) + .select_related("sponsor", "package") + .order_by("applied_on")[:10] + ) + pending_contracts = ( + year_sponsorships.filter(status=Sponsorship.APPROVED) + .select_related("sponsor", "package") + .order_by("approved_on")[:10] + ) + + # Revenue summary + total_revenue = ( + year_sponsorships.filter( + status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED], + ).aggregate(total=Sum("sponsorship_fee"))["total"] + or 0 + ) + + # Expiring sponsorships (finalized, end_date within 90 days from today) + # Cross-year: shown on every dashboard regardless of selected year + today = tz.now().date() + expiring_soon = ( + Sponsorship.objects.filter( + status=Sponsorship.FINALIZED, + end_date__gte=today, + end_date__lte=today + datetime.timedelta(days=90), + ) + .select_related("sponsor", "package") + .order_by("end_date")[:10] + ) + + # Recently expired (finalized, end_date in the past, not overlapped) + # Cross-year: shown on every dashboard regardless of selected year + recently_expired = ( + Sponsorship.objects.filter( + status=Sponsorship.FINALIZED, + end_date__lt=today, + overlapped_by__isnull=True, + ) + .select_related("sponsor", "package") + .order_by("-end_date")[:10] + ) + + # Sponsors without a sponsorship for this year + sponsors_with_sponsorship_ids = year_sponsorships.values_list("sponsor_id", flat=True) if selected_year else [] + unsponsored = ( + Sponsor.objects.exclude(pk__in=sponsors_with_sponsorship_ids).order_by("name")[:20] if selected_year else [] + ) + + context.update( + { + "years": years, + "selected_year": selected_year, + "current_year": current_year, + "year_benefits": year_benefits, + "year_packages": year_packages.order_by("-sponsorship_amount"), + "program_stats": program_stats, + "total_benefits": year_benefits.count(), + "total_packages": year_packages.count(), + "count_applied": count_applied, + "count_approved": count_approved, + "count_finalized": count_finalized, + "count_rejected": count_rejected, + "total_sponsorships": count_applied + count_approved + count_finalized + count_rejected, + "total_revenue": total_revenue, + "needs_review": needs_review, + "pending_contracts": pending_contracts, + "expiring_soon": expiring_soon, + "recently_expired": recently_expired, + "unsponsored": unsponsored, + "today": today, + } + ) + return context + + +class BenefitListView(SponsorshipAdminRequiredMixin, ListView): + """List benefits with filtering by year, program, package.""" + + template_name = "sponsors/manage/benefit_list.html" + context_object_name = "benefits" + paginate_by = 50 + + def get_queryset(self): + """Return benefits filtered by year, program, and package.""" + qs = ( + SponsorshipBenefit.objects.select_related("program") + .prefetch_related("packages") + .order_by("-year", "program__order", "order") + ) + + self.filter_year = self.request.GET.get("year", "") + self.filter_program = self.request.GET.get("program", "") + self.filter_package = self.request.GET.get("package", "") + + if self.filter_year: + qs = qs.filter(year=int(self.filter_year)) + if self.filter_program: + qs = qs.filter(program_id=int(self.filter_program)) + if self.filter_package: + qs = qs.filter(packages__id=int(self.filter_package)) + return qs + + def get_context_data(self, **kwargs): + """Return context with benefit filter form.""" + context = super().get_context_data(**kwargs) + context["filter_form"] = BenefitFilterForm( + self.request.GET, + selected_year=self.filter_year or None, + ) + context["filter_year"] = self.filter_year + context["filter_program"] = self.filter_program + context["filter_package"] = self.filter_package + return context + + +class BenefitCreateView(SponsorshipAdminRequiredMixin, CreateView): + """Create a new sponsorship benefit.""" + + model = SponsorshipBenefit + form_class = SponsorshipBenefitManageForm + template_name = "sponsors/manage/benefit_form.html" + + def get_success_url(self): + """Return URL to benefit list filtered by year.""" + messages.success(self.request, f'Benefit "{self.object.name}" created successfully.') + return reverse("manage_benefit_list") + f"?year={self.object.year}" + + def get_initial(self): + """Return initial form data from query parameters.""" + initial = super().get_initial() + year = self.request.GET.get("year") + if year: + initial["year"] = int(year) + program = self.request.GET.get("program") + if program: + initial["program"] = int(program) + return initial + + def get_context_data(self, **kwargs): + """Return context with create flag.""" + context = super().get_context_data(**kwargs) + context["is_create"] = True + return context + + +class BenefitUpdateView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit an existing sponsorship benefit.""" + + model = SponsorshipBenefit + form_class = SponsorshipBenefitManageForm + template_name = "sponsors/manage/benefit_form.html" + + def get_success_url(self): + """Return URL to benefit list filtered by year.""" + messages.success(self.request, f'Benefit "{self.object.name}" updated successfully.') + return reverse("manage_benefit_list") + f"?year={self.object.year}" + + def get_context_data(self, **kwargs): + """Return context with related sponsorships, packages, and feature configurations.""" + context = super().get_context_data(**kwargs) + context["is_create"] = False + related = self.object.related_sponsorships.select_related("sponsor", "package").order_by("-year", "status") + context["related_sponsorships"] = related + context["related_sponsorships_count"] = related.count() + context["benefit_packages"] = self.object.packages.order_by("order") + # Feature configurations + context["feature_configs"] = self.object.benefitfeatureconfiguration_set.all() + context["config_types"] = CONFIG_TYPES + return context + + +class BenefitDeleteView(SponsorshipAdminRequiredMixin, DeleteView): + """Delete a sponsorship benefit.""" + + model = SponsorshipBenefit + template_name = "sponsors/manage/benefit_confirm_delete.html" + + def get_success_url(self): + """Return URL to benefit list after deletion.""" + messages.success(self.request, f'Benefit "{self.object.name}" deleted.') + year = self.object.year + return reverse("manage_benefit_list") + (f"?year={year}" if year else "") + + +class BenefitSyncView(SponsorshipAdminRequiredMixin, View): + """Sync a SponsorshipBenefit template to its related SponsorBenefit instances.""" + + def get(self, request, pk): + """Show eligible sponsorships with checkboxes for syncing.""" + benefit = get_object_or_404(SponsorshipBenefit.objects.select_related("program"), pk=pk) + today = tz.now().date() + eligible = ( + benefit.related_sponsorships.exclude(Q(end_date__lt=today) | Q(status=Sponsorship.REJECTED)) + .select_related("sponsor", "package") + .order_by("sponsor__name") + ) + return render( + request, + "sponsors/manage/benefit_sync.html", + { + "benefit": benefit, + "eligible": eligible, + }, + ) + + @transaction.atomic + def post(self, request, pk): + """Sync benefit attributes to selected sponsorships.""" + benefit = get_object_or_404(SponsorshipBenefit.objects.select_related("program"), pk=pk) + selected_ids = request.POST.getlist("sponsorship_ids") + if not selected_ids: + messages.warning(request, "No sponsorships selected.") + return redirect(reverse("manage_benefit_sync", args=[pk])) + + count = 0 + for sp_id in selected_ids: + try: + sponsor_benefit = benefit.sponsorbenefit_set.get(sponsorship_id=int(sp_id)) + sponsor_benefit.reset_attributes(benefit) + count += 1 + except SponsorBenefit.DoesNotExist: + continue + messages.success(request, f"Updated {count} sponsorship(s) with latest benefit data.") + return redirect(reverse("manage_benefit_edit", args=[pk])) + + +# ── Legal Clause Views ──────────────────────────────────────────────── + + +class LegalClauseListView(SponsorshipAdminRequiredMixin, ListView): + """List legal clauses with ordering controls.""" + + model = LegalClause + template_name = "sponsors/manage/legal_clause_list.html" + context_object_name = "clauses" + + def get_queryset(self): + """Return clauses ordered by position.""" + return LegalClause.objects.all().order_by("order") + + +class LegalClauseCreateView(SponsorshipAdminRequiredMixin, CreateView): + """Create a new legal clause.""" + + model = LegalClause + form_class = LegalClauseForm + template_name = "sponsors/manage/legal_clause_form.html" + + def get_success_url(self): + """Return URL to clause list.""" + messages.success(self.request, f'Legal clause "{self.object.internal_name}" created.') + return reverse("manage_legal_clauses") + + def get_context_data(self, **kwargs): + """Return context with create flag.""" + context = super().get_context_data(**kwargs) + context["is_create"] = True + return context + + +class LegalClauseUpdateView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit an existing legal clause.""" + + model = LegalClause + form_class = LegalClauseForm + template_name = "sponsors/manage/legal_clause_form.html" + + def get_success_url(self): + """Return URL to clause list.""" + messages.success(self.request, f'Legal clause "{self.object.internal_name}" updated.') + return reverse("manage_legal_clauses") + + def get_context_data(self, **kwargs): + """Return context with benefit count.""" + context = super().get_context_data(**kwargs) + context["is_create"] = False + context["benefit_count"] = self.object.benefits.count() + return context + + +class LegalClauseDeleteView(SponsorshipAdminRequiredMixin, DeleteView): + """Delete a legal clause.""" + + model = LegalClause + template_name = "sponsors/manage/legal_clause_confirm_delete.html" + + def get_success_url(self): + """Return URL to clause list.""" + messages.success(self.request, f'Legal clause "{self.object.internal_name}" deleted.') + return reverse("manage_legal_clauses") + + +class LegalClauseMoveView(SponsorshipAdminRequiredMixin, View): + """Move a legal clause up or down in order.""" + + def post(self, request, pk): + """Move clause up or down based on direction parameter.""" + clause = get_object_or_404(LegalClause, pk=pk) + direction = request.POST.get("direction") + if direction == "up": + clause.up() + elif direction == "down": + clause.down() + return redirect(reverse("manage_legal_clauses")) + + +# ── Asset Browser ───────────────────────────────────────────────────── + + +class AssetBrowserView(SponsorshipAdminRequiredMixin, TemplateView): + """Browse all sponsor/sponsorship assets with filters.""" + + template_name = "sponsors/manage/asset_browser.html" + + def _apply_queryset_filters(self, qs): + """Apply database-level filters from query params and return filtered queryset.""" + from django.contrib.contenttypes.models import ContentType + + if self.filter_type: + type_map = {cls.__name__: cls for cls in GenericAsset.all_asset_types()} + if self.filter_type in type_map: + qs = qs.instance_of(type_map[self.filter_type]) + if self.filter_related: + with contextlib.suppress(ContentType.DoesNotExist, ValueError): + qs = qs.filter(content_type=ContentType.objects.get(pk=int(self.filter_related))) + if self.filter_search: + qs = qs.filter(internal_name__icontains=self.filter_search) + return qs + + def _resolve_and_group(self, assets): + """Resolve owners, exclude expired/rejected, and group assets by company.""" + from collections import OrderedDict + + today = tz.now().date() + sponsor_ids = {a.object_id for a in assets if a.from_sponsor} + sponsorship_ids = {a.object_id for a in assets if a.from_sponsorship} + sponsors_map = {s.pk: s for s in Sponsor.objects.filter(pk__in=sponsor_ids)} if sponsor_ids else {} + sponsorships_map = ( + {s.pk: s for s in Sponsorship.objects.filter(pk__in=sponsorship_ids).select_related("sponsor", "package")} + if sponsorship_ids + else {} + ) + + def _is_active_sponsorship_asset(a): + sp = sponsorships_map.get(a.object_id) + return sp and sp.status != Sponsorship.REJECTED and not (sp.end_date and sp.end_date < today) + + assets = [a for a in assets if a.from_sponsor or _is_active_sponsorship_asset(a)] + + for asset in assets: + if asset.from_sponsor: + owner = sponsors_map.get(asset.object_id) + asset.resolved_owner = owner + asset.owner_type = "sponsor" + asset.company_name = owner.name if owner else "Unknown" + else: + owner = sponsorships_map.get(asset.object_id) + asset.resolved_owner = owner + asset.owner_type = "sponsorship" + asset.company_name = owner.sponsor.name if owner and owner.sponsor else "Unknown" + + grouped = OrderedDict() + for asset in assets: + name = asset.company_name + if name not in grouped: + grouped[name] = {"assets": [], "submitted": 0, "total": 0, "sponsorship_id": None} + grouped[name]["assets"].append(asset) + grouped[name]["total"] += 1 + if asset.has_value: + grouped[name]["submitted"] += 1 + if not grouped[name]["sponsorship_id"] and asset.owner_type == "sponsorship" and asset.resolved_owner: + grouped[name]["sponsorship_id"] = asset.resolved_owner.pk + + return assets, grouped + + def get_context_data(self, **kwargs): + """Return context with filtered assets grouped by company.""" + context = super().get_context_data(**kwargs) + from django.contrib.contenttypes.models import ContentType + + self.filter_type = self.request.GET.get("type", "") + self.filter_related = self.request.GET.get("related", "") + self.filter_value = self.request.GET.get("value", "") + self.filter_search = self.request.GET.get("search", "") + + qs = self._apply_queryset_filters(GenericAsset.objects.all_assets().select_related("content_type")) + assets = list(qs[:200]) + + if self.filter_value == "with": + assets = [a for a in assets if a.has_value] + elif self.filter_value == "without": + assets = [a for a in assets if not a.has_value] + + assets, grouped = self._resolve_and_group(assets) + + content_types = ContentType.objects.filter(model__in=["sponsor", "sponsorship"]) + context.update( + { + "grouped_assets": grouped, + "company_count": len(grouped), + "asset_count": len(assets), + "type_choices": [(cls.__name__, cls._meta.verbose_name) for cls in GenericAsset.all_asset_types()], + "content_type_choices": [(ct.pk, ct.model.title()) for ct in content_types], + "filter_type": self.filter_type, + "filter_related": self.filter_related, + "filter_value": self.filter_value, + "filter_search": self.filter_search, + } + ) + return context + + +# ── Finances ────────────────────────────────────────────────────────── + + +class FinancesView(SponsorshipAdminRequiredMixin, TemplateView): + """Financial overview with revenue breakdowns, trends, and charts.""" + + template_name = "sponsors/manage/finances.html" + + def _year_summary(self, year): + """Return revenue stats for a single year.""" + base = Sponsorship.objects.filter(year=year) + committed = base.filter(status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED]) + total = committed.aggregate(total=Sum("sponsorship_fee"))["total"] or 0 + finalized = base.filter(status=Sponsorship.FINALIZED).aggregate(total=Sum("sponsorship_fee"))["total"] or 0 + count = committed.count() + return { + "year": year, + "total": total, + "finalized": finalized, + "pending": total - finalized, + "count": count, + "avg": total // count if count else 0, + } + + def _package_breakdown(self, year_qs): + """Return revenue grouped by package tier.""" + rows = ( + year_qs.values("package__name") + .annotate(revenue=Sum("sponsorship_fee"), count=Count("id")) + .order_by("-revenue") + ) + return [ + {"name": r["package__name"] or "Custom", "revenue": r["revenue"] or 0, "count": r["count"]} for r in rows + ] + + def get_context_data(self, **kwargs): + """Return context with financial data for charts.""" + context = super().get_context_data(**kwargs) + import json + + all_years = Sponsorship.objects.values_list("year", flat=True).distinct().order_by("year") + all_years = [y for y in all_years if y] + + selected_year = self.request.GET.get("year") + if selected_year: + selected_year = int(selected_year) + elif all_years: + selected_year = all_years[-1] + + # YoY data (chronological for charts) + yoy = [self._year_summary(y) for y in all_years] + + # Selected year detail + year_qs = Sponsorship.objects.filter( + year=selected_year, status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED] + ) + total_revenue = year_qs.aggregate(total=Sum("sponsorship_fee"))["total"] or 0 + total_count = year_qs.count() + finalized_revenue = ( + year_qs.filter(status=Sponsorship.FINALIZED).aggregate(total=Sum("sponsorship_fee"))["total"] or 0 + ) + approved_revenue = ( + year_qs.filter(status=Sponsorship.APPROVED).aggregate(total=Sum("sponsorship_fee"))["total"] or 0 + ) + + # Package breakdown + by_package = self._package_breakdown(year_qs) + + # Status breakdown (all statuses for selected year) + all_year = Sponsorship.objects.filter(year=selected_year) + status_counts = { + "applied": all_year.filter(status=Sponsorship.APPLIED).count(), + "approved": all_year.filter(status=Sponsorship.APPROVED).count(), + "finalized": all_year.filter(status=Sponsorship.FINALIZED).count(), + "rejected": all_year.filter(status=Sponsorship.REJECTED).count(), + } + + # Per-sponsorship detail table + sponsorships = ( + year_qs.select_related("sponsor", "package").prefetch_related("benefits").order_by("-sponsorship_fee") + ) + for sp in sponsorships: + sp.internal_total = sp.estimated_cost + + # JSON data for Chart.js + chart_data = { + "yoy_labels": [s["year"] for s in yoy], + "yoy_revenue": [s["total"] for s in yoy], + "yoy_finalized": [s["finalized"] for s in yoy], + "yoy_pending": [s["pending"] for s in yoy], + "yoy_counts": [s["count"] for s in yoy], + "yoy_avg": [s["avg"] for s in yoy], + "pkg_labels": [p["name"] for p in by_package], + "pkg_revenue": [p["revenue"] for p in by_package], + "pkg_counts": [p["count"] for p in by_package], + "status_labels": ["Applied", "Approved", "Finalized", "Rejected"], + "status_counts": [ + status_counts["applied"], + status_counts["approved"], + status_counts["finalized"], + status_counts["rejected"], + ], + } + + context.update( + { + "years": list(reversed(all_years)), + "selected_year": selected_year, + "total_revenue": total_revenue, + "total_count": total_count, + "avg_deal": total_revenue // total_count if total_count else 0, + "finalized_revenue": finalized_revenue, + "approved_revenue": approved_revenue, + "by_package": by_package, + "yoy": yoy, + "status_counts": status_counts, + "sponsorships": sponsorships, + "chart_data_json": json.dumps(chart_data), + } + ) + return context + + +# ── Sponsor Directory ───────────────────────────────────────────────── + + +class SponsorListView(SponsorshipAdminRequiredMixin, ListView): + """Browse and search all sponsors.""" + + template_name = "sponsors/manage/sponsor_list.html" + context_object_name = "sponsors" + paginate_by = 50 + + def get_queryset(self): + """Return sponsors filtered by search, annotated with sponsorship count.""" + qs = Sponsor.objects.annotate( + sponsorship_count=Count("sponsorship"), + contact_count=Count("contacts"), + ).order_by("name") + self.filter_search = self.request.GET.get("search", "") + if self.filter_search: + qs = qs.filter(name__icontains=self.filter_search) + return qs + + def get_context_data(self, **kwargs): + """Return context with search term.""" + context = super().get_context_data(**kwargs) + context["filter_search"] = self.filter_search + return context + + +class PackageListView(SponsorshipAdminRequiredMixin, ListView): + """List sponsorship packages grouped by year.""" + + template_name = "sponsors/manage/package_list.html" + context_object_name = "packages" + + def get_queryset(self): + """Return packages optionally filtered by year.""" + qs = SponsorshipPackage.objects.order_by("-year", "-sponsorship_amount") + self.filter_year = self.request.GET.get("year", "") + if self.filter_year: + qs = qs.filter(year=int(self.filter_year)) + return qs + + def get_context_data(self, **kwargs): + """Return context with packages grouped by year.""" + context = super().get_context_data(**kwargs) + years = SponsorshipPackage.objects.values_list("year", flat=True).distinct().order_by("-year") + context["years"] = [y for y in years if y] + context["filter_year"] = self.filter_year + + # Group packages by year for display + packages_by_year = {} + for pkg in context["packages"]: + packages_by_year.setdefault(pkg.year, []).append(pkg) + context["packages_by_year"] = dict(sorted(packages_by_year.items(), key=lambda x: x[0] or 0, reverse=True)) + return context + + +class PackageCreateView(SponsorshipAdminRequiredMixin, CreateView): + """Create a new sponsorship package.""" + + model = SponsorshipPackage + form_class = SponsorshipPackageManageForm + template_name = "sponsors/manage/package_form.html" + + def get_success_url(self): + """Return URL to package list filtered by year.""" + messages.success(self.request, f'Package "{self.object.name}" created successfully.') + return reverse("manage_packages") + f"?year={self.object.year}" + + def get_initial(self): + """Return initial form data from query parameters.""" + initial = super().get_initial() + year = self.request.GET.get("year") + if year: + initial["year"] = int(year) + return initial + + def get_context_data(self, **kwargs): + """Return context with create flag.""" + context = super().get_context_data(**kwargs) + context["is_create"] = True + return context + + +class PackageUpdateView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit an existing sponsorship package.""" + + model = SponsorshipPackage + form_class = SponsorshipPackageManageForm + template_name = "sponsors/manage/package_form.html" + + def get_success_url(self): + """Return URL to package list filtered by year.""" + messages.success(self.request, f'Package "{self.object.name}" updated successfully.') + return reverse("manage_packages") + f"?year={self.object.year}" + + def get_context_data(self, **kwargs): + """Return context with benefit count.""" + context = super().get_context_data(**kwargs) + context["is_create"] = False + context["benefit_count"] = self.object.benefits.count() + return context + + +class PackageDeleteView(SponsorshipAdminRequiredMixin, DeleteView): + """Delete a sponsorship package.""" + + model = SponsorshipPackage + template_name = "sponsors/manage/package_confirm_delete.html" + + def get_success_url(self): + """Return URL to package list after deletion.""" + messages.success(self.request, f'Package "{self.object.name}" deleted.') + year = self.object.year + return reverse("manage_packages") + (f"?year={year}" if year else "") + + +class CloneYearView(SponsorshipAdminRequiredMixin, FormView): + """Wizard to clone benefits and packages from one year to another.""" + + template_name = "sponsors/manage/clone_year.html" + form_class = CloneYearForm + + def get_context_data(self, **kwargs): + """Return context with source year preview data.""" + context = super().get_context_data(**kwargs) + # Preview data for the source year + source_year = self.request.GET.get("source_year") + if source_year: + source_year = int(source_year) + context["preview_benefits"] = ( + SponsorshipBenefit.objects.filter(year=source_year) + .select_related("program") + .order_by("program__order", "order") + ) + context["preview_packages"] = SponsorshipPackage.objects.filter(year=source_year).order_by("order") + context["source_year"] = source_year + return context + + @transaction.atomic + def form_valid(self, form): + """Clone packages and benefits from source to target year.""" + source_year = int(form.cleaned_data["source_year"]) + target_year = form.cleaned_data["target_year"] + clone_packages = form.cleaned_data["clone_packages"] + clone_benefits = form.cleaned_data["clone_benefits"] + + cloned_packages = 0 + cloned_benefits = 0 + + if clone_packages: + for pkg in SponsorshipPackage.objects.filter(year=source_year): + _, created = pkg.clone(target_year) + if created: + cloned_packages += 1 + + if clone_benefits: + for benefit in SponsorshipBenefit.objects.filter(year=source_year): + _, created = benefit.clone(target_year) + if created: + cloned_benefits += 1 + + messages.success( + self.request, + f"Cloned {cloned_packages} package(s) and {cloned_benefits} benefit(s) from {source_year} to {target_year}.", + ) + return super().form_valid(form) + + def get_success_url(self): + """Return URL to dashboard filtered by target year.""" + return reverse("manage_dashboard") + f"?year={self.request.POST.get('target_year', '')}" + + +class CurrentYearUpdateView(SponsorshipAdminRequiredMixin, UpdateView): + """Update the active sponsorship year.""" + + model = SponsorshipCurrentYear + form_class = CurrentYearForm + template_name = "sponsors/manage/current_year_form.html" + + def get_object(self, queryset=None): + """Return the singleton current year object.""" + return SponsorshipCurrentYear.objects.first() + + def get_success_url(self): + """Return URL to dashboard after update.""" + messages.success(self.request, f"Active year updated to {self.object.year}.") + return reverse("manage_dashboard") + + +# ── Sponsorship Review Views ────────────────────────────────────────── + + +class SponsorshipListView(SponsorshipAdminRequiredMixin, ListView): + """List sponsorships with filters for status, year, and search.""" + + template_name = "sponsors/manage/sponsorship_list.html" + context_object_name = "sponsorships" + paginate_by = 30 + + def get_queryset(self): + """Return sponsorships filtered by status, year, and search term.""" + qs = Sponsorship.objects.select_related("sponsor", "package").order_by("-applied_on") + + self.filter_status = self.request.GET.get("status", "") + self.filter_year = self.request.GET.get("year", "") + self.filter_search = self.request.GET.get("search", "") + + qs = qs.filter(status=self.filter_status) if self.filter_status else qs.exclude(status=Sponsorship.REJECTED) + if self.filter_year: + qs = qs.filter(year=int(self.filter_year)) + if self.filter_search: + qs = qs.filter(Q(sponsor__name__icontains=self.filter_search)) + return qs + + def get_context_data(self, **kwargs): + """Return context with filter form and status counts.""" + context = super().get_context_data(**kwargs) + context["filter_form"] = SponsorshipFilterForm(self.request.GET) + context["filter_status"] = self.filter_status + context["filter_year"] = self.filter_year + context["filter_search"] = self.filter_search + context["today"] = tz.now().date() + # Individual count vars for template + context["count_applied"] = Sponsorship.objects.filter(status=Sponsorship.APPLIED).count() + context["count_approved"] = Sponsorship.objects.filter(status=Sponsorship.APPROVED).count() + context["count_finalized"] = Sponsorship.objects.filter(status=Sponsorship.FINALIZED).count() + context["count_rejected"] = Sponsorship.objects.filter(status=Sponsorship.REJECTED).count() + return context + + +class SponsorshipDetailView(SponsorshipAdminRequiredMixin, DetailView): + """Detail view for reviewing a sponsorship application.""" + + model = Sponsorship + template_name = "sponsors/manage/sponsorship_detail.html" + context_object_name = "sponsorship" + + def get_queryset(self): + """Return sponsorships with related sponsor, package, and submitter.""" + return Sponsorship.objects.select_related("sponsor", "package", "submited_by") + + def get_context_data(self, **kwargs): + """Return context with benefits, contacts, and status flags.""" + context = super().get_context_data(**kwargs) + sp = self.object + context["benefits"] = sp.benefits.select_related("program", "sponsorship_benefit").order_by( + "program__order", "order" + ) + context["contacts"] = sp.sponsor.contacts.all() if sp.sponsor else [] + context["can_approve"] = Sponsorship.APPROVED in sp.next_status + context["can_reject"] = Sponsorship.REJECTED in sp.next_status + context["can_rollback"] = ( + sp.status in [Sponsorship.APPLIED, Sponsorship.APPROVED, Sponsorship.REJECTED] + and sp.status != Sponsorship.FINALIZED + ) + context["can_unlock"] = sp.locked and sp.status == Sponsorship.FINALIZED + context["can_lock"] = not sp.locked and sp.status != Sponsorship.APPLIED + # Contract info + try: + context["contract"] = sp.contract + except Contract.DoesNotExist: + context["contract"] = None + # Required assets + required_assets = list(BenefitFeature.objects.required_assets().from_sponsorship(sp)) + assets_submitted = 0 + for asset in required_assets: + with contextlib.suppress(Exception): + val = asset.value + if val and (not hasattr(val, "url") or val.url): + assets_submitted += 1 + context["required_assets"] = required_assets + context["assets_submitted"] = assets_submitted + context["assets_total"] = len(required_assets) + # Benefit add form (only when editable) + if sp.open_for_editing: + context["add_benefit_form"] = AddBenefitToSponsorshipForm(sponsorship=sp) + # Historical (detached) contracts for this sponsor + if sp.sponsor: + context["historical_contracts"] = Contract.objects.filter( + sponsor_info__startswith=sp.sponsor.name + ",", + sponsorship__isnull=True, + status=Contract.OUTDATED, + ).order_by("-last_update") + else: + context["historical_contracts"] = Contract.objects.none() + # Financial breakdown by program + program_values = ( + sp.benefits.values("program__name").annotate(total=Sum("benefit_internal_value")).order_by("-total") + ) + context["program_breakdown"] = [ + {"name": pv["program__name"] or "Other", "total": pv["total"] or 0} for pv in program_values + ] + context["max_program_value"] = max((pv["total"] or 0 for pv in program_values), default=1) or 1 + # Communication history + context["notification_logs"] = sp.notification_logs.select_related("sent_by").all()[:20] + # Renewal info + today = tz.now().date() + context["today"] = today + context["can_create_renewal"] = sp.status == Sponsorship.FINALIZED and sp.sponsor_id is not None + context["is_expiring_soon"] = ( + sp.status == Sponsorship.FINALIZED + and sp.end_date + and today <= sp.end_date <= today + datetime.timedelta(days=90) + ) + context["is_expired"] = sp.status == Sponsorship.FINALIZED and sp.end_date and sp.end_date < today + return context + + +class SponsorshipApproveView(SponsorshipAdminRequiredMixin, UpdateView): + """Approve a sponsorship application with date/fee/package form.""" + + model = Sponsorship + form_class = SponsorshipApproveForm + template_name = "sponsors/manage/sponsorship_approve.html" + + def get_queryset(self): + """Return sponsorships with related sponsor and package.""" + return Sponsorship.objects.select_related("sponsor", "package") + + def get_initial(self): + """Return initial form data from the sponsorship instance.""" + return { + "package": self.object.package, + "start_date": self.object.start_date, + "end_date": self.object.end_date, + "sponsorship_fee": self.object.sponsorship_fee, + } + + def get_context_data(self, **kwargs): + """Return context with previous effective date.""" + context = super().get_context_data(**kwargs) + context["previous_effective"] = self.object.previous_effective_date + return context + + def form_valid(self, form): + """Approve the sponsorship and redirect to detail.""" + sp = self.object + try: + kwargs = form.cleaned_data + kwargs["request"] = self.request + use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() + use_case.execute(sp, **kwargs) + messages.success(self.request, f'Sponsorship for "{sp.sponsor.name}" approved.') + except InvalidStatusError as e: + messages.error(self.request, str(e)) + return redirect(reverse("manage_sponsorship_detail", args=[sp.pk])) + + +class SponsorshipApproveSignedView(SponsorshipAdminRequiredMixin, View): + """Approve a sponsorship and execute contract with an already-signed document.""" + + def get(self, request, pk): + """Render the approve-with-signed-contract form.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor", "package"), pk=pk) + form = SponsorshipApproveSignedForm( + instance=sp, + initial={ + "package": sp.package, + "start_date": sp.start_date, + "end_date": sp.end_date, + "sponsorship_fee": sp.sponsorship_fee, + }, + ) + context = { + "sponsorship": sp, + "form": form, + "previous_effective": sp.previous_effective_date, + } + return render(request, "sponsors/manage/sponsorship_approve_signed.html", context) + + def post(self, request, pk): + """Approve sponsorship and execute the uploaded signed contract.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor", "package"), pk=pk) + form = SponsorshipApproveSignedForm(request.POST, request.FILES, instance=sp) + if form.is_valid(): + kwargs = form.cleaned_data + kwargs["request"] = request + try: + # Delete existing draft contract if one exists (e.g. from composer) + try: + existing_contract = sp.contract + if existing_contract.is_draft: + existing_contract.delete() + except Contract.DoesNotExist: + pass + # Approve the sponsorship and create a draft contract + use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() + sp = use_case.execute(sp, **kwargs) + # Execute it with the uploaded signed contract + use_case = use_cases.ExecuteExistingContractUseCase.build() + use_case.execute(sp.contract, kwargs["signed_contract"], request=request) + messages.success(request, f'Sponsorship for "{sp.sponsor.name}" approved with signed contract.') + except InvalidStatusError as e: + messages.error(request, str(e)) + return redirect(reverse("manage_sponsorship_detail", args=[sp.pk])) + + context = { + "sponsorship": sp, + "form": form, + "previous_effective": sp.previous_effective_date, + } + return render(request, "sponsors/manage/sponsorship_approve_signed.html", context) + + +class AssetExportView(SponsorshipAdminRequiredMixin, View): + """Export required assets for a sponsorship as a ZIP file.""" + + def get(self, request, pk): + """Generate and return a ZIP of all submitted assets for the sponsorship.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor"), pk=pk) + assets = list(BenefitFeature.objects.required_assets().from_sponsorship(sp)) + + # Filter to only assets that have values + assets_with_values = [a for a in assets if a.has_value] + if not assets_with_values: + messages.warning(request, "No submitted assets to export.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + sponsor_name = sp.sponsor.name if sp.sponsor else "unknown" + buffer = io.BytesIO() + zip_file = zipfile.ZipFile(buffer, "w") + + for asset in assets_with_values: + if not asset.is_file: + zip_file.writestr(f"{sponsor_name}/{asset.internal_name}.txt", asset.value) + else: + suffix = "." + asset.value.name.split(".")[-1] + prefix = asset.internal_name + with NamedTemporaryFile(suffix=suffix, prefix=prefix) as temp_file: + temp_file.write(asset.value.read()) + zip_file.write(temp_file.name, arcname=f"{sponsor_name}/{prefix}{suffix}") + + zip_file.close() + response = HttpResponse(buffer.getvalue()) + response["Content-Type"] = "application/x-zip-compressed" + response["Content-Disposition"] = f'attachment; filename="{sponsor_name}-assets.zip"' + return response + + +class BulkAssetExportView(SponsorshipAdminRequiredMixin, View): + """Export assets for multiple sponsorships as a ZIP file (bulk action).""" + + def post(self, request): + """Generate and return a ZIP of all submitted assets for selected sponsorships.""" + selected_ids = request.POST.getlist("selected_ids") + if not selected_ids: + messages.warning(request, "No sponsorships selected.") + return redirect(reverse("manage_sponsorships")) + + sponsorships = Sponsorship.objects.select_related("sponsor").filter(pk__in=selected_ids) + if not sponsorships.exists(): + messages.warning(request, "No sponsorships found.") + return redirect(reverse("manage_sponsorships")) + + buffer = io.BytesIO() + zip_file = zipfile.ZipFile(buffer, "w") + total_assets = 0 + + for sp in sponsorships: + assets = list(BenefitFeature.objects.required_assets().from_sponsorship(sp)) + sponsor_name = sp.sponsor.name if sp.sponsor else "unknown" + + for asset in assets: + if not asset.has_value: + continue + total_assets += 1 + if not asset.is_file: + zip_file.writestr(f"{sponsor_name}/{asset.internal_name}.txt", asset.value) + else: + suffix = "." + asset.value.name.split(".")[-1] + prefix = asset.internal_name + with NamedTemporaryFile(suffix=suffix, prefix=prefix) as temp_file: + temp_file.write(asset.value.read()) + zip_file.write(temp_file.name, arcname=f"{sponsor_name}/{prefix}{suffix}") + + zip_file.close() + + if total_assets == 0: + messages.warning(request, "No submitted assets found for the selected sponsorships.") + return redirect(reverse("manage_sponsorships")) + + response = HttpResponse(buffer.getvalue()) + response["Content-Type"] = "application/x-zip-compressed" + response["Content-Disposition"] = 'attachment; filename="sponsorship-assets.zip"' + return response + + +class SponsorshipRejectView(SponsorshipAdminRequiredMixin, View): + """Reject a sponsorship application.""" + + def post(self, request, pk): + """Reject the sponsorship, optionally without notification or redirecting to notify page.""" + sp = get_object_or_404(Sponsorship, pk=pk) + action = request.POST.get("action", "reject_notify") + + try: + if action == "reject_silent": + # Reject without sending any emails + sp.reject() + sp.save() + messages.success(request, f'Sponsorship for "{sp.sponsor.name}" rejected (no notification sent).') + else: + # Reject and redirect to notify page for customizable email + sp.reject() + sp.save() + messages.success( + request, f'Sponsorship for "{sp.sponsor.name}" rejected. Compose the rejection email below.' + ) + return redirect(reverse("manage_sponsorship_notify", args=[pk]) + "?prefill=rejection") + except InvalidStatusError as e: + messages.error(request, str(e)) + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class SponsorshipRollbackView(SponsorshipAdminRequiredMixin, View): + """Roll back a sponsorship to editing/applied status.""" + + def post(self, request, pk): + """Roll back sponsorship to editing status.""" + sp = get_object_or_404(Sponsorship, pk=pk) + try: + sp.rollback_to_editing() + sp.save() + messages.success(request, f'Sponsorship for "{sp.sponsor.name}" rolled back to editing.') + except InvalidStatusError as e: + messages.error(request, str(e)) + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class SponsorshipLockToggleView(SponsorshipAdminRequiredMixin, View): + """Toggle lock/unlock on a sponsorship.""" + + def post(self, request, pk): + """Toggle the lock state of a sponsorship.""" + sp = get_object_or_404(Sponsorship, pk=pk) + action = request.POST.get("action") + if action == "lock": + sp.locked = True + sp.save(update_fields=["locked"]) + messages.success(request, "Sponsorship locked.") + elif action == "unlock": + sp.locked = False + sp.save(update_fields=["locked"]) + messages.success(request, "Sponsorship unlocked.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class SponsorshipEditView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit sponsorship details (package, fee, year).""" + + model = Sponsorship + form_class = SponsorshipEditForm + template_name = "sponsors/manage/sponsorship_edit.html" + + def get_queryset(self): + """Return sponsorships with related sponsor and package.""" + return Sponsorship.objects.select_related("sponsor", "package") + + def get_success_url(self): + """Return URL to sponsorship detail after update.""" + messages.success(self.request, "Sponsorship updated.") + return reverse("manage_sponsorship_detail", args=[self.object.pk]) + + +class SponsorCreateView(SponsorshipAdminRequiredMixin, CreateView): + """Create a new sponsor (standalone, not via composer).""" + + model = Sponsor + form_class = SponsorEditForm + template_name = "sponsors/manage/sponsor_edit.html" + + def get_context_data(self, **kwargs): + """Return context with create flag.""" + context = super().get_context_data(**kwargs) + context["is_create"] = True + return context + + def get_success_url(self): + """Return URL to sponsor edit page to add contacts etc.""" + messages.success( + self.request, + f'Sponsor "{self.object.name}" created. Add contacts or start a sponsorship.', + ) + return reverse("manage_sponsor_edit", args=[self.object.pk]) + + +class SponsorEditView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit sponsor company details.""" + + model = Sponsor + form_class = SponsorEditForm + template_name = "sponsors/manage/sponsor_edit.html" + + def get_context_data(self, **kwargs): + """Return context with originating sponsorship reference.""" + context = super().get_context_data(**kwargs) + sp_pk = self.request.GET.get("from_sponsorship") + if sp_pk: + context["from_sponsorship"] = sp_pk + return context + + def get_success_url(self): + """Return URL to sponsorship detail or sponsor list.""" + messages.success(self.request, f'Sponsor "{self.object.name}" updated.') + sp_pk = self.request.POST.get("from_sponsorship") or self.request.GET.get("from_sponsorship") + if sp_pk: + return reverse("manage_sponsorship_detail", args=[sp_pk]) + return reverse("manage_sponsors") + + +# ── Benefit management on sponsorships ──────────────────────────────── + + +class SponsorshipAddBenefitView(SponsorshipAdminRequiredMixin, View): + """Add a benefit to a sponsorship.""" + + def post(self, request, pk): + """Add a selected benefit to the sponsorship.""" + sp = get_object_or_404(Sponsorship, pk=pk) + if not sp.open_for_editing: + messages.error(request, "Sponsorship is locked and cannot be edited.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + form = AddBenefitToSponsorshipForm(request.POST, sponsorship=sp) + if form.is_valid(): + benefit = form.cleaned_data["benefit"] + SponsorBenefit.new_copy(benefit, sponsorship=sp, added_by_user=True) + messages.success(request, f'Added "{benefit.name}" to sponsorship.') + else: + messages.error(request, "Invalid benefit selection.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class SponsorshipRemoveBenefitView(SponsorshipAdminRequiredMixin, View): + """Remove a benefit from a sponsorship.""" + + def post(self, request, pk, benefit_pk): + """Remove a benefit from the sponsorship.""" + sp = get_object_or_404(Sponsorship, pk=pk) + if not sp.open_for_editing: + messages.error(request, "Sponsorship is locked and cannot be edited.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + benefit = get_object_or_404(SponsorBenefit, pk=benefit_pk, sponsorship=sp) + name = benefit.name + benefit.delete() + messages.success(request, f'Removed "{name}" from sponsorship.') + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +# ── Contract management ─────────────────────────────────────────────── + + +class ContractPreviewView(SponsorshipAdminRequiredMixin, View): + """Preview/download a contract as PDF or DOCX.""" + + def get(self, request, pk): + """Render contract preview in the requested format.""" + from apps.sponsors.contracts import render_contract_to_docx_response, render_contract_to_pdf_response + + sp = get_object_or_404(Sponsorship, pk=pk) + try: + contract = sp.contract + except Contract.DoesNotExist: + messages.error(request, "No contract exists.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + output_format = request.GET.get("format", "pdf") + if output_format == "docx": + response = render_contract_to_docx_response(request, contract) + else: + response = render_contract_to_pdf_response(request, contract) + response["X-Frame-Options"] = "SAMEORIGIN" + return response + + +class ContractSendView(SponsorshipAdminRequiredMixin, View): + """Generate contract and send to sponsor or internal review.""" + + def get(self, request, pk): + """Render the contract send page with both options.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor"), pk=pk) + try: + contract = sp.contract + except Contract.DoesNotExist: + messages.error(request, "No contract exists for this sponsorship.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + context = { + "sponsorship": sp, + "contract": contract, + "sponsor_emails": sp.verified_emails if sp.sponsor else [], + "internal_email": request.GET.get("internal_email", ""), + } + return render(request, "sponsors/manage/contract_send.html", context) + + def post(self, request, pk): + """Handle send to sponsor or internal review.""" + sp = get_object_or_404(Sponsorship, pk=pk) + action = request.POST.get("action", "") + + try: + contract = sp.contract + except Contract.DoesNotExist: + messages.error(request, "No contract exists.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + handler = { + "generate": self._handle_generate, + "send_sponsor": self._handle_send_sponsor, + "send_internal": self._handle_send_internal, + }.get(action) + + if handler: + return handler(request, sp, contract) + + messages.error(request, "Unknown action.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + @staticmethod + def _handle_generate(request, sp, contract): + try: + use_case = use_cases.SendContractUseCase.build() + use_case.execute(contract, request=request) + messages.success(request, "Contract generated and finalized. Ready to send.") + except InvalidStatusError as e: + messages.error(request, str(e)) + return redirect(reverse("manage_contract_send", args=[sp.pk])) + + @staticmethod + def _handle_send_sponsor(request, sp, contract): + from apps.sponsors.notifications import ContractNotificationToSponsors + + if not contract.document: + messages.error(request, "Generate the contract first before sending to sponsor.") + return redirect(reverse("manage_contract_send", args=[sp.pk])) + notification = ContractNotificationToSponsors() + notification.notify(contract=contract, request=request) + recipient_list = ", ".join(sp.verified_emails) + messages.success(request, f"Contract sent to sponsor ({recipient_list}).") + return redirect(reverse("manage_sponsorship_detail", args=[sp.pk])) + + @staticmethod + def _handle_send_internal(request, sp, contract): + from django.core.mail import EmailMessage + + from apps.sponsors.contracts import render_contract_to_docx_file, render_contract_to_pdf_file + + internal_email = request.POST.get("internal_email", "").strip() + if not internal_email: + messages.error(request, "Please enter an email address.") + return redirect(reverse("manage_contract_send", args=[sp.pk])) + + email = EmailMessage( + subject=f"[Internal Review] Contract for {sp.sponsor.name}", + body=f"Contract for {sp.sponsor.name} ({sp.level_name}, ${sp.sponsorship_fee}) attached for review.", + from_email=settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, + to=[internal_email], + ) + + # Use stored files if available, otherwise render live + pdf_content = None + if contract.document: + try: + with contract.document.open("rb") as f: + pdf_content = f.read() + except FileNotFoundError: + pass + if not pdf_content: + pdf_content = render_contract_to_pdf_file(contract) + + if pdf_content: + email.attach("Contract.pdf", pdf_content, "application/pdf") + + docx_content = None + if contract.document_docx: + try: + with contract.document_docx.open("rb") as f: + docx_content = f.read() + except FileNotFoundError: + pass + if not docx_content: + docx_content = render_contract_to_docx_file(contract) + + if docx_content: + email.attach( + "Contract.docx", + docx_content, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + email.send() + messages.success(request, f"Contract sent to {internal_email} for internal review.") + return redirect(reverse("manage_sponsorship_detail", args=[sp.pk])) + + +class ContractExecuteView(SponsorshipAdminRequiredMixin, View): + """Upload signed document and execute contract.""" + + def get(self, request, pk): + """Render the contract execution form.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor"), pk=pk) + try: + contract = sp.contract + except Contract.DoesNotExist: + messages.error(request, "No contract exists.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + form = ExecuteContractForm() + context = {"sponsorship": sp, "contract": contract, "form": form} + return render(request, "sponsors/manage/contract_execute.html", context) + + def post(self, request, pk): + """Upload signed document and execute the contract.""" + sp = get_object_or_404(Sponsorship, pk=pk) + form = ExecuteContractForm(request.POST, request.FILES) + if form.is_valid(): + try: + contract = sp.contract + signed_doc = form.cleaned_data["signed_document"] + use_case = use_cases.ExecuteContractUseCase.build() + use_case.execute(contract, signed_doc, request=request) + messages.success(request, "Contract executed. Sponsorship finalized.") + except Contract.DoesNotExist: + messages.error(request, "No contract exists.") + except InvalidStatusError as e: + messages.error(request, str(e)) + else: + messages.error(request, "Please upload the signed document.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class ContractNullifyView(SponsorshipAdminRequiredMixin, View): + """Nullify/void a contract.""" + + def post(self, request, pk): + """Nullify the contract and redirect to detail.""" + sp = get_object_or_404(Sponsorship, pk=pk) + try: + contract = sp.contract + use_case = use_cases.NullifyContractUseCase.build() + use_case.execute(contract, request=request) + messages.success(request, "Contract nullified.") + except Contract.DoesNotExist: + messages.error(request, "No contract exists.") + except InvalidStatusError as e: + messages.error(request, str(e)) + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class ContractRedraftView(SponsorshipAdminRequiredMixin, View): + """Re-draft a nullified contract, creating a new revision.""" + + def post(self, request, pk): + """Transition contract from nullified back to draft.""" + sp = get_object_or_404(Sponsorship, pk=pk) + try: + contract = sp.contract + if Contract.DRAFT not in contract.next_status: + messages.error(request, f"Cannot re-draft a {contract.get_status_display()} contract.") + else: + contract.status = Contract.DRAFT + contract.save() + messages.success(request, f"Contract re-drafted (Revision {contract.revision}).") + except Contract.DoesNotExist: + messages.error(request, "No contract exists.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +class ContractRegenerateView(SponsorshipAdminRequiredMixin, View): + """Detach the current contract (preserving it as outdated) and create a new draft.""" + + def post(self, request, pk): + """Regenerate the contract for a sponsorship.""" + sp = get_object_or_404(Sponsorship, pk=pk) + try: + old_contract = sp.contract + old_contract.sponsorship = None + old_contract.status = Contract.OUTDATED + old_contract.save() + except Contract.DoesNotExist: + pass + new_contract = Contract.new(sp) + # Set revision to count of historical contracts for this sponsor + historical_count = Contract.objects.filter( + sponsor_info__contains=sp.sponsor.name, sponsorship__isnull=True, status=Contract.OUTDATED + ).count() + new_contract.revision = historical_count + new_contract.save() + messages.success( + request, + f"New contract draft created (Revision {new_contract.revision}). Previous contract preserved.", + ) + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + +# ── Notification views ──────────────────────────────────────────────── + + +class SponsorshipNotifyView(SponsorshipAdminRequiredMixin, View): + """Send a notification to sponsor contacts for a specific sponsorship.""" + + def get(self, request, pk): + """Render the notification form with optional preview.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor"), pk=pk) + + initial = {} + if request.GET.get("prefill") == "rejection": + initial["subject"] = f"PSF Sponsorship Application Update — {sp.sponsor.name}" + initial["content"] = ( + "Dear {{ sponsor_name }},\n\n" + "Thank you for your interest in sponsoring the Python Software Foundation.\n\n" + "After careful review, we are unable to move forward with the " + "{{ sponsorship_level }} sponsorship application at this time.\n\n" + "If you have any questions or would like to discuss this further, " + "please don't hesitate to reach out to us at sponsors@python.org.\n\n" + "Best regards,\n" + "The PSF Sponsorship Team" + ) + initial["contact_types"] = ["primary", "administrative"] + + form = SendSponsorshipNotificationManageForm(initial=initial) + context = { + "sponsorship": sp, + "form": form, + "email_preview": None, + "template_vars": NOTIFICATION_TEMPLATE_VARS, + } + return render(request, "sponsors/manage/sponsorship_notify.html", context) + + def post(self, request, pk): + """Preview or send the notification.""" + sp = get_object_or_404(Sponsorship.objects.select_related("sponsor"), pk=pk) + form = SendSponsorshipNotificationManageForm(request.POST) + email_preview = None + + if "preview" in request.POST: + if form.is_valid(): + notification = form.get_notification() + msg_kwargs = { + "to_primary": True, + "to_administrative": True, + "to_accounting": True, + "to_manager": True, + } + email_preview = notification.get_email_message(sp, **msg_kwargs) + context = { + "sponsorship": sp, + "form": form, + "email_preview": email_preview, + "template_vars": NOTIFICATION_TEMPLATE_VARS, + } + return render(request, "sponsors/manage/sponsorship_notify.html", context) + + if "confirm" in request.POST and form.is_valid(): + use_case = use_cases.SendSponsorshipNotificationUseCase.build() + use_case.execute( + notification=form.get_notification(), + sponsorships=[sp], + contact_types=form.cleaned_data["contact_types"], + request=request, + ) + messages.success(request, f"Notification sent to {sp.sponsor.name} contacts.") + return redirect(reverse("manage_sponsorship_detail", args=[pk])) + + context = { + "sponsorship": sp, + "form": form, + "email_preview": email_preview, + "template_vars": NOTIFICATION_TEMPLATE_VARS, + } + return render(request, "sponsors/manage/sponsorship_notify.html", context) + + +# ── Notification template CRUD ──────────────────────────────────────── + + +class NotificationTemplateListView(SponsorshipAdminRequiredMixin, ListView): + """List all SponsorEmailNotificationTemplate instances.""" + + model = SponsorEmailNotificationTemplate + template_name = "sponsors/manage/notification_template_list.html" + context_object_name = "templates" + + def get_queryset(self): + """Return templates ordered by most recently updated.""" + return SponsorEmailNotificationTemplate.objects.order_by("-updated_at") + + +NOTIFICATION_TEMPLATE_VARS = [ + "sponsor_name", + "sponsorship_level", + "sponsorship_start_date", + "sponsorship_end_date", + "sponsorship_status", +] + + +class NotificationTemplateCreateView(SponsorshipAdminRequiredMixin, CreateView): + """Create a new notification template.""" + + model = SponsorEmailNotificationTemplate + form_class = NotificationTemplateForm + template_name = "sponsors/manage/notification_template_form.html" + + def get_success_url(self): + """Return URL to template list after creation.""" + messages.success(self.request, f'Template "{self.object.internal_name}" created.') + return reverse("manage_notification_templates") + + def get_context_data(self, **kwargs): + """Return context with create flag and template variables.""" + context = super().get_context_data(**kwargs) + context["is_create"] = True + context["template_vars"] = NOTIFICATION_TEMPLATE_VARS + return context + + +class NotificationTemplateUpdateView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit an existing notification template.""" + + model = SponsorEmailNotificationTemplate + form_class = NotificationTemplateForm + template_name = "sponsors/manage/notification_template_form.html" + + def get_success_url(self): + """Return URL to template list after update.""" + messages.success(self.request, f'Template "{self.object.internal_name}" updated.') + return reverse("manage_notification_templates") + + def get_context_data(self, **kwargs): + """Return context with edit flag and template variables.""" + context = super().get_context_data(**kwargs) + context["is_create"] = False + context["template_vars"] = NOTIFICATION_TEMPLATE_VARS + return context + + +class NotificationTemplateDeleteView(SponsorshipAdminRequiredMixin, DeleteView): + """Delete a notification template.""" + + model = SponsorEmailNotificationTemplate + template_name = "sponsors/manage/notification_template_confirm_delete.html" + + def get_success_url(self): + """Return URL to template list after deletion.""" + messages.success(self.request, f'Template "{self.object.internal_name}" deleted.') + return reverse("manage_notification_templates") + + +class NotificationHistoryView(SponsorshipAdminRequiredMixin, ListView): + """Show history of all sent notifications across all sponsorships.""" + + model = SponsorshipNotificationLog + template_name = "sponsors/manage/notification_history.html" + context_object_name = "logs" + paginate_by = 50 + + def get_queryset(self): + """Return logs ordered by most recent, with related data.""" + qs = SponsorshipNotificationLog.objects.select_related("sponsorship__sponsor", "sent_by").order_by("-sent_at") + search = self.request.GET.get("search", "").strip() + if search: + qs = qs.filter( + Q(subject__icontains=search) + | Q(recipients__icontains=search) + | Q(sponsorship__sponsor__name__icontains=search) + ) + return qs + + def get_context_data(self, **kwargs): + """Add search term to context.""" + context = super().get_context_data(**kwargs) + context["filter_search"] = self.request.GET.get("search", "") + return context + + +# ── Sponsor contact management ─────────────────────────────────────── + + +class SponsorContactCreateView(SponsorshipAdminRequiredMixin, CreateView): + """Add a contact to a sponsor.""" + + model = SponsorContact + form_class = SponsorContactForm + template_name = "sponsors/manage/contact_form.html" + + def dispatch(self, request, *args, **kwargs): + """Look up the sponsor from the URL.""" + self.sponsor = get_object_or_404(Sponsor, pk=kwargs["sponsor_pk"]) + self.from_sponsorship = request.GET.get("from_sponsorship", "") + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """Return context with sponsor and create flag.""" + context = super().get_context_data(**kwargs) + context["sponsor"] = self.sponsor + context["is_create"] = True + context["from_sponsorship"] = self.from_sponsorship + return context + + def form_valid(self, form): + """Set sponsor on the contact before saving.""" + form.instance.sponsor = self.sponsor + return super().form_valid(form) + + def get_success_url(self): + """Return URL back to sponsorship detail or sponsor edit.""" + messages.success(self.request, f'Contact "{self.object.name}" added.') + if self.from_sponsorship: + return reverse("manage_sponsorship_detail", args=[self.from_sponsorship]) + return reverse("manage_sponsor_edit", args=[self.sponsor.pk]) + + +class SponsorContactUpdateView(SponsorshipAdminRequiredMixin, UpdateView): + """Edit a sponsor contact.""" + + model = SponsorContact + form_class = SponsorContactForm + template_name = "sponsors/manage/contact_form.html" + + def dispatch(self, request, *args, **kwargs): + """Store from_sponsorship for redirect.""" + self.from_sponsorship = request.GET.get("from_sponsorship", "") + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """Return context with sponsor and edit flag.""" + context = super().get_context_data(**kwargs) + context["sponsor"] = self.object.sponsor + context["is_create"] = False + context["from_sponsorship"] = self.from_sponsorship + return context + + def get_success_url(self): + """Return URL back to sponsorship detail or sponsor edit.""" + messages.success(self.request, f'Contact "{self.object.name}" updated.') + if self.from_sponsorship: + return reverse("manage_sponsorship_detail", args=[self.from_sponsorship]) + return reverse("manage_sponsor_edit", args=[self.object.sponsor.pk]) + + +class SponsorContactDeleteView(SponsorshipAdminRequiredMixin, View): + """Delete a sponsor contact.""" + + def post(self, request, pk): + """Delete the contact and redirect.""" + contact = get_object_or_404(SponsorContact, pk=pk) + name = contact.name + sponsor_pk = contact.sponsor_id + from_sp = request.POST.get("from_sponsorship", "") + contact.delete() + messages.success(request, f'Contact "{name}" deleted.') + if from_sp: + return redirect(reverse("manage_sponsorship_detail", args=[from_sp])) + return redirect(reverse("manage_sponsor_edit", args=[sponsor_pk])) + + +# ── CSV Export & Bulk Actions ───────────────────────────────────────── + + +def _filtered_sponsorship_queryset(request): + """Build a Sponsorship queryset from request query params. + + Applies the same filters as SponsorshipListView: status, year, search. + """ + qs = Sponsorship.objects.select_related("sponsor", "package").order_by("-applied_on") + + status = request.GET.get("status", "") or request.POST.get("status", "") + year = request.GET.get("year", "") or request.POST.get("year", "") + search = request.GET.get("search", "") or request.POST.get("search", "") + + qs = qs.filter(status=status) if status else qs.exclude(status=Sponsorship.REJECTED) + if year: + qs = qs.filter(year=int(year)) + if search: + qs = qs.filter(Q(sponsor__name__icontains=search)) + + return qs + + +def _write_sponsorship_csv(sponsorships, response): + """Write sponsorship rows to a CSV response using csv.writer.""" + writer = csv.writer(response) + writer.writerow( + [ + "Sponsor Name", + "Package", + "Fee", + "Year", + "Status", + "Applied Date", + "Start Date", + "End Date", + "Primary Contact Name", + "Primary Contact Email", + ] + ) + # Pre-fetch primary contacts for all sponsors in one query + sponsor_ids = [sp.sponsor_id for sp in sponsorships if sp.sponsor_id] + primary_contacts = {} + if sponsor_ids: + for contact in SponsorContact.objects.filter(sponsor_id__in=sponsor_ids, primary=True): + # Keep the first primary contact per sponsor + primary_contacts.setdefault(contact.sponsor_id, contact) + + for sp in sponsorships: + contact = primary_contacts.get(sp.sponsor_id) if sp.sponsor_id else None + writer.writerow( + [ + sp.sponsor.name if sp.sponsor else "Unknown", + sp.package.name if sp.package else "", + sp.sponsorship_fee or "", + sp.year or "", + sp.get_status_display(), + sp.applied_on.isoformat() if sp.applied_on else "", + sp.start_date.isoformat() if sp.start_date else "", + sp.end_date.isoformat() if sp.end_date else "", + contact.name if contact else "", + contact.email if contact else "", + ] + ) + return response + + +class SponsorshipExportView(SponsorshipAdminRequiredMixin, View): + """Export sponsorships as CSV for accounting. + + Supports both GET (with filter query params) and POST (with selected_ids + for bulk export of specific sponsorships). + """ + + def get(self, request): + """Export all sponsorships matching current filters.""" + sponsorships = list(_filtered_sponsorship_queryset(request)) + return self._make_csv(sponsorships) + + def post(self, request): + """Export specific sponsorships by selected IDs.""" + selected_ids = request.POST.getlist("selected_ids") + if selected_ids: + sponsorships = list( + Sponsorship.objects.select_related("sponsor", "package") + .filter(pk__in=selected_ids) + .order_by("-applied_on") + ) + else: + sponsorships = list(_filtered_sponsorship_queryset(request)) + return self._make_csv(sponsorships) + + def _make_csv(self, sponsorships): + """Build and return an HttpResponse with CSV content.""" + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="sponsorships.csv"' + return _write_sponsorship_csv(sponsorships, response) + + +class BulkActionDispatchView(SponsorshipAdminRequiredMixin, View): + """Dispatch bulk actions from the sponsorship list. + + Routes to the appropriate handler based on the ``action`` field: + - ``export_csv``: export selected sponsorships as CSV + - ``send_notification``: redirect to bulk notification page + """ + + def post(self, request): + """Route to the correct bulk action.""" + action = request.POST.get("action", "") + selected_ids = request.POST.getlist("selected_ids") + + if action == "export_csv": + if selected_ids: + sponsorships = list( + Sponsorship.objects.select_related("sponsor", "package") + .filter(pk__in=selected_ids) + .order_by("-applied_on") + ) + else: + messages.warning(request, "No sponsorships selected.") + return redirect(reverse("manage_sponsorships")) + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="sponsorships.csv"' + return _write_sponsorship_csv(sponsorships, response) + + if action == "send_notification": + if not selected_ids: + messages.warning(request, "No sponsorships selected.") + return redirect(reverse("manage_sponsorships")) + request.session["bulk_notify_ids"] = selected_ids + return redirect(reverse("manage_bulk_notify")) + + if action == "export_assets": + return BulkAssetExportView.as_view()(request) + + messages.error(request, "Unknown action.") + return redirect(reverse("manage_sponsorships")) + + +class BulkNotifyView(SponsorshipAdminRequiredMixin, View): + """Send a notification to contacts for multiple sponsorships at once.""" + + def get(self, request): + """Render the bulk notification form.""" + ids = request.session.get("bulk_notify_ids", []) + sponsorships = list(Sponsorship.objects.select_related("sponsor").filter(pk__in=ids).order_by("-applied_on")) + if not sponsorships: + messages.warning(request, "No sponsorships selected for notification.") + return redirect(reverse("manage_sponsorships")) + form = SendSponsorshipNotificationManageForm() + context = { + "sponsorships": sponsorships, + "form": form, + "email_preview": None, + "template_vars": NOTIFICATION_TEMPLATE_VARS, + } + return render(request, "sponsors/manage/bulk_notify.html", context) + + def post(self, request): + """Preview or send bulk notification.""" + ids = request.session.get("bulk_notify_ids", []) + sponsorships = list(Sponsorship.objects.select_related("sponsor").filter(pk__in=ids).order_by("-applied_on")) + if not sponsorships: + messages.warning(request, "No sponsorships selected for notification.") + return redirect(reverse("manage_sponsorships")) + + form = SendSponsorshipNotificationManageForm(request.POST) + email_preview = None + + if "preview" in request.POST: + if form.is_valid(): + notification = form.get_notification() + msg_kwargs = { + "to_primary": True, + "to_administrative": True, + "to_accounting": True, + "to_manager": True, + } + # Preview using the first sponsorship + email_preview = notification.get_email_message(sponsorships[0], **msg_kwargs) + context = { + "sponsorships": sponsorships, + "form": form, + "email_preview": email_preview, + "template_vars": NOTIFICATION_TEMPLATE_VARS, + } + return render(request, "sponsors/manage/bulk_notify.html", context) + + if "confirm" in request.POST and form.is_valid(): + use_case = use_cases.SendSponsorshipNotificationUseCase.build() + use_case.execute( + notification=form.get_notification(), + sponsorships=sponsorships, + contact_types=form.cleaned_data["contact_types"], + request=request, + ) + # Clear session data + request.session.pop("bulk_notify_ids", None) + names = ", ".join(sp.sponsor.name for sp in sponsorships if sp.sponsor) + messages.success(request, f"Notification sent to {len(sponsorships)} sponsor(s): {names}.") + return redirect(reverse("manage_sponsorships")) + + context = { + "sponsorships": sponsorships, + "form": form, + "email_preview": email_preview, + "template_vars": NOTIFICATION_TEMPLATE_VARS, + } + return render(request, "sponsors/manage/bulk_notify.html", context) + + +# ── Benefit Feature Configuration management ───────────────────────── + + +def _get_config_type_slug(config_instance): + """Return the CONFIG_TYPES slug for a polymorphic config instance.""" + for slug, (model_cls, _form_cls, _label) in CONFIG_TYPES.items(): + if isinstance(config_instance, model_cls): + return slug + return None + + +class BenefitConfigAddView(SponsorshipAdminRequiredMixin, View): + """Add a feature configuration to a benefit.""" + + def dispatch(self, request, *args, **kwargs): + """Look up the benefit and validate the config type.""" + self.benefit = get_object_or_404(SponsorshipBenefit, pk=kwargs["pk"]) + self.config_type = kwargs["config_type"] + if self.config_type not in CONFIG_TYPES: + messages.error(request, f"Unknown configuration type: {self.config_type}") + return redirect(reverse("manage_benefit_edit", args=[self.benefit.pk])) + self.model_cls, self.form_cls, self.type_label = CONFIG_TYPES[self.config_type] + return super().dispatch(request, *args, **kwargs) + + def get(self, request, pk, config_type): + """Render the add configuration form.""" + form = self.form_cls() + context = { + "benefit": self.benefit, + "form": form, + "type_label": self.type_label, + "is_create": True, + } + return render(request, "sponsors/manage/benefit_config_form.html", context) + + def post(self, request, pk, config_type): + """Create the configuration and redirect to benefit edit.""" + form = self.form_cls(request.POST, request.FILES) + if form.is_valid(): + config = form.save(commit=False) + config.benefit = self.benefit + config.save() + messages.success(request, f"{self.type_label} configuration added.") + return redirect(reverse("manage_benefit_edit", args=[self.benefit.pk])) + context = { + "benefit": self.benefit, + "form": form, + "type_label": self.type_label, + "is_create": True, + } + return render(request, "sponsors/manage/benefit_config_form.html", context) + + +class BenefitConfigEditView(SponsorshipAdminRequiredMixin, View): + """Edit an existing feature configuration.""" + + def dispatch(self, request, *args, **kwargs): + """Look up the config instance and resolve its polymorphic form.""" + self.config = get_object_or_404(BenefitFeatureConfiguration, pk=kwargs["pk"]) + self.config_slug = _get_config_type_slug(self.config) + if not self.config_slug: + messages.error(request, "Unknown configuration type.") + return redirect(reverse("manage_benefit_edit", args=[self.config.benefit_id])) + _model_cls, self.form_cls, self.type_label = CONFIG_TYPES[self.config_slug] + return super().dispatch(request, *args, **kwargs) + + def get(self, request, pk): + """Render the edit configuration form.""" + form = self.form_cls(instance=self.config) + context = { + "benefit": self.config.benefit, + "form": form, + "type_label": self.type_label, + "is_create": False, + "config": self.config, + } + return render(request, "sponsors/manage/benefit_config_form.html", context) + + def post(self, request, pk): + """Update the configuration and redirect to benefit edit.""" + form = self.form_cls(request.POST, request.FILES, instance=self.config) + if form.is_valid(): + form.save() + messages.success(request, f"{self.type_label} configuration updated.") + return redirect(reverse("manage_benefit_edit", args=[self.config.benefit_id])) + context = { + "benefit": self.config.benefit, + "form": form, + "type_label": self.type_label, + "is_create": False, + "config": self.config, + } + return render(request, "sponsors/manage/benefit_config_form.html", context) + + +class BenefitConfigDeleteView(SponsorshipAdminRequiredMixin, View): + """Delete a feature configuration (POST only).""" + + def post(self, request, pk): + """Delete the configuration and redirect to benefit edit.""" + config = get_object_or_404(BenefitFeatureConfiguration, pk=pk) + benefit_pk = config.benefit_id + config.delete() + messages.success(request, "Configuration deleted.") + return redirect(reverse("manage_benefit_edit", args=[benefit_pk])) + + +class ComposerView(SponsorshipAdminRequiredMixin, View): + """Multi-step wizard for building a custom sponsorship. + + Steps: + 1. Select or create a sponsor + 2. Choose a base package + 3. Customize benefits + 4. Set terms (fee, dates, renewal, notes) + 5. Review & create sponsorship + draft contract + 6. Contract editor & send + """ + + TOTAL_STEPS = 6 + + def _get_step(self, request): + """Return the current step number, clamped to valid range.""" + try: + step = int(request.GET.get("step", 1)) + except (TypeError, ValueError): + step = 1 + return max(1, min(step, self.TOTAL_STEPS)) + + def _get_composer_data(self, request): + """Return the composer session data dict.""" + return request.session.get("composer", {}) + + def _set_composer_data(self, request, data): + """Save composer data to session.""" + request.session["composer"] = data + request.session.modified = True + + def _max_allowed_step(self, data): + """Return the highest step the user can navigate to based on completed data.""" + if not data.get("sponsor_id") and not data.get("new_sponsor"): + return 1 + if "package_id" not in data and "custom_package" not in data: + return 2 + if "benefit_ids" not in data: + return 3 + if "fee" not in data: + return 4 + if not data.get("sponsorship_id"): + return 5 + return 6 + + def get(self, request): + """Render the current wizard step.""" + # Clear session when starting a new composer session + if request.GET.get("new") == "1": + data = {} + # Pre-select sponsor if passed + sponsor_id = request.GET.get("sponsor_id") + if sponsor_id: + try: + sponsor = Sponsor.objects.get(pk=int(sponsor_id)) + data["sponsor_id"] = sponsor.pk + except (Sponsor.DoesNotExist, TypeError, ValueError): + pass + if request.GET.get("renewal") == "1": + data["renewal"] = True + self._set_composer_data(request, data) + # Skip to step 2 if sponsor was pre-selected + if data.get("sponsor_id"): + return redirect(reverse("manage_composer") + "?step=2") + return redirect(reverse("manage_composer")) + + step = self._get_step(request) + data = self._get_composer_data(request) + + # Don't let user skip ahead + max_step = self._max_allowed_step(data) + step = min(step, max_step) + + handler = { + 1: self._render_step1, + 2: self._render_step2, + 3: self._render_step3, + 4: self._render_step4, + 5: self._render_step5, + 6: self._render_step6, + }[step] + return handler(request, data) + + def post(self, request): + """Process the current step's form data and advance.""" + step = self._get_step(request) + handler = { + 1: self._process_step1, + 2: self._process_step2, + 3: self._process_step3, + 4: self._process_step4, + 5: self._process_step5, + 6: self._process_step6, + }[step] + return handler(request) + + # ── Step 1: Select Sponsor ── + + def _render_step1(self, request, data): + q = request.GET.get("q", "") + sponsors = Sponsor.objects.order_by("name") + if q: + sponsors = sponsors.filter(Q(name__icontains=q)) + sponsors = sponsors[:50] + form = ComposerSponsorForm() + context = { + "step": 1, + "total_steps": self.TOTAL_STEPS, + "data": data, + "sponsors": sponsors, + "search_query": q, + "form": form, + } + return render(request, "sponsors/manage/composer.html", context) + + def _process_step1(self, request): + data = self._get_composer_data(request) + action = request.POST.get("action", "") + + if action == "select_sponsor": + sponsor_id = request.POST.get("sponsor_id") + if sponsor_id: + sponsor = get_object_or_404(Sponsor, pk=sponsor_id) + # Reset all data for fresh start with this sponsor + data = {"sponsor_id": sponsor.pk} + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=2") + + elif action == "create_sponsor": + form = ComposerSponsorForm(request.POST) + if form.is_valid(): + sponsor = form.save(commit=False) + sponsor.creator = request.user + sponsor.save() + data["sponsor_id"] = sponsor.pk + data.pop("new_sponsor", None) + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=2") + # Re-render with errors + sponsors = Sponsor.objects.order_by("name")[:50] + context = { + "step": 1, + "total_steps": self.TOTAL_STEPS, + "data": data, + "sponsors": sponsors, + "search_query": "", + "form": form, + } + return render(request, "sponsors/manage/composer.html", context) + + messages.error(request, "Please select or create a sponsor.") + return redirect(reverse("manage_composer") + "?step=1") + + # ── Step 2: Choose Package ── + + def _get_composer_year(self, data): + """Return the year to use for the composer, defaulting to current year.""" + if data.get("year"): + return data["year"] + try: + return SponsorshipCurrentYear.get_year() + except SponsorshipCurrentYear.DoesNotExist: + return None + + def _render_step2(self, request, data): + year = self._get_composer_year(data) + if request.GET.get("year"): + with contextlib.suppress(TypeError, ValueError): + year = int(request.GET["year"]) + + packages = SponsorshipPackage.objects.filter(year=year).order_by("-sponsorship_amount") if year else [] + years = sorted( + set(SponsorshipPackage.objects.values_list("year", flat=True).distinct()) - {None}, + reverse=True, + ) + context = { + "step": 2, + "total_steps": self.TOTAL_STEPS, + "data": data, + "packages": packages, + "years": years, + "selected_year": year, + } + return render(request, "sponsors/manage/composer.html", context) + + def _process_step2(self, request): + data = self._get_composer_data(request) + package_id = request.POST.get("package_id", "") + year = request.POST.get("year", "") + + if year: + with contextlib.suppress(TypeError, ValueError): + data["year"] = int(year) + + if package_id == "custom": + data["package_id"] = None + data["custom_package"] = True + data["benefit_ids"] = [] + elif package_id: + try: + pkg = SponsorshipPackage.objects.get(pk=int(package_id)) + data["package_id"] = pkg.pk + data.pop("custom_package", None) + # Pre-populate benefits from package + data["benefit_ids"] = list(pkg.benefits.values_list("pk", flat=True)) + data["year"] = pkg.year + except (SponsorshipPackage.DoesNotExist, ValueError): + messages.error(request, "Invalid package selection.") + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=2") + else: + messages.error(request, "Please select a package.") + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=2") + + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=3") + + # ── Step 3: Customize Benefits ── + + def _render_step3(self, request, data): + year = self._get_composer_year(data) + programs = SponsorshipProgram.objects.all().order_by("order") + benefits_by_program = [] + for program in programs: + benefits = SponsorshipBenefit.objects.filter(program=program, year=year).order_by("order") + if benefits.exists(): + benefits_by_program.append({"program": program, "benefits": benefits}) + + selected_ids = set(data.get("benefit_ids", [])) + selected_benefits = SponsorshipBenefit.objects.filter(pk__in=selected_ids).select_related("program") + total_value = sum(b.internal_value or 0 for b in selected_benefits) + + # Determine which benefits come from the selected package (locked) + package_benefit_ids = set() + if data.get("package_id"): + pkg = SponsorshipPackage.objects.filter(pk=data["package_id"]).first() + if pkg: + package_benefit_ids = set(pkg.benefits.values_list("pk", flat=True)) + + context = { + "step": 3, + "total_steps": self.TOTAL_STEPS, + "data": data, + "benefits_by_program": benefits_by_program, + "selected_benefits": selected_benefits, + "selected_ids": selected_ids, + "total_value": total_value, + "package_benefit_ids": package_benefit_ids, + } + return render(request, "sponsors/manage/composer.html", context) + + def _process_step3(self, request): + data = self._get_composer_data(request) + benefit_ids_raw = request.POST.getlist("benefit_ids") + benefit_ids = [] + for bid in benefit_ids_raw: + try: + benefit_ids.append(int(bid)) + except (TypeError, ValueError): + continue + data["benefit_ids"] = benefit_ids + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=4") + + # ── Step 4: Set Terms ── + + def _render_step4(self, request, data): + initial = {} + if data.get("fee") is not None: + initial["fee"] = data["fee"] + elif data.get("package_id"): + try: + pkg = SponsorshipPackage.objects.get(pk=data["package_id"]) + initial["fee"] = pkg.sponsorship_amount + except SponsorshipPackage.DoesNotExist: + pass + if data.get("start_date"): + initial["start_date"] = data["start_date"] + if data.get("end_date"): + initial["end_date"] = data["end_date"] + if data.get("renewal") is not None: + initial["renewal"] = data["renewal"] + if data.get("notes"): + initial["notes"] = data["notes"] + + form = ComposerTermsForm(initial=initial) + + # Calculate total internal value from selected benefits for staff reference + total_internal_value = 0 + benefit_ids = data.get("benefit_ids", []) + if benefit_ids: + total_internal_value = ( + SponsorshipBenefit.objects.filter(pk__in=benefit_ids).aggregate(total=Sum("internal_value"))["total"] + or 0 + ) + + context = { + "step": 4, + "total_steps": self.TOTAL_STEPS, + "data": data, + "form": form, + "total_internal_value": total_internal_value, + } + return render(request, "sponsors/manage/composer.html", context) + + def _process_step4(self, request): + data = self._get_composer_data(request) + form = ComposerTermsForm(request.POST) + if form.is_valid(): + data["fee"] = form.cleaned_data["fee"] + data["start_date"] = form.cleaned_data["start_date"].isoformat() + data["end_date"] = form.cleaned_data["end_date"].isoformat() + data["renewal"] = form.cleaned_data["renewal"] + data["notes"] = form.cleaned_data["notes"] + self._set_composer_data(request, data) + return redirect(reverse("manage_composer") + "?step=5") + # Re-render with errors + context = { + "step": 4, + "total_steps": self.TOTAL_STEPS, + "data": data, + "form": form, + } + return render(request, "sponsors/manage/composer.html", context) + + # ── Step 5: Review & Create ── + + def _render_step5(self, request, data): + sponsor = None + if data.get("sponsor_id"): + sponsor = Sponsor.objects.filter(pk=data["sponsor_id"]).first() + + package = None + if data.get("package_id"): + package = SponsorshipPackage.objects.filter(pk=data["package_id"]).first() + + selected_benefits = ( + SponsorshipBenefit.objects.filter(pk__in=data.get("benefit_ids", [])) + .select_related("program") + .order_by("program__order", "order") + ) + total_value = sum(b.internal_value or 0 for b in selected_benefits) + + # Group selected benefits by program for the review display + programs_map = {} + for b in selected_benefits: + prog = b.program + if prog.pk not in programs_map: + programs_map[prog.pk] = {"program": prog, "benefits": []} + programs_map[prog.pk]["benefits"].append(b) + review_benefits_by_program = list(programs_map.values()) + + # Determine which benefits come from the selected package + package_benefit_ids = set() + if data.get("package_id") and package: + package_benefit_ids = set(package.benefits.values_list("pk", flat=True)) + + context = { + "step": 5, + "total_steps": self.TOTAL_STEPS, + "data": data, + "sponsor": sponsor, + "package": package, + "selected_benefits": selected_benefits, + "total_value": total_value, + "review_benefits_by_program": review_benefits_by_program, + "package_benefit_ids": package_benefit_ids, + } + return render(request, "sponsors/manage/composer.html", context) + + @transaction.atomic + def _process_step5(self, request): + data = self._get_composer_data(request) + + # Resolve sponsor + sponsor = None + if data.get("sponsor_id"): + sponsor = Sponsor.objects.filter(pk=data["sponsor_id"]).first() + if not sponsor: + messages.error(request, "Sponsor not found. Please start over.") + return redirect(reverse("manage_composer") + "?step=1") + + # Resolve benefits + benefit_ids = data.get("benefit_ids", []) + benefits = list(SponsorshipBenefit.objects.filter(pk__in=benefit_ids).select_related("program")) + + # Resolve package + package = None + if data.get("package_id"): + package = SponsorshipPackage.objects.filter(pk=data["package_id"]).first() + + from apps.sponsors.exceptions import SponsorWithExistingApplicationError + + try: + sponsorship = self._create_sponsorship(request, data, sponsor, package, benefits) + except SponsorWithExistingApplicationError: + existing = Sponsorship.objects.in_progress().filter(sponsor=sponsor).first() + if existing: + from django.utils.html import format_html + + url = reverse("manage_sponsorship_detail", args=[existing.pk]) + messages.error( + request, + format_html( + '{} already has an in-progress sponsorship. View existing', + sponsor.name, + url, + ), + ) + else: + messages.error(request, f"{sponsor.name} already has an in-progress sponsorship.") + return redirect(reverse("manage_composer") + "?step=5") + + # Create draft contract + contract = Contract.new(sponsorship) + + # Store IDs in session and advance to step 6 + data["sponsorship_id"] = sponsorship.pk + data["contract_id"] = contract.pk + self._set_composer_data(request, data) + messages.success( + request, + f"Sponsorship and draft contract created for {sponsor.name}. You can now edit the contract and send it.", + ) + return redirect(reverse("manage_composer") + "?step=6") + + def _create_sponsorship(self, request, data, sponsor, package, benefits): + """Create the Sponsorship and SponsorBenefit copies.""" + import datetime + + from apps.sponsors.exceptions import SponsorWithExistingApplicationError + + year = data.get("year") or SponsorshipCurrentYear.get_year() + + # Guard: prevent duplicate in-progress sponsorships (same check as Sponsorship.new()) + if Sponsorship.objects.in_progress().filter(sponsor=sponsor).exists(): + msg = f"Sponsor pk: {sponsor.pk}" + raise SponsorWithExistingApplicationError(msg) + + sponsorship = Sponsorship.objects.create( + submited_by=request.user, + sponsor=sponsor, + level_name="" if not package else package.name, + package=package, + sponsorship_fee=data.get("fee"), + for_modified_package=True, + year=year, + start_date=datetime.date.fromisoformat(data["start_date"]) if data.get("start_date") else None, + end_date=datetime.date.fromisoformat(data["end_date"]) if data.get("end_date") else None, + renewal=data.get("renewal", False), + ) + + for benefit in benefits: + SponsorBenefit.new_copy(benefit, sponsorship=sponsorship) + + return sponsorship + + # ── Step 6: Contract & Send ── + + def _render_step6(self, request, data): + contract_id = data.get("contract_id") + if not contract_id: + messages.error(request, "No contract found. Please go back and create the sponsorship.") + return redirect(reverse("manage_composer") + "?step=5") + + contract = Contract.objects.filter(pk=contract_id).select_related("sponsorship__sponsor").first() + if not contract: + messages.error(request, "Contract not found. Please start over.") + return redirect(reverse("manage_composer") + "?step=1") + + sponsor = contract.sponsorship.sponsor + package = None + if data.get("package_id"): + package = SponsorshipPackage.objects.filter(pk=data["package_id"]).first() + + sponsor_contacts = sponsor.contacts.all() + + # Pre-fill email body + default_body = ( + f"Dear {sponsor.name},\n\n" + "Thank you for your interest in sponsoring the Python Software Foundation!\n\n" + f"Please find attached the sponsorship agreement for the proposed " + f"{package.name if package else 'custom'} sponsorship package " + f"for {data.get('year', 'N/A')}.\n\n" + f"Fee: ${data.get('fee', 0):,}\n" + f"Period: {data.get('start_date', 'TBD')} to {data.get('end_date', 'TBD')}\n\n" + "Please review the attached contract and let us know if you have " + "any questions or would like to discuss adjustments.\n\n" + "Best regards,\n" + "The PSF Sponsorship Team\n" + "sponsors@python.org" + ) + + primary_contact = sponsor.primary_contact + + context = { + "step": 6, + "total_steps": self.TOTAL_STEPS, + "data": data, + "sponsor": sponsor, + "package": package, + "contract": contract, + "sponsor_contacts": sponsor_contacts, + "primary_contact": primary_contact, + "contract_sponsor_info": contract.sponsor_info, + "contract_sponsor_contact": contract.sponsor_contact, + "contract_benefits_list": contract.benefits_list.raw, + "contract_legal_clauses": contract.legal_clauses.raw, + "available_clauses": LegalClause.objects.all().order_by("order"), + "default_subject": "Sponsorship Proposal from the Python Software Foundation", + "default_body": default_body, + } + return render(request, "sponsors/manage/composer.html", context) + + def _load_step6_contract(self, data): + """Load and validate the contract for step 6. + + Returns: + Tuple of (contract, sponsor, error_redirect). If error_redirect is not None, + the caller should return it immediately. + + """ + contract_id = data.get("contract_id") + sponsorship_id = data.get("sponsorship_id") + if not contract_id or not sponsorship_id: + return None, None, redirect(reverse("manage_composer") + "?step=1") + + contract = Contract.objects.filter(pk=contract_id).select_related("sponsorship__sponsor").first() + if not contract: + return None, None, redirect(reverse("manage_composer") + "?step=1") + + return contract, contract.sponsorship.sponsor, None + + def _process_step6(self, request): + data = self._get_composer_data(request) + action = request.POST.get("action", "") + + contract, sponsor, error_redirect = self._load_step6_contract(data) + if error_redirect: + messages.error(request, "Missing contract or sponsorship. Please start over.") + return error_redirect + + handlers = { + "save_contract": self._handle_save_contract, + "download_pdf": self._handle_download_pdf, + "download_docx": self._handle_download_docx, + "send_proposal": self._handle_send_proposal_with_contract, + "send_internal": self._handle_send_internal_with_contract, + "finish": self._handle_finish, + } + handler = handlers.get(action) + if handler: + return handler(request, data, contract, sponsor) + + messages.error(request, "Unknown action.") + return redirect(reverse("manage_composer") + "?step=6") + + def _handle_save_contract(self, request, data, contract, sponsor): + """Save edited contract fields. + + Rebuilds sponsor_info from structured form fields (name + description). + Rebuilds sponsor_contact from the sponsor's primary contact. + Falls back to the hidden field values if structured data is missing. + """ + # Rebuild sponsor_info from structured fields + si_description = request.POST.get("si_description", "").strip() + if si_description: + contract.sponsor_info = f"{sponsor.name}, {si_description}" + else: + contract.sponsor_info = request.POST.get("sponsor_info", contract.sponsor_info) + + # Also update the sponsor model fields if provided + sponsor.description = si_description or sponsor.description + si_website = request.POST.get("si_website", "").strip() + sponsor.landing_page_url = si_website or sponsor.landing_page_url + si_phone = request.POST.get("si_phone", "").strip() + sponsor.primary_phone = si_phone or sponsor.primary_phone + si_address1 = request.POST.get("si_address1", "").strip() + if si_address1: + sponsor.mailing_address_line_1 = si_address1 + sponsor.mailing_address_line_2 = request.POST.get("si_address2", "").strip() + si_city = request.POST.get("si_city", "").strip() + if si_city: + sponsor.city = si_city + si_state = request.POST.get("si_state", "").strip() + sponsor.state = si_state + si_postal = request.POST.get("si_postal", "").strip() + if si_postal: + sponsor.postal_code = si_postal + si_country = request.POST.get("si_country", "").strip() + if si_country: + sponsor.country = si_country + sponsor.save() + + # Rebuild sponsor_contact from primary contact + primary_contact = sponsor.primary_contact + if primary_contact: + parts = [primary_contact.name] + contact_details = [] + if primary_contact.phone: + contact_details.append(primary_contact.phone) + if primary_contact.email: + contact_details.append(primary_contact.email) + if contact_details: + parts.append(" - " + " | ".join(contact_details)) + contract.sponsor_contact = "".join(parts) + else: + contract.sponsor_contact = request.POST.get("sponsor_contact", contract.sponsor_contact) + + contract.benefits_list = request.POST.get("benefits_list", "") + contract.legal_clauses = request.POST.get("legal_clauses", "") + contract.save() + messages.success(request, "Contract updated.") + return redirect(reverse("manage_composer") + "?step=6") + + def _handle_download_pdf(self, request, data, contract, sponsor): + """Return the contract as a PDF download.""" + from apps.sponsors.contracts import render_contract_to_pdf_response + + return render_contract_to_pdf_response(request, contract) + + def _handle_download_docx(self, request, data, contract, sponsor): + """Return the contract as a DOCX download.""" + from apps.sponsors.contracts import render_contract_to_docx_response + + return render_contract_to_docx_response(request, contract) + + def _handle_finish(self, request, data, contract, sponsor): + """Clear session and redirect to sponsorship detail without sending email.""" + request.session.pop("composer", None) + messages.success(request, f"Sponsorship for {sponsor.name} is ready. No email was sent.") + return redirect(reverse("manage_sponsorship_detail", args=[data["sponsorship_id"]])) + + def _render_contract_files(self, contract): + """Generate PDF and DOCX bytes from a contract. + + Returns: + Tuple of (pdf_bytes, docx_bytes). Either may be None if generation fails. + + """ + from apps.sponsors.contracts import render_contract_to_docx_file, render_contract_to_pdf_file + + pdf_bytes = None + docx_bytes = None + with contextlib.suppress(OSError, RuntimeError, ImportError): + pdf_bytes = render_contract_to_pdf_file(contract) + with contextlib.suppress(OSError, RuntimeError, ImportError): + docx_bytes = render_contract_to_docx_file(contract) + return pdf_bytes, docx_bytes + + def _collect_recipients(self, request, sponsor): + """Collect email recipients from sponsor contacts and form data. + + Returns: + List of email addresses, possibly empty. + + """ + contacts = SponsorContact.objects.filter(sponsor=sponsor) + emails = [c.email for c in contacts if c.email] + extra_to = request.POST.get("extra_to", "").strip() + if extra_to and extra_to.endswith(("@python.org", "@pyfound.org")): + emails.append(extra_to) + return emails + + def _attach_contract_files(self, email, sponsor, pdf_bytes, docx_bytes): + """Attach PDF and/or DOCX contract files to an EmailMessage.""" + sponsor_slug = sponsor.name.replace(" ", "-").replace(".", "") + if pdf_bytes: + email.attach(f"sponsorship-contract-{sponsor_slug}.pdf", pdf_bytes, "application/pdf") + if docx_bytes: + email.attach( + f"sponsorship-contract-{sponsor_slug}.docx", + docx_bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + def _handle_send_proposal_with_contract(self, request, data, contract, sponsor): + """Generate final contract files, attach to email, and send to sponsor.""" + from django.core.mail import EmailMessage + + emails = self._collect_recipients(request, sponsor) + if not emails: + messages.error(request, "No recipients specified.") + return redirect(reverse("manage_composer") + "?step=6") + + subject = ( + request.POST.get("email_subject", "").strip() or "Sponsorship Proposal from the Python Software Foundation" + ) + body = ( + request.POST.get("email_body", "").strip() + or f"Please find the attached sponsorship agreement for {sponsor.name}." + ) + + pdf_bytes, docx_bytes = self._render_contract_files(contract) + if not pdf_bytes and not docx_bytes: + messages.error(request, "Failed to generate contract files. Check that pypandoc is installed.") + return redirect(reverse("manage_composer") + "?step=6") + + # Finalize the contract + if pdf_bytes: + contract.set_final_version(pdf_bytes, docx_bytes) + + email = EmailMessage( + subject=subject, + body=body, + from_email=settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, + to=emails, + ) + cc = request.POST.get("cc_email", "").strip() + bcc = request.POST.get("bcc_email", "").strip() + if cc: + email.cc = [cc] + if bcc: + email.bcc = [bcc] + + self._attach_contract_files(email, sponsor, pdf_bytes, docx_bytes) + email.send() + + request.session.pop("composer", None) + messages.success(request, f"Contract sent to {', '.join(emails)}.") + return redirect(reverse("manage_sponsorship_detail", args=[data["sponsorship_id"]])) + + def _handle_send_internal_with_contract(self, request, data, contract, sponsor): + """Send the contract to an internal address for review.""" + from django.core.mail import EmailMessage + + email_addr = request.POST.get("internal_email", "").strip() + if not email_addr: + messages.error(request, "Please enter an email address for internal review.") + return redirect(reverse("manage_composer") + "?step=6") + + email = EmailMessage( + subject=f"[Internal Review] Sponsorship Contract for {sponsor.name}", + body=f"Please review the attached draft contract for {sponsor.name}.", + from_email=settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, + to=[email_addr], + ) + + pdf_bytes, docx_bytes = self._render_contract_files(contract) + self._attach_contract_files(email, sponsor, pdf_bytes, docx_bytes) + + email.send() + messages.success(request, f"Contract sent to {email_addr} for internal review.") + return redirect(reverse("manage_composer") + "?step=6") + + +class ComposerContractPreviewView(SponsorshipAdminRequiredMixin, View): + """GET endpoint to preview the contract PDF in a new browser tab.""" + + def get(self, request): + """Return the contract PDF for preview.""" + from apps.sponsors.contracts import render_contract_to_pdf_response + + data = request.session.get("composer", {}) + contract_id = data.get("contract_id") + if not contract_id: + messages.error(request, "No contract to preview.") + return redirect(reverse("manage_composer")) + + contract = Contract.objects.filter(pk=contract_id).select_related("sponsorship__sponsor").first() + if not contract: + messages.error(request, "Contract not found.") + return redirect(reverse("manage_composer")) + + return render_contract_to_pdf_response(request, contract) + + +class GuideView(SponsorshipAdminRequiredMixin, TemplateView): + """Help guide for the sponsor management UI.""" + + template_name = "sponsors/manage/guide.html" diff --git a/apps/sponsors/management/commands/seed_sponsor_manage_data.py b/apps/sponsors/management/commands/seed_sponsor_manage_data.py new file mode 100644 index 000000000..8c638c307 --- /dev/null +++ b/apps/sponsors/management/commands/seed_sponsor_manage_data.py @@ -0,0 +1,414 @@ +"""Create realistic test data for the sponsor management UI.""" + +from datetime import datetime, timedelta + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from apps.sponsors.models import ( + Contract, + Sponsor, + SponsorBenefit, + SponsorContact, + SponsorEmailNotificationTemplate, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + SponsorshipNotificationLog, + SponsorshipPackage, + SponsorshipProgram, +) + +User = get_user_model() + +SPONSORS = [ + { + "name": "CloudScale Inc", + "desc": "Enterprise cloud infrastructure provider", + "city": "San Francisco", + "country": "US", + }, + {"name": "DataForge Analytics", "desc": "Data analytics and ML platform", "city": "New York", "country": "US"}, + {"name": "SecureNet Solutions", "desc": "Cybersecurity tools and services", "city": "London", "country": "GB"}, + {"name": "DevTools GmbH", "desc": "Developer productivity tools", "city": "Berlin", "country": "DE"}, + {"name": "OpenStack Labs", "desc": "Open source infrastructure company", "city": "Austin", "country": "US"}, + {"name": "AIVentures Corp", "desc": "AI/ML research and deployment platform", "city": "Palo Alto", "country": "US"}, + {"name": "PackagePro", "desc": "Package management and distribution", "city": "Portland", "country": "US"}, + {"name": "WebFramework Ltd", "desc": "Web application framework and hosting", "city": "Seattle", "country": "US"}, + {"name": "PyData Systems", "desc": "Scientific computing and data engineering", "city": "Boston", "country": "US"}, + {"name": "DocuSign Tech", "desc": "Digital document management", "city": "Chicago", "country": "US"}, + {"name": "Serverless.io", "desc": "Serverless deployment platform", "city": "Denver", "country": "US"}, + {"name": "TestRunner AG", "desc": "CI/CD and testing infrastructure", "city": "Zurich", "country": "CH"}, +] + +# year_offset: 0=current, -1=previous +SCENARIOS = [ + # Current year — Applied (needs review) + (0, "visionary", 0, "applied", False, 2), + (1, "sustainability", 0, "applied", False, 5), + (2, "maintaining", 0, "applied", False, 1), + (8, "contributing", 0, "applied", False, 10), + # Current year — Approved (contract pending) + (3, "contributing", 0, "approved", False, 25), + (4, "visionary", 0, "approved", True, 18), + # Current year — Finalized (active) + (5, "visionary", 0, "finalized", False, 80), + (6, "sustainability", 0, "finalized", True, 55), + (9, "maintaining", 0, "finalized", False, 70), + # Current year — Rejected + (7, "supporting", 0, "rejected", False, 40), + # Previous year — Finalized (last year's sponsors, some renewing) + (0, "visionary", -1, "finalized", False, 400), + (5, "sustainability", -1, "finalized", False, 380), + (6, "maintaining", -1, "finalized", False, 370), + (10, "contributing", -1, "finalized", False, 360), + (11, "supporting", -1, "finalized", False, 350), + # Previous year — Rejected + (3, "supporting", -1, "rejected", False, 390), +] + + +class Command(BaseCommand): + help = "Seed realistic sponsor data for the management UI (dev only)" + + def add_arguments(self, parser): + parser.add_argument("--clean", action="store_true", help="Remove seeded data first") + + def handle(self, *args, **options): + if not settings.DEBUG: + msg = "Only run in DEBUG mode." + raise CommandError(msg) + + if options["clean"]: + self._clean() + return + + today = timezone.now().date() + current_year = today.year + + if not self._ensure_current_year(current_year): + return + if not self._ensure_programs_exist(): + return + + user = self._get_or_create_admin_user() + years = [current_year, current_year - 1] + + if not self._ensure_packages_for_years(years): + return + + sponsors = self._create_sponsors() + created_count = self._create_sponsorships(sponsors, user, current_year, today) + self._create_notification_templates() + notif_count = self._create_notification_logs(user, today) + + self.stdout.write( + self.style.SUCCESS( + f"Created {created_count} sponsorships across {len(sponsors)} sponsors, years {years}. " + f"{notif_count} notification logs." + ) + ) + self.stdout.write("View at: http://localhost:8000/sponsors/manage/sponsorships/") + + def _ensure_current_year(self, current_year): + cy = SponsorshipCurrentYear.objects.first() + if cy: + if cy.year != current_year: + cy.year = current_year + cy.save() + return True + self.stdout.write("No SponsorshipCurrentYear exists. Create one first.") + return False + + def _ensure_programs_exist(self): + if SponsorshipProgram.objects.exists(): + return True + self.stdout.write("No programs exist. Create at least one SponsorshipProgram first.") + return False + + def _get_or_create_admin_user(self): + user, _ = User.objects.get_or_create( + username="sponsor_admin", + defaults={"email": "admin@python.org", "is_staff": True, "first_name": "Sponsor", "last_name": "Admin"}, + ) + return user + + def _ensure_packages_for_years(self, years): + existing_year = SponsorshipPackage.objects.values_list("year", flat=True).distinct().order_by("-year").first() + if not existing_year: + self.stdout.write("No packages exist at all. Create packages in Django admin first.") + return False + + for year in years: + if not SponsorshipPackage.objects.filter(year=year).exists(): + self.stdout.write(f" Cloning packages and benefits from {existing_year} to {year}...") + for pkg in SponsorshipPackage.objects.filter(year=existing_year): + pkg.clone(year) + for ben in SponsorshipBenefit.objects.filter(year=existing_year): + ben.clone(year) + return True + + def _create_sponsors(self): + sponsors = [] + for data in SPONSORS: + sponsor, created = Sponsor.objects.get_or_create( + name=data["name"], + defaults={ + "description": data["desc"], + "primary_phone": "+1-555-000-0000", + "city": data["city"], + "country": data["country"], + "mailing_address_line_1": "123 Tech Blvd", + "postal_code": "10001", + }, + ) + if created: + self._create_contacts(sponsor, data) + sponsors.append(sponsor) + return sponsors + + def _create_contacts(self, sponsor, data): + SponsorContact.objects.create( + sponsor=sponsor, + name="Primary Contact", + email=f"contact@{data['name'].lower().replace(' ', '')}.com", + phone="+1-555-000-0000", + primary=True, + ) + SponsorContact.objects.create( + sponsor=sponsor, + name="Billing Dept", + email=f"billing@{data['name'].lower().replace(' ', '')}.com", + phone="+1-555-000-0001", + accounting=True, + ) + + def _create_sponsorships(self, sponsors, user, current_year, today): + created_count = 0 + for sponsor_idx, pkg_slug, year_offset, status, renewal, days_ago in SCENARIOS: + year = current_year + year_offset + packages = {p.slug: p for p in SponsorshipPackage.objects.filter(year=year)} + package = packages.get(pkg_slug) + if not package: + continue + + sponsor = sponsors[sponsor_idx] + if Sponsorship.objects.filter(sponsor=sponsor, year=year, package=package).exists(): + continue + + applied_on = today - timedelta(days=days_ago) + sp = Sponsorship( + sponsor=sponsor, + submited_by=user, + package=package, + sponsorship_fee=package.sponsorship_amount, + year=year, + for_modified_package=sponsor_idx in (1, 8), + renewal=renewal, + applied_on=applied_on, + status=Sponsorship.APPLIED, + ) + self._apply_status(sp, status, applied_on) + sp.save() + + benefits = SponsorshipBenefit.objects.filter(year=year, packages=package).order_by("order")[:5] + for b in benefits: + SponsorBenefit.new_copy(b, sponsorship=sp) + + # Create contracts for approved/finalized sponsorships + if status in ("approved", "finalized"): + try: + contract = Contract.new(sp) + if status == "finalized": + # Mark contract as executed + contract.status = Contract.EXECUTED + contract.save() + except (AttributeError, ValueError, TypeError): + self.stderr.write(f" Could not create contract for {sp.sponsor.name}") + + created_count += 1 + return created_count + + @staticmethod + def _apply_status(sp, status, applied_on): + if status in ("approved", "finalized"): + sp.status = Sponsorship.APPROVED + sp.start_date = applied_on + timedelta(days=10) + sp.end_date = sp.start_date + timedelta(days=365) + sp.approved_on = applied_on + timedelta(days=5) + sp.locked = True + + if status == "finalized": + sp.status = Sponsorship.FINALIZED + sp.finalized_on = sp.approved_on + timedelta(days=7) + + if status == "rejected": + sp.status = Sponsorship.REJECTED + sp.rejected_on = applied_on + timedelta(days=3) + sp.locked = True + + def _create_notification_templates(self): + templates = [ + { + "internal_name": "Welcome & Next Steps", + "subject": "Welcome to the PSF Sponsorship Program, {{ sponsor_name }}!", + "content": ( + "Dear {{ sponsor_name }},\n\n" + "Thank you for your {{ sponsorship_level }} sponsorship! " + "Your sponsorship period runs from {{ sponsorship_start_date }} to {{ sponsorship_end_date }}.\n\n" + "We will be in touch shortly with next steps regarding your benefits.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + }, + { + "internal_name": "Asset Reminder", + "subject": "Action Required: Sponsorship assets needed for {{ sponsor_name }}", + "content": ( + "Dear {{ sponsor_name }},\n\n" + "This is a reminder that we still need your sponsorship assets (logos, descriptions, etc.) " + "for your {{ sponsorship_level }} sponsorship.\n\n" + "Please submit them at your earliest convenience.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + }, + { + "internal_name": "Renewal Invitation", + "subject": "Renew your PSF Sponsorship, {{ sponsor_name }}?", + "content": ( + "Dear {{ sponsor_name }},\n\n" + "Your {{ sponsorship_level }} sponsorship is approaching its end date ({{ sponsorship_end_date }}). " + "We'd love to have you continue as a sponsor!\n\n" + "Please let us know if you'd like to renew.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + }, + ] + created = 0 + for tpl in templates: + _, was_created = SponsorEmailNotificationTemplate.objects.get_or_create( + internal_name=tpl["internal_name"], + defaults={"subject": tpl["subject"], "content": tpl["content"]}, + ) + if was_created: + created += 1 + if created: + self.stdout.write(f" Created {created} notification templates.") + + def _create_notification_logs(self, user, today): + """Create realistic notification log entries for existing sponsorships.""" + # Skip if logs already exist for seeded sponsors + names = [s["name"] for s in SPONSORS] + if SponsorshipNotificationLog.objects.filter(sponsorship__sponsor__name__in=names).exists(): + return 0 + + messages = [ + { + "subject": "Welcome to the PSF Sponsorship Program, {name}!", + "content": ( + "Dear {name},\n\n" + "Thank you for your {level} sponsorship! " + "Your sponsorship period runs from {start} to {end}.\n\n" + "We will be in touch shortly with next steps regarding your benefits.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + "contact_types": "primary, administrative", + "days_after_applied": 1, + "statuses": ("approved", "finalized"), + }, + { + "subject": "Action Required: Sponsorship assets needed for {name}", + "content": ( + "Dear {name},\n\n" + "This is a reminder that we still need your sponsorship assets " + "(logos, descriptions, etc.) for your {level} sponsorship.\n\n" + "Please submit them at your earliest convenience.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + "contact_types": "primary", + "days_after_applied": 15, + "statuses": ("finalized",), + }, + { + "subject": "Contract Sent: {name} {level} Sponsorship", + "content": ( + "Dear {name},\n\n" + "Please find attached the sponsorship contract for your " + "{level} sponsorship.\n\n" + "Please review and sign at your earliest convenience.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + "contact_types": "primary, administrative, accounting", + "days_after_applied": 7, + "statuses": ("approved", "finalized"), + }, + { + "subject": "Sponsorship Finalized: Welcome aboard, {name}!", + "content": ( + "Dear {name},\n\n" + "Great news! Your {level} sponsorship has been finalized. " + "Your benefits are now active.\n\n" + "If you have any questions, don't hesitate to reach out.\n\n" + "Best regards,\nPSF Sponsorship Team" + ), + "contact_types": "primary, manager", + "days_after_applied": 20, + "statuses": ("finalized",), + }, + ] + + created = 0 + sponsorships = Sponsorship.objects.filter( + sponsor__name__in=names, + status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED], + ).select_related("sponsor", "package") + + for sp in sponsorships: + primary_email = f"contact@{sp.sponsor.name.lower().replace(' ', '')}.com" + billing_email = f"billing@{sp.sponsor.name.lower().replace(' ', '')}.com" + + for msg in messages: + if sp.status not in msg["statuses"]: + continue + + recipients = primary_email + if "accounting" in msg["contact_types"] or "administrative" in msg["contact_types"]: + recipients = f"{primary_email}, {billing_email}" + + base_date = sp.applied_on or today + sent_at = timezone.make_aware( + datetime.combine(base_date + timedelta(days=msg["days_after_applied"]), datetime.min.time()) + ) + + SponsorshipNotificationLog.objects.create( + sponsorship=sp, + subject=msg["subject"].format( + name=sp.sponsor.name, + level=sp.level_name, + ), + content=msg["content"].format( + name=sp.sponsor.name, + level=sp.level_name, + start=sp.start_date or "TBD", + end=sp.end_date or "TBD", + ), + recipients=recipients, + contact_types=msg["contact_types"], + sent_by=user, + sent_at=sent_at, + ) + created += 1 + + if created: + self.stdout.write(f" Created {created} notification log entries.") + return created + + def _clean(self): + names = [s["name"] for s in SPONSORS] + SponsorshipNotificationLog.objects.filter(sponsorship__sponsor__name__in=names).delete() + Contract.objects.filter(sponsorship__sponsor__name__in=names).delete() + Sponsorship.objects.filter(sponsor__name__in=names).delete() + Sponsor.objects.filter(name__in=names).delete() + User.objects.filter(username="sponsor_admin").delete() + self.stdout.write(self.style.SUCCESS("Cleaned seeded sponsor data.")) diff --git a/apps/sponsors/migrations/0104_add_notification_log.py b/apps/sponsors/migrations/0104_add_notification_log.py new file mode 100644 index 000000000..4b97db1a8 --- /dev/null +++ b/apps/sponsors/migrations/0104_add_notification_log.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.11 on 2026-03-22 20:15 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("sponsors", "0103_alter_benefitfeature_polymorphic_ctype_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SponsorshipNotificationLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("subject", models.CharField(max_length=500)), + ("content", models.TextField(blank=True)), + ("recipients", models.TextField(help_text="Comma-separated email addresses")), + ("contact_types", models.CharField(blank=True, max_length=200)), + ("sent_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "sent_by", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ( + "sponsorship", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notification_logs", + to="sponsors.sponsorship", + ), + ), + ], + options={ + "verbose_name": "Notification Log", + "verbose_name_plural": "Notification Logs", + "ordering": ["-sent_at"], + }, + ), + ] diff --git a/apps/sponsors/models/__init__.py b/apps/sponsors/models/__init__.py index 0b6ac4298..ac2aea6cc 100644 --- a/apps/sponsors/models/__init__.py +++ b/apps/sponsors/models/__init__.py @@ -29,7 +29,11 @@ TieredBenefitConfiguration, ) from apps.sponsors.models.contract import Contract, LegalClause, signed_contract_random_path -from apps.sponsors.models.notifications import SPONSOR_TEMPLATE_HELP_TEXT, SponsorEmailNotificationTemplate +from apps.sponsors.models.notifications import ( + SPONSOR_TEMPLATE_HELP_TEXT, + SponsorEmailNotificationTemplate, + SponsorshipNotificationLog, +) from apps.sponsors.models.sponsors import Sponsor, SponsorBenefit, SponsorContact from apps.sponsors.models.sponsorship import ( Sponsorship, @@ -40,19 +44,15 @@ ) __all__ = [ - # notifications "SPONSOR_TEMPLATE_HELP_TEXT", - # benefits "BaseEmailTargetable", "BaseLogoPlacement", "BaseTieredBenefit", "BenefitFeature", "BenefitFeatureConfiguration", - # contract "Contract", "EmailTargetable", "EmailTargetableConfiguration", - # assets "FileAsset", "GenericAsset", "ImgAsset", @@ -70,15 +70,14 @@ "RequiredTextAsset", "RequiredTextAssetConfiguration", "ResponseAsset", - # sponsors "Sponsor", "SponsorBenefit", "SponsorContact", "SponsorEmailNotificationTemplate", - # sponsorship "Sponsorship", "SponsorshipBenefit", "SponsorshipCurrentYear", + "SponsorshipNotificationLog", "SponsorshipPackage", "SponsorshipProgram", "TextAsset", diff --git a/apps/sponsors/models/notifications.py b/apps/sponsors/models/notifications.py index ee5eb77ea..01d82b46d 100644 --- a/apps/sponsors/models/notifications.py +++ b/apps/sponsors/models/notifications.py @@ -1,6 +1,8 @@ """Email notification template models for sponsor communications.""" from django.conf import settings +from django.db import models +from django.utils import timezone from apps.mailing.models import BaseEmailTemplate @@ -57,3 +59,45 @@ def get_email_message(self, sponsorship, **kwargs): to=recipients, context={"sponsorship": sponsorship}, ) + + +class SponsorshipNotificationLog(models.Model): + """Persisted record of every notification sent to a sponsorship.""" + + sponsorship = models.ForeignKey( + "sponsors.Sponsorship", + on_delete=models.CASCADE, + related_name="notification_logs", + ) + subject = models.CharField(max_length=500) + content = models.TextField(blank=True) + recipients = models.TextField(help_text="Comma-separated email addresses") + contact_types = models.CharField(max_length=200, blank=True) + sent_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + sent_at = models.DateTimeField(default=timezone.now) + + class Meta: + """Meta configuration for SponsorshipNotificationLog.""" + + ordering = ["-sent_at"] + verbose_name = "Notification Log" + verbose_name_plural = "Notification Logs" + + def __str__(self): + """Return a human-readable representation of the log entry.""" + return f"{self.subject} → {self.sponsorship} ({self.sent_at:%Y-%m-%d %H:%M})" + + @property + def recipient_list(self): + """Return recipients as a list of email addresses.""" + return [r.strip() for r in self.recipients.split(",") if r.strip()] + + @property + def contact_type_list(self): + """Return contact types as a list of strings.""" + return [t.strip() for t in self.contact_types.split(",") if t.strip()] diff --git a/apps/sponsors/templates/sponsors/email/sponsor_contract.txt b/apps/sponsors/templates/sponsors/email/sponsor_contract.txt index 75f8cb087..e684c97dc 100644 --- a/apps/sponsors/templates/sponsors/email/sponsor_contract.txt +++ b/apps/sponsors/templates/sponsors/email/sponsor_contract.txt @@ -1,5 +1,13 @@ -content email sponsors +Dear {{ contract.sponsorship.sponsor.name }}, -STATEMENT OF WORK +Thank you for your {{ contract.sponsorship.level_name }} sponsorship of the Python Software Foundation! -{{ sponsorship }} +Please find attached your Statement of Work for the sponsorship period{% if contract.sponsorship.start_date %} from {{ contract.sponsorship.start_date }} to {{ contract.sponsorship.end_date }}{% endif %}. + +Please review the document and return a signed copy at your earliest convenience. + +If you have any questions, please don't hesitate to reach out. + +Best regards, +The PSF Sponsorship Team +sponsors@python.org diff --git a/apps/sponsors/templates/sponsors/email/sponsor_contract_subject.txt b/apps/sponsors/templates/sponsors/email/sponsor_contract_subject.txt index bcf3f5256..b6af04cd4 100644 --- a/apps/sponsors/templates/sponsors/email/sponsor_contract_subject.txt +++ b/apps/sponsors/templates/sponsors/email/sponsor_contract_subject.txt @@ -1 +1 @@ -STATEMENT OF WORK subject email user + verified emails +PSF Sponsorship Agreement - {{ contract.sponsorship.sponsor.name }} diff --git a/apps/sponsors/templates/sponsors/manage/_base.html b/apps/sponsors/templates/sponsors/manage/_base.html new file mode 100644 index 000000000..8448080cd --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/_base.html @@ -0,0 +1,775 @@ +{% extends "base.html" %} + +{% block page_title %}{{ page_title|default:"Sponsor Management" }} | Python Software Foundation{% endblock %} + +{% block body_attributes %}class="psf pages"{% endblock %} + +{% block section-logo %}{% endblock %} + +{% block main-nav_attributes %}psf-navigation{% endblock %} + +{% block main_navigation %} +{% load sitetree %} +{% sitetree_menu from "main" include "psf-meta" template "sitetree/submenu.html" %} +{% endblock %} + +{% block content_attributes %}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} + + +
+ + + + + {% block manage_breadcrumbs %}{% endblock %} + + {% block manage_content %}{% endblock %} +
+{% endblock content %} + +{% block left_sidebar %}{% endblock %} + +{% block extra_js %} + +{% endblock extra_js %} diff --git a/apps/sponsors/templates/sponsors/manage/asset_browser.html b/apps/sponsors/templates/sponsors/manage/asset_browser.html new file mode 100644 index 000000000..139a31ba6 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/asset_browser.html @@ -0,0 +1,134 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Assets | Sponsor Management{% endblock %} + +{% with active_tab="more" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Assets +
+{% endblock %} + +{% block manage_content %} + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {% if filter_type or filter_related or filter_value or filter_search %} + Clear + {% endif %} +
+
+ {{ company_count }} sponsor{{ company_count|pluralize }} · {{ asset_count }} asset{{ asset_count|pluralize }}{% if asset_count >= 200 %} (first 200){% endif %} +
+
+
+ + {% if grouped_assets %} + {% for company_name, group in grouped_assets.items %} + + {% endfor %} + {% else %} +
+
📁
+

No assets match your filters.

+
+ {% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/benefit_config_form.html b/apps/sponsors/templates/sponsors/manage/benefit_config_form.html new file mode 100644 index 000000000..b477317d2 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/benefit_config_form.html @@ -0,0 +1,73 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}Add{% else %}Edit{% endif %} {{ type_label }} | Sponsor Management{% endblock %} + +{% with active_tab="benefits" %} + +{% block topbar_actions %}{% endblock %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Benefits + / + {{ benefit.short_name }} + / + {% if is_create %}Add{% else %}Edit{% endif %} {{ type_label }} +
+{% endblock %} + +{% block manage_content %} +
+

+ {% if is_create %}Add {{ type_label }} Configuration{% else %}Edit {{ type_label }} Configuration{% endif %} +

+

+ Benefit: {{ benefit.name }} · {{ benefit.program.name }} · {{ benefit.year }} +

+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ {{ type_label }} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}{{ field.help_text }}{% endif %} + {% if field.errors %}
{{ field.errors.0 }}
{% endif %} +
+ {% endfor %} + + {% if not form.fields %} +

This configuration type has no additional fields.

+ {% endif %} +
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/benefit_confirm_delete.html b/apps/sponsors/templates/sponsors/manage/benefit_confirm_delete.html new file mode 100644 index 000000000..09f256ba6 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/benefit_confirm_delete.html @@ -0,0 +1,47 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Delete {{ object.short_name }} | Sponsor Management{% endblock %} + +{% with active_tab="benefits" %} + +{% block topbar_actions %}{% endblock %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Benefits + / + {{ object.short_name }} + / + Delete +
+{% endblock %} + +{% block manage_content %} +
+

Delete Benefit

+

+ Are you sure you want to delete {{ object.name }} + ({{ object.program.name }}, {{ object.year }})? +

+ + {% if object.related_sponsorships.count %} +
+ This benefit is referenced by {{ object.related_sponsorships.count }} + sponsorship{{ object.related_sponsorships.count|pluralize }}. Deleting it will remove the + benefit from those sponsorships. +
+ {% endif %} + +
+ {% csrf_token %} +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/benefit_form.html b/apps/sponsors/templates/sponsors/manage/benefit_form.html new file mode 100644 index 000000000..520a8fe02 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/benefit_form.html @@ -0,0 +1,300 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}New Benefit{% else %}Edit {{ object.short_name }}{% endif %} | Sponsor Management{% endblock %} + +{% with active_tab="benefits" %} + +{% block topbar_actions %}{% endblock %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Benefits + / + {% if is_create %}New Benefit{% else %}{{ object.short_name }}{% endif %} +
+{% endblock %} + +{% block manage_content %} +
+

+ {% if is_create %}Create New Benefit{% else %}Edit Benefit{% endif %} +

+ {% if not is_create %} +

+ {{ object.program.name }} · {{ object.year }} + {% if related_sponsorships_count %} + · {{ related_sponsorships_count }} sponsorship{{ related_sponsorships_count|pluralize }} + {% endif %} +

+ {% else %} +

+ Fill in the benefit details below. +

+ {% endif %} + + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} + + +
+ Public Information + +
+ + {{ form.name }} + {% if form.name.help_text %}{{ form.name.help_text }}{% endif %} + {% if form.name.errors %}
{{ form.name.errors.0 }}
{% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.help_text %}{{ form.description.help_text }}{% endif %} + {% if form.description.errors %}
{{ form.description.errors.0 }}
{% endif %} +
+ +
+
+ + {{ form.program }} + {% if form.program.errors %}
{{ form.program.errors.0 }}
{% endif %} +
+
+ + {{ form.year }} + {% if form.year.errors %}
{{ form.year.errors.0 }}
{% endif %} +
+
+ +
+ +
+ {% for checkbox in form.packages %} + {{ checkbox }} + {% endfor %} +
+ {% if form.packages.errors %}
{{ form.packages.errors.0 }}
{% endif %} +
+
+ + +
+ Flags & Visibility +
+ + + + +
+
+ + +
+ Internal Details + +
+ + {{ form.internal_description }} +
+ +
+
+ + {{ form.internal_value }} + {% if form.internal_value.help_text %}{{ form.internal_value.help_text }}{% endif %} +
+
+ + {{ form.capacity }} + {% if form.capacity.help_text %}{{ form.capacity.help_text }}{% endif %} +
+
+ + +
+ +
+ + Cancel + {% if not is_create %} + Delete + {% endif %} +
+
+
+ + {% if not is_create %} + +
+ + +
+
+

Feature Configurations {{ feature_configs|length }}

+
+ + +
+
+ {% if feature_configs %} + + + + + + + + + + {% for cfg in feature_configs %} + + + + + + {% endfor %} + +
TypeDetailsActions
{{ cfg.polymorphic_ctype.name|title }}{{ cfg }} + Edit +
+ {% csrf_token %} + +
+
+ {% else %} +
+ No feature configurations yet. Click "Add Configuration" to create one. +
+ {% endif %} +
+ + + {% if benefit_packages %} +
+
+

Included in Packages {{ benefit_packages|length }}

+
+ + + + + + + + + + + {% for pkg in benefit_packages %} + + + + + + + {% endfor %} + +
PackageAmountAdvertised
{{ pkg.name }}${{ pkg.sponsorship_amount|floatformat:"0" }}{% if pkg.advertise %}Yes{% else %}No{% endif %}Edit
+
+ {% endif %} + + + {% if related_sponsorships %} +
+
+

Sponsors Using This Benefit {{ related_sponsorships_count }}

+ Sync to Sponsorships +
+ + + + + + + + + + + + {% for sp in related_sponsorships %} + + + + + + + + {% endfor %} + +
SponsorPackageYearStatusDate Range
+ {% if sp.sponsor %} + {{ sp.sponsor.name }} + {% else %} + Unknown sponsor + {% endif %} + + {% if sp.package %} + {{ sp.package.name }} + {% else %} + + {% endif %} + {{ sp.year|default:"—" }} + {% if sp.status == 'applied' %}Applied + {% elif sp.status == 'approved' %}Approved + {% elif sp.status == 'finalized' %}Finalized + {% elif sp.status == 'rejected' %}Rejected + {% else %}{{ sp.get_status_display }} + {% endif %} + + {% if sp.start_date and sp.end_date %} + {{ sp.start_date|date:"M j, Y" }} – {{ sp.end_date|date:"M j, Y" }} + {% else %} + + {% endif %} +
+
+ {% endif %} + + {% if not benefit_packages and not related_sponsorships %} +
+ No packages or sponsors are using this benefit yet. +
+ {% endif %} + +
+ {% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/benefit_list.html b/apps/sponsors/templates/sponsors/manage/benefit_list.html new file mode 100644 index 000000000..ca87139f2 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/benefit_list.html @@ -0,0 +1,126 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Benefits | Sponsor Management{% endblock %} + +{% with active_tab="benefits" %} + +{% block topbar_actions %} + + New Benefit +{% endblock %} + +{% block manage_content %} + +
+
+
+ + +
+
+ + +
+
+ + {% if filter_year or filter_program or filter_package %} + Clear + {% endif %} +
+
+
+ + +
+ Showing {{ benefits|length }} benefit{{ benefits|length|pluralize }} + {% if filter_year %} for {{ filter_year }}{% endif %} +
+ + {% if benefits %} + + + + + {% if not filter_year %}{% endif %} + + + + + + + + + {% for benefit in benefits %} + + + {% if not filter_year %}{% endif %} + + + + + + + {% endfor %} + +
BenefitYearProgramTiersValueStatus
+ {{ benefit.short_name }} + {% if benefit.new %}New{% endif %} + {% if benefit.standalone %}Standalone{% endif %} + {% if benefit.description %} +
{{ benefit.description|truncatewords:12 }}
+ {% endif %} +
{{ benefit.year|default:"-" }}{{ benefit.program.name }} + {% with pkg_count=benefit.packages.count %} + {% if pkg_count %} + + {{ pkg_count }} pkg{{ pkg_count|pluralize }} + + {% else %} + + {% endif %} + {% endwith %} + + {% if benefit.internal_value %}${{ benefit.internal_value|floatformat:"0" }}{% else %}{% endif %} + + {% if benefit.unavailable %}Unavailable + {% elif benefit.package_only %}Pkg Only + {% else %}Available + {% endif %} + + Edit +
+ + + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} + + {% else %} +
+
🔍
+

No benefits match your filters.

+ Create Benefit +
+ {% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/benefit_sync.html b/apps/sponsors/templates/sponsors/manage/benefit_sync.html new file mode 100644 index 000000000..a10b2912d --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/benefit_sync.html @@ -0,0 +1,118 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sync {{ benefit.short_name }} | Sponsor Management{% endblock %} + +{% with active_tab="benefits" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Benefits + / + {{ benefit.short_name }} + / + Sync +
+{% endblock %} + +{% block manage_content %} +
+

+ Sync Benefit to Sponsorships +

+

+ {{ benefit.name }} · {{ benefit.program.name }} · {{ benefit.year }} +

+ +
+ This will push the current benefit definition (name, description, value, program, features) to the selected sponsorships. Each sponsor's copy of this benefit will be overwritten with the latest template data. +
+ + {% if eligible %} +
+ {% csrf_token %} + +
+ Select Sponsorships to Update + +
+ +
+ + + + + + + + + + + + + + {% for sp in eligible %} + + + + + + + + + {% endfor %} + +
SponsorPackageYearStatusPeriod
+ + + + {% if sp.sponsor %}{{ sp.sponsor.name }}{% else %}Unknown{% endif %} + + + {% if sp.package %}{{ sp.package.name }}{% else %}{% endif %} + {{ sp.year|default:"—" }} + {% if sp.status == 'applied' %}Applied + {% elif sp.status == 'approved' %}Approved + {% elif sp.status == 'finalized' %}Finalized + {% endif %} + + {% if sp.start_date and sp.end_date %} + {{ sp.start_date|date:"M j, Y" }} – {{ sp.end_date|date:"M j, Y" }} + {% else %}{% endif %} +
+
+ +
+ + Cancel +
+
+ + + + {% else %} +
+
+

No active sponsorships use this benefit. Nothing to sync.

+ Back to Benefit +
+ {% endif %} +
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/bulk_notify.html b/apps/sponsors/templates/sponsors/manage/bulk_notify.html new file mode 100644 index 000000000..a464a51bd --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/bulk_notify.html @@ -0,0 +1,134 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Bulk Notify | Sponsorship Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + Bulk Notification +
+{% endblock %} + +{% block manage_content %} + + +
+

Send Bulk Notification

+ +
+ Recipients ({{ sponsorships|length }} sponsorship{{ sponsorships|length|pluralize }}) +
    + {% for sp in sponsorships %} +
  • {{ sp.sponsor.name }}{% if sp.package %} — {{ sp.package.name }}{% endif %}
  • + {% endfor %} +
+
+ + {% if form.non_field_errors %} +
+
    {% for e in form.non_field_errors %}
  • {{ e }}
  • {% endfor %}
+
+ {% endif %} + + {% if email_preview %} +
+ Email Preview ({{ sponsorships.0.sponsor.name }}) +
+
Subject
+
{{ email_preview.subject }}
+
+
+
Body
+
{{ email_preview.body }}
+
+
+
Recipients
+
{{ email_preview.to|join:", " }}
+
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ Contact Types +
+
+ {% for checkbox in form.contact_types %} + + {% endfor %} +
+ {% if form.contact_types.errors %} +
{{ form.contact_types.errors }}
+ {% endif %} +
+
+ +
+ Message +
+ + {{ form.notification }} + {% if form.notification.help_text %}{{ form.notification.help_text }}{% endif %} +
+ +
-- or custom content --
+ +
+ + {{ form.subject }} +
+
+ + {{ form.content }} +
+
+ + +
+ Template Variables +

Click to copy, then paste into subject or content.

+
+ {% for var in template_vars %} + + {% endfor %} +
+
+ +
+ + + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/clone_year.html b/apps/sponsors/templates/sponsors/manage/clone_year.html new file mode 100644 index 000000000..773c0fbce --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/clone_year.html @@ -0,0 +1,110 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Clone Year | Sponsor Management{% endblock %} + +{% with active_tab="clone" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Clone Year +
+{% endblock %} + +{% block manage_content %} +
+

Clone Year Configuration

+

+ Copy all packages and benefits from one year to another. Existing items in the target year won't be duplicated. +

+ + {% if form.errors %} +
+ Please correct the errors below: + {% if form.non_field_errors %} +
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+ {% csrf_token %} + +
+ Clone Settings + +
+
+ + {{ form.source_year }} + {% if form.source_year.errors %}
{{ form.source_year.errors.0 }}
{% endif %} +
+
+ + {{ form.target_year }} + {% if form.target_year.errors %}
{{ form.target_year.errors.0 }}
{% endif %} +
+
+ +
+ + +
+
+ + {% if preview_benefits or preview_packages %} +
+

Preview: {{ source_year }} configuration

+ + {% if preview_packages %} +
+
+ Packages ({{ preview_packages|length }}) +
+
    + {% for pkg in preview_packages %} +
  • + {{ pkg.name }} + ${{ pkg.sponsorship_amount|floatformat:"0" }} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if preview_benefits %} +
+
+ Benefits ({{ preview_benefits|length }}) +
+
    + {% for benefit in preview_benefits %} +
  • + {{ benefit.program.name }} + {{ benefit.name }} +
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/composer.html b/apps/sponsors/templates/sponsors/manage/composer.html new file mode 100644 index 000000000..ff27927e2 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/composer.html @@ -0,0 +1,1574 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sponsorship Composer | Sponsor Management{% endblock %} + +{% with active_tab="composer" %} + +{% block manage_content %} + + + +
+ {% if step > 1 %} + + 1 Sponsor + + {% elif step == 1 %} + 1 Sponsor + {% else %} + 1 Sponsor + {% endif %} + +
+ + {% if step > 2 %} + + 2 Package + + {% elif step == 2 %} + 2 Package + {% else %} + 2 Package + {% endif %} + +
+ + {% if step > 3 %} + + 3 Benefits + + {% elif step == 3 %} + 3 Benefits + {% else %} + 3 Benefits + {% endif %} + +
+ + {% if step > 4 %} + + 4 Terms + + {% elif step == 4 %} + 4 Terms + {% else %} + 4 Terms + {% endif %} + +
+ + {% if step > 5 %} + + 5 Review & Create + + {% elif step == 5 %} + 5 Review & Create + {% else %} + 5 Review & Create + {% endif %} + +
+ + {% if step == 6 %} + 6 Contract & Send + {% else %} + 6 Contract & Send + {% endif %} +
+ +{% if messages %} +{% for message in messages %} +
+ {{ message }} +
+{% endfor %} +{% endif %} + + + + +{% if step == 1 %} +
+
+

Select a Sponsor

+
+ + +
+ +
+ + + {% if sponsors %} + + + {% elif search_query %} +
+

No sponsors found matching "{{ search_query }}".

+
+ {% endif %} + + +
+
+ + Create New Sponsor +
+
+
+ {% csrf_token %} + + + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}{{ field.help_text }}{% endif %} + {% if field.errors %}
{{ field.errors.0 }}
{% endif %} +
+ {% endfor %} + +
+ + +
+
+
+
+
+{% endif %} + + + + +{% if step == 2 %} +
+
+

Choose a Base Package

+
+ + + {% if years %} +
+ {% for y in years %} + {{ y }} + {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + + + +
+ {% for pkg in packages %} +
+
{{ pkg.name }}
+
${{ pkg.sponsorship_amount|floatformat:"0" }}
+
{{ pkg.benefits.count }} benefits
+
+ {% endfor %} + + +
+
Custom
+
A la carte
+
Build from scratch
+
+
+ +
+ Back +
+ +
+
+
+ + +{% endif %} + + + + +{% if step == 3 %} +
+
+

Customize Benefits

+
+ +
+ {% csrf_token %} +
+ {% for b in selected_benefits %} + + {% endfor %} +
+ +
+ +
+
+ Available Benefits +
+
+ +
+
+ {% for group in benefits_by_program %} +
+
{{ group.program.name }} ({{ group.benefits|length }})
+
+ {% for b in group.benefits %} +
+
+ {{ b.name }} + {% if b.description %}?{% endif %} + {% if b.internal_value %} + ${{ b.internal_value|floatformat:"0" }} + {% endif %} + + {% if b.new %}New{% endif %} + {% if b.package_only %}Pkg Only{% endif %} + {% if b.standalone %}Standalone{% endif %} + +
+
+ + +
+
+ {% endfor %} +
+
+ {% endfor %} +
+
+ + +
+
+ Selected Benefits + {{ selected_benefits|length }} +
+
+ {% for b in selected_benefits %} +
+
+ {{ b.name }} + {% if b.description %}?{% endif %} + {% if b.internal_value %} + ${{ b.internal_value|floatformat:"0" }} + {% endif %} +
+
+ {% if b.pk in package_benefit_ids %} + Pkg + {% else %} + + {% endif %} +
+
+ {% endfor %} +
+
+ Total Internal Value + ${{ total_value|floatformat:"0" }} +
+
+
+ +
+ Back +
+ +
+
+
+ + +{% endif %} + + + + +{% if step == 4 %} +
+
+

Set Sponsorship Terms

+
+ +
+ {% csrf_token %} + + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% for field, errors in form.errors.items %} + {% if field != '__all__' %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + +
+ Financial +
+ + {{ form.fee }} + {% if form.fee.errors %}
{{ form.fee.errors.0 }}
{% endif %} + {% if total_internal_value %} +
+ Internal benefit cost: ${{ total_internal_value|floatformat:"0" }} + (staff only) +
+ +
+ + + + + + +
+ + + + {% endif %} +
+
+ +
+ Dates +
+
+ + {{ form.start_date }} +
+ {% if form.start_date.errors %}
{{ form.start_date.errors.0 }}
{% endif %} +
+
+ + {{ form.end_date }} +
+ {% if form.end_date.errors %}
{{ form.end_date.errors.0 }}
{% endif %} +
+
+ + +
+ +
+ Options +
+ + {% if form.renewal.help_text %}{{ form.renewal.help_text }}{% endif %} +
+
+ + {{ form.notes }} + {% if form.notes.errors %}
{{ form.notes.errors.0 }}
{% endif %} +
+
+ +
+ Back +
+ +
+
+
+{% endif %} + + + + +{% if step == 5 %} +
+
+
Sponsorship Summary
+ +
+

Sponsor

+ {% if sponsor %} +
+ Name + {{ sponsor.name }} +
+ {% if sponsor.city %} +
+ Location + {{ sponsor.city }}{% if sponsor.country %}, {{ sponsor.country }}{% endif %} +
+ {% endif %} + {% endif %} +
+ +
+

Package

+
+ Package + {% if package %}{{ package.name }}{% else %}Custom{% endif %} +
+
+ Year + {{ data.year|default:"N/A" }} +
+
+ +
+

Benefits ({{ selected_benefits|length }})

+ {% for group in review_benefits_by_program %} +
+
+ + + {{ group.program.name }} + ({{ group.benefits|length }}) + +
+
    + {% for b in group.benefits %} +
  • + + {{ b.name }} + {% if b.pk in package_benefit_ids %}Pkg{% endif %} + {% if b.description %}?{% endif %} + + ${{ b.internal_value|default:"0"|floatformat:"0" }} +
  • + {% endfor %} +
+
+ {% endfor %} +
+ Total Internal Value + + ${{ total_value|floatformat:"0" }} + (staff only) + +
+
+ +
+

Terms

+
+ Fee + ${{ data.fee|floatformat:"0" }} +
+
+ Start Date + {{ data.start_date }} +
+
+ End Date + {{ data.end_date }} +
+
+ Renewal + {% if data.renewal %}Yes{% else %}No{% endif %} +
+ {% if data.notes %} +
+
Notes
+
{{ data.notes }}
+
+ {% endif %} +
+
+ + +
+
+ {% csrf_token %} + +
+

+ This will create the sponsorship record (status: Applied) and a draft contract. + You can edit the contract on the next step before sending. +

+
+ +
+ Back +
+
+
+{% endif %} + + + + +{% if step == 6 %} +
+
+

Contract Editor & Send

+
+ Contract status: {{ contract.get_status_display }} + · Revision: {{ contract.revision }} +
+
+ + +
+

Edit Contract Fields

+
+ {% csrf_token %} + + + + + + +
+ +
+
Sponsor Information
+
+
+ +
{{ sponsor.name }}
+
+
+ + +
+
+ + +
+
+ + +
+
Mailing Address
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ Sponsor Contacts + + Add Contact +
+
+ {% for c in sponsor_contacts %} +
+
+ {{ c.name }} +
+ {% if c.primary %}PRIMARY{% endif %} + Edit +
+
+
+ {% if c.email %}
{{ c.email }}
{% endif %} + {% if c.phone %}
{{ c.phone }}
{% endif %} + {% if c.type %}
{{ c.type }}
{% endif %} +
+
+ {% empty %} +
+ No contacts on file. + Add one now. +
+ {% endfor %} + {% if sponsor_contacts %} +
The primary contact is used for the contract's contact field.
+ {% endif %} +
+
+
+ +
+
Benefits List (Markdown)
+
+
+ + + + + + +
+ +
+
+ +
+
Legal Clauses (Markdown)
+
+
+ + + + + + +
+ +
+ {% if available_clauses %} +
+
Insert clause:
+
+ {% for clause in available_clauses %} + + {% endfor %} +
+
+ + {% endif %} +
+ + +
+
+ + +
+

Preview & Download

+
+ + Preview PDF + +
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+
+ + +
+

Send Contract to Sponsor

+ +
+ {% csrf_token %} + + + +
To
+
+ {% for c in sponsor_contacts %} +
+ {{ c.name }} <{{ c.email }}> + {{ c.get_type_display|default:"Contact" }} +
+ {% endfor %} + {% if not sponsor_contacts %} +
No sponsor contacts on file. Send to a PSF team address instead:
+ {% endif %} +
+ + +
+ +
+ +
+
+
CC
+ +
+
+
BCC
+ +
+
+ + +
Subject
+ + + +
Message Body
+ + + +
+ Attachments: The contract will be attached as PDF and DOCX. The contract status will change to "Awaiting Signature". +
+ + +
+
+ + +
+

Send for Internal Review

+

Email the draft contract to a colleague for review. The contract status will not change.

+
+ {% csrf_token %} + +
+ + +
+
+
+ + +
+ Back to Review +
+
+ {% csrf_token %} + + +
+
+
+{% endif %} + + + +{% endblock manage_content %} +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/contact_form.html b/apps/sponsors/templates/sponsors/manage/contact_form.html new file mode 100644 index 000000000..b51142e73 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/contact_form.html @@ -0,0 +1,91 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}Add Contact{% else %}Edit {{ object.name }}{% endif %} | {{ sponsor.name }}{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + {% if from_sponsorship %} + {{ sponsor.name }} + {% else %} + Sponsorships + {% endif %} + / + {% if is_create %}Add Contact{% else %}Edit Contact{% endif %} +
+{% endblock %} + +{% block manage_content %} +
+

+ {% if is_create %}Add Contact{% else %}Edit Contact{% endif %} +

+

{{ sponsor.name }}

+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %}
  • {{ field }}: {{ error }}
  • {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ Contact Info +
+ + {{ form.name }} +
+
+
+ + {{ form.email }} +
+
+ + {{ form.phone }} +
+
+
+ +
+ Roles + +
+ + + + +
+
+ +
+ + {% if from_sponsorship %} + Cancel + {% else %} + Cancel + {% endif %} +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/contract_execute.html b/apps/sponsors/templates/sponsors/manage/contract_execute.html new file mode 100644 index 000000000..3640f1185 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/contract_execute.html @@ -0,0 +1,50 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Execute Contract | {{ sponsorship.sponsor.name }}{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {{ sponsorship.sponsor.name }} + / + Execute Contract +
+{% endblock %} + +{% block manage_content %} +
+

Execute Contract

+

+ Upload the signed contract to finalize the sponsorship. +

+ +
+ Executing the contract will mark the sponsorship as Finalized. This cannot be undone without admin intervention. +
+ +
+ {% csrf_token %} +
+ Signed Document +
+ + {{ form.signed_document }} + {% if form.signed_document.help_text %}{{ form.signed_document.help_text }}{% endif %} + {% if form.signed_document.errors %}
{{ form.signed_document.errors.0 }}
{% endif %} +
+
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/contract_send.html b/apps/sponsors/templates/sponsors/manage/contract_send.html new file mode 100644 index 000000000..d975cdd4a --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/contract_send.html @@ -0,0 +1,99 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Send Contract | {{ sponsorship.sponsor.name }}{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {{ sponsorship.sponsor.name }} + / + Send Contract +
+{% endblock %} + +{% block manage_content %} +
+

Contract for {{ sponsorship.sponsor.name }}

+

+ {{ sponsorship.level_name }} · ${{ sponsorship.sponsorship_fee|default:"0"|floatformat:"0" }} + · {{ contract.get_status_display }} (Rev {{ contract.revision }}) +

+ + +
+ Step 1: Generate Contract + {% if contract.document %} +
+ Contract has been generated. + Download PDF + {% if contract.document_docx %} + Download DOCX + {% endif %} +
+ {% else %} +

+ Generate the contract PDF and DOCX. This finalizes the document and marks it as "Awaiting Signature". +

+
+ {% csrf_token %} + + +
+ {% endif %} +
+ + {% if contract.document %} + +
+ +
+ Send to Sponsor +

+ Send the contract directly to the sponsor's contacts: +

+
+ {% for email in sponsor_emails %} +
{{ email }}
+ {% empty %} +
No verified emails found.
+ {% endfor %} +
+ {% if sponsor_emails %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+ + +
+ Internal Review +

+ Send to a PSF team member for review before sending to sponsor: +

+
+ {% csrf_token %} + +
+ +
+ +
+
+
+ {% endif %} + + +
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/current_year_form.html b/apps/sponsors/templates/sponsors/manage/current_year_form.html new file mode 100644 index 000000000..398841c25 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/current_year_form.html @@ -0,0 +1,57 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Active Year | Sponsor Management{% endblock %} + +{% with active_tab="dashboard" %} + +{% block topbar_actions %}{% endblock %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Active Year +
+{% endblock %} + +{% block manage_content %} +
+

Active Sponsorship Year

+

+ The active year controls which benefits and packages are shown in new sponsorship applications. + Currently set to {{ object.year }}. +

+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} +
+ Update Year +
+ + {{ form.year }} + Must be between 2022 and 2050. +
+
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/dashboard.html b/apps/sponsors/templates/sponsors/manage/dashboard.html new file mode 100644 index 000000000..a105ad4b0 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/dashboard.html @@ -0,0 +1,387 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sponsor Dashboard | Python Software Foundation{% endblock %} + +{% with active_tab="dashboard" %} + +{% block topbar_actions %} + {% if current_year %}{{ current_year }} ▾{% else %}Set Year ▾{% endif %} +{% endblock %} + +{% block manage_content %} + + {% if years %} +
+ {% for year in years %} + + {{ year }} + {% if year == current_year %}{% endif %} + + {% endfor %} +
+ {% endif %} + + {% if selected_year %} + + +
+
+
{{ total_sponsorships }}
+
Sponsorships
+
+ +
${{ total_revenue|floatformat:"0" }}
+
Revenue →
+
+
+
{{ count_applied }}
+
Pending Review
+
+
+
{{ count_approved }}
+
Approved
+
+
+
{{ count_finalized }}
+
Finalized
+
+
+ + + {% if needs_review %} +
+
+

Needs Review {{ count_applied }}

+ View All +
+ + + + + + + + + + + + {% for sp in needs_review %} + + + + + + + + {% endfor %} + +
SponsorPackageFeeApplied
+ + {{ sp.sponsor.name }} + + {% if sp.renewal %}Renewal{% endif %} + {% if sp.for_modified_package %}Custom{% endif %} + {% if sp.package %}{{ sp.package.name }}{% endif %}${{ sp.sponsorship_fee|default:"0"|floatformat:"0" }}{{ sp.applied_on|date:"M j" }}Review
+
+ {% endif %} + + {% if pending_contracts %} +
+
+

Pending Contracts {{ count_approved }}

+ View All +
+ + + + + + + + + + + + + {% for sp in pending_contracts %} + + + + + + + + + {% endfor %} + +
SponsorPackageFeeApprovedPeriod
+ + {{ sp.sponsor.name }} + + {% if sp.package %}{{ sp.package.name }}{% endif %}${{ sp.sponsorship_fee|default:"0"|floatformat:"0" }}{{ sp.approved_on|date:"M j" }} + {% if sp.start_date %}{{ sp.start_date|date:"M j" }} – {{ sp.end_date|date:"M j, Y" }}{% endif %} + Manage
+
+ {% endif %} + + {% if expiring_soon %} +
+
+

Expiring Soon {{ expiring_soon|length }}

+ View All Finalized +
+ + + + + + + + + + + + + {% load sponsors_manage %} + {% for sp in expiring_soon %} + + + + + + + + + {% endfor %} + +
SponsorPackageFeeEndsDays Left
+ + {{ sp.sponsor.name }} + + {% if sp.renewal %}Renewal{% endif %} + {% if sp.package %}{{ sp.package.name }}{% endif %}${{ sp.sponsorship_fee|default:"0"|floatformat:"0" }}{{ sp.end_date|date:"M j, Y" }} + {% days_until sp.end_date today as days_left %} + {% if days_left <= 30 %} + {{ days_left }}d + {% elif days_left <= 60 %} + {{ days_left }}d + {% else %} + {{ days_left }}d + {% endif %} + + Renewal
+
+ {% endif %} + + {% if recently_expired %} +
+
+

Recently Expired {{ recently_expired|length }}

+
+ + + + + + + + + + + + {% for sp in recently_expired %} + + + + + + + + {% endfor %} + +
SponsorPackageFeeEnded
+ + {{ sp.sponsor.name }} + + {% if sp.package %}{{ sp.package.name }}{% endif %}${{ sp.sponsorship_fee|default:"0"|floatformat:"0" }}{{ sp.end_date|date:"M j, Y" }}+ Renewal
+
+ {% endif %} + + {% if not needs_review and not pending_contracts and not expiring_soon and not recently_expired %} +
+ No sponsorships need attention for {{ selected_year }}. All caught up. +
+ {% endif %} + + + {% if unsponsored %} +
+
+

+ + No Sponsorship for {{ selected_year }} {{ unsponsored|length }} +

+
+
+ + + + + + + + + + {% for s in unsponsored %} + + + + + + {% endfor %} + +
SponsorLocation
{{ s.name }}{% if s.city %}{{ s.city }}{% endif %}{% if s.country %}, {{ s.country }}{% endif %}+ Sponsorship
+
+
+ {% endif %} + + +
+
+ +
+ {% for stat in program_stats %} + {{ stat.program.name }}: {{ stat.count }}{% if not forloop.last %} · {% endif %} + {% endfor %} +
+
+
+ +
+ {% for pkg in year_packages %} + {{ pkg.name }}: ${{ pkg.sponsorship_amount|floatformat:"0" }}{% if not forloop.last %} · {% endif %} + {% endfor %} +
+
+
+ + + {% if year_packages %} + + {% endif %} + + + {% for stat in program_stats %} + + {% endfor %} + + {% if not program_stats and not needs_review %} +
+
+

No benefits configured for {{ selected_year }}.

+ Create First Benefit + or + Clone From Another Year +
+ {% endif %} + + {% else %} +
+
📅
+

No sponsorship years found. Create benefits to get started.

+ Create Benefit +
+ {% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/finances.html b/apps/sponsors/templates/sponsors/manage/finances.html new file mode 100644 index 000000000..48709abcd --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/finances.html @@ -0,0 +1,299 @@ +{% extends "sponsors/manage/_base.html" %} +{% load humanize %} + +{% block page_title %}Finances | Sponsor Management{% endblock %} + +{% with active_tab="more" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Finances +
+{% endblock %} + +{% block manage_content %} + + + + + + +{% if years %} +
+ {% for year in years %} + {{ year }} + {% endfor %} +
+{% endif %} + +{% if selected_year %} + + +
+
+
${{ total_revenue|intcomma }}
+
Total Revenue
+
+
+
${{ finalized_revenue|intcomma }}
+
Finalized
+
+
+
${{ approved_revenue|intcomma }}
+
Approved (pending)
+
+
+
{{ total_count }}
+
Sponsorships · ${{ avg_deal|intcomma }} avg
+
+
+ + +
+
+

Revenue Trend

+ +
+
+

{{ selected_year }} Status Breakdown

+ +
+
+ + +
+
+

{{ selected_year }} Revenue by Package

+ +
+
+

Deal Count & Avg Size Trend

+ +
+
+ + +{% if sponsorships %} +
+
+

Sponsorship Detail {{ total_count }}

+
+ + + + + + + + + + + + + + {% for sp in sponsorships %} + + + + + + + + + + {% endfor %} + + + + + + + + +
SponsorPackageStatusFeeInternal ValueMarginPeriod
+ + {% if sp.sponsor %}{{ sp.sponsor.name }}{% else %}Unknown{% endif %} + + + {% if sp.package %}{{ sp.package.name }}{% else %}Custom{% endif %} + + {% if sp.status == 'approved' %}Approved + {% elif sp.status == 'finalized' %}Finalized + {% endif %} + + ${{ sp.sponsorship_fee|default:0|intcomma }} + + ${{ sp.internal_total|intcomma }} + + {% if sp.sponsorship_fee and sp.internal_total %} + {% widthratio sp.sponsorship_fee sp.internal_total 100 %}% + {% else %} + + {% endif %} + + {% if sp.start_date and sp.end_date %} + {{ sp.start_date|date:"M j" }} – {{ sp.end_date|date:"M j, Y" }} + {% else %}{% endif %} +
Total${{ total_revenue|intcomma }}
+
+{% endif %} + + + +{% else %} +
+
📈
+

No sponsorship data found.

+
+{% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/guide.html b/apps/sponsors/templates/sponsors/manage/guide.html new file mode 100644 index 000000000..dd3378d97 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/guide.html @@ -0,0 +1,393 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Guide | Sponsor Management{% endblock %} + +{% with active_tab="guide" %} + +{% block manage_content %} +
+ +

Sponsor Management Guide

+

For PSF sponsorship team members. Last updated March 2026.

+ + + + + +
+

Quick Start

+ +

+This is the PSF's internal sponsorship management UI. It lives at /sponsors/manage/ and handles the full lifecycle of sponsorships: reviewing applications, building custom packages, generating contracts, sending notifications, and tracking everything by year. +

+ +

+You need to be in the Sponsorship Admin group or have Django staff status to access it. If you're seeing a 403, ask another admin to add you to the group in Django Admin under /admin/auth/group/. +

+ +

+The navigation bar at the top gives you tabs for Dashboard, Sponsorships, Composer, Benefits, Packages, and Notifications. The "More" dropdown has Clone Year and this guide. +

+
+ + +
+

The Dashboard

+ +

+The Dashboard is your landing page. It shows summary cards for the selected year: how many sponsorships are applied, approved, finalized, and the total revenue. +

+ +

+Year pills across the top let you switch between years. The yellow dot marks the Active Year, which is the year used as the default throughout the UI (the Composer pulls benefits and packages from it, new sponsorships default to it, etc.). Change the active year from the Dashboard using the "Set Active Year" button, or go to /sponsors/manage/current-year/ directly. +

+ +
+Switching years on the dashboard is view-only. It doesn't change the active year. The active year is a site-wide setting that affects what the Composer and other pages default to. +
+
+ + +
+

Reviewing Sponsorships

+ +

+Every sponsorship moves through a lifecycle: +AppliedApprovedFinalized, or it gets Rejected at any point. +

+ +

+Go to Sponsorships to see the full list. Filter by status, year, or package using the filter bar. Click any row to open the detail page. +

+ +
+Actions on a Sponsorship + +

+Approve: Sets the sponsorship to Approved. You'll pick start and end dates (use the quick-date buttons to auto-fill common ranges like "Jan 1 - Dec 31"), confirm the fee, and select the package. This creates a draft contract automatically. +

+ +

+Approve with Signed Contract: Use this when a sponsor already sent back a signed contract before you entered it into the system. Upload the signed PDF during approval and it skips the entire contract generation/send flow. The sponsorship goes straight to Finalized. +

+ +

+Reject: Moves the sponsorship to Rejected. The sponsor gets an email notification. +

+ +

+Rollback: Returns an Approved or Rejected sponsorship back to Applied. Use this when something was approved by mistake or when a rejected sponsor comes back to renegotiate. +

+ +

+Lock / Unlock: Finalized sponsorships are locked by default. Unlock one to edit its benefits, fee, or dates after finalization. Lock it again when you're done. +

+
+ +
+The detail page also shows the sponsor's company info, contacts, benefits, contract status, and a notification history. Most actions branch off from this page. +
+
+ + +
+

The Composer

+ +

+The Composer is a 6-step wizard for building a sponsorship from scratch. It's the main way to create new sponsorships on behalf of sponsors. Start a new session from the Composer tab (it always resets when you click it). +

+ +

+Here's what each step does: +

+ +
+Step 1: Pick a Sponsor +

+Search for an existing sponsor by name (live search, shows top 50 results) or create a new one inline. If you landed here from a sponsor's detail page, the sponsor is pre-selected and you skip straight to step 2. +

+
+ +
+Step 2: Choose a Package +

+Pick a base package tier (Visionary, Sustainability, Maintaining, etc.) or go with "Custom / A La Carte" for a one-off deal. Packages are scoped to a year, so make sure you're looking at the right one. The year defaults to the Active Year. +

+
+ +
+Step 3: Customize Benefits +

+If you picked a package, its benefits are pre-selected and locked (shown in blue). You can add more benefits on top of those. If a benefit is marked "package-only," you'll see a warning before it's added. For custom/a-la-carte, nothing is pre-selected and everything is fair game. +

+

+Benefits are grouped by program (Foundation, PyCon, PyPI, etc.). The sidebar shows your current selection and a running total of internal values. +

+
+ +
+Step 4: Set Terms +

+Enter the sponsorship fee. If you picked a package, the fee pre-fills from the package amount. Use the "Match" button to set it to the total internal value of selected benefits, or "Adjust" to bump it up or down. Set start and end dates with the quick-fill buttons (common fiscal year ranges). Toggle renewal on or off and add internal notes. +

+
+ +
+Step 5: Review & Create +

+Shows a full summary: sponsor, package, benefits, fee, dates. Hit "Create Sponsorship" to save it. This creates the sponsorship record and a draft contract in one shot, then advances you to step 6. +

+
+ +
+Step 6: Contract & Send +

+This is the contract editor. You can edit the contract's sponsor info, contact details, benefits list, and legal clauses before sending. Preview or download the contract as PDF or DOCX. Compose an email with the contract attached and send it to the sponsor's contacts, or send it to an internal @pyfound.org address for review first. You can also click "Finish without sending" to save everything and deal with the email later. +

+
+ +
+The Composer stores its state in your session. If you navigate away mid-wizard, your progress is still there when you come back (unless you click Composer again, which resets it). Opening a second browser tab with the Composer will share the same session state, so don't run two wizards at once. +
+
+ + +
+

Contracts

+ +

+Contracts follow their own lifecycle: DraftAwaiting SignatureExecuted. +

+ +

+A draft contract is created automatically when you approve a sponsorship or complete step 5 of the Composer. From the sponsorship detail page, you can: +

+ +
    +
  • Preview / Download the contract as PDF or DOCX at any stage
  • +
  • Generate & Send to the sponsor's contacts (moves it to Awaiting Signature)
  • +
  • Send internally to any email address for review before sending to the sponsor
  • +
  • Execute by uploading the signed PDF (this finalizes the sponsorship too)
  • +
  • Nullify to void a contract that's no longer valid
  • +
  • Re-draft to create a new draft from a nullified contract
  • +
  • Regenerate to create a brand new contract when a sponsor changes level mid-cycle. The old contract is preserved as "Outdated" in the Contract History section. Unlock the sponsorship first, change the package/fee/benefits, then hit Regenerate
  • +
+ +
+Mid-cycle upgrades: When a sponsor upgrades (e.g. Contributing to Visionary), unlock the sponsorship, change the package and fee, then click Regenerate on the contract. This preserves the old contract in history and creates a fresh draft reflecting the new level. No need to delete anything or use the Django shell. +
+ +
+Contract content (sponsor info, benefits list, legal clauses) is editable in step 6 of the Composer or from the contract send page. The generated PDF/DOCX files labeled "Sent" are snapshots from when the contract was sent — editing the contract text after sending won't change those files. Regenerate to create a new version. +
+
+ + +
+

Benefits & Packages

+ +

+Benefits are the individual perks sponsors get: logo placement, blog posts, job listings, booth space, etc. Each benefit belongs to a program and a year. +

+ +

+When creating or editing a benefit, you set its name, description, internal value, program, year, and whether it's package-only. After saving, you can add feature configurations that control how the benefit behaves: +

+ +
    +
  • Logo Placement — where the sponsor's logo appears (footer, sidebar, downloads page, etc.)
  • +
  • Tiered Benefit — quantity varies by package level (e.g., 1 blog post for Maintaining, 3 for Visionary)
  • +
  • Required Asset — the sponsor must upload something (logo files, ad copy, etc.)
  • +
+ +

+Packages are the sponsorship tiers (Visionary, Sustainability, Maintaining, etc.). Each package has a name, year, fee amount, and a set of included benefits. Create and edit packages from the Packages tab. +

+ +

+Cloning a year: When setting up a new sponsorship year, go to Clone Year (under More in the nav). Pick a source year and a target year, and it copies all packages and benefits over. You can then tweak the new year's config without touching the previous year's data. +

+ +

+Syncing benefit changes: When you update a benefit's name, description, value, or feature configurations, those changes only affect new sponsorships. Existing sponsorships keep a snapshot of the benefit as it was when they were created. To push your changes to active sponsorships, open the benefit edit page and click "Sync to Sponsorships" in the "Sponsors Using This Benefit" section. You'll see a list of eligible sponsorships (non-expired, non-rejected) and can select which ones to update. +

+ +
+Cloning duplicates everything: packages, benefits, benefit-to-package associations, and feature configurations. It does not copy sponsorships or contracts. Review the cloned data after running it to make sure fees and benefit values are still correct for the new year. +
+
+ + +
+

Notifications

+ +

+Send emails to sponsor contacts from two places: the sponsorship detail page ("Notify" button) or in bulk from the sponsorships list. +

+ +

+The notification form lets you write a subject and body, pick which contact roles receive it (Primary, Administrative, Accounting, Manager), and preview the rendered email before sending. +

+ +

+Template variables get replaced with the sponsor's actual data when the email is sent. Use these in your subject or body: +

+ +
    +
  • {% templatetag openvariable %} sponsor_name {% templatetag closevariable %}
  • +
  • {% templatetag openvariable %} sponsorship_level {% templatetag closevariable %}
  • +
  • {% templatetag openvariable %} sponsorship_start_date {% templatetag closevariable %}
  • +
  • {% templatetag openvariable %} sponsorship_end_date {% templatetag closevariable %}
  • +
  • {% templatetag openvariable %} sponsorship_status {% templatetag closevariable %}
  • +
+ +

+Reusable templates: Go to Notifications in the nav to create, edit, and delete saved templates. These show up as quick-fill options in the notification form so you don't have to rewrite common emails. The History tab shows every notification sent, who it went to, and when. +

+
+ + +
+

Sponsors & Contacts

+ +

+A sponsor is the company entity. Browse all sponsors from More → Sponsors — search by name, see contact and sponsorship counts, and jump to edit or create a new sponsorship. You can also create new sponsors from the Composer (step 1) or from the direct create page. Edit a sponsor's company info (name, description, website, address, phone) from their sponsorship detail page by clicking "Edit Sponsor." +

+ +

+Each sponsor has one or more contacts with roles: +

+ +
    +
  • Primary — the main point of contact, used in contracts
  • +
  • Administrative — handles logistics and agreements
  • +
  • Accounting — invoicing and payments
  • +
  • Manager — internal sponsor-side manager
  • +
+ +

+Add, edit, and remove contacts from the sponsor card on the sponsorship detail page. The primary contact's info feeds into the contract's sponsor contact field, so keep it accurate. +

+
+ + +
+

Renewals & Expiry

+ +

+The dashboard automatically surfaces sponsorships that need renewal attention: +

+ +
    +
  • Expiring Soon — finalized sponsorships ending within 90 days. Shows a countdown with color-coded urgency: red for 30 days or less, gold for 31-60 days, gray for 61-90 days
  • +
  • Recently Expired — finalized sponsorships that have already ended and haven't been renewed (no overlapping replacement sponsorship). These are sponsors you should reach out to
  • +
+ +

+Both sections include a "+ Renewal" button that launches the Composer with the sponsor pre-selected and the renewal flag pre-checked. This means the contract will use the renewal template instead of the new-sponsorship template. +

+ +

+You'll also see expiry indicators on the sponsorship list — finalized sponsorships show days-remaining tags in the Period column. On a sponsorship's detail page, Expiring Soon or Expired tags appear in the header, and a "+ Renewal" button is available for any finalized sponsorship. +

+ +
+When a renewal sponsorship's contract is executed, the system automatically links the old sponsorship via the "overlapped_by" field. This hides the old sponsorship from public sponsor listings while keeping it in the admin for historical records. +
+
+ + +
+

Assets

+ +

+The Asset Browser (under More) shows all sponsor-submitted assets grouped by company. Each company section has a submitted/total badge: +

+ +
    +
  • green — all assets submitted
  • +
  • orange — partially submitted
  • +
  • red — nothing submitted
  • +
+ +

+Filter by asset type (text, image, file, response), related object (sponsor vs sponsorship), submission status, or search by internal name. Assets from expired or rejected sponsorships are automatically hidden. Companies with missing assets show a "Send Reminder" button that links to the notification page for that sponsorship. +

+
+ + +
+

Finances

+ +

+The Finances (under More) gives a financial overview of sponsorship income by year. It includes: +

+ +
    +
  • Summary cards — total revenue, finalized revenue, approved (pending), sponsorship count with average deal size
  • +
  • Revenue by package — horizontal bar chart showing income by package tier with deal counts
  • +
  • Year-over-year — compare total revenue across all years at a glance
  • +
  • Sponsorship detail table — every sponsorship for the selected year with fee, internal value, margin percentage, and period
  • +
+ +

+Only approved and finalized sponsorships are counted toward revenue. Applied and rejected sponsorships are excluded. +

+
+ + +
+

Bulk Actions & Export

+ +

+On the Sponsorships page, select rows using the checkboxes and pick an action from the bulk action dropdown: +

+ +
    +
  • Export CSV — download selected sponsorships as a CSV file. You can also export the full filtered list (without selecting rows) using the "Export CSV" button in the filter bar
  • +
  • Export Assets (ZIP) — download all submitted assets for selected sponsorships in a single ZIP file. You can also export assets for a single sponsorship from its detail page
  • +
  • Bulk Notify — send a notification to all contacts of the selected sponsorships at once. Same form as single notify, but it fans out to every selected sponsor
  • +
+
+ + +
+

What's Still in Django Admin

+ +

+Some things haven't been moved into this UI yet. For these, go to /admin/sponsors/: +

+ +
    +
  • Benefit ordering — drag-to-reorder benefits within a program. The manage UI shows them in order but doesn't let you rearrange
  • +
  • Benefit conflicts — configuring which benefits are mutually exclusive (M2M relationship in the admin)
  • +
  • Sponsorship program management — adding or editing programs (Foundation, PyCon, PyPI, etc.)
  • +
  • Advanced import/export — django-import-export for bulk data operations
  • +
+ +
+If you find yourself doing something repeatedly in Django Admin that should be in this UI, file a feature request. The goal is to move everything here eventually. +
+
+ +
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/legal_clause_confirm_delete.html b/apps/sponsors/templates/sponsors/manage/legal_clause_confirm_delete.html new file mode 100644 index 000000000..b17b641d5 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/legal_clause_confirm_delete.html @@ -0,0 +1,44 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Delete {{ object.internal_name }} | Sponsor Management{% endblock %} + +{% with active_tab="more" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Legal Clauses + / + Delete +
+{% endblock %} + +{% block manage_content %} +
+

+ Delete Legal Clause +

+ +
+ Are you sure you want to delete "{{ object.internal_name }}"? + {% with bc=object.benefits.count %} + {% if bc %}This clause is used by {{ bc }} benefit{{ bc|pluralize }}. Deleting it will remove it from those benefits' contracts.{% endif %} + {% endwith %} +
+ +
+ {{ object.clause|truncatechars:300 }} +
+ +
+ {% csrf_token %} +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/legal_clause_form.html b/apps/sponsors/templates/sponsors/manage/legal_clause_form.html new file mode 100644 index 000000000..ac491730f --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/legal_clause_form.html @@ -0,0 +1,85 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}New Legal Clause{% else %}Edit {{ object.internal_name }}{% endif %} | Sponsor Management{% endblock %} + +{% with active_tab="more" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Legal Clauses + / + {% if is_create %}New{% else %}{{ object.internal_name }}{% endif %} +
+{% endblock %} + +{% block manage_content %} +
+

+ {% if is_create %}Create Legal Clause{% else %}Edit Legal Clause{% endif %} +

+ {% if not is_create and benefit_count %} +

+ Used by {{ benefit_count }} benefit{{ benefit_count|pluralize }} +

+ {% else %} +

+ {% if is_create %}Legal clause text that gets pulled into contracts via benefit associations.{% endif %} +

+ {% endif %} + + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ Clause Details + +
+ + {{ form.internal_name }} + Friendly name for PSF staff reference (not shown in contracts). + {% if form.internal_name.errors %}
{{ form.internal_name.errors.0 }}
{% endif %} +
+ +
+ + {{ form.clause }} + The legal text that appears in contracts. Rendered as markdown. + {% if form.clause.errors %}
{{ form.clause.errors.0 }}
{% endif %} +
+ +
+ + {{ form.notes }} + {% if form.notes.errors %}
{{ form.notes.errors.0 }}
{% endif %} +
+
+ +
+ + Cancel + {% if not is_create %} + Delete + {% endif %} +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/legal_clause_list.html b/apps/sponsors/templates/sponsors/manage/legal_clause_list.html new file mode 100644 index 000000000..0a54142a3 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/legal_clause_list.html @@ -0,0 +1,83 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Legal Clauses | Sponsor Management{% endblock %} + +{% with active_tab="more" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Legal Clauses +
+{% endblock %} + +{% block topbar_actions %} + + New Clause +{% endblock %} + +{% block manage_content %} + {% if clauses %} + + + + + + + + + + + + + {% for clause in clauses %} + + + + + + + + + {% endfor %} + +
OrderInternal NameClause TextBenefitsMoveActions
{{ clause.order }} + + {{ clause.internal_name }} + + + {{ clause.clause|truncatechars:120 }} + + {% with bc=clause.benefits.count %} + {% if bc %}{{ bc }}{% else %}{% endif %} + {% endwith %} + +
+ {% if not forloop.first %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% if not forloop.last %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+ Edit +
+ {% else %} +
+
§
+

No legal clauses defined yet.

+ Create First Clause +
+ {% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/notification_history.html b/apps/sponsors/templates/sponsors/manage/notification_history.html new file mode 100644 index 000000000..b793c02be --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/notification_history.html @@ -0,0 +1,115 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Notification History | Sponsorship Management{% endblock %} + +{% with active_tab="notifications" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Notification History +
+{% endblock %} + +{% block manage_content %} + + + +
+
+

Sent Notifications {{ paginator.count|default:logs|length }}

+
+ + + {% if filter_search %} + Clear + {% endif %} +
+
+ + {% if logs %} + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + + + + {% endfor %} + +
SentSponsorSubjectRecipientsContact TypesSent By
{{ log.sent_at|date:"M j, Y H:i" }} + {% if log.sponsorship.sponsor %} + {{ log.sponsorship.sponsor.name }} + {% else %}Unknown{% endif %} + {{ log.subject|truncatewords:12 }}{{ log.recipients|truncatechars:40 }} + {% for ct in log.contact_type_list %} + {{ ct }} + {% endfor %} + + {% if log.sent_by %}{{ log.sent_by.get_full_name|default:log.sent_by.email }}{% else %}system{% endif %} +
+ + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} + + {% else %} +
+

{% if filter_search %}No notifications match your search.{% else %}No notifications have been sent yet.{% endif %}

+
+ {% endif %} +
+ + +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/notification_template_confirm_delete.html b/apps/sponsors/templates/sponsors/manage/notification_template_confirm_delete.html new file mode 100644 index 000000000..fbecca723 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/notification_template_confirm_delete.html @@ -0,0 +1,31 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Delete Template | Sponsorship Management{% endblock %} + +{% with active_tab="notifications" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Notification Templates + / + Delete +
+{% endblock %} + +{% block manage_content %} +
+

Delete Notification Template

+

Are you sure you want to delete "{{ object.internal_name }}"? This action cannot be undone.

+
+ {% csrf_token %} +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/notification_template_form.html b/apps/sponsors/templates/sponsors/manage/notification_template_form.html new file mode 100644 index 000000000..95e9d0a43 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/notification_template_form.html @@ -0,0 +1,94 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}New{% else %}Edit{% endif %} Notification Template | Sponsorship Management{% endblock %} + +{% with active_tab="notifications" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Notification Templates + / + {% if is_create %}New Template{% else %}{{ object.internal_name }}{% endif %} +
+{% endblock %} + +{% block manage_content %} +
+

+ {% if is_create %}Create Notification Template{% else %}Edit: {{ object.internal_name }}{% endif %} +

+ + {% if form.non_field_errors %} +
+
    {% for e in form.non_field_errors %}
  • {{ e }}
  • {% endfor %}
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ Template Details + +
+ + {{ form.internal_name }} + {% if form.internal_name.errors %}
{{ form.internal_name.errors }}
{% endif %} +
+ +
+ + {{ form.subject }} + {% if form.subject.errors %}
{{ form.subject.errors }}
{% endif %} +
+ +
+ + {{ form.content }} + {% if form.content.errors %}
{{ form.content.errors }}
{% endif %} +
+
+ + +
+ Template Variables +

Click to copy, then paste into subject or content fields.

+
+ {% for var in template_vars %} + + {% endfor %} +
+
+ + + +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/notification_template_list.html b/apps/sponsors/templates/sponsors/manage/notification_template_list.html new file mode 100644 index 000000000..dd9cc4f08 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/notification_template_list.html @@ -0,0 +1,61 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Notification Templates | Sponsorship Management{% endblock %} + +{% with active_tab="notifications" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Notifications +
+{% endblock %} + +{% block manage_content %} + + + +
+
+

Notification Templates {{ templates|length }}

+ New Template +
+ + {% if templates %} + + + + + + + + + + + {% for tpl in templates %} + + + + + + + {% endfor %} + +
NameSubjectUpdatedActions
{{ tpl.internal_name }}{{ tpl.subject|truncatewords:10 }}{{ tpl.updated_at|date:"M j, Y H:i" }} + Edit + Delete +
+ {% else %} +
+

No notification templates yet.

+ Create your first template +
+ {% endif %} +
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/package_confirm_delete.html b/apps/sponsors/templates/sponsors/manage/package_confirm_delete.html new file mode 100644 index 000000000..0051c4380 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/package_confirm_delete.html @@ -0,0 +1,46 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Delete {{ object.name }} | Sponsor Management{% endblock %} + +{% with active_tab="packages" %} + +{% block topbar_actions %}{% endblock %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Packages + / + {{ object.name }} + / + Delete +
+{% endblock %} + +{% block manage_content %} +
+

Delete Package

+

+ Are you sure you want to delete {{ object.name }} ({{ object.year }})? +

+ + {% with bc=object.benefits.count %} + {% if bc %} +
+ This package has {{ bc }} benefit{{ bc|pluralize }} assigned to it. +
+ {% endif %} + {% endwith %} + +
+ {% csrf_token %} +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/package_form.html b/apps/sponsors/templates/sponsors/manage/package_form.html new file mode 100644 index 000000000..149552763 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/package_form.html @@ -0,0 +1,121 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}New Package{% else %}Edit {{ object.name }}{% endif %} | Sponsor Management{% endblock %} + +{% with active_tab="packages" %} + +{% block topbar_actions %}{% endblock %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Packages + / + {% if is_create %}New Package{% else %}{{ object.name }}{% endif %} +
+{% endblock %} + +{% block manage_content %} +
+

+ {% if is_create %}Create New Package{% else %}Edit Package{% endif %} +

+ {% if not is_create %} +

+ {{ object.name }} ({{ object.year }}) + {% if benefit_count %}· {{ benefit_count }} benefit{{ benefit_count|pluralize }}{% endif %} +

+ {% else %} +

Define a sponsorship tier/level.

+ {% endif %} + + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ Package Details + +
+
+ + {{ form.name }} +
+
+ + {{ form.slug }} +
+
+ +
+
+ + {{ form.sponsorship_amount }} +
+
+ + {{ form.year }} +
+
+ +
+ + {{ form.logo_dimension }} + Internal value used to control logos dimensions at sponsors page. +
+
+ +
+ Options +
+ + +
+
+ +
+ + Cancel + {% if not is_create %} + Delete + {% endif %} +
+
+ {% if is_create %} + + {% endif %} +
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/package_list.html b/apps/sponsors/templates/sponsors/manage/package_list.html new file mode 100644 index 000000000..804b08e85 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/package_list.html @@ -0,0 +1,66 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Packages | Sponsor Management{% endblock %} + +{% with active_tab="packages" %} + +{% block topbar_actions %} + + New Package +{% endblock %} + +{% block manage_content %} + + {% if years %} +
+ All + {% for year in years %} + {{ year }} + {% endfor %} +
+ {% endif %} + + {% for year, pkgs in packages_by_year.items %} +
+
+

{{ year|default:"No Year" }} {{ pkgs|length }}

+ + Add +
+ + + + + + + + + + + + + + {% for pkg in pkgs %} + + + + + + + + + + {% endfor %} + +
PackageAmountSlugAdvertisedA La CarteBenefits
+ {{ pkg.name }} + ${{ pkg.sponsorship_amount|floatformat:"0" }}{{ pkg.slug }}{% if pkg.advertise %}Yes{% else %}No{% endif %}{% if pkg.allow_a_la_carte %}Yes{% else %}No{% endif %}{{ pkg.benefits.count }}Edit
+
+ {% empty %} +
+
📦
+

No packages found.

+ Create First Package +
+ {% endfor %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsor_edit.html b/apps/sponsors/templates/sponsors/manage/sponsor_edit.html new file mode 100644 index 000000000..428928ec3 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsor_edit.html @@ -0,0 +1,119 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{% if is_create %}New Sponsor{% else %}Edit {{ object.name }}{% endif %} | Sponsor Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {% if from_sponsorship %} + {{ object.name }} + / + {% endif %} + {% if is_create %}New Sponsor{% else %}Edit Sponsor{% endif %} +
+{% endblock %} + +{% block manage_content %} +
+
+

{% if is_create %}New Sponsor{% else %}Edit Sponsor: {{ object.name }}{% endif %}

+ {% if not is_create and object %} + + {% endif %} +
+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %}
  • {{ field }}: {{ error }}
  • {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} + {% if from_sponsorship %}{% endif %} + +
+ Company Info + +
+ + {{ form.name }} +
+ +
+ + {{ form.description }} +
+ +
+
+ + {{ form.landing_page_url }} +
+
+ + {{ form.primary_phone }} +
+
+
+ +
+ Address + +
+ + {{ form.mailing_address_line_1 }} +
+
+ + {{ form.mailing_address_line_2 }} +
+
+
+ + {{ form.city }} +
+
+ + {{ form.state }} +
+
+
+
+ + {{ form.postal_code }} +
+
+ + {{ form.country }} +
+
+
+ +
+ + {% if from_sponsorship %} + Cancel + {% else %} + Cancel + {% endif %} +
+
+ +
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsor_list.html b/apps/sponsors/templates/sponsors/manage/sponsor_list.html new file mode 100644 index 000000000..510e9bd97 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsor_list.html @@ -0,0 +1,111 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sponsors | Sponsor Management{% endblock %} + +{% with active_tab="more" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsors +
+{% endblock %} + +{% block topbar_actions %} + + New Sponsor +{% endblock %} + +{% block manage_content %} + +
+
+
+ +
+
+ + {% if filter_search %} + Clear + {% endif %} +
+
+ {{ page_obj.paginator.count }} sponsor{{ page_obj.paginator.count|pluralize }} +
+
+
+ + {% if sponsors %} + + + + + + + + + + + + + {% for sponsor in sponsors %} + + + + + + + + + {% endfor %} + +
CompanyLocationContactsSponsorshipsWebsiteActions
+ + {{ sponsor.name }} + + + {% if sponsor.city %}{{ sponsor.city }}{% endif %}{% if sponsor.city and sponsor.country %}, {% endif %}{% if sponsor.country %}{{ sponsor.country }}{% endif %} + + {% if sponsor.contact_count %} + {{ sponsor.contact_count }} + {% else %} + + {% endif %} + + {% if sponsor.sponsorship_count %} + {{ sponsor.sponsorship_count }} + {% else %} + + {% endif %} + + {% if sponsor.landing_page_url %} + {{ sponsor.landing_page_url|truncatechars:30 }} + {% else %} + + {% endif %} + + Edit + + Sponsorship +
+ + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} + + {% else %} +
+
🏢
+

{% if filter_search %}No sponsors match "{{ filter_search }}"{% else %}No sponsors found{% endif %}

+
+ {% endif %} +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_approve.html b/apps/sponsors/templates/sponsors/manage/sponsorship_approve.html new file mode 100644 index 000000000..cd9058ffd --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_approve.html @@ -0,0 +1,204 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Approve {{ object.sponsor.name }} | Sponsor Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {{ object.sponsor.name }} + / + Approve +
+{% endblock %} + +{% block manage_content %} +
+

+ Approve Sponsorship +

+

+ {{ object.sponsor.name }} · {{ object.level_name }} · ${{ object.sponsorship_fee|default:"0"|floatformat:"0" }} + {% if previous_effective %} +
Previous effective date: {{ previous_effective }} + {% endif %} +

+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% for field, errors in form.errors.items %} + {% if field != '__all__' %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + +
+ Approving will create a draft contract. The sponsor will not be emailed until you send the contract. +
+ +
+ {% csrf_token %} + +
+ Approval Details + +
+
+ + {{ form.start_date }} +
+ {% if form.start_date.errors %}
{{ form.start_date.errors.0 }}
{% endif %} +
+
+ + {{ form.end_date }} +
+ {% if form.end_date.errors %}
{{ form.end_date.errors.0 }}
{% endif %} +
+
+ + + + +
+
+ + {{ form.package }} +
+
+ + {{ form.sponsorship_fee }} +
+
+ + + {% if form.renewal.help_text %}{{ form.renewal.help_text }}{% endif %} +
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_approve_signed.html b/apps/sponsors/templates/sponsors/manage/sponsorship_approve_signed.html new file mode 100644 index 000000000..6c004afdf --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_approve_signed.html @@ -0,0 +1,210 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Approve with Signed Contract | {{ sponsorship.sponsor.name }}{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {{ sponsorship.sponsor.name }} + / + Approve with Signed Contract +
+{% endblock %} + +{% block manage_content %} +
+

+ Approve with Signed Contract +

+

+ {{ sponsorship.sponsor.name }} · {{ sponsorship.level_name }} · ${{ sponsorship.sponsorship_fee|default:"0"|floatformat:"0" }} + {% if previous_effective %} +
Previous effective date: {{ previous_effective }} + {% endif %} +

+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% for field, errors in form.errors.items %} + {% if field != '__all__' %} + {% for error in errors %} +
  • {{ field }}: {{ error }}
  • + {% endfor %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + +
+ This will approve the sponsorship and immediately execute the contract using the uploaded signed document. The generate/send flow will be skipped. +
+ +
+ {% csrf_token %} + +
+ Approval Details + +
+
+ + {{ form.start_date }} +
+ {% if form.start_date.errors %}
{{ form.start_date.errors.0 }}
{% endif %} +
+
+ + {{ form.end_date }} +
+ {% if form.end_date.errors %}
{{ form.end_date.errors.0 }}
{% endif %} +
+
+ + + + +
+
+ + {{ form.package }} +
+
+ + {{ form.sponsorship_fee }} +
+
+ + + {% if form.renewal.help_text %}{{ form.renewal.help_text }}{% endif %} +
+ +
+ Signed Contract +
+ + {{ form.signed_contract }} + {% if form.signed_contract.help_text %}{{ form.signed_contract.help_text }}{% endif %} + {% if form.signed_contract.errors %}
{{ form.signed_contract.errors.0 }}
{% endif %} +
+
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html b/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html new file mode 100644 index 000000000..618fe8e74 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html @@ -0,0 +1,684 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}{{ sponsorship.sponsor.name }} | Sponsorship Review{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {{ sponsorship.sponsor.name }} +
+{% endblock %} + +{% block manage_content %} + + + +
+
+

{{ sponsorship.sponsor.name }}

+
+ {{ sponsorship.level_name }} · {{ sponsorship.year }} + · + {% if sponsorship.status == 'applied' %}Applied + {% elif sponsorship.status == 'approved' %}Approved + {% elif sponsorship.status == 'finalized' %}Finalized + {% elif sponsorship.status == 'rejected' %}Rejected + {% endif %} + {% if not sponsorship.locked and sponsorship.status != 'applied' %} + Unlocked + {% endif %} + {% if sponsorship.for_modified_package %}Custom Package{% endif %} + {% if sponsorship.renewal %}Renewal{% endif %} + {% if is_expiring_soon %}Expiring Soon{% endif %} + {% if is_expired %}Expired{% endif %} +
+
+
+ {% if can_approve %} + Approve + Approve with Signed Contract + {% endif %} + {% if can_reject %} + + {% endif %} + {% if can_rollback and sponsorship.status != 'applied' %} + + {% endif %} + {% if can_create_renewal %} + + Renewal + {% endif %} + Notify + CSV + Assets + {% if sponsorship.sponsor %}Sponsor View{% endif %} + {% if can_lock %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if can_unlock %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+ + +{% if can_reject %} +
+
+ Reject this sponsorship? +
+
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+ +
+
+{% endif %} + + +{% if can_rollback and sponsorship.status != 'applied' %} +
+
+
+ Roll back to editing? +

Status will change to Applied. Any draft contract will be deleted.

+
+
+ {% csrf_token %} + + +
+
+
+{% endif %} + + +
+
+
Applied
+
{% if sponsorship.applied_on %}{{ sponsorship.applied_on|date:"M j, Y" }}{% endif %}
+
+ {% if sponsorship.status == 'rejected' %} +
+
Rejected
+
{% if sponsorship.rejected_on %}{{ sponsorship.rejected_on|date:"M j, Y" }}{% endif %}
+
+ {% else %} +
+
Approved
+
{% if sponsorship.approved_on %}{{ sponsorship.approved_on|date:"M j, Y" }}{% endif %}
+
+
+
Finalized
+
{% if sponsorship.finalized_on %}{{ sponsorship.finalized_on|date:"M j, Y" }}{% endif %}
+
+ {% endif %} +
+ + +
+ +
+

Sponsorship + {% if sponsorship.open_for_editing %}Edit{% endif %} +

+
+
Package / Level
+
{{ sponsorship.level_name|default:"Custom" }}
+
+
+
Fee
+
${{ sponsorship.sponsorship_fee|default:"0"|floatformat:"0" }}
+
+
+
Estimated Internal Value
+
${{ sponsorship.estimated_cost|floatformat:"0" }}
+
+
+
Year
+
{{ sponsorship.year|default:"—" }}
+
+ {% if sponsorship.start_date %} +
+
Period
+
{{ sponsorship.start_date|date:"M j, Y" }} – {{ sponsorship.end_date|date:"M j, Y" }}
+
+ {% endif %} + {% if contract %} +
+
Contract
+
+ + {{ contract.get_status_display }} + +
+
+ {% endif %} + {% if sponsorship.submited_by %} +
+
Submitted By
+
{{ sponsorship.submited_by.get_full_name|default:sponsorship.submited_by.email }}
+
+ {% endif %} +
+ + +
+

Sponsor + {% if sponsorship.sponsor %}Edit{% endif %} +

+ {% if sponsorship.sponsor %} + {% with s=sponsorship.sponsor %} +
+
Name
+
{{ s.name }}
+
+ {% if s.description %} +
+
Description
+
{{ s.description|truncatewords:40 }}
+
+ {% endif %} + {% if s.primary_phone %} +
+
Phone
+
{{ s.primary_phone }}
+
+ {% endif %} + {% if s.mailing_address_line_1 %} +
+
Address
+
{{ s.full_address }}
+
+ {% endif %} + {% endwith %} + {% endif %} + +
+
+
Contacts
+ {% if sponsorship.sponsor %} + + Add + {% endif %} +
+ {% for contact in contacts %} +
+
+ {{ contact.name }} + {{ contact.email }} + {% if contact.primary %}Primary{% endif %} + {% if contact.administrative %}Admin{% endif %} + {% if contact.accounting %}Billing{% endif %} + {% if contact.manager %}Manager{% endif %} +
+
+ Edit +
+ {% csrf_token %} + + +
+
+
+ {% empty %} +
No contacts.
+ {% endfor %} +
+
+
+ + +{% if sponsorship.sponsorship_fee or program_breakdown %} +
+
+

+ + Financial Breakdown +

+
+
+
+ +
+
+
+
Sponsorship Fee
+
${{ sponsorship.sponsorship_fee|default:"0"|floatformat:"0" }}
+
+
+
Internal Value
+
${{ sponsorship.estimated_cost|floatformat:"0" }}
+
+
+ {% if sponsorship.sponsorship_fee and sponsorship.estimated_cost %} +
+
+
+
+ Internal value covers {% widthratio sponsorship.estimated_cost sponsorship.sponsorship_fee 100 %}% of fee +
+ {% endif %} +
+ + +
+
Value by Program
+ {% for pv in program_breakdown %} +
+
+ {{ pv.name }} + ${{ pv.total|floatformat:"0" }} +
+
+
+
+
+ {% endfor %} +
+
+
+
+{% endif %} + + +{% if contract or sponsorship.status != 'applied' %} +
+
+

Contract

+
+ {% if contract %} +
+
+
+ Revision {{ contract.revision }} + · + {% if contract.status == 'draft' %}Draft + {% elif contract.status == 'awaiting signature' %}Awaiting Signature + {% elif contract.status == 'executed' %}Executed + {% elif contract.status == 'nullified' %}Nullified + {% elif contract.status == 'outdated' %}Outdated + {% endif %} + {% if contract.sent_on %} + Sent {{ contract.sent_on|date:"M j, Y" }} + {% endif %} +
+
+ {% if 'awaiting signature' in contract.next_status %} + Generate + {% endif %} + {% if contract.status == 'awaiting signature' %} + Send + {% endif %} + {% if 'executed' in contract.next_status %} + Upload Signed + {% endif %} + {% if 'nullified' in contract.next_status %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if 'draft' in contract.next_status and contract.status == 'nullified' %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if sponsorship.open_for_editing %}Regenerate{% endif %} +
+
+ + +
+ + + PDF Preview + + + DOCX Preview + + + {% if contract.document %} + + PDF Sent + + {% endif %} + {% if contract.document_docx %} + + DOCX Sent + + {% endif %} + + {% if contract.signed_document %} + + PDF Signed Contract + + {% endif %} +
+ + + +
+ + + {% if historical_contracts %} +
+
+ Contract History {{ historical_contracts|length }} +
+ +
+ {% endif %} + + {% else %} +
+

No contract on file for this sponsorship.

+ {% if sponsorship.status == 'approved' or sponsorship.status == 'finalized' %} +

Contract may have been managed outside this system or before this tool was available.

+ {% endif %} +
+ {% endif %} +
+{% endif %} + + +{% if required_assets %} +
+
+

Required Assets {{ assets_submitted }}/{{ assets_total }}

+ {% if assets_submitted > 0 %} + Export Assets + {% endif %} +
+ + + + + + + + + + + + {% for asset in required_assets %} + + + + + + + + {% endfor %} + +
TypeBenefitLabelDue DateStatus
{{ asset|title }}{{ asset.sponsor_benefit.name }}{{ asset.label|default:"--" }} + {% if asset.due_date %}{{ asset.due_date|date:"M j, Y" }}{% else %}--{% endif %} + + + {% if asset.value and asset.value != None %}Submitted{% else %}Pending{% endif %} + +
+
+{% endif %} + + +
+
+

Benefits {{ benefits|length }}

+
+ {% if benefits %} + + + + + + + + {% if sponsorship.open_for_editing %}{% endif %} + + + + {% for b in benefits %} + + + + + + {% if sponsorship.open_for_editing %} + + {% endif %} + + {% endfor %} + +
BenefitProgramValueSource
{% if b.sponsorship_benefit %}{{ b.name }}{% else %}{{ b.name }}{% endif %}{% if b.program %}{{ b.program_name }}{% else %}{{ b.program_name }}{% endif %} + {% if b.benefit_internal_value %}${{ b.benefit_internal_value|floatformat:"0" }}{% else %}{% endif %} + + {% if b.added_by_user %}Added + {% elif b.standalone %}Standalone + {% else %}Package + {% endif %} + +
+ {% csrf_token %} + +
+
+ {% else %} +

No benefits attached.

+ {% endif %} + + + {% if add_benefit_form %} +
+
+ {% csrf_token %} +
+ + + + +
+ +
+ +
+ {% endif %} +
+ + +
+
+

Communications {{ notification_logs|length }}

+ Send Notification +
+ {% if notification_logs %} + + + + + + + + + + + {% for log in notification_logs %} + + + + + + + + + + {% endfor %} + +
DateSubjectRecipientsSent By
{{ log.sent_at|date:"M j, Y H:i" }}{{ log.subject|truncatewords:12 }}{{ log.recipients|truncatechars:50 }} + {% if log.sent_by %}{{ log.sent_by.get_full_name|default:log.sent_by.email }}{% else %}system{% endif %} +
+ {% else %} +

No notifications sent yet.

+ {% endif %} +
+ + + +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_edit.html b/apps/sponsors/templates/sponsors/manage/sponsorship_edit.html new file mode 100644 index 000000000..1808cb98d --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_edit.html @@ -0,0 +1,67 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Edit Sponsorship | {{ object.sponsor.name }}{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} + +{% endblock %} + +{% block manage_content %} +
+

Edit Sponsorship

+

+ {{ object.sponsor.name }} · {{ object.get_status_display }} +

+ + {% if form.errors %} +
+ Please correct the errors below: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %}
  • {{ field }}: {{ error }}
  • {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} +
+ Sponsorship Details + +
+ + {{ form.package }} +
+ +
+
+ + {{ form.sponsorship_fee }} +
+
+ + {{ form.year }} +
+
+
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_list.html b/apps/sponsors/templates/sponsors/manage/sponsorship_list.html new file mode 100644 index 000000000..66552a697 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_list.html @@ -0,0 +1,238 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sponsorships | Sponsor Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_content %} + {% load sponsors_manage %} + +
+
+ {% if filter_status %}{% endif %} + + +
+ +
+
+ +
+
+ + {% if filter_year or filter_search or filter_status %} + Clear + {% endif %} +
+ +
+
+ + {% if sponsorships %} +
+ {% csrf_token %} + +
+ + + 0 selected +
+ + + + + + + + + + + + + + + + + {% for sp in sponsorships %} + + + + + + + + + + + + {% endfor %} + +
+ + SponsorPackageFeeYearStatusAppliedPeriod
+ + + + {% if sp.sponsor %}{{ sp.sponsor.name }}{% else %}Unknown{% endif %} + + {% if sp.for_modified_package %}Custom{% endif %} + {% if sp.renewal %}Renewal{% endif %} + + {% if sp.package %}{{ sp.package.name }}{% else %}{% endif %} + + {% if sp.sponsorship_fee %}${{ sp.sponsorship_fee|floatformat:"0" }}{% else %}{% endif %} + {{ sp.year|default:"—" }} + {% if sp.status == 'applied' %}Applied + {% elif sp.status == 'approved' %}Approved + {% elif sp.status == 'finalized' %}Finalized + {% elif sp.status == 'rejected' %}Rejected + {% endif %} + {% if not sp.locked and sp.status != 'applied' %} + 🔓 + {% endif %} + {{ sp.applied_on|date:"M j, Y" }} + {% if sp.start_date and sp.end_date %} + {{ sp.start_date|date:"M j" }} – {{ sp.end_date|date:"M j, Y" }} + {% if sp.status == 'finalized' and sp.end_date %} + {% days_until sp.end_date today as days_left %} + {% if days_left < 0 %}Expired + {% elif days_left <= 30 %}{{ days_left }}d left + {% elif days_left <= 90 %}{{ days_left }}d left + {% endif %} + {% endif %} + {% else %}{% endif %} + + Review +
+
+ + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} + + {% else %} +
+
📋
+

No sponsorships match your filters.

+
+ {% endif %} + + +{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_notify.html b/apps/sponsors/templates/sponsors/manage/sponsorship_notify.html new file mode 100644 index 000000000..50f82f58b --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_notify.html @@ -0,0 +1,127 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Notify {{ sponsorship.sponsor.name }} | Sponsorship Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {{ sponsorship.sponsor.name }} + / + Send Notification +
+{% endblock %} + +{% block manage_content %} + + +
+

Send Notification to {{ sponsorship.sponsor.name }}

+ + {% if form.non_field_errors %} +
+
    {% for e in form.non_field_errors %}
  • {{ e }}
  • {% endfor %}
+
+ {% endif %} + + {% if email_preview %} +
+ Email Preview +
+
Subject
+
{{ email_preview.subject }}
+
+
+
Body
+
{{ email_preview.body }}
+
+
+
Recipients
+
{{ email_preview.to|join:", " }}
+
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ Contact Types +
+
+ {% for checkbox in form.contact_types %} + + {% endfor %} +
+ {% if form.contact_types.errors %} +
{{ form.contact_types.errors }}
+ {% endif %} +
+
+ +
+ Message +
+ + {{ form.notification }} + {% if form.notification.help_text %}{{ form.notification.help_text }}{% endif %} +
+ +
-- or custom content --
+ +
+ + {{ form.subject }} +
+
+ + {{ form.content }} +
+
+ + +
+ Template Variables +

Click to copy, then paste into subject or content.

+
+ {% for var in template_vars %} + + {% endfor %} +
+
+ +
+ + + Cancel +
+
+
+{% endblock %} + +{% endwith %} diff --git a/apps/sponsors/templatetags/sponsors_manage.py b/apps/sponsors/templatetags/sponsors_manage.py new file mode 100644 index 000000000..8a4ee291a --- /dev/null +++ b/apps/sponsors/templatetags/sponsors_manage.py @@ -0,0 +1,13 @@ +"""Template tags for the sponsor management UI.""" + +from django import template + +register = template.Library() + + +@register.simple_tag +def days_until(target_date, reference_date): + """Return the number of days between reference_date and target_date.""" + if not target_date or not reference_date: + return 0 + return (target_date - reference_date).days diff --git a/apps/sponsors/urls.py b/apps/sponsors/urls.py index 508162eda..f675d4da0 100644 --- a/apps/sponsors/urls.py +++ b/apps/sponsors/urls.py @@ -1,6 +1,6 @@ """URL configuration for the sponsors app.""" -from django.urls import path +from django.urls import include, path from apps.sponsors import views @@ -15,4 +15,6 @@ views.SelectSponsorshipApplicationBenefitsView.as_view(), name="select_sponsorship_application_benefits", ), + # Staff-only management UI + path("manage/", include("apps.sponsors.manage.urls")), ] diff --git a/apps/sponsors/use_cases.py b/apps/sponsors/use_cases.py index 3e1e48c27..f4976c12e 100644 --- a/apps/sponsors/use_cases.py +++ b/apps/sponsors/use_cases.py @@ -10,6 +10,7 @@ SponsorEmailNotificationTemplate, Sponsorship, SponsorshipBenefit, + SponsorshipNotificationLog, SponsorshipPackage, ) @@ -187,6 +188,7 @@ def execute(self, notification: SponsorEmailNotificationTemplate, sponsorships, "to_accounting": SponsorContact.ACCOUTING_CONTACT in contact_types, "to_manager": SponsorContact.MANAGER_CONTACT in contact_types, } + request = kwargs.get("request") for sponsorship in sponsorships: email = notification.get_email_message(sponsorship, **msg_kwargs) @@ -194,11 +196,27 @@ def execute(self, notification: SponsorEmailNotificationTemplate, sponsorships, continue email.send() + # Persist notification log (best-effort, don't break sending) + try: + sent_by = None + if request and hasattr(request, "user") and getattr(request.user, "is_authenticated", False): + sent_by = request.user + SponsorshipNotificationLog.objects.create( + sponsorship=sponsorship, + subject=getattr(email, "subject", ""), + content=getattr(email, "body", ""), + recipients=", ".join(getattr(email, "to", [])), + contact_types=", ".join(contact_types), + sent_by=sent_by, + ) + except Exception: # noqa: BLE001, S110 + pass + self.notify( notification=notification, sponsorship=sponsorship, contact_types=contact_types, - request=kwargs.get("request"), + request=request, ) diff --git a/docker-compose.yml b/docker-compose.yml index 1f5ea36b4..1616c2aa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,12 @@ services: test: ["CMD", "redis-cli","ping"] interval: 1s + maildev: + image: maildev/maildev:2.1.0 + ports: + - "1080:1080" + - "1025:1025" + web: build: . image: pythondotorg:docker-compose @@ -31,6 +37,8 @@ services: environment: DATABASE_URL: postgresql://pythondotorg:pythondotorg@postgres:5432/pythondotorg DJANGO_SETTINGS_MODULE: pydotorg.settings.local + EMAIL_HOST: maildev + EMAIL_PORT: "1025" depends_on: postgres: condition: service_healthy diff --git a/pydotorg/settings/local.py b/pydotorg/settings/local.py index e93395bc5..6315067fa 100644 --- a/pydotorg/settings/local.py +++ b/pydotorg/settings/local.py @@ -22,7 +22,14 @@ }, } -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +# Use maildev SMTP when EMAIL_HOST is set (via docker-compose), otherwise console +EMAIL_HOST = config("EMAIL_HOST", default="") +if EMAIL_HOST: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_PORT = config("EMAIL_PORT", default=1025, cast=int) + EMAIL_USE_TLS = False +else: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" try: