Skip to content

Commit 8a9d173

Browse files
authored
Sponsorship application detail view (#1733)
* Querset method and property shortcut to list user's visible sponsorships * New detail view to display sponsorship application data * Add sponsorship detail link in user's navbar * Detail sponsorship application data * Style user action buttons * Add missing .user-profile-controls class as a button * Do not display sponsorship fee if has customization * Introduce agreed_fee property to deal with internal display logics * Add unit tests to fix broken and naive implementation of agreed fee property
1 parent a9ebf2f commit 8a9d173

14 files changed

+324
-36
lines changed

pydotorg/context_processors.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ def blog_url(request):
3333
def user_nav_bar_links(request):
3434
nav = {}
3535
if request.user.is_authenticated:
36-
user = request.user.username
36+
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+
]
3741
nav = {
3842
"account": {
3943
"label": "Your Account",
4044
"urls": [
41-
{"url": reverse("users:user_detail", args=[user]), "label": "View profile"},
45+
{"url": reverse("users:user_detail", args=[user.username]), "label": "View profile"},
4246
{"url": reverse("users:user_profile_edit"), "label": "Edit profile"},
4347
{"url": reverse("account_change_password"), "label": "Change password"},
4448
],
@@ -48,6 +52,10 @@ def user_nav_bar_links(request):
4852
"urls": [
4953
{"url": reverse("users:user_nominations_view"), "label": "Nominations"},
5054
],
55+
},
56+
"sponsorships": {
57+
"label": "Sponsorships",
58+
"urls": sponsorship_urls,
5159
}
5260
}
5361

pydotorg/tests/test_context_processors.py

+26
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def test_user_nav_bar_links_for_non_psf_members(self):
5050
{"url": reverse("users:user_nominations_view"), "label": "Nominations"},
5151
{"url": reverse("users:user_membership_create"), "label": "Become a PSF member"},
5252
],
53+
},
54+
"sponsorships": {
55+
"label": "Sponsorships",
56+
"urls": [],
5357
}
5458
}
5559

@@ -78,6 +82,10 @@ def test_user_nav_bar_links_for_psf_members(self):
7882
{"url": reverse("users:user_nominations_view"), "label": "Nominations"},
7983
{"url": reverse("users:user_membership_edit"), "label": "Edit PSF membership"},
8084
],
85+
},
86+
"sponsorships": {
87+
"label": "Sponsorships",
88+
"urls": [],
8189
}
8290
}
8391

@@ -86,6 +94,24 @@ def test_user_nav_bar_links_for_psf_members(self):
8694
context_processors.user_nav_bar_links(request)
8795
)
8896

97+
def test_user_nav_bar_sponsorship_links(self):
98+
request = self.factory.get('/about/')
99+
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+
]
108+
}
109+
110+
self.assertEqual(
111+
expected_sponsorships,
112+
context_processors.user_nav_bar_links(request)['USER_NAV_BAR']['sponsorships']
113+
)
114+
89115
def test_user_nav_bar_links_for_anonymous_user(self):
90116
request = self.factory.get('/about/')
91117
request.user = AnonymousUser()

sponsors/managers.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
from django.db.models import Q, Subquery
12
from django.db.models.query import QuerySet
23

34

45
class SponsorshipQuerySet(QuerySet):
56
def in_progress(self):
67
status = [self.model.APPLIED, self.model.APPROVED]
78
return self.filter(status__in=status)
9+
10+
def visible_to(self, user):
11+
contacts = user.sponsorcontact_set.values_list('sponsor_id', flat=True)
12+
status = [self.model.APPLIED, self.model.APPROVED, self.model.FINALIZED]
13+
return self.filter(
14+
Q(submited_by=user) | Q(sponsor_id__in=Subquery(contacts)),
15+
status__in=status,
16+
).select_related('sponsor')

sponsors/models.py

+19
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ def new(cls, sponsor, benefits, package=None, submited_by=None):
304304
"""
305305
for_modified_package = False
306306
package_benefits = []
307+
307308
if package and package.has_user_customization(benefits):
308309
package_benefits = package.benefits.all()
309310
for_modified_package = True
@@ -345,6 +346,20 @@ def estimated_cost(self):
345346
or 0
346347
)
347348

349+
@property
350+
def agreed_fee(self):
351+
valid_status = [Sponsorship.APPROVED, Sponsorship.FINALIZED]
352+
if self.status in valid_status:
353+
return self.sponsorship_fee
354+
try:
355+
package = SponsorshipPackage.objects.get(name=self.level_name)
356+
benefits = [sb.sponsorship_benefit for sb in self.package_benefits.all().select_related('sponsorship_benefit')]
357+
if package and not package.has_user_customization(benefits):
358+
return self.sponsorship_fee
359+
except SponsorshipPackage.DoesNotExist: # sponsorship level names can change over time
360+
return None
361+
362+
348363
def reject(self):
349364
if self.REJECTED not in self.next_status:
350365
msg = f"Can't reject a {self.get_status_display()} sponsorship."
@@ -379,6 +394,10 @@ def verified_emails(self):
379394
def admin_url(self):
380395
return reverse("admin:sponsors_sponsorship_change", args=[self.pk])
381396

397+
@property
398+
def detail_url(self):
399+
return reverse("sponsorship_application_detail", args=[self.pk])
400+
382401
@cached_property
383402
def package_benefits(self):
384403
return self.benefits.filter(added_by_user=False)

sponsors/tests/test_managers.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from model_bakery import baker
2+
3+
from django.conf import settings
4+
from django.test import TestCase
5+
6+
from ..models import Sponsorship
7+
8+
9+
class SponsorshipQuerySetTests(TestCase):
10+
11+
def setUp(self):
12+
self.user = baker.make(settings.AUTH_USER_MODEL)
13+
self.contact = baker.make('sponsors.SponsorContact', user=self.user)
14+
15+
def test_visible_to_user(self):
16+
visible = [
17+
baker.make(Sponsorship, submited_by=self.user, status=Sponsorship.APPLIED),
18+
baker.make(Sponsorship, sponsor=self.contact.sponsor, status=Sponsorship.APPROVED),
19+
baker.make(Sponsorship, submited_by=self.user, status=Sponsorship.FINALIZED),
20+
]
21+
baker.make(Sponsorship) # should not be visible because it's from other sponsor
22+
baker.make(Sponsorship, submited_by=self.user, status=Sponsorship.REJECTED) # don't list rejected
23+
24+
qs = Sponsorship.objects.visible_to(self.user)
25+
26+
self.assertEqual(len(visible), qs.count())
27+
for sp in visible:
28+
self.assertIn(sp, qs)
29+
self.assertEqual(list(qs), list(self.user.sponsorships))

sponsors/tests/test_models.py

+16
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def test_create_new_sponsorship(self):
7777
self.assertIsNone(sponsorship.end_date)
7878
self.assertEqual(sponsorship.level_name, "")
7979
self.assertIsNone(sponsorship.sponsorship_fee)
80+
self.assertIsNone(sponsorship.agreed_fee)
8081
self.assertTrue(sponsorship.for_modified_package)
8182

8283
self.assertEqual(sponsorship.benefits.count(), len(self.benefits))
@@ -97,6 +98,7 @@ def test_create_new_sponsorship_with_package(self):
9798

9899
self.assertEqual(sponsorship.level_name, "PSF Sponsorship Program")
99100
self.assertEqual(sponsorship.sponsorship_fee, 100)
101+
self.assertEqual(sponsorship.agreed_fee, 100) # can display the price because there's not customizations
100102
self.assertFalse(sponsorship.for_modified_package)
101103
for benefit in sponsorship.benefits.all():
102104
self.assertFalse(benefit.added_by_user)
@@ -109,6 +111,7 @@ def test_create_new_sponsorship_with_package_modifications(self):
109111

110112
self.assertTrue(sponsorship.for_modified_package)
111113
self.assertEqual(sponsorship.benefits.count(), 2)
114+
self.assertIsNone(sponsorship.agreed_fee) # can't display the price with customizations
112115
for benefit in sponsorship.benefits.all():
113116
self.assertFalse(benefit.added_by_user)
114117

@@ -193,6 +196,19 @@ def test_raise_exception_when_trying_to_create_sponsorship_for_same_sponsor(self
193196
with self.assertRaises(SponsorWithExistingApplicationException):
194197
Sponsorship.new(self.sponsor, self.benefits)
195198

199+
def test_display_agreed_fee_for_approved_and_finalized_status(self):
200+
sponsorship = Sponsorship.new(self.sponsor, self.benefits)
201+
sponsorship.sponsorship_fee = 2000
202+
sponsorship.save()
203+
204+
finalized_status = [Sponsorship.APPROVED, Sponsorship.FINALIZED]
205+
for status in finalized_status:
206+
sponsorship.status = status
207+
sponsorship.save()
208+
209+
self.assertEqual(sponsorship.agreed_fee, 2000)
210+
211+
196212

197213
class SponsorshipPackageTests(TestCase):
198214
def setUp(self):

sponsors/tests/test_views.py

+39
Original file line numberDiff line numberDiff line change
@@ -613,3 +613,42 @@ def test_message_user_if_approving_invalid_sponsorship(self):
613613
self.assertEqual(self.sponsorship.status, Sponsorship.FINALIZED)
614614
msg = list(get_messages(response.wsgi_request))[0]
615615
assertMessage(msg, "Can't approve a Finalized sponsorship.", messages.ERROR)
616+
617+
618+
class SponsorshipDetailViewTests(TestCase):
619+
620+
def setUp(self):
621+
self.user = baker.make(settings.AUTH_USER_MODEL)
622+
self.client.force_login(self.user)
623+
self.sponsorship = baker.make(
624+
Sponsorship, submited_by=self.user, status=Sponsorship.APPLIED, _fill_optional=True
625+
)
626+
self.url = reverse(
627+
"sponsorship_application_detail", args=[self.sponsorship.pk]
628+
)
629+
630+
def test_display_template_with_sponsorship_info(self):
631+
response = self.client.get(self.url)
632+
context = response.context
633+
634+
self.assertTemplateUsed(response, "sponsors/sponsorship_detail.html")
635+
self.assertEqual(context["sponsorship"], self.sponsorship)
636+
637+
def test_404_if_sponsorship_does_not_exist(self):
638+
self.sponsorship.delete()
639+
response = self.client.get(self.url)
640+
self.assertEqual(response.status_code, 404)
641+
642+
def test_login_required(self):
643+
login_url = settings.LOGIN_URL
644+
redirect_url = f"{login_url}?next={self.url}"
645+
self.client.logout()
646+
647+
r = self.client.get(self.url)
648+
649+
self.assertRedirects(r, redirect_url)
650+
651+
def test_404_if_sponsorship_does_not_belong_to_user(self):
652+
self.client.force_login(baker.make(settings.AUTH_USER_MODEL)) # log in with a new user
653+
response = self.client.get(self.url)
654+
self.assertEqual(response.status_code, 404)

sponsors/urls.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.conf.urls import url
2-
from django.views.generic.base import TemplateView
2+
from django.urls import path
33

44
from . import views
55

@@ -15,4 +15,9 @@
1515
views.SelectSponsorshipApplicationBenefitsView.as_view(),
1616
name="select_sponsorship_application_benefits",
1717
),
18+
path(
19+
"application/<int:pk>/detail/",
20+
views.SponsorshipDetailView.as_view(),
21+
name="sponsorship_application_detail",
22+
),
1823
]

sponsors/views.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
from django.contrib.auth.mixins import LoginRequiredMixin
77
from django.db import transaction
88
from django.forms.utils import ErrorList
9-
from django.utils.decorators import method_decorator
109
from django.http import JsonResponse
11-
from django.views.generic import ListView, FormView
12-
from django.urls import reverse_lazy, reverse
1310
from django.shortcuts import redirect, render
11+
from django.urls import reverse_lazy, reverse
12+
from django.utils.decorators import method_decorator
13+
from django.views.generic import ListView, FormView, DetailView
1414

1515
from .models import (
1616
Sponsor,
@@ -174,3 +174,12 @@ def form_valid(self, form):
174174
)
175175
cookies.delete_sponsorship_selected_benefits(response)
176176
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)