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..2d4a9dd10 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(BaseEmailSponsorshipNotification): + 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..b393961ea 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) @@ -226,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 @@ -300,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): 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 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