From 4aba973881c1a70a56146bebcbde238713c5cb51 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 26 Jun 2025 11:37:23 -0500 Subject: [PATCH 1/3] feat: add new cancelled status for sponsors adds cancellation notifications, admin form buttons and page, state transitions, migration --- sponsors/admin.py | 8 +++++ ...ed_on_sponsorship_withdrawn_on_and_more.py | 23 +++++++++++++ sponsors/models/managers.py | 2 +- sponsors/models/sponsorship.py | 29 +++++++++++++--- sponsors/notifications.py | 15 ++++++++ sponsors/tests/test_admin.py | 1 + sponsors/tests/test_models.py | 5 +-- sponsors/use_cases.py | 13 +++++++ sponsors/views_admin.py | 24 +++++++++++++ .../sponsors/admin/cancel_application.html | 34 +++++++++++++++++++ .../admin/sponsorship_change_form.html | 6 ++++ .../email/psf_cancelled_sponsorship.txt | 5 +++ .../psf_cancelled_sponsorship_subject.txt | 1 + .../email/sponsor_cancelled_sponsorship.txt | 5 +++ .../sponsor_cancelled_sponsorship_subject.txt | 1 + 15 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 sponsors/migrations/0104_sponsorship_cancelled_on_sponsorship_withdrawn_on_and_more.py create mode 100644 templates/sponsors/admin/cancel_application.html create mode 100644 templates/sponsors/email/psf_cancelled_sponsorship.txt create mode 100644 templates/sponsors/email/psf_cancelled_sponsorship_subject.txt create mode 100644 templates/sponsors/email/sponsor_cancelled_sponsorship.txt create mode 100644 templates/sponsors/email/sponsor_cancelled_sponsorship_subject.txt diff --git a/sponsors/admin.py b/sponsors/admin.py index dc7278c08..ee5abf6fd 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -588,6 +588,11 @@ def get_urls(self): self.admin_site.admin_view(self.approve_sponsorship_view), name=f"{base_name}_approve", ), + path( + "/cancel", + self.admin_site.admin_view(self.cancel_sponsorship_view), + name=f"{base_name}_cancel", + ), path( "/enable-edit", self.admin_site.admin_view(self.rollback_to_editing_view), @@ -745,6 +750,9 @@ def reject_sponsorship_view(self, request, pk): def approve_sponsorship_view(self, request, pk): return views_admin.approve_sponsorship_view(self, request, pk) + def cancel_sponsorship_view(self, request, pk): + return views_admin.cancel_sponsorship_view(self, request, pk) + def approve_signed_sponsorship_view(self, request, pk): return views_admin.approve_signed_sponsorship_view(self, request, pk) diff --git a/sponsors/migrations/0104_sponsorship_cancelled_on_sponsorship_withdrawn_on_and_more.py b/sponsors/migrations/0104_sponsorship_cancelled_on_sponsorship_withdrawn_on_and_more.py new file mode 100644 index 000000000..f70c503c2 --- /dev/null +++ b/sponsors/migrations/0104_sponsorship_cancelled_on_sponsorship_withdrawn_on_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.22 on 2025-06-26 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0103_alter_benefitfeature_polymorphic_ctype_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='sponsorship', + name='cancelled_on', + field=models.DateField(blank=True, null=True), + ), + migrations.AlterField( + model_name='sponsorship', + name='status', + field=models.CharField(choices=[('applied', 'Applied'), ('rejected', 'Rejected'), ('approved', 'Approved'), ('finalized', 'Finalized'), ('cancelled', 'Cancelled')], db_index=True, default='applied', max_length=20), + ), + ] diff --git a/sponsors/models/managers.py b/sponsors/models/managers.py index 5cb241fc9..d0affd4f3 100644 --- a/sponsors/models/managers.py +++ b/sponsors/models/managers.py @@ -17,7 +17,7 @@ def approved(self): def visible_to(self, user): contacts = user.sponsorcontact_set.values_list('sponsor_id', flat=True) - status = [self.model.APPLIED, self.model.APPROVED, self.model.FINALIZED] + status = [self.model.APPLIED, self.model.APPROVED, self.model.FINALIZED, self.model.CANCELLED] return self.filter( Q(submited_by=user) | Q(sponsor_id__in=Subquery(contacts)), status__in=status, diff --git a/sponsors/models/sponsorship.py b/sponsors/models/sponsorship.py index d230e91c3..392cebe98 100644 --- a/sponsors/models/sponsorship.py +++ b/sponsors/models/sponsorship.py @@ -156,12 +156,14 @@ class Sponsorship(models.Model): REJECTED = "rejected" APPROVED = "approved" FINALIZED = "finalized" + CANCELLED = "cancelled" STATUS_CHOICES = [ (APPLIED, "Applied"), (REJECTED, "Rejected"), (APPROVED, "Approved"), (FINALIZED, "Finalized"), + (CANCELLED, "Cancelled"), ] objects = SponsorshipQuerySet.as_manager() @@ -180,6 +182,7 @@ class Sponsorship(models.Model): applied_on = models.DateField(auto_now_add=True) approved_on = models.DateField(null=True, blank=True) rejected_on = models.DateField(null=True, blank=True) + cancelled_on = models.DateField(null=True, blank=True) finalized_on = models.DateField(null=True, blank=True) year = models.PositiveIntegerField(null=True, validators=YEAR_VALIDATORS, db_index=True) @@ -218,7 +221,14 @@ def level_name(self, value): @cached_property def user_customizations(self): benefits = [b.sponsorship_benefit for b in self.benefits.select_related("sponsorship_benefit")] - return self.package.get_user_customization(benefits) + if self.package: + return self.package.get_user_customization(benefits) + else: + # Return default customization structure for sponsorships without packages + return { + "added_by_user": [], + "removed_by_user": [] + } def __str__(self): repr = f"{self.level_name} - {self.year} - ({self.get_status_display()}) for sponsor {self.sponsor.name}" @@ -327,8 +337,17 @@ def approve(self, start_date, end_date): self.end_date = end_date self.approved_on = timezone.now().date() + + def cancel(self): + if self.CANCELLED not in self.next_status: + msg = f"Can't cancel a {self.get_status_display()} sponsorship." + raise InvalidStatusException(msg) + self.status = self.CANCELLED + self.locked = True + self.cancelled_on = timezone.now().date() + def rollback_to_editing(self): - accepts_rollback = [self.APPLIED, self.APPROVED, self.REJECTED] + accepts_rollback = [self.APPLIED, self.APPROVED, self.REJECTED, self.CANCELLED] if self.status not in accepts_rollback: msg = f"Can't rollback to edit a {self.get_status_display()} sponsorship." raise InvalidStatusException(msg) @@ -345,6 +364,7 @@ def rollback_to_editing(self): self.status = self.APPLIED self.approved_on = None self.rejected_on = None + self.cancelled_on = None @property def unlocked(self): @@ -388,10 +408,11 @@ def open_for_editing(self): @property def next_status(self): states_map = { - self.APPLIED: [self.APPROVED, self.REJECTED], - self.APPROVED: [self.FINALIZED], + self.APPLIED: [self.APPROVED, self.REJECTED, self.CANCELLED], + self.APPROVED: [self.FINALIZED, self.CANCELLED], self.REJECTED: [], self.FINALIZED: [], + self.CANCELLED: [], } return states_map[self.status] diff --git a/sponsors/notifications.py b/sponsors/notifications.py index 196cc94b6..b136df1cf 100644 --- a/sponsors/notifications.py +++ b/sponsors/notifications.py @@ -86,6 +86,21 @@ class RejectedSponsorshipNotificationToSponsors(BaseEmailSponsorshipNotification def get_recipient_list(self, context): return context["sponsorship"].verified_emails +class CancelledSponsorshipNotificationToPSF: + subject_template = "sponsors/email/psf_cancelled_sponsorship_subject.txt" + message_template = "sponsors/email/psf_cancelled_sponsorship.txt" + email_context_keys = ["sponsorship"] + + def get_recipient_list(self, context): + return [settings.SPONSORSHIP_NOTIFICATION_TO_EMAIL] + +class CancelledSponsorshipNotificationToSponsors(BaseEmailSponsorshipNotification): + subject_template = "sponsors/email/sponsor_cancelled_sponsorship_subject.txt" + message_template = "sponsors/email/sponsor_cancelled_sponsorship.txt" + email_context_keys = ["sponsorship"] + + def get_recipient_list(self, context): + return context["sponsorship"].verified_emails class ContractNotificationToPSF(BaseEmailSponsorshipNotification): subject_template = "sponsors/email/psf_contract_subject.txt" diff --git a/sponsors/tests/test_admin.py b/sponsors/tests/test_admin.py index 1e94fa6df..e552ff0df 100644 --- a/sponsors/tests/test_admin.py +++ b/sponsors/tests/test_admin.py @@ -31,6 +31,7 @@ def test_lookups(self): ("rejected", "Rejected"), ("approved", "Approved"), ("finalized", "Finalized"), + ("cancelled", "Cancelled"), ] self.assertEqual(expected, self.filter.lookups(self.request, self.model_admin)) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 3566f0b08..fd24f2bcb 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -101,10 +101,11 @@ def setUp(self): def test_control_sponsorship_next_status(self): states_map = { - Sponsorship.APPLIED: [Sponsorship.APPROVED, Sponsorship.REJECTED], - Sponsorship.APPROVED: [Sponsorship.FINALIZED], + Sponsorship.APPLIED: [Sponsorship.APPROVED, Sponsorship.REJECTED, Sponsorship.CANCELLED], + Sponsorship.APPROVED: [Sponsorship.FINALIZED, Sponsorship.CANCELLED], Sponsorship.REJECTED: [], Sponsorship.FINALIZED: [], + Sponsorship.CANCELLED: [], } for status, exepcted in states_map.items(): sponsorship = baker.prepare(Sponsorship, status=status) diff --git a/sponsors/use_cases.py b/sponsors/use_cases.py index 91271ff64..35b16cd9d 100644 --- a/sponsors/use_cases.py +++ b/sponsors/use_cases.py @@ -46,6 +46,19 @@ def execute(self, sponsorship, request=None): return sponsorship +class CancelSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): + notifications = [ + notifications.CancelledSponsorshipNotificationToPSF(), + notifications.CancelledSponsorshipNotificationToSponsors(), + ] + + def execute(self, sponsorship, request=None): + sponsorship.cancel() + sponsorship.save() + self.notify(request=request, sponsorship=sponsorship) + return sponsorship + + class ApproveSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): notifications = [ notifications.SponsorshipApprovalLogger(), diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index fd8631d3f..5ddda0d64 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -52,6 +52,30 @@ def reject_sponsorship_view(ModelAdmin, request, pk): return render(request, "sponsors/admin/reject_application.html", context=context) + + +def cancel_sponsorship_view(ModelAdmin, request, pk): + sponsorship = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + + if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": + try: + use_case = use_cases.CancelSponsorshipApplicationUseCase.build() + use_case.execute(sponsorship) + ModelAdmin.message_user( + request, "Sponsorship was cancelled!", messages.SUCCESS + ) + except InvalidStatusException as e: + ModelAdmin.message_user(request, str(e), messages.ERROR) + + redirect_url = reverse( + "admin:sponsors_sponsorship_change", args=[sponsorship.pk] + ) + return redirect(redirect_url) + + context = {"sponsorship": sponsorship} + return render(request, "sponsors/admin/cancel_application.html", context=context) + + def approve_sponsorship_view(ModelAdmin, request, pk): """ Approves a sponsorship and create an empty contract diff --git a/templates/sponsors/admin/cancel_application.html b/templates/sponsors/admin/cancel_application.html new file mode 100644 index 000000000..03bbea10f --- /dev/null +++ b/templates/sponsors/admin/cancel_application.html @@ -0,0 +1,34 @@ +{% extends 'admin/base_site.html' %} +{% load i18n static sponsors %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block title %}Cancel {{ sponsorship }} | python.org{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

Cancel Sponsorship

+

Please review the sponsorship application and click the Cancel button to mark it as cancelled by administration.

+
+
+{% csrf_token %} + +
{% full_sponsorship sponsorship display_fee=True %}
+ + + +
+ +
+ +
+
{% endblock %} \ No newline at end of file diff --git a/templates/sponsors/admin/sponsorship_change_form.html b/templates/sponsors/admin/sponsorship_change_form.html index 9d7bcce85..55f6496fa 100644 --- a/templates/sponsors/admin/sponsorship_change_form.html +++ b/templates/sponsors/admin/sponsorship_change_form.html @@ -23,6 +23,12 @@ {% endif %} + {% if sp.CANCELLED in sp.next_status %} +
  • + Cancel +
  • + {% endif %} + {% if sp.FINALIZED in sp.next_status %}
  • Review Contract diff --git a/templates/sponsors/email/psf_cancelled_sponsorship.txt b/templates/sponsors/email/psf_cancelled_sponsorship.txt new file mode 100644 index 000000000..a141431ed --- /dev/null +++ b/templates/sponsors/email/psf_cancelled_sponsorship.txt @@ -0,0 +1,5 @@ +content email psf-sponsors@python.org + +CANCELLED SPONSORSHIP + +{{ sponsorship }} diff --git a/templates/sponsors/email/psf_cancelled_sponsorship_subject.txt b/templates/sponsors/email/psf_cancelled_sponsorship_subject.txt new file mode 100644 index 000000000..6b281f3a4 --- /dev/null +++ b/templates/sponsors/email/psf_cancelled_sponsorship_subject.txt @@ -0,0 +1 @@ +CANCELLED SPONSORSHIP to psf-sponsors@python.org diff --git a/templates/sponsors/email/sponsor_cancelled_sponsorship.txt b/templates/sponsors/email/sponsor_cancelled_sponsorship.txt new file mode 100644 index 000000000..5cf2167e3 --- /dev/null +++ b/templates/sponsors/email/sponsor_cancelled_sponsorship.txt @@ -0,0 +1,5 @@ +content email sponsors + +CANCELLED SPONSORSHIP + +{{ sponsorship }} diff --git a/templates/sponsors/email/sponsor_cancelled_sponsorship_subject.txt b/templates/sponsors/email/sponsor_cancelled_sponsorship_subject.txt new file mode 100644 index 000000000..9d5dd47d2 --- /dev/null +++ b/templates/sponsors/email/sponsor_cancelled_sponsorship_subject.txt @@ -0,0 +1 @@ +CANCELLED SPONSORSHIP subject email user + verified emails From df8fb5477c85552e31bb4dace9459d7a203b5a9b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 26 Jun 2025 11:39:23 -0500 Subject: [PATCH 2/3] fix: oops forgot inherit base class --- sponsors/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sponsors/notifications.py b/sponsors/notifications.py index b136df1cf..2d4a9dd10 100644 --- a/sponsors/notifications.py +++ b/sponsors/notifications.py @@ -86,7 +86,7 @@ class RejectedSponsorshipNotificationToSponsors(BaseEmailSponsorshipNotification def get_recipient_list(self, context): return context["sponsorship"].verified_emails -class CancelledSponsorshipNotificationToPSF: +class CancelledSponsorshipNotificationToPSF(BaseEmailSponsorshipNotification): subject_template = "sponsors/email/psf_cancelled_sponsorship_subject.txt" message_template = "sponsors/email/psf_cancelled_sponsorship.txt" email_context_keys = ["sponsorship"] From 2c521c47fec0689da8eb392f8b099727616a742e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 26 Jun 2025 11:42:46 -0500 Subject: [PATCH 3/3] tests: add test for cancel, test for rollback to edit from cancel --- sponsors/tests/test_models.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index fd24f2bcb..b393961ea 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -227,6 +227,7 @@ def test_rollback_sponsorship_to_edit(self): Sponsorship.APPLIED, Sponsorship.APPROVED, Sponsorship.REJECTED, + Sponsorship.CANCELLED, ] for status in can_rollback_from: sponsorship.status = status @@ -301,6 +302,16 @@ def test_display_agreed_fee_for_approved_and_finalized_status(self): self.assertEqual(sponsorship.agreed_fee, 2000) + def test_cancel_sponsorship(self): + sponsorship = Sponsorship.new(self.sponsor, self.benefits) + sponsorship.status = Sponsorship.APPROVED + sponsorship.save() + + sponsorship.cancel() + + self.assertEqual(sponsorship.status, Sponsorship.CANCELLED) + self.assertIsNotNone(sponsorship.cancelled_on) + class SponsorshipCurrentYearTests(TestCase):