Skip to content

Commit e4a3d88

Browse files
berinhardewdurbin
andauthored
User workflow for sponsorship applications (#1667)
* Initial sponsor application modeling * Form to create a new sponsor application * Simple view to display new sponsor application form * Use cookie to store selected benefits * Redirect user back to benefits form if no data in cookie * Simple template view to work as the final step * Move helper test method to utils * Make sure wokflow finishes as expected * Create sponsorship using information, benefits and package * Post also creates sponsorship * Use the package amount as sponsorship fee * Inform users about why they are being redirect * Clean up cookies after creating sponsorship * View to preview sponsorship * Remove old TODO note * Trailing slashes * Clean up current Sponsor model implementation * SponsorInformation is now the Sponsor model * Create select field for user to pick existing sponsors * Create formset for contacts * Refactor sponsorship application form to use contacts formset * Remove deprecated comment * Better determine primary and user contact * Install django widget tweaks * Render form fields manually * Better display for contacts form * Implement missing __str__ * JS to control contact field sets * Hide/show logic if user has previous sponsor * Fix CSS error with upload inputs * Display SponsorContact in the admin * Unit test new property * New sponsor fields are only required if no sponsor was selected * Display message with sponsorship information * Check benefits if already selected * Display form errors * Fix test to properly force error * Checkbox to select primary contacts * Force order to avoid tests inconsistency * Display price preview * CSS fix for contacts form * Display the form if errors * Refactor how contacts are being structured so they fit well if errors * Implement logic to check if application is for a modified package * Add status field to Sponsorship * allow reordering sponsorship programs in django admin * apply black formatting * add indicator for benefits marked as new Co-authored-by: Ernest W. Durbin III <ewdurbin@gmail.com>
1 parent e32831e commit e4a3d88

28 files changed

+1889
-150
lines changed

base-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ django-waffle==0.14
3737
djangorestframework==3.8.2
3838
django-filter==1.1.0
3939
django-ordered-model==3.4.1
40+
django-widget-tweaks==1.4.8

pydotorg/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
'honeypot',
153153
'waffle',
154154
'ordered_model',
155+
'widget_tweaks',
155156

156157
'users',
157158
'boxes',

sponsors/admin.py

+60-10
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
1-
from django.contrib import admin
21
from ordered_model.admin import OrderedModelAdmin
32

4-
from .models import SponsorshipPackage, SponsorshipProgram, SponsorshipBenefit, Sponsor
3+
from django.urls import path, reverse
4+
from django.contrib import admin
5+
from django.utils.html import mark_safe
6+
from django.shortcuts import get_object_or_404, render
7+
8+
from .models import (
9+
SponsorshipPackage,
10+
SponsorshipProgram,
11+
SponsorshipBenefit,
12+
Sponsor,
13+
Sponsorship,
14+
SponsorContact,
15+
)
516
from cms.admin import ContentManageableModelAdmin
617

718

819
@admin.register(SponsorshipProgram)
920
class SponsorshipProgramAdmin(OrderedModelAdmin):
10-
pass
21+
ordering = ("order",)
22+
list_display = [
23+
"name",
24+
"move_up_down_links",
25+
]
1126

1227

1328
@admin.register(SponsorshipBenefit)
@@ -58,14 +73,49 @@ class SponsorshipPackageAdmin(OrderedModelAdmin):
5873
list_display = ["name", "move_up_down_links"]
5974

6075

76+
class SponsorContactInline(admin.TabularInline):
77+
model = SponsorContact
78+
extra = 0
79+
80+
6181
@admin.register(Sponsor)
6282
class SponsorAdmin(ContentManageableModelAdmin):
63-
raw_id_fields = ["company"]
83+
inlines = [SponsorContactInline]
84+
85+
86+
@admin.register(Sponsorship)
87+
class SponsorshipAdmin(admin.ModelAdmin):
88+
list_display = [
89+
"sponsor",
90+
"applied_on",
91+
"approved_on",
92+
"start_date",
93+
"end_date",
94+
"display_sponsorship_link",
95+
]
96+
97+
def get_queryset(self, *args, **kwargs):
98+
qs = super().get_queryset(*args, **kwargs)
99+
return qs.select_related("sponsor")
100+
101+
def display_sponsorship_link(self, obj):
102+
url = reverse("admin:sponsors_sponsorship_preview", args=[obj.pk])
103+
return mark_safe(f'<a href="{url}" target="_blank">Click to preview</a>')
104+
105+
display_sponsorship_link.short_description = "Preview sponsorship"
64106

65-
def get_list_filter(self, request):
66-
fields = list(super().get_list_filter(request))
67-
return fields + ["is_published"]
107+
def preview_sponsorship_view(self, request, pk):
108+
sponsorship = get_object_or_404(self.get_queryset(request), pk=pk)
109+
ctx = {"sponsorship": sponsorship}
110+
return render(request, "sponsors/admin/preview-sponsorship.html", context=ctx)
68111

69-
def get_list_display(self, request):
70-
fields = list(super().get_list_display(request))
71-
return fields + ["is_published"]
112+
def get_urls(self, *args, **kwargs):
113+
urls = super().get_urls(*args, **kwargs)
114+
custom_urls = [
115+
path(
116+
"<int:pk>/preview",
117+
self.admin_site.admin_view(self.preview_sponsorship_view),
118+
name="sponsors_sponsorship_preview",
119+
),
120+
]
121+
return custom_urls + urls

sponsors/cookies.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import json
2+
3+
BENEFITS_COOKIE_NAME = "sponsorship_selected_benefits"
4+
5+
6+
def get_sponsorship_selected_benefits(request):
7+
sponsorship_selected_benefits = request.COOKIES.get(BENEFITS_COOKIE_NAME)
8+
if sponsorship_selected_benefits:
9+
try:
10+
return json.loads(sponsorship_selected_benefits)
11+
except json.JSONDecodeError:
12+
pass
13+
return {}
14+
15+
16+
def set_sponsorship_selected_benefits(response, data):
17+
max_age = 60 * 60 * 24 # one day
18+
response.set_cookie(BENEFITS_COOKIE_NAME, json.dumps(data), max_age=max_age)
19+
20+
21+
def delete_sponsorship_selected_benefits(response):
22+
response.delete_cookie(BENEFITS_COOKIE_NAME)

sponsors/forms.py

+155-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@
22
from django import forms
33
from django.utils.text import slugify
44
from django.utils.translation import ugettext_lazy as _
5+
from django.utils.functional import cached_property
56

6-
from sponsors.models import SponsorshipBenefit, SponsorshipPackage, SponsorshipProgram
7+
from sponsors.models import (
8+
SponsorshipBenefit,
9+
SponsorshipPackage,
10+
SponsorshipProgram,
11+
Sponsor,
12+
SponsorContact,
13+
)
714

815

916
class PickSponsorshipBenefitsField(forms.ModelMultipleChoiceField):
@@ -13,6 +20,23 @@ def label_from_instance(self, obj):
1320
return obj.name
1421

1522

23+
class SponsorContactForm(forms.ModelForm):
24+
class Meta:
25+
model = SponsorContact
26+
fields = ["name", "email", "phone", "primary"]
27+
28+
29+
SponsorContactFormSet = forms.formset_factory(
30+
SponsorContactForm,
31+
extra=0,
32+
min_num=1,
33+
validate_min=True,
34+
can_delete=False,
35+
can_order=False,
36+
max_num=5,
37+
)
38+
39+
1640
class SponsorshiptBenefitsForm(forms.Form):
1741
package = forms.ModelChoiceField(
1842
queryset=SponsorshipPackage.objects.all(),
@@ -54,6 +78,9 @@ def get_benefits(self, cleaned_data=None):
5478
chain(*(cleaned_data.get(bp.name) for bp in self.benefits_programs))
5579
)
5680

81+
def get_package(self):
82+
return self.cleaned_data.get("package")
83+
5784
def _clean_benefits(self, cleaned_data):
5885
"""
5986
Validate chosen benefits. Invalid scenarios are:
@@ -100,3 +127,130 @@ def _clean_benefits(self, cleaned_data):
100127
def clean(self):
101128
cleaned_data = super().clean()
102129
return self._clean_benefits(cleaned_data)
130+
131+
132+
class SponsorshipApplicationForm(forms.Form):
133+
name = forms.CharField(
134+
max_length=100,
135+
label="Sponsor name",
136+
help_text="Name of the sponsor, for public display.",
137+
required=False,
138+
)
139+
description = forms.CharField(
140+
label="Sponsor description",
141+
help_text="Brief description of the sponsor for public display.",
142+
required=False,
143+
widget=forms.TextInput,
144+
)
145+
landing_page_url = forms.URLField(
146+
label="Sponsor landing page",
147+
help_text="Sponsor landing page URL. This may be provided by the sponsor, however the linked page may not contain any sales or marketing information.",
148+
required=False,
149+
)
150+
web_logo = forms.ImageField(
151+
label="Sponsor web logo",
152+
help_text="For display on our sponsor webpage. High resolution PNG or JPG, smallest dimension no less than 256px",
153+
required=False,
154+
)
155+
print_logo = forms.FileField(
156+
label="Sponsor print logo",
157+
help_text="For printed materials, signage, and projection. SVG or EPS",
158+
required=False,
159+
)
160+
161+
primary_phone = forms.CharField(
162+
label="Sponsor Primary Phone", max_length=32, required=False,
163+
)
164+
mailing_address = forms.CharField(
165+
label="Sponsor Mailing/Billing Address", widget=forms.TextInput, required=False,
166+
)
167+
168+
def __init__(self, *args, **kwargs):
169+
self.user = kwargs.pop("user", None)
170+
super().__init__(*args, **kwargs)
171+
qs = Sponsor.objects.none()
172+
if self.user:
173+
sponsor_ids = SponsorContact.objects.filter(user=self.user).values_list(
174+
"sponsor", flat=True
175+
)
176+
qs = Sponsor.objects.filter(id__in=sponsor_ids)
177+
self.fields["sponsor"] = forms.ModelChoiceField(queryset=qs, required=False)
178+
179+
formset_kwargs = {"prefix": "contact"}
180+
if self.data:
181+
self.contacts_formset = SponsorContactFormSet(self.data, **formset_kwargs)
182+
else:
183+
self.contacts_formset = SponsorContactFormSet(**formset_kwargs)
184+
185+
def clean(self):
186+
cleaned_data = super().clean()
187+
sponsor = self.data.get("sponsor")
188+
if not sponsor and not self.contacts_formset.is_valid():
189+
msg = "Errors with contact(s) information"
190+
if not self.contacts_formset.errors:
191+
msg = "You have to enter at least one contact"
192+
raise forms.ValidationError(msg)
193+
elif not sponsor:
194+
has_primary_contact = any(
195+
f.cleaned_data.get("primary") for f in self.contacts_formset.forms
196+
)
197+
if not has_primary_contact:
198+
msg = "You have to mark at least one contact as the primary one."
199+
raise forms.ValidationError(msg)
200+
201+
def clean_name(self):
202+
name = self.cleaned_data.get("name", "")
203+
sponsor = self.data.get("sponsor")
204+
if not sponsor and not name:
205+
raise forms.ValidationError("This field is required.")
206+
return name.strip()
207+
208+
def clean_web_logo(self):
209+
web_logo = self.cleaned_data.get("web_logo", "")
210+
sponsor = self.data.get("sponsor")
211+
if not sponsor and not web_logo:
212+
raise forms.ValidationError("This field is required.")
213+
return web_logo
214+
215+
def clean_primary_phone(self):
216+
primary_phone = self.cleaned_data.get("primary_phone", "")
217+
sponsor = self.data.get("sponsor")
218+
if not sponsor and not primary_phone:
219+
raise forms.ValidationError("This field is required.")
220+
return primary_phone.strip()
221+
222+
def clean_mailing_address(self):
223+
mailing_address = self.cleaned_data.get("mailing_address", "")
224+
sponsor = self.data.get("sponsor")
225+
if not sponsor and not mailing_address:
226+
raise forms.ValidationError("This field is required.")
227+
return mailing_address.strip()
228+
229+
def save(self):
230+
selected_sponsor = self.cleaned_data.get("sponsor")
231+
if selected_sponsor:
232+
return selected_sponsor
233+
234+
sponsor = Sponsor.objects.create(
235+
name=self.cleaned_data["name"],
236+
web_logo=self.cleaned_data["web_logo"],
237+
primary_phone=self.cleaned_data["primary_phone"],
238+
mailing_address=self.cleaned_data["mailing_address"],
239+
description=self.cleaned_data.get("description", ""),
240+
landing_page_url=self.cleaned_data.get("landing_page_url", ""),
241+
print_logo=self.cleaned_data.get("print_logo"),
242+
)
243+
contacts = [f.save(commit=False) for f in self.contacts_formset.forms]
244+
for contact in contacts:
245+
if self.user and self.user.email.lower() == contact.email.lower():
246+
contact.user = self.user
247+
contact.sponsor = sponsor
248+
contact.save()
249+
250+
return sponsor
251+
252+
@cached_property
253+
def user_with_previous_sponsors(self):
254+
if not self.user:
255+
return False
256+
return self.fields["sponsor"].queryset.exists()

sponsors/migrations/0001_initial.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,7 @@ class Migration(migrations.Migration):
9191
),
9292
),
9393
],
94-
options={
95-
"verbose_name": "sponsor",
96-
"verbose_name_plural": "sponsors",
97-
},
94+
options={"verbose_name": "sponsor", "verbose_name_plural": "sponsors",},
9895
bases=(models.Model,),
9996
),
10097
]

sponsors/migrations/0004_auto_20201014_1622.py

+3-12
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@ class Migration(migrations.Migration):
4040
),
4141
),
4242
],
43-
options={
44-
"ordering": ("order",),
45-
"abstract": False,
46-
},
43+
options={"ordering": ("order",), "abstract": False,},
4744
),
4845
migrations.CreateModel(
4946
name="SponsorshipLevel",
@@ -66,10 +63,7 @@ class Migration(migrations.Migration):
6663
("name", models.CharField(max_length=64)),
6764
("sponsorship_amount", models.PositiveIntegerField()),
6865
],
69-
options={
70-
"ordering": ("order",),
71-
"abstract": False,
72-
},
66+
options={"ordering": ("order",), "abstract": False,},
7367
),
7468
migrations.CreateModel(
7569
name="SponsorshipProgram",
@@ -92,10 +86,7 @@ class Migration(migrations.Migration):
9286
("name", models.CharField(max_length=64)),
9387
("description", models.TextField(blank=True, null=True)),
9488
],
95-
options={
96-
"ordering": ("order",),
97-
"abstract": False,
98-
},
89+
options={"ordering": ("order",), "abstract": False,},
9990
),
10091
migrations.AddField(
10192
model_name="sponsorshipbenefit",

sponsors/migrations/0006_auto_20201016_1517.py

+3-10
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,10 @@ class Migration(migrations.Migration):
1111

1212
operations = [
1313
migrations.RenameModel(
14-
old_name="SponsorshipLevel",
15-
new_name="SponsorshipPackage",
16-
),
17-
migrations.RemoveField(
18-
model_name="sponsorshipbenefit",
19-
name="levels",
20-
),
21-
migrations.RemoveField(
22-
model_name="sponsorshipbenefit",
23-
name="minimum_level",
14+
old_name="SponsorshipLevel", new_name="SponsorshipPackage",
2415
),
16+
migrations.RemoveField(model_name="sponsorshipbenefit", name="levels",),
17+
migrations.RemoveField(model_name="sponsorshipbenefit", name="minimum_level",),
2518
migrations.AddField(
2619
model_name="sponsorshipbenefit",
2720
name="new",

0 commit comments

Comments
 (0)