Skip to content

Commit a256e96

Browse files
authored
Finalize sponsorships with existing contracts (#1845)
* Sponsorship approval should also log contract creation * Fix test names to be more accurate * Create use case to approve contract using existing file * Sponsorship approval form must require all data * Create new view to approve a sponsorship within a signed contract * Add links to generate new contract or upload existing one when approving sponsorship * Add links to easily navigate between sponsorships and contracts within admin * Update Contract model to always use random names to signed documents
1 parent fc9cc47 commit a256e96

File tree

11 files changed

+340
-23
lines changed

11 files changed

+340
-23
lines changed

sponsors/admin.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.template import Context, Template
55
from django.contrib import admin
66
from django.contrib.humanize.templatetags.humanize import intcomma
7-
from django.urls import path
7+
from django.urls import path, reverse
88
from django.utils.html import mark_safe
99

1010
from .models import (
@@ -217,6 +217,7 @@ class SponsorshipAdmin(admin.ModelAdmin):
217217
"get_estimated_cost",
218218
"start_date",
219219
"end_date",
220+
"get_contract"
220221
),
221222
},
222223
),
@@ -267,6 +268,7 @@ def get_readonly_fields(self, request, obj):
267268
"get_sponsor_primary_phone",
268269
"get_sponsor_mailing_address",
269270
"get_sponsor_contacts",
271+
"get_contract",
270272
]
271273

272274
if obj and obj.status != Sponsorship.APPLIED:
@@ -287,9 +289,16 @@ def get_estimated_cost(self, obj):
287289
cost = intcomma(obj.estimated_cost)
288290
html = f"{cost} USD <br/><b>Important: </b> {msg}"
289291
return mark_safe(html)
290-
291292
get_estimated_cost.short_description = "Estimated cost"
292293

294+
def get_contract(self, obj):
295+
if not obj.contract:
296+
return "---"
297+
url = reverse("admin:sponsors_contract_change", args=[obj.contract.pk])
298+
html = f"<a href='{url}' target='_blank'>{obj.contract}</a>"
299+
return mark_safe(html)
300+
get_contract.short_description = "Contract"
301+
293302
def get_urls(self):
294303
urls = super().get_urls()
295304
my_urls = [
@@ -300,6 +309,11 @@ def get_urls(self):
300309
self.admin_site.admin_view(self.reject_sponsorship_view),
301310
name="sponsors_sponsorship_reject",
302311
),
312+
path(
313+
"<int:pk>/approve-existing",
314+
self.admin_site.admin_view(self.approve_signed_sponsorship_view),
315+
name="sponsors_sponsorship_approve_existing_contract",
316+
),
303317
path(
304318
"<int:pk>/approve",
305319
self.admin_site.admin_view(self.approve_sponsorship_view),
@@ -403,6 +417,9 @@ def reject_sponsorship_view(self, request, pk):
403417
def approve_sponsorship_view(self, request, pk):
404418
return views_admin.approve_sponsorship_view(self, request, pk)
405419

420+
def approve_signed_sponsorship_view(self, request, pk):
421+
return views_admin.approve_signed_sponsorship_view(self, request, pk)
422+
406423

407424
@admin.register(LegalClause)
408425
class LegalClauseModelAdmin(OrderedModelAdmin):
@@ -435,7 +452,7 @@ def get_revision(self, obj):
435452
(
436453
"Info",
437454
{
438-
"fields": ("sponsorship", "status", "revision"),
455+
"fields": ("get_sponsorship_url", "status", "revision"),
439456
},
440457
),
441458
(
@@ -480,6 +497,7 @@ def get_readonly_fields(self, request, obj):
480497
"sponsorship",
481498
"revision",
482499
"document",
500+
"get_sponsorship_url",
483501
]
484502

485503
if obj and not obj.is_draft:
@@ -509,9 +527,17 @@ def document_link(self, obj):
509527
if url and msg:
510528
html = f'<a href="{url}" target="_blank">{msg}</a>'
511529
return mark_safe(html)
512-
513530
document_link.short_description = "Contract document"
514531

532+
533+
def get_sponsorship_url(self, obj):
534+
if not obj.sponsorship:
535+
return "---"
536+
url = reverse("admin:sponsors_sponsorship_change", args=[obj.sponsorship.pk])
537+
html = f"<a href='{url}' target='_blank'>{obj.sponsorship}</a>"
538+
return mark_safe(html)
539+
get_sponsorship_url.short_description = "Sponsorship"
540+
515541
def get_urls(self):
516542
urls = super().get_urls()
517543
my_urls = [

sponsors/forms.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,13 @@ def clean(self):
361361
return cleaned_data
362362

363363

364+
class SignedSponsorshipReviewAdminForm(SponsorshipReviewAdminForm):
365+
"""
366+
Form to approve sponsorships that already have a signed contract
367+
"""
368+
signed_contract = forms.FileField(help_text="Please upload the final version of the signed contract.")
369+
370+
364371
class SponsorBenefitAdminInlineForm(forms.ModelForm):
365372
sponsorship_benefit = forms.ModelChoiceField(
366373
queryset=SponsorshipBenefit.objects.order_by('program', 'order').select_related("program"),

sponsors/models.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from abc import ABC
23
from pathlib import Path
34
from itertools import chain
@@ -697,6 +698,16 @@ class Meta(OrderedModel.Meta):
697698
pass
698699

699700

701+
def signed_contract_random_path(instance, filename):
702+
"""
703+
Use random UUID to name signed contracts
704+
"""
705+
dir = instance.SIGNED_PDF_DIR
706+
ext = "".join(Path(filename).suffixes)
707+
name = uuid.uuid4()
708+
return f"{dir}{name}{ext}"
709+
710+
700711
class Contract(models.Model):
701712
"""
702713
Contract model to oficialize a Sponsorship
@@ -729,7 +740,7 @@ class Contract(models.Model):
729740
verbose_name="Unsigned PDF",
730741
)
731742
signed_document = models.FileField(
732-
upload_to=SIGNED_PDF_DIR,
743+
upload_to=signed_contract_random_path,
733744
blank=True,
734745
verbose_name="Signed PDF",
735746
)
@@ -872,8 +883,8 @@ def set_final_version(self, pdf_file):
872883
self.status = self.AWAITING_SIGNATURE
873884
self.save()
874885

875-
def execute(self, commit=True):
876-
if self.EXECUTED not in self.next_status:
886+
def execute(self, commit=True, force=False):
887+
if not force and self.EXECUTED not in self.next_status:
877888
msg = f"Can't execute a {self.get_status_display()} contract."
878889
raise InvalidStatusException(msg)
879890

sponsors/notifications.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.core.mail import EmailMessage
22
from django.template.loader import render_to_string
33
from django.conf import settings
4-
from django.contrib.admin.models import LogEntry, CHANGE
4+
from django.contrib.admin.models import LogEntry, CHANGE, ADDITION
55
from django.contrib.contenttypes.models import ContentType
66

77
from sponsors.models import Sponsorship, Contract
@@ -110,7 +110,7 @@ def get_attachments(self, context):
110110

111111
class SponsorshipApprovalLogger():
112112

113-
def notify(self, request, sponsorship, **kwargs):
113+
def notify(self, request, sponsorship, contract, **kwargs):
114114
LogEntry.objects.log_action(
115115
user_id=request.user.id,
116116
content_type_id=ContentType.objects.get_for_model(Sponsorship).pk,
@@ -119,6 +119,14 @@ def notify(self, request, sponsorship, **kwargs):
119119
action_flag=CHANGE,
120120
change_message="Sponsorship Approval"
121121
)
122+
LogEntry.objects.log_action(
123+
user_id=request.user.id,
124+
content_type_id=ContentType.objects.get_for_model(Contract).pk,
125+
object_id=contract.pk,
126+
object_repr=str(contract),
127+
action_flag=ADDITION,
128+
change_message="Created After Sponsorship Approval"
129+
)
122130

123131

124132
class SentContractLogger():
@@ -147,6 +155,19 @@ def notify(self, request, contract, **kwargs):
147155
)
148156

149157

158+
class ExecutedExistingContractLogger():
159+
160+
def notify(self, request, contract, **kwargs):
161+
LogEntry.objects.log_action(
162+
user_id=request.user.id,
163+
content_type_id=ContentType.objects.get_for_model(Contract).pk,
164+
object_id=contract.pk,
165+
object_repr=str(contract),
166+
action_flag=CHANGE,
167+
change_message="Existing Contract Uploaded and Executed"
168+
)
169+
170+
150171
class NullifiedContractLogger():
151172

152173
def notify(self, request, contract, **kwargs):

sponsors/tests/test_notifications.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from django.core import mail
66
from django.template.loader import render_to_string
77
from django.test import TestCase, RequestFactory
8-
from django.contrib.admin.models import LogEntry, CHANGE
8+
from django.contrib.admin.models import LogEntry, CHANGE, ADDITION
9+
from django.contrib.contenttypes.models import ContentType
910

1011
from sponsors import notifications
1112
from sponsors.models import Sponsorship, Contract
@@ -233,24 +234,34 @@ def setUp(self):
233234
self.request = RequestFactory().get('/')
234235
self.request.user = baker.make(settings.AUTH_USER_MODEL)
235236
self.sponsorship = baker.make(Sponsorship, status=Sponsorship.APPROVED, sponsor__name='foo', _fill_optional=True)
237+
self.contract = baker.make_recipe("sponsors.tests.empty_contract", sponsorship=self.sponsorship)
236238
self.kwargs = {
237239
"request": self.request,
238240
"sponsorship": self.sponsorship,
241+
"contract": self.contract
239242
}
240243
self.logger = notifications.SponsorshipApprovalLogger()
241244

242245
def test_create_log_entry_for_change_operation_with_approval_message(self):
243246
self.assertEqual(LogEntry.objects.count(), 0)
247+
sponsorship_content_id = ContentType.objects.get_for_model(Sponsorship).pk
248+
contract_id = ContentType.objects.get_for_model(Contract).pk
244249

245250
self.logger.notify(**self.kwargs)
246251

247-
self.assertEqual(LogEntry.objects.count(), 1)
248-
log_entry = LogEntry.objects.get()
252+
self.assertEqual(LogEntry.objects.count(), 2)
253+
log_entry = LogEntry.objects.get(content_type_id=sponsorship_content_id)
249254
self.assertEqual(log_entry.user, self.request.user)
250255
self.assertEqual(log_entry.object_id, str(self.sponsorship.pk))
251256
self.assertEqual(str(self.sponsorship), log_entry.object_repr)
252257
self.assertEqual(log_entry.action_flag, CHANGE)
253258
self.assertEqual(log_entry.change_message, "Sponsorship Approval")
259+
log_entry = LogEntry.objects.get(content_type_id=contract_id)
260+
self.assertEqual(log_entry.user, self.request.user)
261+
self.assertEqual(log_entry.object_id, str(self.contract.pk))
262+
self.assertEqual(str(self.contract), log_entry.object_repr)
263+
self.assertEqual(log_entry.action_flag, ADDITION)
264+
self.assertEqual(log_entry.change_message, "Created After Sponsorship Approval")
254265

255266

256267
class SentContractLoggerTests(TestCase):
@@ -305,6 +316,32 @@ def test_create_log_entry_for_change_operation_with_approval_message(self):
305316
self.assertEqual(log_entry.change_message, "Contract Executed")
306317

307318

319+
class ExecutedExistingContractLoggerTests(TestCase):
320+
321+
def setUp(self):
322+
self.request = RequestFactory().get('/')
323+
self.request.user = baker.make(settings.AUTH_USER_MODEL)
324+
self.contract = baker.make_recipe('sponsors.tests.empty_contract')
325+
self.kwargs = {
326+
"request": self.request,
327+
"contract": self.contract,
328+
}
329+
self.logger = notifications.ExecutedExistingContractLogger()
330+
331+
def test_create_log_entry_for_change_operation_with_approval_message(self):
332+
self.assertEqual(LogEntry.objects.count(), 0)
333+
334+
self.logger.notify(**self.kwargs)
335+
336+
self.assertEqual(LogEntry.objects.count(), 1)
337+
log_entry = LogEntry.objects.get()
338+
self.assertEqual(log_entry.user, self.request.user)
339+
self.assertEqual(log_entry.object_id, str(self.contract.pk))
340+
self.assertEqual(str(self.contract), log_entry.object_repr)
341+
self.assertEqual(log_entry.action_flag, CHANGE)
342+
self.assertEqual(log_entry.change_message, "Existing Contract Uploaded and Executed")
343+
344+
308345
class NullifiedContractLoggerTests(TestCase):
309346

310347
def setUp(self):

sponsors/tests/test_use_cases.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from unittest.mock import Mock
1+
from unittest.mock import Mock, patch
22
from model_bakery import baker
33
from datetime import timedelta, date
44

55
from django.conf import settings
66
from django.test import TestCase
77
from django.utils import timezone
8+
from django.core.files.uploadedfile import SimpleUploadedFile
89

910
from sponsors import use_cases
1011
from sponsors.notifications import *
@@ -122,7 +123,7 @@ def test_send_notifications_using_sponsorship(self):
122123
contract=self.sponsorship.contract,
123124
)
124125

125-
def test_build_use_case_without_notificationss(self):
126+
def test_build_use_case_with_default_notificationss(self):
126127
uc = use_cases.ApproveSponsorshipApplicationUseCase.build()
127128
self.assertEqual(len(uc.notifications), 1)
128129
self.assertIsInstance(uc.notifications[0], SponsorshipApprovalLogger)
@@ -147,7 +148,7 @@ def test_send_and_update_contract_with_document(self):
147148
contract=self.contract,
148149
)
149150

150-
def test_build_use_case_without_notificationss(self):
151+
def test_build_use_case_with_default_notificationss(self):
151152
uc = use_cases.SendContractUseCase.build()
152153
self.assertEqual(len(uc.notifications), 2)
153154
self.assertIsInstance(uc.notifications[0], ContractNotificationToPSF)
@@ -168,14 +169,38 @@ def test_execute_and_update_database_object(self):
168169
self.contract.refresh_from_db()
169170
self.assertEqual(self.contract.status, Contract.EXECUTED)
170171

171-
def test_build_use_case_without_notificationss(self):
172+
def test_build_use_case_with_default_notificationss(self):
172173
uc = use_cases.ExecuteContractUseCase.build()
173174
self.assertEqual(len(uc.notifications), 1)
174175
self.assertIsInstance(
175176
uc.notifications[0], ExecutedContractLogger
176177
)
177178

178179

180+
class ExecuteExistingContractUseCaseTests(TestCase):
181+
def setUp(self):
182+
self.notifications = [Mock()]
183+
self.use_case = use_cases.ExecuteExistingContractUseCase(self.notifications)
184+
self.user = baker.make(settings.AUTH_USER_MODEL)
185+
self.file = SimpleUploadedFile("contract.txt", b"Contract content")
186+
self.contract = baker.make_recipe("sponsors.tests.empty_contract", status=Contract.DRAFT)
187+
188+
@patch("sponsors.models.uuid.uuid4", Mock(return_value="1234"))
189+
def test_execute_and_update_database_object(self):
190+
self.use_case.execute(self.contract, self.file)
191+
self.contract.refresh_from_db()
192+
self.assertEqual(self.contract.status, Contract.EXECUTED)
193+
self.assertEqual(b"Contract content", self.contract.signed_document.read())
194+
self.assertEqual(f"{Contract.SIGNED_PDF_DIR}1234.txt", self.contract.signed_document.name)
195+
196+
def test_build_use_case_with_default_notificationss(self):
197+
uc = use_cases.ExecuteExistingContractUseCase.build()
198+
self.assertEqual(len(uc.notifications), 1)
199+
self.assertIsInstance(
200+
uc.notifications[0], ExecutedExistingContractLogger
201+
)
202+
203+
179204
class NullifyContractUseCaseTests(TestCase):
180205
def setUp(self):
181206
self.notifications = [Mock()]
@@ -188,7 +213,7 @@ def test_nullify_and_update_database_object(self):
188213
self.contract.refresh_from_db()
189214
self.assertEqual(self.contract.status, Contract.NULLIFIED)
190215

191-
def test_build_use_case_without_notificationss(self):
216+
def test_build_use_case_with_default_notificationss(self):
192217
uc = use_cases.NullifyContractUseCase.build()
193218
self.assertEqual(len(uc.notifications), 1)
194219
self.assertIsInstance(

0 commit comments

Comments
 (0)