Skip to content

Commit 1064bf1

Browse files
authored
Enable PSF staff to force a required image from a sponsorship via benefit configuration (#1900)
* Add missing migrations after changes on the field names and texts * Add new benefit feature to flag benefits with required image assets * Add new configuration to admin * Validate min/max ranges * Model class to hold generic img assets * Use UUID to format file paths * Add new class to require text inputs * Add asset to store text input from user * Do not use UUID field as primary key Django polymorphic does not work with non-integer ids * Move benefit feature creation to specific method under feature cfg * Remove duplicated tests * Create empty ImgAsset during sponsorship creation * Create empty TextAsset during sponsorship creation * Check if asset relates to sponsor or sponsorship before creating it * Add generic relation to iter over all assets from sponsor/sponsorship models * Assets base models Meta should inherit too * Prevent same required asset from being created twice * Optimizes query to list sponsorship benefits * List sponsorship assets under sponsor/sponsorship detail admin * Add extra card on sponsorship detail page to link to assets form * Revert "Add extra card on sponsorship detail page to link to assets form" This reverts commit 30a8672. * Update the docs of the sponsors app to give a broader vision of the models structure
1 parent d3efcc6 commit 1064bf1

19 files changed

+728
-78
lines changed

docs/source/administration.rst

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,21 @@ The jobs application is using to display Python jobs on the site. The data items
9191
Sponsors
9292
--------
9393

94-
The Sponsors app is a place to store PSF Sponsors. Sponsors have to be associated to a Company model from
95-
the companies app. If they are marked as `is_published` they will be shown on the main sponsor page which
96-
is located at /sponsors/.
94+
The Sponsors app is a place to store PSF Sponsors and Sponsorships. This is the most complex app in the
95+
project due to the multiple possibilities on how to configure a sponsorship and, to support this, the
96+
app has a lot of models that are grouped by context. Here's a list of the group of models and what do
97+
they represent:
98+
99+
:sponsorship.py: The `Sponsorship` model and all the related information to configure a new sponsorship
100+
appplication like programs, packages and benefits;
101+
:benefits.py: List models that are used to configure benefits. Here you'll find models that forces a
102+
benefit to have an asset or controls it maximum quantity;
103+
:assets.py: Models that are used to configure the type of assets that a benefit can have;
104+
:sponsors.py: Has the `Sponsor` model and all related information such as their contacts and benefits;
105+
:notifications.py: Any type of sponsor notification that's configurable via admin;
106+
:contract.py: The `Contract` model which is used to generate the final contract document and other
107+
support models;
97108

98-
If a Sponsor is marked as `featured` they will be included in the sponsor rotation on the main PSF landing
99-
page. In the fourth "Sponsors" column.
100109

101110
Events
102111
------

sponsors/admin.py

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,41 @@
1+
from django.contrib.contenttypes.admin import GenericTabularInline
12
from ordered_model.admin import OrderedModelAdmin
23
from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline
34

45
from django.db.models import Subquery
56
from django.template import Context, Template
67
from django.contrib import admin
78
from django.contrib.humanize.templatetags.humanize import intcomma
8-
from django.urls import path, reverse
9+
from django.urls import path, reverse, resolve
910
from django.utils.functional import cached_property
1011
from django.utils.html import mark_safe
1112

1213
from mailing.admin import BaseEmailTemplateAdmin
13-
from .models import (
14-
SponsorshipPackage,
15-
SponsorshipProgram,
16-
SponsorshipBenefit,
17-
Sponsor,
18-
Sponsorship,
19-
SponsorContact,
20-
SponsorBenefit,
21-
LegalClause,
22-
Contract,
23-
BenefitFeature,
24-
BenefitFeatureConfiguration,
25-
LogoPlacementConfiguration,
26-
TieredQuantityConfiguration,
27-
EmailTargetableConfiguration,
28-
SponsorEmailNotificationTemplate,
29-
)
14+
from sponsors.models import *
3015
from sponsors import views_admin
31-
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm
16+
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm
3217
from cms.admin import ContentManageableModelAdmin
3318

3419

20+
class AssetsInline(GenericTabularInline):
21+
model = GenericAsset
22+
extra = 0
23+
max_num = 0
24+
has_delete_permission = lambda self, request, obj: False
25+
readonly_fields = ["internal_name", "user_submitted_info", "value"]
26+
27+
def value(self, request, obj=None):
28+
if not obj or not obj.value:
29+
return ""
30+
return obj.value
31+
value.short_description = "Submitted information"
32+
33+
def user_submitted_info(self, request, obj=None):
34+
return bool(self.value(request, obj))
35+
user_submitted_info.short_description = "Fullfilled data?"
36+
user_submitted_info.boolean = True
37+
38+
3539
@admin.register(SponsorshipProgram)
3640
class SponsorshipProgramAdmin(OrderedModelAdmin):
3741
ordering = ("order",)
@@ -55,11 +59,20 @@ class EmailTargetableConfigurationInline(StackedPolymorphicInline.Child):
5559
def display(self, obj):
5660
return "Enabled"
5761

62+
class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child):
63+
model = RequiredImgAssetConfiguration
64+
form = RequiredImgAssetConfigurationForm
65+
66+
class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child):
67+
model = RequiredTextAssetConfiguration
68+
5869
model = BenefitFeatureConfiguration
5970
child_inlines = [
6071
LogoPlacementConfigurationInline,
6172
TieredQuantityConfigurationInline,
6273
EmailTargetableConfigurationInline,
74+
RequiredImgAssetConfigurationInline,
75+
RequiredTextAssetConfigurationInline,
6376
]
6477

6578

@@ -144,7 +157,7 @@ class SponsorContactInline(admin.TabularInline):
144157

145158
@admin.register(Sponsor)
146159
class SponsorAdmin(ContentManageableModelAdmin):
147-
inlines = [SponsorContactInline]
160+
inlines = [SponsorContactInline, AssetsInline]
148161
search_fields = ["name"]
149162

150163

@@ -153,6 +166,7 @@ class SponsorBenefitInline(admin.TabularInline):
153166
form = SponsorBenefitAdminInlineForm
154167
fields = ["sponsorship_benefit", "benefit_internal_value"]
155168
extra = 0
169+
max_num = 0
156170

157171
def has_add_permission(self, request, obj=None):
158172
has_add_permission = super().has_add_permission(request, obj=obj)
@@ -172,6 +186,10 @@ def has_delete_permission(self, request, obj=None):
172186
return True
173187
return obj.open_for_editing
174188

189+
def get_queryset(self, *args, **kwargs):
190+
qs = super().get_queryset(*args, **kwargs)
191+
return qs.select_related("sponsorship_benefit__program", "program")
192+
175193

176194
class TargetableEmailBenefitsFilter(admin.SimpleListFilter):
177195
title = "targetable email benefits"
@@ -185,7 +203,7 @@ def benefits(self):
185203

186204
def lookups(self, request, model_admin):
187205
return [
188-
(k, b.name) for k, b in self.benefits.items()
206+
(k, b.name) for k, b in self.benefits.items()
189207
]
190208

191209
def queryset(self, request, queryset):
@@ -202,7 +220,7 @@ def queryset(self, request, queryset):
202220
class SponsorshipAdmin(admin.ModelAdmin):
203221
change_form_template = "sponsors/admin/sponsorship_change_form.html"
204222
form = SponsorshipReviewAdminForm
205-
inlines = [SponsorBenefitInline]
223+
inlines = [SponsorBenefitInline, AssetsInline]
206224
search_fields = ["sponsor__name"]
207225
list_display = [
208226
"sponsor",
@@ -264,7 +282,7 @@ class SponsorshipAdmin(admin.ModelAdmin):
264282

265283
def get_queryset(self, *args, **kwargs):
266284
qs = super().get_queryset(*args, **kwargs)
267-
return qs.select_related("sponsor", "package")
285+
return qs.select_related("sponsor", "package", "submited_by")
268286

269287
def send_notifications(self, request, queryset):
270288
return views_admin.send_sponsorship_notifications_action(self, request, queryset)
@@ -306,6 +324,7 @@ def get_estimated_cost(self, obj):
306324
cost = intcomma(obj.estimated_cost)
307325
html = f"{cost} USD <br/><b>Important: </b> {msg}"
308326
return mark_safe(html)
327+
309328
get_estimated_cost.short_description = "Estimated cost"
310329

311330
def get_contract(self, obj):
@@ -314,6 +333,7 @@ def get_contract(self, obj):
314333
url = reverse("admin:sponsors_contract_change", args=[obj.contract.pk])
315334
html = f"<a href='{url}' target='_blank'>{obj.contract}</a>"
316335
return mark_safe(html)
336+
317337
get_contract.short_description = "Contract"
318338

319339
def get_urls(self):
@@ -346,14 +366,17 @@ def get_urls(self):
346366

347367
def get_sponsor_name(self, obj):
348368
return obj.sponsor.name
369+
349370
get_sponsor_name.short_description = "Name"
350371

351372
def get_sponsor_description(self, obj):
352373
return obj.sponsor.description
374+
353375
get_sponsor_description.short_description = "Description"
354376

355377
def get_sponsor_landing_page_url(self, obj):
356378
return obj.sponsor.landing_page_url
379+
357380
get_sponsor_landing_page_url.short_description = "Landing Page URL"
358381

359382
def get_sponsor_web_logo(self, obj):
@@ -362,6 +385,7 @@ def get_sponsor_web_logo(self, obj):
362385
context = Context({'sponsor': obj.sponsor})
363386
html = template.render(context)
364387
return mark_safe(html)
388+
365389
get_sponsor_web_logo.short_description = "Web Logo"
366390

367391
def get_sponsor_print_logo(self, obj):
@@ -373,10 +397,12 @@ def get_sponsor_print_logo(self, obj):
373397
context = Context({'img': img})
374398
html = template.render(context)
375399
return mark_safe(html) if html else "---"
400+
376401
get_sponsor_print_logo.short_description = "Print Logo"
377402

378403
def get_sponsor_primary_phone(self, obj):
379404
return obj.sponsor.primary_phone
405+
380406
get_sponsor_primary_phone.short_description = "Primary Phone"
381407

382408
def get_sponsor_mailing_address(self, obj):
@@ -395,6 +421,7 @@ def get_sponsor_mailing_address(self, obj):
395421
html += f"<p>{mail_row}</p>"
396422
html += f"<p>{sponsor.postal_code}</p>"
397423
return mark_safe(html)
424+
398425
get_sponsor_mailing_address.short_description = "Mailing/Billing Address"
399426

400427
def get_sponsor_contacts(self, obj):
@@ -415,6 +442,7 @@ def get_sponsor_contacts(self, obj):
415442
)
416443
html += "</ul>"
417444
return mark_safe(html)
445+
418446
get_sponsor_contacts.short_description = "Contacts"
419447

420448
def rollback_to_editing_view(self, request, pk):
@@ -538,15 +566,16 @@ def document_link(self, obj):
538566
if url and msg:
539567
html = f'<a href="{url}" target="_blank">{msg}</a>'
540568
return mark_safe(html)
541-
document_link.short_description = "Contract document"
542569

570+
document_link.short_description = "Contract document"
543571

544572
def get_sponsorship_url(self, obj):
545573
if not obj.sponsorship:
546574
return "---"
547575
url = reverse("admin:sponsors_sponsorship_change", args=[obj.sponsorship.pk])
548576
html = f"<a href='{url}' target='_blank'>{obj.sponsorship}</a>"
549577
return mark_safe(html)
578+
550579
get_sponsorship_url.short_description = "Sponsorship"
551580

552581
def get_urls(self):

sponsors/forms.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
SponsorContact,
1818
Sponsorship,
1919
SponsorBenefit,
20-
SponsorEmailNotificationTemplate
20+
SponsorEmailNotificationTemplate,
21+
RequiredImgAssetConfiguration,
2122
)
2223

2324

@@ -526,3 +527,22 @@ def clean(self):
526527
def save(self, *args, **kwargs):
527528
super().save(*args, **kwargs)
528529
self.contacts_formset.save()
530+
531+
532+
class RequiredImgAssetConfigurationForm(forms.ModelForm):
533+
534+
def clean(self):
535+
data = super().clean()
536+
537+
min_width, max_width = data.get("min_width"), data.get("max_width")
538+
if min_width and max_width and max_width < min_width:
539+
raise forms.ValidationError("Max width must be greater than min width")
540+
min_height, max_height = data.get("min_height"), data.get("max_height")
541+
if min_height and max_height and max_height < min_height:
542+
raise forms.ValidationError("Max height must be greater than min height")
543+
544+
return data
545+
546+
class Meta:
547+
model = RequiredImgAssetConfiguration
548+
fields = "__all__"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Generated by Django 2.2.24 on 2021-10-22 14:03
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('sponsors', '0050_emailtargetable_emailtargetableconfiguration'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='sponsor',
16+
name='description',
17+
field=models.TextField(help_text='Brief description of the sponsor for public display.', verbose_name='Description'),
18+
),
19+
migrations.AlterField(
20+
model_name='sponsor',
21+
name='landing_page_url',
22+
field=models.URLField(blank=True, help_text='Landing page URL. This may be provided by the sponsor, however the linked page may not contain any sales or marketing information.', null=True, verbose_name='Landing page URL'),
23+
),
24+
migrations.AlterField(
25+
model_name='sponsor',
26+
name='name',
27+
field=models.CharField(help_text='Name of the sponsor, for public display.', max_length=100, verbose_name='Name'),
28+
),
29+
migrations.AlterField(
30+
model_name='sponsor',
31+
name='primary_phone',
32+
field=models.CharField(max_length=32, verbose_name='Primary Phone'),
33+
),
34+
migrations.AlterField(
35+
model_name='sponsor',
36+
name='print_logo',
37+
field=models.FileField(blank=True, help_text='For printed materials, signage, and projection. SVG or EPS', null=True, upload_to='sponsor_print_logos', verbose_name='Print logo'),
38+
),
39+
migrations.AlterField(
40+
model_name='sponsor',
41+
name='twitter_handle',
42+
field=models.CharField(blank=True, max_length=32, null=True, verbose_name='Twitter handle'),
43+
),
44+
migrations.AlterField(
45+
model_name='sponsor',
46+
name='web_logo',
47+
field=models.ImageField(help_text='For display on our sponsor webpage. High resolution PNG or JPG, smallest dimension no less than 256px', upload_to='sponsor_web_logos', verbose_name='Web logo'),
48+
),
49+
migrations.AlterField(
50+
model_name='sponsorcontact',
51+
name='primary',
52+
field=models.BooleanField(default=False, help_text='The primary contact for a sponsorship will be responsible for managing deliverables we need to fulfill benefits. Primary contacts will receive all email notifications regarding sponsorship. '),
53+
),
54+
]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated by Django 2.2.24 on 2021-10-22 14:04
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('sponsors', '0051_auto_20211022_1403'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='RequiredImgAsset',
16+
fields=[
17+
('benefitfeature_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.BenefitFeature')),
18+
('related_to', models.CharField(choices=[('sponsor', 'Sponsor'), ('sponsorship', 'Sponsorship')], help_text='To which instance (Sponsor or Sponsorship) should this asset relate to.', max_length=30, verbose_name='Related To')),
19+
('internal_name', models.CharField(db_index=True, help_text='Unique name used internally to control if the sponsor/sponsorship already has the asset', max_length=128, unique=True, verbose_name='Internal Name')),
20+
('min_width', models.PositiveIntegerField()),
21+
('max_width', models.PositiveIntegerField()),
22+
('min_height', models.PositiveIntegerField()),
23+
('max_height', models.PositiveIntegerField()),
24+
],
25+
options={
26+
'verbose_name': 'Require Image Benefit',
27+
'verbose_name_plural': 'Require Image Benefits',
28+
'abstract': False,
29+
'base_manager_name': 'objects',
30+
},
31+
bases=('sponsors.benefitfeature', models.Model),
32+
),
33+
migrations.CreateModel(
34+
name='RequiredImgAssetConfiguration',
35+
fields=[
36+
('benefitfeatureconfiguration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.BenefitFeatureConfiguration')),
37+
('related_to', models.CharField(choices=[('sponsor', 'Sponsor'), ('sponsorship', 'Sponsorship')], help_text='To which instance (Sponsor or Sponsorship) should this asset relate to.', max_length=30, verbose_name='Related To')),
38+
('internal_name', models.CharField(db_index=True, help_text='Unique name used internally to control if the sponsor/sponsorship already has the asset', max_length=128, unique=True, verbose_name='Internal Name')),
39+
('min_width', models.PositiveIntegerField()),
40+
('max_width', models.PositiveIntegerField()),
41+
('min_height', models.PositiveIntegerField()),
42+
('max_height', models.PositiveIntegerField()),
43+
],
44+
options={
45+
'verbose_name': 'Require Image Configuration',
46+
'verbose_name_plural': 'Require Image Configurations',
47+
'abstract': False,
48+
'base_manager_name': 'objects',
49+
},
50+
bases=('sponsors.benefitfeatureconfiguration', models.Model),
51+
),
52+
]

0 commit comments

Comments
 (0)