Skip to content

Commit 301bfe2

Browse files
authored
Sponsorship dashboard (#1892)
* Sort apps by name * Add missing migration after help text change * Add new app to handle custom email dispatching * Add new model to configure sponsor notifications * Minimal admin * Update admin form to validate content as django template * Add button to preview how template will render * Add new benefit configuration to flag email targeatable * Add method to filter sponsorships by included features * Enable user to select which notification template to use * Rename variable * Display warning message if selected sponsorships aren't targetable * Introduce indirection with use case to send the emails * Implement method to create a EmailMessage from a notification template * Display non targetable sponsorship as checkbox instead of text * Add select all/delete all links * Filter emails by benefits, not feature configuration * Better display for notification objects * Add checkbox to select contact type * Update get_message method to accept boolean flags to control recipients * Rename form field name * Send notification to sponsors * Register email dispatch with admin log entry activity * Add input for custom email content * Display input for custom email content * UC expects sponsorship object, not PK * Consider email subject as a template as well * Refactor to move specific email building part to mailing app * Remove warning message * Optimizes sponsorship admin query * Add option to preview notification * Fix parameters names * Organize sections as divs * Remove unused imports and optimize query * Minimal working code to list user sponsorships * Change Sponsorships button to redirect user to sponsorships dashboard * Update HTML to list active/finalized sponsorships * Move sponsorship detail view to users app * Minimal view to update sponsor info * Inline display sponsor contacts * Reuse style from sponsorships application form to edit sponsor information * Improve fields display * Add link to edit sponsor information from sponsorship detail page * Display sponsor information in sponsorship detail page * Do not list the sponsors at sponsorship dashboard page * Add JS to handle with the inline contact forms * TODO note with my tests' checklist * Move button to right * Display fields as readonly * Add unit tests * Remove file inputs * Better organize sponsorships * Revert "Remove file inputs" This reverts commit d7f9047. * Do not allow user to clear file inputs * Do not change file input background * Run code linter * Remove unecessary br * File inputs should be required
1 parent 5defd8a commit 301bfe2

17 files changed

+870
-178
lines changed

pydotorg/context_processors.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ def user_nav_bar_links(request):
3434
nav = {}
3535
if request.user.is_authenticated:
3636
user = request.user
37-
sponsorship_urls = [
38-
{"url": sp.detail_url, "label": f"{sp.sponsor.name}'s sponsorship"}
39-
for sp in user.sponsorships
40-
]
37+
sponsorship_url = None
38+
if user.sponsorships.exists():
39+
sponsorship_url = reverse("users:user_sponsorships_dashboard")
40+
41+
# if the section has a urls key, the section buttion will work as a drop-down menu
42+
# if the section has only a url key, the button will be a link instead
4143
nav = {
4244
"account": {
4345
"label": "Your Account",
@@ -54,8 +56,8 @@ def user_nav_bar_links(request):
5456
],
5557
},
5658
"sponsorships": {
57-
"label": "Sponsorships",
58-
"urls": sponsorship_urls,
59+
"label": "Sponsorships Dashboard",
60+
"url": sponsorship_url
5961
}
6062
}
6163

pydotorg/tests/test_context_processors.py

+10-13
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ def test_user_nav_bar_links_for_non_psf_members(self):
5252
],
5353
},
5454
"sponsorships": {
55-
"label": "Sponsorships",
56-
"urls": [],
55+
"label": "Sponsorships Dashboard",
56+
"url": None,
5757
}
5858
}
5959

@@ -84,8 +84,8 @@ def test_user_nav_bar_links_for_psf_members(self):
8484
],
8585
},
8686
"sponsorships": {
87-
"label": "Sponsorships",
88-
"urls": [],
87+
"label": "Sponsorships Dashboard",
88+
"url": None,
8989
}
9090
}
9191

@@ -97,18 +97,15 @@ def test_user_nav_bar_links_for_psf_members(self):
9797
def test_user_nav_bar_sponsorship_links(self):
9898
request = self.factory.get('/about/')
9999
request.user = baker.make(settings.AUTH_USER_MODEL, username='foo')
100-
sponsorships = baker.make("sponsors.Sponsorship", submited_by=request.user, _quantity=2, _fill_optional=True)
101-
102-
expected_sponsorships = {
103-
"label": "Sponsorships",
104-
"urls": [
105-
{"url": sp.detail_url, "label": f"{sp.sponsor.name}'s sponsorship"}
106-
for sp in request.user.sponsorships
107-
]
100+
baker.make("sponsors.Sponsorship", submited_by=request.user, _quantity=2, _fill_optional=True)
101+
102+
expected_section = {
103+
"label": "Sponsorships Dashboard",
104+
"url": reverse("users:user_sponsorships_dashboard")
108105
}
109106

110107
self.assertEqual(
111-
expected_sponsorships,
108+
expected_section,
112109
context_processors.user_nav_bar_links(request)['USER_NAV_BAR']['sponsorships']
113110
)
114111

sponsors/forms.py

+63
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,66 @@ def get_notification(self):
463463
subject=self.cleaned_data["subject"],
464464
)
465465
return self.cleaned_data.get("notification") or default_notification
466+
467+
468+
class SponsorUpdateForm(forms.ModelForm):
469+
READONLY_FIELDS = [
470+
"name",
471+
]
472+
473+
web_logo = forms.ImageField(
474+
widget=forms.widgets.FileInput,
475+
help_text="For display on our sponsor webpage. High resolution PNG or JPG, smallest dimension no less than 256px",
476+
required=False,
477+
)
478+
print_logo = forms.ImageField(
479+
widget=forms.widgets.FileInput,
480+
help_text="For printed materials, signage, and projection. SVG or EPS",
481+
required=False,
482+
)
483+
484+
def __init__(self, *args, **kwargs):
485+
super().__init__(*args, **kwargs)
486+
formset_kwargs = {"prefix": "contact", "instance": self.instance}
487+
factory = forms.inlineformset_factory(
488+
Sponsor,
489+
SponsorContact,
490+
form=SponsorContactForm,
491+
extra=0,
492+
min_num=1,
493+
validate_min=True,
494+
can_delete=True,
495+
can_order=False,
496+
max_num=5,
497+
)
498+
if self.data:
499+
self.contacts_formset = factory(self.data, **formset_kwargs)
500+
else:
501+
self.contacts_formset = factory(**formset_kwargs)
502+
# display fields as read-only
503+
for disabled in self.READONLY_FIELDS:
504+
self.fields[disabled].widget.attrs['readonly'] = True
505+
506+
class Meta:
507+
exclude = ["created", "updated", "creator", "last_modified_by"]
508+
model = Sponsor
509+
510+
def clean(self):
511+
cleaned_data = super().clean()
512+
513+
if not self.contacts_formset.is_valid():
514+
msg = "Errors with contact(s) information"
515+
if not self.contacts_formset.errors:
516+
msg = "You have to enter at least one contact"
517+
raise forms.ValidationError(msg)
518+
519+
has_primary_contact = any(
520+
f.cleaned_data.get("primary") for f in self.contacts_formset.forms
521+
)
522+
if not has_primary_contact:
523+
msg = "You have to mark at least one contact as the primary one."
524+
raise forms.ValidationError(msg)
525+
526+
def save(self, *args, **kwargs):
527+
super().save(*args, **kwargs)
528+
self.contacts_formset.save()

sponsors/models.py

+17-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import uuid
22
from itertools import chain
33
from num2words import num2words
4+
from datetime import date
45
from django.conf import settings
56
from django.core.exceptions import ObjectDoesNotExist
67
from django.db import models, transaction
@@ -433,6 +434,13 @@ def agreed_fee(self):
433434
except SponsorshipPackage.DoesNotExist: # sponsorship level names can change over time
434435
return None
435436

437+
@property
438+
def is_active(self):
439+
conditions = [
440+
self.status == self.FINALIZED,
441+
self.end_date and self.end_date > date.today()
442+
]
443+
436444
def reject(self):
437445
if self.REJECTED not in self.next_status:
438446
msg = f"Can't reject a {self.get_status_display()} sponsorship."
@@ -492,7 +500,7 @@ def contract_admin_url(self):
492500

493501
@property
494502
def detail_url(self):
495-
return reverse("sponsorship_application_detail", args=[self.pk])
503+
return reverse("users:sponsorship_application_detail", args=[self.pk])
496504

497505
@cached_property
498506
def package_benefits(self):
@@ -620,39 +628,39 @@ class Sponsor(ContentManageable):
620628

621629
name = models.CharField(
622630
max_length=100,
623-
verbose_name="Sponsor name",
631+
verbose_name="Name",
624632
help_text="Name of the sponsor, for public display.",
625633
)
626634
description = models.TextField(
627-
verbose_name="Sponsor description",
635+
verbose_name="Description",
628636
help_text="Brief description of the sponsor for public display.",
629637
)
630638
landing_page_url = models.URLField(
631639
blank=True,
632640
null=True,
633-
verbose_name="Sponsor landing page",
634-
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.",
641+
verbose_name="Landing page URL",
642+
help_text="Landing page URL. This may be provided by the sponsor, however the linked page may not contain any sales or marketing information.",
635643
)
636644
twitter_handle = models.CharField(
637645
max_length=32, # Actual limit set by twitter is 15 characters, but that may change?
638646
blank=True,
639647
null=True,
640-
verbose_name="Sponsor twitter hanlde",
648+
verbose_name="Twitter handle",
641649
)
642650
web_logo = models.ImageField(
643651
upload_to="sponsor_web_logos",
644-
verbose_name="Sponsor web logo",
652+
verbose_name="Web logo",
645653
help_text="For display on our sponsor webpage. High resolution PNG or JPG, smallest dimension no less than 256px",
646654
)
647655
print_logo = models.FileField(
648656
upload_to="sponsor_print_logos",
649657
blank=True,
650658
null=True,
651-
verbose_name="Sponsor print logo",
659+
verbose_name="Print logo",
652660
help_text="For printed materials, signage, and projection. SVG or EPS",
653661
)
654662

655-
primary_phone = models.CharField("Sponsor Primary Phone", max_length=32)
663+
primary_phone = models.CharField("Primary Phone", max_length=32)
656664
mailing_address_line_1 = models.CharField(
657665
verbose_name="Mailing Address line 1", max_length=128, default=""
658666
)

sponsors/tests/test_views.py

-39
Original file line numberDiff line numberDiff line change
@@ -317,42 +317,3 @@ def test_redirect_user_back_to_benefits_selection_if_post_without_valid_set_of_b
317317
self.client.cookies["sponsorship_selected_benefits"] = "invalid"
318318
r = self.client.post(self.url, data=self.data)
319319
self.assertRedirects(r, reverse("select_sponsorship_application_benefits"))
320-
321-
322-
class SponsorshipDetailViewTests(TestCase):
323-
324-
def setUp(self):
325-
self.user = baker.make(settings.AUTH_USER_MODEL)
326-
self.client.force_login(self.user)
327-
self.sponsorship = baker.make(
328-
Sponsorship, submited_by=self.user, status=Sponsorship.APPLIED, _fill_optional=True
329-
)
330-
self.url = reverse(
331-
"sponsorship_application_detail", args=[self.sponsorship.pk]
332-
)
333-
334-
def test_display_template_with_sponsorship_info(self):
335-
response = self.client.get(self.url)
336-
context = response.context
337-
338-
self.assertTemplateUsed(response, "sponsors/sponsorship_detail.html")
339-
self.assertEqual(context["sponsorship"], self.sponsorship)
340-
341-
def test_404_if_sponsorship_does_not_exist(self):
342-
self.sponsorship.delete()
343-
response = self.client.get(self.url)
344-
self.assertEqual(response.status_code, 404)
345-
346-
def test_login_required(self):
347-
login_url = settings.LOGIN_URL
348-
redirect_url = f"{login_url}?next={self.url}"
349-
self.client.logout()
350-
351-
r = self.client.get(self.url)
352-
353-
self.assertRedirects(r, redirect_url)
354-
355-
def test_404_if_sponsorship_does_not_belong_to_user(self):
356-
self.client.force_login(baker.make(settings.AUTH_USER_MODEL)) # log in with a new user
357-
response = self.client.get(self.url)
358-
self.assertEqual(response.status_code, 404)

sponsors/urls.py

-5
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,4 @@
1010
path('application/', views.SelectSponsorshipApplicationBenefitsView.as_view(),
1111
name="select_sponsorship_application_benefits",
1212
),
13-
path(
14-
"application/<int:pk>/detail/",
15-
views.SponsorshipDetailView.as_view(),
16-
name="sponsorship_application_detail",
17-
),
1813
]

sponsors/views.py

+1-15
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,18 @@
1-
import json
21
from itertools import chain
32
from django.conf import settings
43
from django.contrib import messages
54
from django.contrib.auth.decorators import login_required
6-
from django.contrib.auth.mixins import LoginRequiredMixin
75
from django.db import transaction
86
from django.forms.utils import ErrorList
9-
from django.http import JsonResponse
107
from django.shortcuts import redirect, render
118
from django.urls import reverse_lazy, reverse
129
from django.utils.decorators import method_decorator
13-
from django.views.generic import ListView, FormView, DetailView
10+
from django.views.generic import FormView, DetailView, RedirectView
1411

1512
from .models import (
16-
Sponsor,
1713
SponsorshipBenefit,
1814
SponsorshipPackage,
1915
SponsorshipProgram,
20-
Sponsorship,
2116
)
2217

2318
from sponsors import cookies
@@ -174,12 +169,3 @@ def form_valid(self, form):
174169
)
175170
cookies.delete_sponsorship_selected_benefits(response)
176171
return response
177-
178-
179-
@method_decorator(login_required(login_url=settings.LOGIN_URL), name="dispatch")
180-
class SponsorshipDetailView(DetailView):
181-
context_object_name = 'sponsorship'
182-
template_name = 'sponsors/sponsorship_detail.html'
183-
184-
def get_queryset(self):
185-
return self.request.user.sponsorships

0 commit comments

Comments
 (0)