Skip to content

Commit d155915

Browse files
berinhardewdurbin
andauthored
Create Statement of Work after approving sponsorship + Send PDF document (python#1702)
* Model statement of work's information * Auto increment revision no * Automatically populate sponsor info and contact fields * Refactor SponsorBenefit creation * List benefits info * Format legal clauses list * Prevents from rejecting or accepting reviewd applications * Display detailed sponsor's address information * Fix f-string and change postal code order * Implement use case to approve sponsorship * Refactor use cases * Model admin for statements of work * Add link from approved sponsorship to draft SOW * Replace sponsorships preview by SoW preview * Black =] * Add button to preview SoW from change form * Move sponsor contact to sponsor info and display primary contact's email * Approve method requires start/end date * Accept use case now updates the sponsorship with more data * Display form when reviewing sponsorship application * Prevent sponsorship from being changed after approval/rejection * Black =S * Join markdowns so footnotes can work * Remove unecessary trailing spaces * Fix issue after rebase * Respect db ordering to avoid tests inconsistencies * Move admin views to a specific file * Install django easy pdf * Display SoW preview as PDF * Move django-easy-pdf code to a specific module * Refactor model's contants * Create auxiliar function to render PDF document as bytes * Add status control to Statement of Work model * Rename exception * Create function to save the final document version * Add vscode dir to gitignore * UC to send SoW * Update notifications to use SoW instead of sponsorship obj * Impleent view to send statement of work to users * Refactor to use EmailMessage instead of send_mail shortcut * Attach SoW PDF to emails * Add button to send SoW * Display an iframe with the PDF file before sending the document * Shouldn't edit document fields if not a draft version * Add administrative flag to sponsor's contact * Enable rollback sponsorship to edit * Admin view to rollback to edit * Add button in sponsorship's change form * Move rollback view to views_admin to respect internals structure * Manage SoW before rolling back an application * Minor lint warnings * Add document summary content * Style page to closer to the reference * Add contract bullet items * List benefits and legal clauses * Do not display legal clauses section if nothing to list * Create merge migration * Remove element that was useful only for development * Fix pdf tests * Return 0 if sponsorship_fee is none None to avoid TypeError from num2words * Replace description text * Fix typo and add link * Rename StatementOfWork model * Update admin links * Rename sow variables to contract * Rename statement_of_work references * Rename last statement references * Move num2words requirement to base-requirements * Create commmand to work as initial data migration * Remove migration that doesn't work * add missing configurations from python#1735 * Add logger to approve sponsorship use case The logger is responsible to add a new entry in Django's LogEntry to document the approval. * Remove expired todos * Add missing migration to replace SoW by Contract * Legal clauses can be empty * Also log actitivy when final contract is sent * fix bad merge * Store original program name on SponsorBenefit objects * Benefits must always have a program * Prevent from sending contract file to sponsor * Implement logic to finalize contract and, thus, enable sponsorship benefits * Implement operation to nullify a contract Co-authored-by: Ernest W. Durbin III <[email protected]> Co-authored-by: Ee Durbin <[email protected]>
1 parent f139f5d commit d155915

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2665
-219
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ static/stylesheets/no-mq.css
1717
static/stylesheets/style.css
1818
__pycache__
1919
*.db
20+
.vscode

base-requirements.txt

+4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ requests[security]>=2.20.0
2929

3030
django-honeypot==0.6.0
3131
django-markupfield==1.4.3
32+
django-markupfield-helpers==0.1.1
3233

3334
django-allauth==0.41.0
3435

@@ -39,3 +40,6 @@ django-filter==1.1.0
3940
django-ordered-model==3.4.1
4041
django-widget-tweaks==1.4.8
4142
django-countries==6.1.3
43+
xhtml2pdf==0.2.5
44+
django-easy-pdf==0.1.1
45+
num2words==0.5.10

pydotorg/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
'ordered_model',
157157
'widget_tweaks',
158158
'django_countries',
159+
'easy_pdf',
159160

160161
'users',
161162
'boxes',

sponsors/admin.py

+172-85
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
from ordered_model.admin import OrderedModelAdmin
22

3-
from django.contrib import messages
4-
from django.urls import path, reverse
53
from django.contrib import admin
64
from django.contrib.humanize.templatetags.humanize import intcomma
5+
from django.urls import path
76
from django.utils.html import mark_safe
8-
from django.shortcuts import get_object_or_404, render, redirect
97

108
from .models import (
119
SponsorshipPackage,
@@ -16,10 +14,10 @@
1614
SponsorContact,
1715
SponsorBenefit,
1816
LegalClause,
17+
Contract,
1918
)
20-
from sponsors import use_cases
19+
from sponsors import views_admin
2120
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm
22-
from sponsors.exceptions import SponsorshipInvalidStatusException
2321
from cms.admin import ContentManageableModelAdmin
2422

2523

@@ -148,7 +146,6 @@ class SponsorshipAdmin(admin.ModelAdmin):
148146
"approved_on",
149147
"start_date",
150148
"end_date",
151-
"display_sponsorship_link",
152149
]
153150
list_filter = ["status", LevelNameFilter]
154151
readonly_fields = [
@@ -215,16 +212,36 @@ class SponsorshipAdmin(admin.ModelAdmin):
215212
),
216213
]
217214

215+
def get_readonly_fields(self, request, obj):
216+
readonly_fields = [
217+
"for_modified_package",
218+
"sponsor",
219+
"status",
220+
"applied_on",
221+
"rejected_on",
222+
"approved_on",
223+
"finalized_on",
224+
"get_estimated_cost",
225+
"get_sponsor_name",
226+
"get_sponsor_description",
227+
"get_sponsor_landing_page_url",
228+
"get_sponsor_web_logo",
229+
"get_sponsor_print_logo",
230+
"get_sponsor_primary_phone",
231+
"get_sponsor_mailing_address",
232+
"get_sponsor_contacts",
233+
]
234+
235+
if obj and obj.status != Sponsorship.APPLIED:
236+
extra = ["start_date", "end_date", "level_name", "sponsorship_fee"]
237+
readonly_fields.extend(extra)
238+
239+
return readonly_fields
240+
218241
def get_queryset(self, *args, **kwargs):
219242
qs = super().get_queryset(*args, **kwargs)
220243
return qs.select_related("sponsor")
221244

222-
def display_sponsorship_link(self, obj):
223-
url = reverse("admin:sponsors_sponsorship_preview", args=[obj.pk])
224-
return mark_safe(f'<a href="{url}" target="_blank">Click to preview</a>')
225-
226-
display_sponsorship_link.short_description = "Preview sponsorship"
227-
228245
def get_estimated_cost(self, obj):
229246
cost = None
230247
html = "This sponsorship has not customizations so there's no estimated cost"
@@ -236,19 +253,9 @@ def get_estimated_cost(self, obj):
236253

237254
get_estimated_cost.short_description = "Estimated cost"
238255

239-
def preview_sponsorship_view(self, request, pk):
240-
sponsorship = get_object_or_404(self.get_queryset(request), pk=pk)
241-
ctx = {"sponsorship": sponsorship}
242-
return render(request, "sponsors/admin/preview-sponsorship.html", context=ctx)
243-
244256
def get_urls(self):
245257
urls = super().get_urls()
246258
my_urls = [
247-
path(
248-
"<int:pk>/preview",
249-
self.admin_site.admin_view(self.preview_sponsorship_view),
250-
name="sponsors_sponsorship_preview",
251-
),
252259
path(
253260
"<int:pk>/reject",
254261
# TODO: maybe it would be better to create a specific
@@ -345,77 +352,157 @@ def get_sponsor_contacts(self, obj):
345352
get_sponsor_contacts.short_description = "Contacts"
346353

347354
def rollback_to_editing_view(self, request, pk):
348-
sponsorship = get_object_or_404(self.get_queryset(request), pk=pk)
349-
350-
if request.method.upper() == "POST" and request.POST.get("confirm") == "yes":
351-
try:
352-
sponsorship.rollback_to_editing()
353-
sponsorship.save()
354-
self.message_user(
355-
request, "Sponsorship is now editable!", messages.SUCCESS
356-
)
357-
except SponsorshipInvalidStatusException as e:
358-
self.message_user(request, str(e), messages.ERROR)
355+
return views_admin.rollback_to_editing_view(self, request, pk)
359356

360-
redirect_url = reverse(
361-
"admin:sponsors_sponsorship_change", args=[sponsorship.pk]
362-
)
363-
return redirect(redirect_url)
357+
def reject_sponsorship_view(self, request, pk):
358+
return views_admin.reject_sponsorship_view(self, request, pk)
364359

365-
context = {"sponsorship": sponsorship}
366-
return render(
367-
request,
368-
"sponsors/admin/rollback_sponsorship_to_editing.html",
369-
context=context,
370-
)
360+
def approve_sponsorship_view(self, request, pk):
361+
return views_admin.approve_sponsorship_view(self, request, pk)
371362

372-
def reject_sponsorship_view(self, request, pk):
373-
sponsorship = get_object_or_404(self.get_queryset(request), pk=pk)
374-
375-
if request.method.upper() == "POST" and request.POST.get("confirm") == "yes":
376-
try:
377-
use_case = use_cases.RejectSponsorshipApplicationUseCase.build()
378-
use_case.execute(sponsorship)
379-
self.message_user(
380-
request, "Sponsorship was rejected!", messages.SUCCESS
381-
)
382-
except SponsorshipInvalidStatusException as e:
383-
self.message_user(request, str(e), messages.ERROR)
384363

385-
redirect_url = reverse(
386-
"admin:sponsors_sponsorship_change", args=[sponsorship.pk]
387-
)
388-
return redirect(redirect_url)
364+
@admin.register(LegalClause)
365+
class LegalClauseModelAdmin(OrderedModelAdmin):
366+
list_display = ["internal_name"]
389367

390-
context = {"sponsorship": sponsorship}
391-
return render(
392-
request, "sponsors/admin/reject_application.html", context=context
393-
)
394368

395-
def approve_sponsorship_view(self, request, pk):
396-
sponsorship = get_object_or_404(self.get_queryset(request), pk=pk)
397-
398-
if request.method.upper() == "POST" and request.POST.get("confirm") == "yes":
399-
try:
400-
sponsorship.approve()
401-
sponsorship.save()
402-
self.message_user(
403-
request, "Sponsorship was approved!", messages.SUCCESS
369+
@admin.register(Contract)
370+
class ContractModelAdmin(admin.ModelAdmin):
371+
change_form_template = "sponsors/admin/contract_change_form.html"
372+
list_display = [
373+
"id",
374+
"sponsorship",
375+
"created_on",
376+
"last_update",
377+
"status",
378+
"get_revision",
379+
"document_link",
380+
]
381+
382+
def get_queryset(self, *args, **kwargs):
383+
qs = super().get_queryset(*args, **kwargs)
384+
return qs.select_related("sponsorship__sponsor")
385+
386+
def get_revision(self, obj):
387+
return obj.revision if obj.is_draft else "Final"
388+
389+
get_revision.short_description = "Revision"
390+
391+
fieldsets = [
392+
(
393+
"Info",
394+
{
395+
"fields": ("sponsorship", "status", "revision"),
396+
},
397+
),
398+
(
399+
"Editable",
400+
{
401+
"fields": (
402+
"sponsor_info",
403+
"sponsor_contact",
404+
"benefits_list",
405+
"legal_clauses",
406+
),
407+
},
408+
),
409+
(
410+
"Files",
411+
{
412+
"fields": (
413+
"document",
414+
"signed_document",
404415
)
405-
except SponsorshipInvalidStatusException as e:
406-
self.message_user(request, str(e), messages.ERROR)
416+
},
417+
),
418+
(
419+
"Activities log",
420+
{
421+
"fields": (
422+
"created_on",
423+
"last_update",
424+
"sent_on",
425+
),
426+
"classes": ["collapse"],
427+
},
428+
),
429+
]
407430

408-
redirect_url = reverse(
409-
"admin:sponsors_sponsorship_change", args=[sponsorship.pk]
410-
)
411-
return redirect(redirect_url)
431+
def get_readonly_fields(self, request, obj):
432+
readonly_fields = [
433+
"status",
434+
"created_on",
435+
"last_update",
436+
"sent_on",
437+
"sponsorship",
438+
"revision",
439+
"document",
440+
]
412441

413-
context = {"sponsorship": sponsorship}
414-
return render(
415-
request, "sponsors/admin/approve_application.html", context=context
416-
)
442+
if obj and not obj.is_draft:
443+
extra = [
444+
"sponsor_info",
445+
"sponsor_contact",
446+
"benefits_list",
447+
"legal_clauses",
448+
]
449+
readonly_fields.extend(extra)
450+
451+
return readonly_fields
452+
453+
def document_link(self, obj):
454+
html, url, msg = "---", "", ""
455+
456+
if obj.is_draft:
457+
url = obj.preview_url
458+
msg = "Preview document"
459+
elif obj.document:
460+
url = obj.document.url
461+
msg = "Download Contract"
462+
elif obj.signed_document:
463+
url = obj.signed_document.url
464+
msg = "Download Signed Contract"
465+
466+
if url and msg:
467+
html = f'<a href="{url}" target="_blank">{msg}</a>'
468+
return mark_safe(html)
417469

470+
document_link.short_description = "Contract document"
418471

419-
@admin.register(LegalClause)
420-
class LegalClauseModelAdmin(OrderedModelAdmin):
421-
list_display = ["internal_name"]
472+
def get_urls(self):
473+
urls = super().get_urls()
474+
my_urls = [
475+
path(
476+
"<int:pk>/preview",
477+
self.admin_site.admin_view(self.preview_contract_view),
478+
name="sponsors_contract_preview",
479+
),
480+
path(
481+
"<int:pk>/send",
482+
self.admin_site.admin_view(self.send_contract_view),
483+
name="sponsors_contract_send",
484+
),
485+
path(
486+
"<int:pk>/execute",
487+
self.admin_site.admin_view(self.execute_contract_view),
488+
name="sponsors_contract_execute",
489+
),
490+
path(
491+
"<int:pk>/nullify",
492+
self.admin_site.admin_view(self.nullify_contract_view),
493+
name="sponsors_contract_nullify",
494+
),
495+
]
496+
return my_urls + urls
497+
498+
def preview_contract_view(self, request, pk):
499+
return views_admin.preview_contract_view(self, request, pk)
500+
501+
def send_contract_view(self, request, pk):
502+
return views_admin.send_contract_view(self, request, pk)
503+
504+
def execute_contract_view(self, request, pk):
505+
return views_admin.execute_contract_view(self, request, pk)
506+
507+
def nullify_contract_view(self, request, pk):
508+
return views_admin.nullify_contract_view(self, request, pk)

sponsors/exceptions.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@ class SponsorWithExistingApplicationException(Exception):
55
"""
66

77

8-
class SponsorshipInvalidStatusException(Exception):
8+
class InvalidStatusException(Exception):
99
"""
1010
Raised when user tries to change the Sponsorship's status
1111
to a new one but from an invalid current status
1212
"""
13+
14+
15+
class SponsorshipInvalidDateRangeException(Exception):
16+
"""
17+
Raised when user tries to approve a sponsorship with a start date
18+
greater than the end date.
19+
"""

0 commit comments

Comments
 (0)