Skip to content

Commit 47fa8dd

Browse files
authored
Implement page for user to submit assets information (#1911)
* 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. * Add extra card on sponsorship detail page to link to assets form * Isolate customization on how to create a benefit feature in a mixin Without that change, an RequiredImgAsset feature was able to call the create_benefit_feature method and this is wrong, since only the feature configuration objects should be able to create benefit features. * Create helper manager to operate on top of benefit features * Implement a higher level API to get assets values from the required assets model * Minimal dynamic form construction * Every asset should have an input label and an optional help text * Unit test form field * Add test to make sure of field names * Add missing directory slash and remove duplicated extension * Implement method to update assets with information uploaded from the user * Refactor to use Python properties instead of Java-ish get/set methods * Make sure initials are being populated and add property to check if form has inputs * If field has initial data, it shouldn't be removed. * Refactor how initial is being populated so widgets can work properly Widgets only consider a field as required if this flag was passed to the field during creation time. So, this PR refactors the as_form_field methods to accept parameters so the form initialization can control required fields * Refactor form to use init and, thus, to expose a more Django-ish API * Implement view to update sponsor assets * Update HTML/CSS to display assets form * Print logo should be an image field too * Update migrations dependencies after merge with main branch * Build form with for specific asset via querystring * Update sponsorship detail to separate fulfilled from pending assets * Display links to specific assets at the sponsorship detail page * Add links for user to submit required assets in the new sponsorship application notification * Customize message if assets form with no fields due to unexisting required asset
1 parent 6c5a004 commit 47fa8dd

19 files changed

+660
-26
lines changed

sponsors/forms.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SponsorBenefit,
2020
SponsorEmailNotificationTemplate,
2121
RequiredImgAssetConfiguration,
22+
BenefitFeature,
2223
)
2324

2425

@@ -174,7 +175,7 @@ class SponsorshipApplicationForm(forms.Form):
174175
help_text="For display on our sponsor webpage. High resolution PNG or JPG, smallest dimension no less than 256px",
175176
required=False,
176177
)
177-
print_logo = forms.FileField(
178+
print_logo = forms.ImageField(
178179
label="Sponsor print logo",
179180
help_text="For printed materials, signage, and projection. SVG or EPS",
180181
required=False,
@@ -546,3 +547,54 @@ def clean(self):
546547
class Meta:
547548
model = RequiredImgAssetConfiguration
548549
fields = "__all__"
550+
551+
552+
class SponsorRequiredAssetsForm(forms.Form):
553+
"""
554+
This form is used by the sponsor to fullfill their information related
555+
to the required assets. The form is built dynamically by fetching the
556+
required assets from the sponsorship.
557+
"""
558+
559+
def __init__(self, *args, **kwargs):
560+
"""
561+
Init method introspect the sponsorship object and
562+
build the form object
563+
"""
564+
self.sponsorship = kwargs.pop("instance", None)
565+
required_assets_ids = kwargs.pop("required_assets_ids", [])
566+
if not self.sponsorship:
567+
msg = "Form must be initialized with a sponsorship passed by the instance parameter"
568+
raise TypeError(msg)
569+
super().__init__(*args, **kwargs)
570+
self.required_assets = BenefitFeature.objects.required_assets().from_sponsorship(self.sponsorship)
571+
if required_assets_ids:
572+
self.required_assets = self.required_assets.filter(pk__in=required_assets_ids)
573+
574+
fields = {}
575+
for required_asset in self.required_assets:
576+
value = required_asset.value
577+
f_name = self._get_field_name(required_asset)
578+
required = bool(value)
579+
fields[f_name] = required_asset.as_form_field(required=required, initial=value)
580+
581+
self.fields.update(fields)
582+
583+
def _get_field_name(self, asset):
584+
return slugify(asset.internal_name).replace("-", "_")
585+
586+
def update_assets(self):
587+
"""
588+
Iterate over every required asset, get the value from form data and
589+
update it
590+
"""
591+
for req_asset in self.required_assets:
592+
f_name = self._get_field_name(req_asset)
593+
value = self.cleaned_data.get(f_name, None)
594+
if value is None:
595+
continue
596+
req_asset.value = value
597+
598+
@property
599+
def has_input(self):
600+
return bool(self.fields)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 2.2.24 on 2021-11-08 14:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('sponsors', '0060_auto_20211111_1526'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='requiredimgasset',
15+
name='help_text',
16+
field=models.CharField(blank=True, default='', help_text='Any helper comment on how the input should be populated', max_length=256),
17+
),
18+
migrations.AddField(
19+
model_name='requiredimgasset',
20+
name='label',
21+
field=models.CharField(default='label', help_text="What's the title used to display the input to the sponsor?", max_length=256),
22+
preserve_default=False,
23+
),
24+
migrations.AddField(
25+
model_name='requiredimgassetconfiguration',
26+
name='help_text',
27+
field=models.CharField(blank=True, default='', help_text='Any helper comment on how the input should be populated', max_length=256),
28+
),
29+
migrations.AddField(
30+
model_name='requiredimgassetconfiguration',
31+
name='label',
32+
field=models.CharField(default='label', help_text="What's the title used to display the input to the sponsor?", max_length=256),
33+
preserve_default=False,
34+
),
35+
migrations.AlterField(
36+
model_name='requiredtextasset',
37+
name='label',
38+
field=models.CharField(help_text="What's the title used to display the input to the sponsor?", max_length=256),
39+
),
40+
migrations.AlterField(
41+
model_name='requiredtextassetconfiguration',
42+
name='label',
43+
field=models.CharField(help_text="What's the title used to display the input to the sponsor?", max_length=256),
44+
),
45+
]

sponsors/migrations/0061_auto_20211111_1529.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
class Migration(migrations.Migration):
77

88
dependencies = [
9-
('sponsors', '0060_auto_20211111_1526'),
9+
('sponsors', '0060_auto_20211108_1419')
1010
]
1111

1212
operations = [

sponsors/models/assets.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ def generic_asset_path(instance, filename):
1717
"""
1818
directory = "sponsors-app-assets"
1919
ext = "".join(Path(filename).suffixes)
20-
name = f"{instance.uuid}{ext}"
21-
return f"{directory}{name}{ext}"
20+
name = f"{instance.uuid}"
21+
return f"{directory}/{name}{ext}"
2222

2323

2424
class GenericAsset(PolymorphicModel):
@@ -68,6 +68,10 @@ class Meta:
6868
def value(self):
6969
return self.image
7070

71+
@value.setter
72+
def value(self, value):
73+
self.image = value
74+
7175

7276
class TextAsset(GenericAsset):
7377
text = models.TextField(default="")
@@ -82,3 +86,7 @@ class Meta:
8286
@property
8387
def value(self):
8488
return self.text
89+
90+
@value.setter
91+
def value(self, value):
92+
self.text = value

sponsors/models/benefits.py

+79-8
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
"""
22
This module holds models related to benefits features and configurations
33
"""
4-
5-
from django.db import models, IntegrityError, transaction
4+
from django import forms
5+
from django.db import models
66
from django.db.models import UniqueConstraint
7+
from django.urls import reverse
78
from polymorphic.models import PolymorphicModel
89

910
from sponsors.models.assets import ImgAsset, TextAsset
1011
from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo
1112

13+
########################################
14+
# Benefit features abstract classes
15+
from sponsors.models.managers import BenefitFeatureQuerySet
16+
1217

1318
########################################
1419
# Benefit features abstract classes
@@ -67,10 +72,32 @@ class BaseRequiredAsset(models.Model):
6772
unique=False,
6873
db_index=True,
6974
)
75+
label = models.CharField(
76+
max_length=256,
77+
help_text="What's the title used to display the input to the sponsor?"
78+
)
79+
help_text = models.CharField(
80+
max_length=256,
81+
help_text="Any helper comment on how the input should be populated",
82+
default="",
83+
blank=True
84+
)
85+
86+
class Meta:
87+
abstract = True
88+
89+
90+
class RequiredAssetConfigurationMixin:
91+
"""
92+
This class should be used to implement assets configuration.
93+
It's a mixin to updates the benefit feature creation to also
94+
create the related assets models
95+
"""
7096

7197
def create_benefit_feature(self, sponsor_benefit, **kwargs):
7298
if not self.ASSET_CLASS:
73-
raise NotImplementedError("Subclasses of BaseRequiredAsset must define an ASSET_CLASS attribute.")
99+
raise NotImplementedError(
100+
"Subclasses of RequiredAssetConfigurationMixin must define an ASSET_CLASS attribute.")
74101

75102
# Super: BenefitFeatureConfiguration.create_benefit_feature
76103
benefit_feature = super().create_benefit_feature(sponsor_benefit, **kwargs)
@@ -122,6 +149,37 @@ class Meta(BaseRequiredAsset.Meta):
122149
abstract = True
123150

124151

152+
class RequiredAssetMixin:
153+
"""
154+
This class should be used to implement required assets.
155+
It's a mixin to get the information submitted by the user
156+
and which is stored in the related asset class.
157+
"""
158+
159+
def __related_asset(self):
160+
object = self.sponsor_benefit.sponsorship
161+
if self.related_to == AssetsRelatedTo.SPONSOR.value:
162+
object = self.sponsor_benefit.sponsorship.sponsor
163+
164+
return object.assets.get(internal_name=self.internal_name)
165+
166+
@property
167+
def value(self):
168+
asset = self.__related_asset()
169+
return asset.value
170+
171+
@value.setter
172+
def value(self, value):
173+
asset = self.__related_asset()
174+
asset.value = value
175+
asset.save()
176+
177+
@property
178+
def user_edit_url(self):
179+
url = reverse("users:update_sponsorship_assets", args=[self.sponsor_benefit.sponsorship.pk])
180+
return url + f"?required_asset={self.pk}"
181+
182+
125183
######################################################
126184
# SponsorshipBenefit features configuration models
127185
class BenefitFeatureConfiguration(PolymorphicModel):
@@ -245,8 +303,7 @@ def __str__(self):
245303
return f"Email targeatable configuration"
246304

247305

248-
class RequiredImgAssetConfiguration(BaseRequiredImgAsset, BenefitFeatureConfiguration):
249-
306+
class RequiredImgAssetConfiguration(RequiredAssetConfigurationMixin, BaseRequiredImgAsset, BenefitFeatureConfiguration):
250307
class Meta(BaseRequiredImgAsset.Meta, BenefitFeatureConfiguration.Meta):
251308
verbose_name = "Require Image Configuration"
252309
verbose_name_plural = "Require Image Configurations"
@@ -260,7 +317,8 @@ def benefit_feature_class(self):
260317
return RequiredImgAsset
261318

262319

263-
class RequiredTextAssetConfiguration(BaseRequiredTextAsset, BenefitFeatureConfiguration):
320+
class RequiredTextAssetConfiguration(RequiredAssetConfigurationMixin, BaseRequiredTextAsset,
321+
BenefitFeatureConfiguration):
264322
class Meta(BaseRequiredTextAsset.Meta, BenefitFeatureConfiguration.Meta):
265323
verbose_name = "Require Text Configuration"
266324
verbose_name_plural = "Require Text Configurations"
@@ -280,6 +338,7 @@ class BenefitFeature(PolymorphicModel):
280338
"""
281339
Base class for sponsor benefits features.
282340
"""
341+
objects = BenefitFeatureQuerySet.as_manager()
283342

284343
sponsor_benefit = models.ForeignKey("sponsors.SponsorBenefit", on_delete=models.CASCADE)
285344

@@ -333,19 +392,31 @@ def __str__(self):
333392
return f"Email targeatable"
334393

335394

336-
class RequiredImgAsset(BaseRequiredImgAsset, BenefitFeature):
395+
class RequiredImgAsset(RequiredAssetMixin, BaseRequiredImgAsset, BenefitFeature):
337396
class Meta(BaseRequiredImgAsset.Meta, BenefitFeature.Meta):
338397
verbose_name = "Require Image"
339398
verbose_name_plural = "Require Images"
340399

341400
def __str__(self):
342401
return f"Require image"
343402

403+
def as_form_field(self, **kwargs):
404+
help_text = kwargs.pop("help_text", self.help_text)
405+
label = kwargs.pop("label", self.label)
406+
required = kwargs.pop("required", False)
407+
return forms.ImageField(required=required, help_text=help_text, label=label, widget=forms.ClearableFileInput, **kwargs)
344408

345-
class RequiredTextAsset(BaseRequiredTextAsset, BenefitFeature):
409+
410+
class RequiredTextAsset(RequiredAssetMixin, BaseRequiredTextAsset, BenefitFeature):
346411
class Meta(BaseRequiredTextAsset.Meta, BenefitFeature.Meta):
347412
verbose_name = "Require Text"
348413
verbose_name_plural = "Require Texts"
349414

350415
def __str__(self):
351416
return f"Require text"
417+
418+
def as_form_field(self, **kwargs):
419+
help_text = kwargs.pop("help_text", self.help_text)
420+
label = kwargs.pop("label", self.label)
421+
required = kwargs.pop("required", False)
422+
return forms.CharField(required=required, help_text=help_text, label=label, widget=forms.TextInput, **kwargs)

sponsors/models/managers.py

+12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.db.models import Q, Subquery
44
from django.db.models.query import QuerySet
55
from django.utils import timezone
6+
from polymorphic.query import PolymorphicQuerySet
67

78

89
class SponsorshipQuerySet(QuerySet):
@@ -92,3 +93,14 @@ def with_packages(self):
9293
class SponsorshipPackageManager(OrderedModelManager):
9394
def list_advertisables(self):
9495
return self.filter(advertise=True)
96+
97+
98+
class BenefitFeatureQuerySet(PolymorphicQuerySet):
99+
100+
def from_sponsorship(self, sponsorship):
101+
return self.filter(sponsor_benefit__sponsorship=sponsorship).select_related("sponsor_benefit__sponsorship")
102+
103+
def required_assets(self):
104+
from sponsors.models.benefits import RequiredAssetMixin
105+
required_assets_classes = RequiredAssetMixin.__subclasses__()
106+
return self.instance_of(*required_assets_classes).select_related("sponsor_benefit__sponsorship")

sponsors/notifications.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.admin.models import LogEntry, CHANGE, ADDITION
55
from django.contrib.contenttypes.models import ContentType
66

7-
from sponsors.models import Sponsorship, Contract
7+
from sponsors.models import Sponsorship, Contract, BenefitFeature
88

99

1010
class BaseEmailSponsorshipNotification:
@@ -27,8 +27,11 @@ def get_attachments(self, context):
2727
"""
2828
return []
2929

30+
def get_email_context(self, **kwargs):
31+
return {k: kwargs.get(k) for k in self.email_context_keys}
32+
3033
def notify(self, **kwargs):
31-
context = {k: kwargs.get(k) for k in self.email_context_keys}
34+
context = self.get_email_context(**kwargs)
3235

3336
email = EmailMessage(
3437
subject=self.get_subject(context),
@@ -54,11 +57,16 @@ def get_recipient_list(self, context):
5457
class AppliedSponsorshipNotificationToSponsors(BaseEmailSponsorshipNotification):
5558
subject_template = "sponsors/email/sponsor_new_application_subject.txt"
5659
message_template = "sponsors/email/sponsor_new_application.txt"
57-
email_context_keys = ["sponsorship"]
60+
email_context_keys = ["sponsorship", "request"]
5861

5962
def get_recipient_list(self, context):
6063
return context["sponsorship"].verified_emails
6164

65+
def get_email_context(self, **kwargs):
66+
context = super().get_email_context(**kwargs)
67+
context["required_assets"] = BenefitFeature.objects.from_sponsorship(context["sponsorship"]).required_assets()
68+
return context
69+
6270

6371
class RejectedSponsorshipNotificationToPSF(BaseEmailSponsorshipNotification):
6472
subject_template = "sponsors/email/psf_rejected_sponsorship_subject.txt"

0 commit comments

Comments
 (0)