Skip to content

Commit e32831e

Browse files
ewdurbinberinhard
andauthored
Implement new sponsorship system - Frontend (#1662)
* apply black formatting to sponsors application * initial modeling for sponsorships revamp * add fixtures with rough draft of sponsor programs, levels, benefits * update modeling with capacity and make Django admin rendering a bit nicer * tidy up benefit admin list * New form to list sponsorship benefits * Simplest FormView so I can access the form * Split benefits by programs * Change fieldname * Organize levels' benefits in the HTML * JS code to auto-fill the benefits from level * adjust modeling based on further clarification rename "Levels" to "Packages" ----------------------------- we will abstract levels as packages for now and just name them as such "Diamond Sponsorship Level" Benefits: minimum_level -> package_only --------------------------------------- rather than specifying a minimum level, these benefits should be selectable only via choosing a package Benefits: add "new" flag ------------------------ this should render a "New For This Year" badge next to benefits where selected Benefits: "soft" capacities --------------------------- for future implementation, some capacities are soft in the sense that we will allow people to "register interest" when they are exhausted * Remove empty choice from the frontend * Clear form button * Unit test the helper property * Implement conflict behavior in the frontend * Backend validation on benefits conflicts * New view to calc the benefits cost * Run black * Safeguard if internal_value is not set * Fix element definition * Display sponsorship cost in the frontend * Only display price if level changes * Move JS code to specific file * Option label should be benefit's name * Better display for packages select * Organize benefits in columns * Disable active labels if checkbox wasn't checked * Display benefits checkboxes manually * Disable packages only benefits * Disable choices if no available quantity * Field shouldn't be required in django admin * Better control of cost message's update * Fits better in mobile * Labels should be updated even if unselect * Install font awesome * Refactoring * Display icons and legend * Improve benefits admin * Render packages info in html using context data * Fetch package cost from html tag attribute * Remove price calculator view * Remove reference for unexisting named url * Unit test package only validation * Validate benefits capacity * Remove form code that was moved to view's context * Unit test invalid post scenario * Unit test models * Code format * Use package attr to display cost * Only staff users can acess sponsorship application form Co-authored-by: Bernardo Fontes <[email protected]>
1 parent 802aa1e commit e32831e

33 files changed

+4016
-67
lines changed

base-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ django-waffle==0.14
3636

3737
djangorestframework==3.8.2
3838
django-filter==1.1.0
39+
django-ordered-model==3.4.1

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ responses==0.10.5
1212
django-debug-toolbar==1.9.1
1313
coverage
1414
ddt
15+
model-bakery==1.2.0

fixtures/sponsors.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

pydotorg/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
'haystack',
152152
'honeypot',
153153
'waffle',
154+
'ordered_model',
154155

155156
'users',
156157
'boxes',

pydotorg/settings/pipeline.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@
3131
'media': 'screen',
3232
},
3333
},
34+
'font-awesome': {
35+
'source_filenames': (
36+
'stylesheets/font-awesome.min.css',
37+
),
38+
'output_filename': 'stylesheets/no-mq.css',
39+
'extra_context': {
40+
'media': 'screen',
41+
},
42+
},
3443
}
3544

3645
PIPELINE_JS = {

sponsors/admin.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,71 @@
11
from django.contrib import admin
2+
from ordered_model.admin import OrderedModelAdmin
23

3-
from .models import Sponsor
4+
from .models import SponsorshipPackage, SponsorshipProgram, SponsorshipBenefit, Sponsor
45
from cms.admin import ContentManageableModelAdmin
56

67

8+
@admin.register(SponsorshipProgram)
9+
class SponsorshipProgramAdmin(OrderedModelAdmin):
10+
pass
11+
12+
13+
@admin.register(SponsorshipBenefit)
14+
class SponsorshipBenefitAdmin(OrderedModelAdmin):
15+
ordering = ("program", "order")
16+
list_display = [
17+
"program",
18+
"short_name",
19+
"package_only",
20+
"internal_value",
21+
"move_up_down_links",
22+
]
23+
list_filter = ["program"]
24+
search_fields = ["name"]
25+
26+
fieldsets = [
27+
(
28+
"Public",
29+
{
30+
"fields": (
31+
"name",
32+
"description",
33+
"program",
34+
"packages",
35+
"package_only",
36+
"new",
37+
),
38+
},
39+
),
40+
(
41+
"Internal",
42+
{
43+
"fields": (
44+
"internal_description",
45+
"internal_value",
46+
"capacity",
47+
"soft_capacity",
48+
"conflicts",
49+
)
50+
},
51+
),
52+
]
53+
54+
55+
@admin.register(SponsorshipPackage)
56+
class SponsorshipPackageAdmin(OrderedModelAdmin):
57+
ordering = ("order",)
58+
list_display = ["name", "move_up_down_links"]
59+
60+
761
@admin.register(Sponsor)
862
class SponsorAdmin(ContentManageableModelAdmin):
9-
raw_id_fields = ['company']
63+
raw_id_fields = ["company"]
1064

1165
def get_list_filter(self, request):
1266
fields = list(super().get_list_filter(request))
13-
return fields + ['is_published']
67+
return fields + ["is_published"]
1468

1569
def get_list_display(self, request):
1670
fields = list(super().get_list_display(request))
17-
return fields + ['is_published']
71+
return fields + ["is_published"]

sponsors/forms.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from itertools import chain
2+
from django import forms
3+
from django.utils.text import slugify
4+
from django.utils.translation import ugettext_lazy as _
5+
6+
from sponsors.models import SponsorshipBenefit, SponsorshipPackage, SponsorshipProgram
7+
8+
9+
class PickSponsorshipBenefitsField(forms.ModelMultipleChoiceField):
10+
widget = forms.CheckboxSelectMultiple
11+
12+
def label_from_instance(self, obj):
13+
return obj.name
14+
15+
16+
class SponsorshiptBenefitsForm(forms.Form):
17+
package = forms.ModelChoiceField(
18+
queryset=SponsorshipPackage.objects.all(),
19+
widget=forms.RadioSelect(),
20+
required=False,
21+
empty_label=None,
22+
)
23+
24+
def __init__(self, *args, **kwargs):
25+
super().__init__(*args, **kwargs)
26+
benefits_qs = SponsorshipBenefit.objects.select_related("program")
27+
for program in SponsorshipProgram.objects.all():
28+
slug = slugify(program.name).replace("-", "_")
29+
self.fields[f"benefits_{slug}"] = PickSponsorshipBenefitsField(
30+
queryset=benefits_qs.filter(program=program),
31+
required=False,
32+
label=_(f"{program.name} Sponsorship Benefits"),
33+
)
34+
35+
@property
36+
def benefits_programs(self):
37+
return [f for f in self if f.name.startswith("benefits_")]
38+
39+
@property
40+
def benefits_conflicts(self):
41+
"""
42+
Returns a dict with benefits ids as keys and their list of conlicts ids as values
43+
"""
44+
conflicts = {}
45+
for benefit in SponsorshipBenefit.objects.with_conflicts():
46+
benefits_conflicts = benefit.conflicts.values_list("id", flat=True)
47+
if benefits_conflicts:
48+
conflicts[benefit.id] = list(benefits_conflicts)
49+
return conflicts
50+
51+
def get_benefits(self, cleaned_data=None):
52+
cleaned_data = cleaned_data or self.cleaned_data
53+
return list(
54+
chain(*(cleaned_data.get(bp.name) for bp in self.benefits_programs))
55+
)
56+
57+
def _clean_benefits(self, cleaned_data):
58+
"""
59+
Validate chosen benefits. Invalid scenarios are:
60+
- benefits with conflits
61+
- package only benefits and form without SponsorshipProgram
62+
- benefit with no capacity, except if soft
63+
"""
64+
package = cleaned_data.get("package")
65+
benefits = self.get_benefits(cleaned_data)
66+
if not benefits:
67+
raise forms.ValidationError(
68+
_("You have to pick a minimum number of benefits.")
69+
)
70+
71+
benefits_ids = [b.id for b in benefits]
72+
for benefit in benefits:
73+
conflicts = set(self.benefits_conflicts.get(benefit.id, []))
74+
if conflicts and set(benefits_ids).intersection(conflicts):
75+
raise forms.ValidationError(
76+
_("The application has 1 or more benefits that conflicts.")
77+
)
78+
79+
if benefit.package_only:
80+
if not package:
81+
raise forms.ValidationError(
82+
_(
83+
"The application has 1 or more package only benefits and no package."
84+
)
85+
)
86+
elif not benefit.packages.filter(id=package.id).exists():
87+
raise forms.ValidationError(
88+
_(
89+
"The application has 1 or more package only benefits but wrong package."
90+
)
91+
)
92+
93+
if not benefit.has_capacity:
94+
raise forms.ValidationError(
95+
_("The application has 1 or more benefits with no capacity.")
96+
)
97+
98+
return cleaned_data
99+
100+
def clean(self):
101+
cleaned_data = super().clean()
102+
return self._clean_benefits(cleaned_data)

sponsors/migrations/0001_initial.py

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,89 @@ class Migration(migrations.Migration):
1111

1212
dependencies = [
1313
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14-
('companies', '0001_initial'),
14+
("companies", "0001_initial"),
1515
]
1616

1717
operations = [
1818
migrations.CreateModel(
19-
name='Sponsor',
19+
name="Sponsor",
2020
fields=[
21-
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
22-
('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now, blank=True)),
23-
('updated', models.DateTimeField(default=django.utils.timezone.now, blank=True)),
24-
('content', markupfield.fields.MarkupField(rendered_field=True, blank=True)),
25-
('content_markup_type', models.CharField(max_length=30, choices=[('', '--'), ('html', 'html'), ('plain', 'plain'), ('markdown', 'markdown'), ('restructuredtext', 'restructuredtext')], default='restructuredtext', blank=True)),
26-
('is_published', models.BooleanField(db_index=True, default=False)),
27-
('featured', models.BooleanField(help_text='Check to include Sponsor in feature rotation', db_index=True, default=False)),
28-
('_content_rendered', models.TextField(editable=False)),
29-
('company', models.ForeignKey(to='companies.Company', on_delete=models.CASCADE)),
30-
('creator', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, related_name='sponsors_sponsor_creator', blank=True, on_delete=models.CASCADE)),
31-
('last_modified_by', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, related_name='sponsors_sponsor_modified', blank=True, on_delete=models.CASCADE)),
21+
(
22+
"id",
23+
models.AutoField(
24+
primary_key=True,
25+
auto_created=True,
26+
serialize=False,
27+
verbose_name="ID",
28+
),
29+
),
30+
(
31+
"created",
32+
models.DateTimeField(
33+
db_index=True, default=django.utils.timezone.now, blank=True
34+
),
35+
),
36+
(
37+
"updated",
38+
models.DateTimeField(default=django.utils.timezone.now, blank=True),
39+
),
40+
(
41+
"content",
42+
markupfield.fields.MarkupField(rendered_field=True, blank=True),
43+
),
44+
(
45+
"content_markup_type",
46+
models.CharField(
47+
max_length=30,
48+
choices=[
49+
("", "--"),
50+
("html", "html"),
51+
("plain", "plain"),
52+
("markdown", "markdown"),
53+
("restructuredtext", "restructuredtext"),
54+
],
55+
default="restructuredtext",
56+
blank=True,
57+
),
58+
),
59+
("is_published", models.BooleanField(db_index=True, default=False)),
60+
(
61+
"featured",
62+
models.BooleanField(
63+
help_text="Check to include Sponsor in feature rotation",
64+
db_index=True,
65+
default=False,
66+
),
67+
),
68+
("_content_rendered", models.TextField(editable=False)),
69+
(
70+
"company",
71+
models.ForeignKey(to="companies.Company", on_delete=models.CASCADE),
72+
),
73+
(
74+
"creator",
75+
models.ForeignKey(
76+
null=True,
77+
to=settings.AUTH_USER_MODEL,
78+
related_name="sponsors_sponsor_creator",
79+
blank=True,
80+
on_delete=models.CASCADE,
81+
),
82+
),
83+
(
84+
"last_modified_by",
85+
models.ForeignKey(
86+
null=True,
87+
to=settings.AUTH_USER_MODEL,
88+
related_name="sponsors_sponsor_modified",
89+
blank=True,
90+
on_delete=models.CASCADE,
91+
),
92+
),
3293
],
3394
options={
34-
'verbose_name': 'sponsor',
35-
'verbose_name_plural': 'sponsors',
95+
"verbose_name": "sponsor",
96+
"verbose_name_plural": "sponsors",
3697
},
3798
bases=(models.Model,),
3899
),

sponsors/migrations/0002_auto_20150416_1853.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,25 @@
77
class Migration(migrations.Migration):
88

99
dependencies = [
10-
('sponsors', '0001_initial'),
10+
("sponsors", "0001_initial"),
1111
]
1212

1313
operations = [
1414
migrations.AlterField(
15-
model_name='sponsor',
16-
name='content_markup_type',
17-
field=models.CharField(max_length=30, choices=[('', '--'), ('html', 'HTML'), ('plain', 'Plain'), ('markdown', 'Markdown'), ('restructuredtext', 'Restructured Text')], default='restructuredtext', blank=True),
15+
model_name="sponsor",
16+
name="content_markup_type",
17+
field=models.CharField(
18+
max_length=30,
19+
choices=[
20+
("", "--"),
21+
("html", "HTML"),
22+
("plain", "Plain"),
23+
("markdown", "Markdown"),
24+
("restructuredtext", "Restructured Text"),
25+
],
26+
default="restructuredtext",
27+
blank=True,
28+
),
1829
preserve_default=True,
1930
),
2031
]

sponsors/migrations/0003_auto_20170821_2000.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,23 @@
77
class Migration(migrations.Migration):
88

99
dependencies = [
10-
('sponsors', '0002_auto_20150416_1853'),
10+
("sponsors", "0002_auto_20150416_1853"),
1111
]
1212

1313
operations = [
1414
migrations.AlterField(
15-
model_name='sponsor',
16-
name='content_markup_type',
17-
field=models.CharField(choices=[('', '--'), ('html', 'HTML'), ('plain', 'Plain'), ('markdown', 'Markdown'), ('restructuredtext', 'Restructured Text')], default='restructuredtext', max_length=30),
15+
model_name="sponsor",
16+
name="content_markup_type",
17+
field=models.CharField(
18+
choices=[
19+
("", "--"),
20+
("html", "HTML"),
21+
("plain", "Plain"),
22+
("markdown", "Markdown"),
23+
("restructuredtext", "Restructured Text"),
24+
],
25+
default="restructuredtext",
26+
max_length=30,
27+
),
1828
),
1929
]

0 commit comments

Comments
 (0)