From 4742c3e69cb4f99ba3233274b6bdb19d88ab8a3e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sun, 22 Mar 2026 12:05:14 -0500 Subject: [PATCH 01/48] sponsor ui for staff --- apps/sponsors/manage/__init__.py | 1 + apps/sponsors/manage/forms.py | 382 +++++++++ apps/sponsors/manage/tests.py | 536 +++++++++++++ apps/sponsors/manage/urls.py | 55 ++ apps/sponsors/manage/views.py | 732 ++++++++++++++++++ .../commands/seed_sponsor_manage_data.py | 238 ++++++ .../templates/sponsors/manage/_base.html | 666 ++++++++++++++++ .../manage/benefit_confirm_delete.html | 47 ++ .../sponsors/manage/benefit_form.html | 252 ++++++ .../sponsors/manage/benefit_list.html | 126 +++ .../templates/sponsors/manage/clone_year.html | 110 +++ .../sponsors/manage/contract_execute.html | 50 ++ .../sponsors/manage/contract_send.html | 48 ++ .../sponsors/manage/current_year_form.html | 57 ++ .../templates/sponsors/manage/dashboard.html | 160 ++++ .../manage/package_confirm_delete.html | 46 ++ .../sponsors/manage/package_form.html | 106 +++ .../sponsors/manage/package_list.html | 66 ++ .../sponsors/manage/sponsor_edit.html | 110 +++ .../sponsors/manage/sponsorship_approve.html | 97 +++ .../sponsors/manage/sponsorship_detail.html | 342 ++++++++ .../sponsors/manage/sponsorship_edit.html | 67 ++ .../sponsors/manage/sponsorship_list.html | 135 ++++ apps/sponsors/urls.py | 4 +- 24 files changed, 4432 insertions(+), 1 deletion(-) create mode 100644 apps/sponsors/manage/__init__.py create mode 100644 apps/sponsors/manage/forms.py create mode 100644 apps/sponsors/manage/tests.py create mode 100644 apps/sponsors/manage/urls.py create mode 100644 apps/sponsors/manage/views.py create mode 100644 apps/sponsors/management/commands/seed_sponsor_manage_data.py create mode 100644 apps/sponsors/templates/sponsors/manage/_base.html create mode 100644 apps/sponsors/templates/sponsors/manage/benefit_confirm_delete.html create mode 100644 apps/sponsors/templates/sponsors/manage/benefit_form.html create mode 100644 apps/sponsors/templates/sponsors/manage/benefit_list.html create mode 100644 apps/sponsors/templates/sponsors/manage/clone_year.html create mode 100644 apps/sponsors/templates/sponsors/manage/contract_execute.html create mode 100644 apps/sponsors/templates/sponsors/manage/contract_send.html create mode 100644 apps/sponsors/templates/sponsors/manage/current_year_form.html create mode 100644 apps/sponsors/templates/sponsors/manage/dashboard.html create mode 100644 apps/sponsors/templates/sponsors/manage/package_confirm_delete.html create mode 100644 apps/sponsors/templates/sponsors/manage/package_form.html create mode 100644 apps/sponsors/templates/sponsors/manage/package_list.html create mode 100644 apps/sponsors/templates/sponsors/manage/sponsor_edit.html create mode 100644 apps/sponsors/templates/sponsors/manage/sponsorship_approve.html create mode 100644 apps/sponsors/templates/sponsors/manage/sponsorship_detail.html create mode 100644 apps/sponsors/templates/sponsors/manage/sponsorship_edit.html create mode 100644 apps/sponsors/templates/sponsors/manage/sponsorship_list.html 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..ac035e310 --- /dev/null +++ b/apps/sponsors/manage/forms.py @@ -0,0 +1,382 @@ +"""Forms for the sponsor management UI.""" + +from django import forms +from django.utils import timezone + +from apps.sponsors.models import ( + Sponsor, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + SponsorshipPackage, + SponsorshipProgram, +) + +MIN_YEAR = 2022 +MAX_YEAR = 2050 + + +def year_choices(): + """Return year choices for select widgets.""" + 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 show year context + if self.instance and self.instance.year: + self.fields["packages"].queryset = SponsorshipPackage.objects.filter(year=self.instance.year) + + +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"] + if year < MIN_YEAR or year > MAX_YEAR: + msg = f"Year must be between {MIN_YEAR} and {MAX_YEAR}." + 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 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 PDF.", + widget=forms.ClearableFileInput(attrs={"style": INPUT_STYLE, "accept": ".pdf,.docx"}), + ) diff --git a/apps/sponsors/manage/tests.py b/apps/sponsors/manage/tests.py new file mode 100644 index 000000000..6dc5afc3e --- /dev/null +++ b/apps/sponsors/manage/tests.py @@ -0,0 +1,536 @@ +"""Tests for the sponsor management UI views.""" + +import datetime + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase, override_settings +from django.urls import reverse + +from apps.sponsors.models import ( + Contract, + Sponsor, + SponsorBenefit, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + SponsorshipPackage, + SponsorshipProgram, +) + + +@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 = 2024 + + # 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 = 2025 + 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_sponsorship(self): + response = self.client.post(reverse("manage_sponsorship_reject", args=[self.sponsorship.pk])) + self.assertEqual(response.status_code, 302) + self.sponsorship.refresh_from_db() + self.assertEqual(self.sponsorship.status, Sponsorship.REJECTED) + + +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 diff --git a/apps/sponsors/manage/urls.py b/apps/sponsors/manage/urls.py new file mode 100644 index 000000000..25e71341d --- /dev/null +++ b/apps/sponsors/manage/urls.py @@ -0,0 +1,55 @@ +"""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"), + # 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//", views.SponsorshipDetailView.as_view(), name="manage_sponsorship_detail"), + path("sponsorships//approve/", views.SponsorshipApproveView.as_view(), name="manage_sponsorship_approve"), + 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/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" + ), + # Sponsor (company) edit + path("sponsors//edit/", views.SponsorEditView.as_view(), name="manage_sponsor_edit"), +] diff --git a/apps/sponsors/manage/views.py b/apps/sponsors/manage/views.py new file mode 100644 index 000000000..cc778c564 --- /dev/null +++ b/apps/sponsors/manage/views.py @@ -0,0 +1,732 @@ +"""Views for the sponsor management UI. + +Locked down to users in the 'Sponsorship Admin' group (or staff/superuser). +""" + +import contextlib + +from django.contrib import messages +from django.db import transaction +from django.db.models import Q, Sum +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +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 ( + AddBenefitToSponsorshipForm, + BenefitFilterForm, + CloneYearForm, + CurrentYearForm, + ExecuteContractForm, + SponsorEditForm, + SponsorshipApproveForm, + SponsorshipBenefitManageForm, + SponsorshipEditForm, + SponsorshipFilterForm, + SponsorshipPackageManageForm, +) +from apps.sponsors.models import ( + Contract, + Sponsor, + SponsorBenefit, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + 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 counts for this year + sponsorship_counts = {} + if selected_year: + for status_code, status_label in Sponsorship.STATUS_CHOICES: + sponsorship_counts[status_label] = Sponsorship.objects.filter( + year=selected_year, status=status_code + ).count() + + 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, + "sponsorship_counts": sponsorship_counts, + "total_benefits": year_benefits.count(), + "total_packages": year_packages.count(), + } + ) + 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 and packages.""" + 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") + 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 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 + # 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 + # Benefit add form (only when editable) + if sp.open_for_editing: + context["add_benefit_form"] = AddBenefitToSponsorshipForm(sponsorship=sp) + 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 SponsorshipRejectView(SponsorshipAdminRequiredMixin, View): + """Reject a sponsorship application.""" + + def post(self, request, pk): + """Reject the sponsorship application.""" + sp = get_object_or_404(Sponsorship, pk=pk) + try: + use_case = use_cases.RejectSponsorshipApplicationUseCase.build() + use_case.execute(sp) + messages.success(request, f'Sponsorship for "{sp.sponsor.name}" rejected.') + 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 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_sponsorships") + + +# ── 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 ContractSendView(SponsorshipAdminRequiredMixin, View): + """Generate and send contract for signing.""" + + def get(self, request, pk): + """Render the contract send confirmation page.""" + 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} + return render(request, "sponsors/manage/contract_send.html", context) + + def post(self, request, pk): + """Generate and send the contract.""" + sp = get_object_or_404(Sponsorship, pk=pk) + try: + contract = sp.contract + use_case = use_cases.SendContractUseCase.build() + use_case.execute(contract, request=request) + messages.success(request, "Contract generated and sent.") + 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 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])) 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..9f7d5cc93 --- /dev/null +++ b/apps/sponsors/management/commands/seed_sponsor_manage_data.py @@ -0,0 +1,238 @@ +"""Create realistic test data for the sponsor management UI.""" + +from datetime import 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 ( + Sponsor, + SponsorBenefit, + SponsorContact, + Sponsorship, + SponsorshipBenefit, + SponsorshipCurrentYear, + 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.stdout.write( + self.style.SUCCESS(f"Created {created_count} sponsorships across {len(sponsors)} sponsors, years {years}.") + ) + 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) + + 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 _clean(self): + names = [s["name"] for s in SPONSORS] + 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/templates/sponsors/manage/_base.html b/apps/sponsors/templates/sponsors/manage/_base.html new file mode 100644 index 000000000..ea4376259 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/_base.html @@ -0,0 +1,666 @@ +{% 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/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..f6a8ef764 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/benefit_form.html @@ -0,0 +1,252 @@ +{% 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 %} + +
+ + + {% 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 }}

+
+ + + + + + + + + + + + {% 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..dbd9c2512 --- /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/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/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..d8a565444 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/contract_send.html @@ -0,0 +1,48 @@ +{% 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 %} +
+

Send Contract for Signing

+

+ {{ sponsorship.sponsor.name }} · {{ sponsorship.level_name }} · ${{ sponsorship.sponsorship_fee|default:"0"|floatformat:"0" }} +

+ +
+ This will generate the contract PDF/DOCX and mark it as Awaiting Signature. + The PSF team will be notified. +
+ +
+
+
Current Status
+
{{ contract.get_status_display }} (Revision {{ contract.revision }})
+
+
+ +
+ {% csrf_token %} +
+ + Cancel +
+
+
+{% 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..4eabc0e9b --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/dashboard.html @@ -0,0 +1,160 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sponsor Dashboard | Python Software Foundation{% endblock %} + +{% with active_tab="dashboard" %} + +{% block topbar_actions %} + {% if current_year %} + Active Year: {{ current_year }} + {% endif %} + Change Active Year +{% endblock %} + +{% block manage_content %} + + {% if years %} +
+ {% for year in years %} + + {{ year }} + {% if year == current_year %}{% endif %} + + {% endfor %} +
+ {% endif %} + + {% if selected_year %} + +
+
+
{{ total_benefits }}
+
Total Benefits
+
+
+
{{ total_packages }}
+
Packages
+
+ {% for label, count in sponsorship_counts.items %} +
+
{{ count }}
+
{{ label }}
+
+ {% endfor %} +
+ + + {% if year_packages %} +
+
+

+ + Packages {{ total_packages }} +

+ Manage +
+
+ + + + + + + + + + + + {% for pkg in year_packages %} + + + + + + + + {% endfor %} + +
PackageAmountSlugAdvertisedA La Carte
{{ 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 %}
+
+
+ {% endif %} + + + {% for stat in program_stats %} +
+
+

+ + {{ stat.program.name }} {{ stat.count }} + {% if stat.new %}{{ stat.new }} new{% endif %} + {% if stat.unavailable %}{{ stat.unavailable }} unavailable{% endif %} +

+
+ View All + + Add +
+
+
+ + + + + + + + + + + + {% for benefit in stat.benefits %} + + + + + + + + {% endfor %} + +
BenefitTiersValueStatus
+ {{ benefit.short_name }} + {% if benefit.new %}New{% endif %} + {% if benefit.standalone %}Standalone{% endif %} + + {% with pkg_count=benefit.packages.count %} + {% if pkg_count %} + {{ pkg_count }} + {% 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
+
+
+ {% endfor %} + + {% if not program_stats %} +
+
+

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/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..03ef08950 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/package_form.html @@ -0,0 +1,106 @@ +{% 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 %} +
+
+
+{% 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..2940e8d8b --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsor_edit.html @@ -0,0 +1,110 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Edit {{ object.name }} | Sponsor Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_breadcrumbs %} +
+ Dashboard + / + Sponsorships + / + {% if from_sponsorship %} + {{ object.name }} + / + {% endif %} + Edit Sponsor +
+{% endblock %} + +{% block manage_content %} +
+

Edit Sponsor: {{ object.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 %} + {% 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/sponsorship_approve.html b/apps/sponsors/templates/sponsors/manage/sponsorship_approve.html new file mode 100644 index 000000000..d4042beec --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_approve.html @@ -0,0 +1,97 @@ +{% 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_detail.html b/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html new file mode 100644 index 000000000..59ff2a17a --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html @@ -0,0 +1,342 @@ +{% 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 can_approve %} + Approve + {% endif %} + {% if can_reject %} + + {% endif %} + {% if can_rollback and sponsorship.status != 'applied' %} + + {% endif %} + {% if can_lock %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if can_unlock %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+ + +{% if can_reject %} +
+
+
+ Reject this sponsorship? +

The sponsor will be notified by email.

+
+
+ {% 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 %} + + {% if contacts %} +
+
Contacts
+ {% for contact in contacts %} +
+ {{ contact.name }} + {{ contact.email }} + {% if contact.primary %}Primary{% endif %} +
+ {% endfor %} +
+ {% endif %} +
+
+ + +{% if contract %} +
+
+

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 %} + Send Contract + {% endif %} + {% if 'executed' in contract.next_status %} + Upload Signed + {% endif %} + {% if 'nullified' in contract.next_status %} +
+ {% csrf_token %} + +
+ {% 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
{{ b.name }}{{ b.program_name }} + {% 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 %} +
+ + {{ add_benefit_form.benefit }} +
+ +
+
+ {% 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..3da071cb3 --- /dev/null +++ b/apps/sponsors/templates/sponsors/manage/sponsorship_list.html @@ -0,0 +1,135 @@ +{% extends "sponsors/manage/_base.html" %} + +{% block page_title %}Sponsorships | Sponsor Management{% endblock %} + +{% with active_tab="sponsorships" %} + +{% block manage_content %} + + + + +
+
+ {% if filter_status %}{% endif %} +
+ + +
+
+ + {{ filter_form.search }} +
+
+ + {% if filter_year or filter_search or filter_status %} + Clear + {% endif %} +
+
+
+ +
+ {{ sponsorships|length }} sponsorship{{ sponsorships|length|pluralize }} + {% if filter_status %} with status {{ filter_status }}{% endif %} + {% if filter_year %} in {{ filter_year }}{% endif %} +
+ + {% if sponsorships %} + + + + + + + + + + + + + + + {% 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" }} + {% 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/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")), ] From 07ed7b02aa4c099af99f683fc9ef455580bd0b10 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sun, 22 Mar 2026 12:14:02 -0500 Subject: [PATCH 02/48] clickable benefits --- apps/sponsors/manage/urls.py | 5 + apps/sponsors/manage/views.py | 62 +++++++- .../commands/seed_sponsor_manage_data.py | 13 ++ .../templates/sponsors/manage/_base.html | 41 +++++ .../sponsors/manage/benefit_list.html | 20 +-- .../templates/sponsors/manage/dashboard.html | 140 ++++++++++++++++-- .../sponsors/manage/sponsorship_detail.html | 91 ++++++++---- .../sponsors/manage/sponsorship_list.html | 20 +-- 8 files changed, 326 insertions(+), 66 deletions(-) diff --git a/apps/sponsors/manage/urls.py b/apps/sponsors/manage/urls.py index 25e71341d..affc793ed 100644 --- a/apps/sponsors/manage/urls.py +++ b/apps/sponsors/manage/urls.py @@ -43,6 +43,11 @@ 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" diff --git a/apps/sponsors/manage/views.py b/apps/sponsors/manage/views.py index cc778c564..bf155313f 100644 --- a/apps/sponsors/manage/views.py +++ b/apps/sponsors/manage/views.py @@ -108,13 +108,29 @@ def get_context_data(self, **kwargs): } ) - # Sponsorship counts for this year - sponsorship_counts = {} - if selected_year: - for status_code, status_label in Sponsorship.STATUS_CHOICES: - sponsorship_counts[status_label] = Sponsorship.objects.filter( - year=selected_year, status=status_code - ).count() + # 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 context.update( { @@ -124,9 +140,16 @@ def get_context_data(self, **kwargs): "year_benefits": year_benefits, "year_packages": year_packages.order_by("-sponsorship_amount"), "program_stats": program_stats, - "sponsorship_counts": sponsorship_counts, "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, } ) return context @@ -650,6 +673,29 @@ def post(self, request, pk, benefit_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 and send contract for signing.""" diff --git a/apps/sponsors/management/commands/seed_sponsor_manage_data.py b/apps/sponsors/management/commands/seed_sponsor_manage_data.py index 9f7d5cc93..4a9222a53 100644 --- a/apps/sponsors/management/commands/seed_sponsor_manage_data.py +++ b/apps/sponsors/management/commands/seed_sponsor_manage_data.py @@ -8,6 +8,7 @@ from django.utils import timezone from apps.sponsors.models import ( + Contract, Sponsor, SponsorBenefit, SponsorContact, @@ -209,6 +210,17 @@ def _create_sponsorships(self, sponsors, user, current_year, today): 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 Exception: + pass # Contract creation may fail if sponsor has no primary contact + created_count += 1 return created_count @@ -232,6 +244,7 @@ def _apply_status(sp, status, applied_on): def _clean(self): names = [s["name"] for s in SPONSORS] + 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() diff --git a/apps/sponsors/templates/sponsors/manage/_base.html b/apps/sponsors/templates/sponsors/manage/_base.html index ea4376259..87fd874e9 100644 --- a/apps/sponsors/templates/sponsors/manage/_base.html +++ b/apps/sponsors/templates/sponsors/manage/_base.html @@ -543,6 +543,47 @@ color: #3776ab; } + /* ── Filter bar ── */ + .manage-filter-bar { + background: #f7f8fa; + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 14px 20px; + margin-bottom: 24px; + } + .manage-filter-bar form { + display: flex; + gap: 14px; + align-items: flex-end; + flex-wrap: wrap; + } + .manage-filter-bar .filter-field label { + display: block; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #777; + margin-bottom: 4px; + } + .manage-filter-bar .filter-field select, + .manage-filter-bar .filter-field input[type="text"], + .manage-filter-bar .filter-field input[type="search"] { + padding: 7px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + height: 34px; + box-sizing: border-box; + } + .manage-filter-bar .filter-actions { + display: flex; + gap: 4px; + align-items: center; + height: 34px; + } + /* ── Breadcrumbs (shown below navbar on sub-pages) ── */ .manage-crumbs { font-size: 12px; diff --git a/apps/sponsors/templates/sponsors/manage/benefit_list.html b/apps/sponsors/templates/sponsors/manage/benefit_list.html index dbd9c2512..2e0fcf0bb 100644 --- a/apps/sponsors/templates/sponsors/manage/benefit_list.html +++ b/apps/sponsors/templates/sponsors/manage/benefit_list.html @@ -10,11 +10,11 @@ {% block manage_content %} -
-
-
- - {% for choice_val, choice_label in filter_form.year.field.choices %} {% if choice_val %} @@ -23,19 +23,19 @@ {% endfor %}
-
- - {% for program in filter_form.program.field.queryset %} {% endfor %}
-
+
{% if filter_year or filter_program or filter_package %} - Clear + Clear {% endif %}
diff --git a/apps/sponsors/templates/sponsors/manage/dashboard.html b/apps/sponsors/templates/sponsors/manage/dashboard.html index 4eabc0e9b..cc2652396 100644 --- a/apps/sponsors/templates/sponsors/manage/dashboard.html +++ b/apps/sponsors/templates/sponsors/manage/dashboard.html @@ -25,27 +25,143 @@ {% endif %} {% if selected_year %} +
-
{{ total_benefits }}
-
Total Benefits
+
{{ total_sponsorships }}
+
Sponsorships
-
{{ total_packages }}
-
Packages
+
${{ total_revenue|floatformat:"0" }}
+
Revenue
- {% for label, count in sponsorship_counts.items %} -
-
{{ count }}
-
{{ label }}
+
+
{{ 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 not needs_review and not pending_contracts %} +
+ No sponsorships need attention for {{ selected_year }}. All caught up. +
+ {% 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 %} +
- {% endfor %}
{% if year_packages %} -
+