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): pFad - Phonifier reborn

    Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

    Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


    Alternative Proxies:

    Alternative Proxy

    pFad Proxy

    pFad v3 Proxy

    pFad v4 Proxy