Skip to content

Commit 723539e

Browse files
berinhardewdurbin
andauthored
Keep track of current sponsorship year (python#2087)
* Add missing migration to the existing models (managers and meta info) * New model to keep track of current sponsorship year * Make sure the singleton object is populated by default via data migration * Make sure the singleton logic is implemented at DB-level * Make sure singleton object cannot be deleted * Add singleton to admin with disabled permissions for adding or deleting * Add django-extensions as a requirement to be able to use shell_plus * Add application year field to sponsorship model * Display new field and enable filter on sponsorship admin * Populate application year when creating it * Rename field to be just "year" * Enable to filter contract by sponsorship year * Refactoring to centralize year validators * Add year field to configure sponsorship benefits and packages * Initialize values for existing sponsorship benefits and packages with current year * Year field should be required when creating/editing configured benefits * Add filter by year to configured benefits and packages * Refactor configured benefits and packages to build custom manager from queryset * New manager methods to filter configured packages and benefits from current year * Sponsorship application form now only lists pkg, benefits, add-ons and a la carte benefits from the current year * Fix requirements organization * Improve form unit tests to make sure we're filtering packages and benefits by the current year * Refactor to encapsulate logic to get the current year within a class method * Add cache to avoid querying the DB every time the system needs the current year * Add db index to year fields so querying by them gets faster * Add migration command to CI to check if it's running them * Move fields definition to init so query for current year happens as execution time instead of interpretation's one * Revert "Add migration command to CI to check if it's running them" This reverts commit 17f7bed. * add necessary fixtures * Introduce clone method to benefit and related objects * Add clone method to be able to copy a benefit configuration to a new benefit * Make sure Tiered Quantity config can be copy using the same year's package * Make sure required assets configurations can be cloned without violating db constraints and with valid due dates * Add unit test to make sure the remaining configuration can be cloned * Make sure benefit features configurations get cloned as well * Upgrade model-bakery version to the most up to date with Django 2.2 support * Implement use case to generate clone an sponsorship year configuration to a * Introduce helper function to build admin base url name * Create admin view to clone sponsorship configuration from one year to another * Add form validation to enforce relations between from and target years * Add workflow to django admin to enable staff users to clone configurations by year * Reverse order so most recent years appear first * Refactoring to introduce more generic function to create django log entries * Update use case to add django admin log entries for new cloned packages and benefits * Add parameter to be able to display form for a specific year * Enable staff user to preview how the application form from a specific year will look like * Display link to preview non active years sponsorship form in admin * Only display links to already configured years if they exist * Also display links to list configured year's packages and benefits from active year list * Add column with links for the active year * Disable submit button if preview for custom year * update style for admin warning on application preview to be extra scary Co-authored-by: Ee Durbin <[email protected]>
1 parent 662ac4c commit 723539e

39 files changed

+5248
-552
lines changed

base-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ django-polymorphic==3.0.0
4848
sorl-thumbnail==12.7.0
4949
docxtpl==0.12.0
5050
reportlab==3.6.6
51+
django-extensions==3.1.4

dev-requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ responses==0.13.3
1212
django-debug-toolbar==3.2.1
1313
coverage
1414
ddt
15-
model-bakery==1.3.2
15+
model-bakery==1.4.0

fixtures/boxes.json

+756-393
Large diffs are not rendered by default.

fixtures/flags.json

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[
2+
{
3+
"model": "waffle.flag",
4+
"pk": 1,
5+
"fields": {
6+
"name": "psf_membership",
7+
"everyone": null,
8+
"percent": null,
9+
"testing": true,
10+
"superusers": true,
11+
"staff": false,
12+
"authenticated": false,
13+
"languages": "",
14+
"rollout": false,
15+
"note": "This flag is used to show the PSF Basic and Advanced member registration process.",
16+
"created": "2015-06-05T09:47:03Z",
17+
"modified": "2017-03-22T01:45:42.077Z",
18+
"groups": [],
19+
"users": []
20+
}
21+
},
22+
{
23+
"model": "waffle.flag",
24+
"pk": 2,
25+
"fields": {
26+
"name": "sponsorship-applications-open",
27+
"everyone": true,
28+
"percent": null,
29+
"testing": false,
30+
"superusers": false,
31+
"staff": false,
32+
"authenticated": false,
33+
"languages": "",
34+
"rollout": false,
35+
"note": "Controls if the application form and benefits \"menu\" is visible at https://www.python.org/sponsors/application/\r\n\r\nThe contents of the page when applications are closed is modifiable at https://www.python.org/admin/boxes/box/106/change/",
36+
"created": "2022-07-21T17:16:05Z",
37+
"modified": "2022-07-21T17:24:06.747Z",
38+
"groups": [],
39+
"users": []
40+
}
41+
}
42+
]

fixtures/sponsors.json

+2,984-1
Large diffs are not rendered by default.

fixtures/users.json

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
[
2+
{
3+
"model": "users.user",
4+
"pk": 1,
5+
"fields": {
6+
"password": "pbkdf2_sha256$150000$TAqxQ4O0uzV2$3lgFMdRiaBnaUfXtjSRlA/9HzMwYa2ThD38AmTzGYEs=",
7+
"last_login": "2022-08-01T18:52:54.206Z",
8+
"is_superuser": true,
9+
"username": "admin",
10+
"first_name": "",
11+
"last_name": "",
12+
"email": "[email protected]",
13+
"is_staff": true,
14+
"is_active": true,
15+
"date_joined": "2022-08-01T18:52:39.307Z",
16+
"bio": "",
17+
"bio_markup_type": "markdown",
18+
"search_visibility": 1,
19+
"_bio_rendered": "",
20+
"email_privacy": 2,
21+
"public_profile": true,
22+
"groups": [],
23+
"user_permissions": []
24+
}
25+
},
26+
{
27+
"model": "users.user",
28+
"pk": 2,
29+
"fields": {
30+
"password": "pbkdf2_sha256$150000$TAqxQ4O0uzV2$3lgFMdRiaBnaUfXtjSRlA/9HzMwYa2ThD38AmTzGYEs=",
31+
"last_login": "2022-08-01T18:54:51.727Z",
32+
"is_superuser": false,
33+
"username": "user",
34+
"first_name": "",
35+
"last_name": "",
36+
"email": "[email protected]",
37+
"is_staff": false,
38+
"is_active": true,
39+
"date_joined": "2022-08-01T18:54:10.023Z",
40+
"bio": "",
41+
"bio_markup_type": "markdown",
42+
"search_visibility": 1,
43+
"_bio_rendered": "",
44+
"email_privacy": 2,
45+
"public_profile": true,
46+
"groups": [],
47+
"user_permissions": []
48+
}
49+
},
50+
{
51+
"model": "account.emailaddress",
52+
"pk": 1,
53+
"fields": {
54+
"user": [
55+
"admin"
56+
],
57+
"email": "[email protected]",
58+
"verified": true,
59+
"primary": true
60+
}
61+
},
62+
{
63+
"model": "account.emailaddress",
64+
"pk": 2,
65+
"fields": {
66+
"user": [
67+
"user"
68+
],
69+
"email": "[email protected]",
70+
"verified": true,
71+
"primary": true
72+
}
73+
}
74+
]

pydotorg/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@
202202
'rest_framework.authtoken',
203203
'django_filters',
204204
'polymorphic',
205+
'django_extensions',
205206
]
206207

207208
# Fixtures

pydotorg/settings/local.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464

6565
CACHES = {
6666
'default': {
67-
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
67+
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
68+
'LOCATION': 'pythondotorg-local-cache',
6869
}
6970
}
7071

sponsors/admin.py

+109-15
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@
1818
from sponsors.models.benefits import RequiredAssetMixin
1919
from sponsors import views_admin
2020
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm, \
21-
SponsorshipBenefitAdminForm
21+
SponsorshipBenefitAdminForm, CloneApplicationConfigForm
2222
from cms.admin import ContentManageableModelAdmin
2323

2424

25+
def get_url_base_name(Model):
26+
return f"{Model._meta.app_label}_{Model._meta.model_name}"
27+
28+
2529
class AssetsInline(GenericTabularInline):
2630
model = GenericAsset
2731
extra = 0
@@ -113,7 +117,7 @@ class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin):
113117
"internal_value",
114118
"move_up_down_links",
115119
]
116-
list_filter = ["program", "package_only", "packages", "new", "a_la_carte", "unavailable"]
120+
list_filter = ["program", "year", "package_only", "packages", "new", "a_la_carte", "unavailable"]
117121
search_fields = ["name"]
118122
form = SponsorshipBenefitAdminForm
119123

@@ -150,11 +154,12 @@ class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin):
150154

151155
def get_urls(self):
152156
urls = super().get_urls()
157+
base_name = get_url_base_name(self.model)
153158
my_urls = [
154159
path(
155160
"<int:pk>/update-related-sponsorships",
156161
self.admin_site.admin_view(self.update_related_sponsorships),
157-
name="sponsors_sponsorshipbenefit_update_related",
162+
name=f"{base_name}_update_related",
158163
),
159164
]
160165
return my_urls + urls
@@ -166,8 +171,8 @@ def update_related_sponsorships(self, *args, **kwargs):
166171
@admin.register(SponsorshipPackage)
167172
class SponsorshipPackageAdmin(OrderedModelAdmin):
168173
ordering = ("order",)
169-
list_display = ["name", "advertise", "move_up_down_links"]
170-
list_filter = ["advertise"]
174+
list_display = ["name", "year", "advertise", "move_up_down_links"]
175+
list_filter = ["advertise", "year"]
171176
search_fields = ["name"]
172177

173178
def get_readonly_fields(self, request, obj=None):
@@ -294,12 +299,13 @@ class SponsorshipAdmin(admin.ModelAdmin):
294299
"sponsor",
295300
"status",
296301
"package",
302+
"year",
297303
"applied_on",
298304
"approved_on",
299305
"start_date",
300306
"end_date",
301307
]
302-
list_filter = [SponsorshipStatusListFilter, "package", TargetableEmailBenefitsFilter]
308+
list_filter = [SponsorshipStatusListFilter, "package", "year", TargetableEmailBenefitsFilter]
303309
actions = ["send_notifications"]
304310
fieldsets = [
305311
(
@@ -311,6 +317,7 @@ class SponsorshipAdmin(admin.ModelAdmin):
311317
"status",
312318
"package",
313319
"sponsorship_fee",
320+
"year",
314321
"get_estimated_cost",
315322
"start_date",
316323
"end_date",
@@ -407,6 +414,9 @@ def get_readonly_fields(self, request, obj):
407414
extra = ["start_date", "end_date", "package", "level_name", "sponsorship_fee"]
408415
readonly_fields.extend(extra)
409416

417+
if obj.year:
418+
readonly_fields.append("year")
419+
410420
return readonly_fields
411421

412422
def sponsor_link(self, obj):
@@ -436,33 +446,34 @@ def get_contract(self, obj):
436446

437447
def get_urls(self):
438448
urls = super().get_urls()
449+
base_name = get_url_base_name(self.model)
439450
my_urls = [
440451
path(
441452
"<int:pk>/reject",
442453
# TODO: maybe it would be better to create a specific
443454
# group or permission to review sponsorship applications
444455
self.admin_site.admin_view(self.reject_sponsorship_view),
445-
name="sponsors_sponsorship_reject",
456+
name=f"{base_name}_reject",
446457
),
447458
path(
448459
"<int:pk>/approve-existing",
449460
self.admin_site.admin_view(self.approve_signed_sponsorship_view),
450-
name="sponsors_sponsorship_approve_existing_contract",
461+
name=f"{base_name}_approve_existing_contract",
451462
),
452463
path(
453464
"<int:pk>/approve",
454465
self.admin_site.admin_view(self.approve_sponsorship_view),
455-
name="sponsors_sponsorship_approve",
466+
name=f"{base_name}_approve",
456467
),
457468
path(
458469
"<int:pk>/enable-edit",
459470
self.admin_site.admin_view(self.rollback_to_editing_view),
460-
name="sponsors_sponsorship_rollback_to_edit",
471+
name=f"{base_name}_rollback_to_edit",
461472
),
462473
path(
463474
"<int:pk>/list-assets",
464475
self.admin_site.admin_view(self.list_uploaded_assets_view),
465-
name="sponsors_sponsorship_list_uploaded_assets",
476+
name=f"{base_name}_list_uploaded_assets",
466477
),
467478
]
468479
return my_urls + urls
@@ -588,6 +599,87 @@ def list_uploaded_assets_view(self, request, pk):
588599
return views_admin.list_uploaded_assets(self, request, pk)
589600

590601

602+
@admin.register(SponsorshipCurrentYear)
603+
class SponsorshipCurrentYearAdmin(admin.ModelAdmin):
604+
list_display = ["year", "links", "other_years"]
605+
change_list_template = "sponsors/admin/sponsors_sponsorshipcurrentyear_changelist.html"
606+
607+
def has_add_permission(self, *args, **kwargs):
608+
return False
609+
610+
def has_delete_permission(self, *args, **kwargs):
611+
return False
612+
613+
def get_urls(self):
614+
urls = super().get_urls()
615+
base_name = get_url_base_name(self.model)
616+
my_urls = [
617+
path(
618+
"clone-year-config",
619+
self.admin_site.admin_view(self.clone_application_config),
620+
name=f"{base_name}_clone",
621+
),
622+
]
623+
return my_urls + urls
624+
625+
def links(self, obj):
626+
clone_form = CloneApplicationConfigForm()
627+
configured_years = clone_form.configured_years
628+
629+
application_url = reverse("select_sponsorship_application_benefits")
630+
benefits_url = reverse("admin:sponsors_sponsorshipbenefit_changelist")
631+
packages_url = reverse("admin:sponsors_sponsorshippackage_changelist")
632+
preview_label = 'View sponsorship application'
633+
year = obj.year
634+
html = "<ul>"
635+
preview_querystring = f"config_year={year}"
636+
preview_url = f"{application_url}?{preview_querystring}"
637+
filter_querystring = f"year={year}"
638+
year_benefits_url = f"{benefits_url}?{filter_querystring}"
639+
year_packages_url = f"{benefits_url}?{filter_querystring}"
640+
641+
html += f"<li><a target='_blank' href='{year_packages_url}'>List packages</a>"
642+
html += f"<li><a target='_blank' href='{year_benefits_url}'>List benefits</a>"
643+
html += f"<li><a target='_blank' href='{preview_url}'>{preview_label}</a>"
644+
html += "</ul>"
645+
return mark_safe(html)
646+
links.short_description = "Links"
647+
648+
def other_years(self, obj):
649+
clone_form = CloneApplicationConfigForm()
650+
configured_years = clone_form.configured_years
651+
try:
652+
configured_years.remove(obj.year)
653+
except ValueError:
654+
pass
655+
if not configured_years:
656+
return "---"
657+
658+
application_url = reverse("select_sponsorship_application_benefits")
659+
benefits_url = reverse("admin:sponsors_sponsorshipbenefit_changelist")
660+
packages_url = reverse("admin:sponsors_sponsorshippackage_changelist")
661+
preview_label = 'View sponsorship application form for this year'
662+
html = "<ul>"
663+
for year in configured_years:
664+
preview_querystring = f"config_year={year}"
665+
preview_url = f"{application_url}?{preview_querystring}"
666+
filter_querystring = f"year={year}"
667+
year_benefits_url = f"{benefits_url}?{filter_querystring}"
668+
year_packages_url = f"{benefits_url}?{filter_querystring}"
669+
670+
html += f"<li><b>{year}</b>:"
671+
html += "<ul>"
672+
html += f"<li><a target='_blank' href='{year_packages_url}'>List packages</a>"
673+
html += f"<li><a target='_blank' href='{year_benefits_url}'>List benefits</a>"
674+
html += f"<li><a target='_blank' href='{preview_url}'>{preview_label}</a>"
675+
html += "</ul></li>"
676+
html += "</ul>"
677+
return mark_safe(html)
678+
other_years.short_description = "Other configured years"
679+
680+
def clone_application_config(self, request):
681+
return views_admin.clone_application_config(self, request)
682+
591683
@admin.register(LegalClause)
592684
class LegalClauseModelAdmin(OrderedModelAdmin):
593685
list_display = ["internal_name"]
@@ -596,6 +688,7 @@ class LegalClauseModelAdmin(OrderedModelAdmin):
596688
@admin.register(Contract)
597689
class ContractModelAdmin(admin.ModelAdmin):
598690
change_form_template = "sponsors/admin/contract_change_form.html"
691+
list_filter = ["sponsorship__year"]
599692
list_display = [
600693
"id",
601694
"sponsorship",
@@ -711,26 +804,27 @@ def get_sponsorship_url(self, obj):
711804

712805
def get_urls(self):
713806
urls = super().get_urls()
807+
base_name = get_url_base_name(self.model)
714808
my_urls = [
715809
path(
716810
"<int:pk>/preview",
717811
self.admin_site.admin_view(self.preview_contract_view),
718-
name="sponsors_contract_preview",
812+
name=f"{base_name}_preview",
719813
),
720814
path(
721815
"<int:pk>/send",
722816
self.admin_site.admin_view(self.send_contract_view),
723-
name="sponsors_contract_send",
817+
name=f"{base_name}_send",
724818
),
725819
path(
726820
"<int:pk>/execute",
727821
self.admin_site.admin_view(self.execute_contract_view),
728-
name="sponsors_contract_execute",
822+
name=f"{base_name}_execute",
729823
),
730824
path(
731825
"<int:pk>/nullify",
732826
self.admin_site.admin_view(self.nullify_contract_view),
733-
name="sponsors_contract_nullify",
827+
name=f"{base_name}_nullify",
734828
),
735829
]
736830
return my_urls + urls

0 commit comments

Comments
 (0)