Skip to content

Commit 743b53b

Browse files
berinhardewdurbin
andauthored
Contract as docx files (#1847)
* Remove unecessary code * Implement backend code to generate contract as a docx This PR introduces python-docx-template which is a library which reads a docx file as jinja2 templates * Update contract view to accept different formats in the querystring * Add new field to save docx version of the unsigned contract * Introduce utilitary function to always return a file from storage * Also generate docx file before sending the email * Update notification email to attach docx version * Admin updates * Update sponsors/models.py * Add missing import Co-authored-by: Ee Durbin <ernest@python.org>
1 parent a256e96 commit 743b53b

20 files changed

+230
-37
lines changed

base-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ django-easy-pdf==0.1.1
4545
num2words==0.5.10
4646
django-polymorphic==2.1.2
4747
sorl-thumbnail==12.7.0
48+
docxtpl==0.12.0

pydotorg/settings/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,12 @@
8282

8383
### Templates
8484

85+
TEMPLATES_DIR = os.path.join(BASE, 'templates')
8586
TEMPLATES = [
8687
{
8788
'BACKEND': 'django.template.backends.django.DjangoTemplates',
8889
'DIRS': [
89-
os.path.join(BASE, 'templates'),
90+
TEMPLATES_DIR,
9091
],
9192
'APP_DIRS': True,
9293
'OPTIONS': {

sponsors/admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ def get_revision(self, obj):
471471
{
472472
"fields": (
473473
"document",
474+
"document_docx",
474475
"signed_document",
475476
)
476477
},
@@ -497,6 +498,7 @@ def get_readonly_fields(self, request, obj):
497498
"sponsorship",
498499
"revision",
499500
"document",
501+
"document_docx",
500502
"get_sponsorship_url",
501503
]
502504

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.0.13 on 2021-08-20 16:41
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('sponsors', '0033_tieredquantity_tieredquantityconfiguration'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='contract',
15+
name='document_docx',
16+
field=models.FileField(blank=True, upload_to='sponsors/statmentes_of_work/docx/', verbose_name='Unsigned Docx'),
17+
),
18+
]

sponsors/models.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import uuid
22
from abc import ABC
3-
from pathlib import Path
43
from itertools import chain
54
from num2words import num2words
65
from django.conf import settings
7-
from django.core.files.storage import default_storage
86
from django.core.exceptions import ObjectDoesNotExist
97
from django.db import models, transaction
108
from django.db.models import Sum, Subquery
@@ -16,6 +14,7 @@
1614
from ordered_model.models import OrderedModel
1715
from allauth.account.admin import EmailAddress
1816
from django_countries.fields import CountryField
17+
from pathlib import Path
1918
from polymorphic.models import PolymorphicModel
2019

2120
from cms.models import ContentManageable
@@ -30,6 +29,7 @@
3029
InvalidStatusException,
3130
SponsorshipInvalidDateRangeException,
3231
)
32+
from .utils import file_from_storage
3333

3434
DEFAULT_MARKUP_TYPE = getattr(settings, "DEFAULT_MARKUP_TYPE", "restructuredtext")
3535

@@ -727,7 +727,8 @@ class Contract(models.Model):
727727
(NULLIFIED, "Nullified"),
728728
]
729729

730-
FINAL_VERSION_PDF_DIR = "sponsors/statmentes_of_work/"
730+
FINAL_VERSION_PDF_DIR = "sponsors/contracts/"
731+
FINAL_VERSION_DOCX_DIR = FINAL_VERSION_PDF_DIR + "docx/"
731732
SIGNED_PDF_DIR = FINAL_VERSION_PDF_DIR + "signed/"
732733

733734
status = models.CharField(
@@ -739,6 +740,11 @@ class Contract(models.Model):
739740
blank=True,
740741
verbose_name="Unsigned PDF",
741742
)
743+
document_docx = models.FileField(
744+
upload_to=FINAL_VERSION_DOCX_DIR,
745+
blank=True,
746+
verbose_name="Unsigned Docx",
747+
)
742748
signed_document = models.FileField(
743749
upload_to=signed_contract_random_path,
744750
blank=True,
@@ -855,31 +861,30 @@ def save(self, **kwargs):
855861
self.revision += 1
856862
return super().save(**kwargs)
857863

858-
def set_final_version(self, pdf_file):
864+
def set_final_version(self, pdf_file, docx_file=None):
859865
if self.AWAITING_SIGNATURE not in self.next_status:
860866
msg = f"Can't send a {self.get_status_display()} contract."
861867
raise InvalidStatusException(msg)
862868

863-
path = f"{self.FINAL_VERSION_PDF_DIR}"
864869
sponsor = self.sponsorship.sponsor.name.upper()
865-
filename = f"{path}SoW: {sponsor}.pdf"
866-
867-
mode = "wb"
868-
try:
869-
# if using S3 Storage the file will always exist
870-
file = default_storage.open(filename, mode)
871-
except FileNotFoundError as e:
872-
# local env, not using S3
873-
path = Path(e.filename).parent
874-
if not path.exists():
875-
path.mkdir(parents=True)
876-
Path(e.filename).touch()
877-
file = default_storage.open(filename, mode)
878870

871+
# save contract as PDF file
872+
path = f"{self.FINAL_VERSION_PDF_DIR}"
873+
pdf_filename = f"{path}SoW: {sponsor}.pdf"
874+
file = file_from_storage(pdf_filename, mode="wb")
879875
file.write(pdf_file)
880876
file.close()
877+
self.document = pdf_filename
878+
879+
# save contract as docx file
880+
if docx_file:
881+
path = f"{self.FINAL_VERSION_DOCX_DIR}"
882+
docx_filename = f"{path}SoW: {sponsor}.docx"
883+
file = file_from_storage(docx_filename, mode="wb")
884+
file.write(docx_file)
885+
file.close()
886+
self.document_docx = docx_filename
881887

882-
self.document = filename
883888
self.status = self.AWAITING_SIGNATURE
884889
self.save()
885890

sponsors/notifications.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,18 @@ def get_recipient_list(self, context):
102102
return context["contract"].sponsorship.verified_emails
103103

104104
def get_attachments(self, context):
105+
contract = context["contract"]
106+
if contract.document_docx:
107+
document = contract.document_docx
108+
ext, app_type = "docx", "msword"
109+
else: # fallback to PDF for existing contracts
110+
document = contract.document
111+
ext, app_type = "pdf", "pdf"
112+
105113
document = context["contract"].document
106114
with document.open("rb") as fd:
107115
content = fd.read()
108-
return [("Contract.pdf", content, "application/pdf")]
116+
return [(f"Contract.{ext}", content, f"application/{app_type}")]
109117

110118

111119
class SponsorshipApprovalLogger():

sponsors/pdf.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
"""
22
This module is a wrapper around django-easy-pdf so we can reuse code
33
"""
4+
import io
5+
import os
6+
from django.conf import settings
7+
from django.http import HttpResponse
8+
from django.utils.dateformat import format
9+
10+
from docxtpl import DocxTemplate
411
from easy_pdf.rendering import render_to_pdf_response, render_to_pdf
512

613
from markupfield_helpers.helpers import render_md
@@ -16,9 +23,11 @@ def _clean_split(text, separator='\n'):
1623

1724

1825
def _contract_context(contract, **context):
26+
start_date = contract.sponsorship.start_date
1927
context.update({
2028
"contract": contract,
21-
"start_date": contract.sponsorship.start_date,
29+
"start_date": start_date,
30+
"start_day_english_suffix": format(start_date, "S"),
2231
"sponsor": contract.sponsorship.sponsor,
2332
"sponsorship": contract.sponsorship,
2433
"benefits": _clean_split(contract.benefits_list.raw),
@@ -30,12 +39,32 @@ def _contract_context(contract, **context):
3039
def render_contract_to_pdf_response(request, contract, **context):
3140
template = "sponsors/admin/preview-contract.html"
3241
context = _contract_context(contract, **context)
33-
from django.shortcuts import render
34-
#return render(request, template, context)
3542
return render_to_pdf_response(request, template, context)
3643

3744

3845
def render_contract_to_pdf_file(contract, **context):
3946
template = "sponsors/admin/preview-contract.html"
4047
context = _contract_context(contract, **context)
4148
return render_to_pdf(template, context)
49+
50+
51+
def _gen_docx_contract(output, contract, **context):
52+
template = os.path.join(settings.TEMPLATES_DIR, "sponsors", "admin", "contract-template.docx")
53+
doc = DocxTemplate(template)
54+
context = _contract_context(contract, **context)
55+
doc.render(context)
56+
doc.save(output)
57+
return output
58+
59+
60+
def render_contract_to_docx_response(request, contract, **context):
61+
response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document')
62+
response['Content-Disposition'] = 'attachment; filename=contract.docx'
63+
return _gen_docx_contract(output=response, contract=contract, **context)
64+
65+
66+
def render_contract_to_docx_file(contract, **context):
67+
fp = io.BytesIO()
68+
fp = _gen_docx_contract(output=fp, contract=contract, **context)
69+
fp.seek(0)
70+
return fp.read()

sponsors/tests/baker_recipes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
empty_contract = Recipe(
1212
Contract,
1313
sponsorship__sponsor__name="Sponsor",
14+
sponsorship__start_date=today,
1415
benefits_list="",
1516
legal_clauses="",
1617
)
1718

1819
awaiting_signature_contract = Recipe(
1920
Contract,
2021
sponsorship__sponsor__name="Awaiting Sponsor",
22+
sponsorship__start_date=today,
2123
benefits_list="- benefit 1",
2224
legal_clauses="",
2325
status=Contract.AWAITING_SIGNATURE,

sponsors/tests/test_models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,19 @@ def test_set_final_document_version(self):
544544
self.assertTrue(contract.document.name)
545545
self.assertEqual(contract.status, Contract.AWAITING_SIGNATURE)
546546

547+
def test_set_final_document_version_saves_docx_document_too(self):
548+
contract = baker.make_recipe(
549+
"sponsors.tests.empty_contract", sponsorship__sponsor__name="foo"
550+
)
551+
content = b"pdf binary content"
552+
docx_content = b"pdf binary content"
553+
554+
contract.set_final_version(content, docx_content)
555+
contract.refresh_from_db()
556+
557+
self.assertTrue(contract.document_docx.name)
558+
self.assertEqual(contract.status, Contract.AWAITING_SIGNATURE)
559+
547560
def test_raise_invalid_status_exception_if_not_draft(self):
548561
contract = baker.make_recipe(
549562
"sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE

sponsors/tests/test_notifications.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def setUp(self):
191191
self.contract = baker.make_recipe(
192192
"sponsors.tests.awaiting_signature_contract",
193193
sponsorship=sponsorship,
194-
_fill_optional=["document"],
194+
_fill_optional=["document", "document_docx"],
195195
_create_files=True,
196196
)
197197
self.subject_template = "sponsors/email/sponsor_contract_subject.txt"
@@ -211,12 +211,14 @@ def test_send_email_using_correct_templates(self):
211211
self.assertEqual(settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, email.from_email)
212212
self.assertEqual([self.user.email], email.to)
213213

214-
def test_attach_contract_pdf(self):
214+
def test_attach_contract_pdf_by_default(self):
215215
self.assertTrue(self.contract.document.name)
216216
with self.contract.document.open("rb") as fd:
217217
expected_content = fd.read()
218218
self.assertTrue(expected_content)
219219

220+
self.contract.document_docx = None
221+
self.contract.save()
220222
self.contract.refresh_from_db()
221223
self.notification.notify(contract=self.contract)
222224
email = mail.outbox[0]
@@ -227,6 +229,22 @@ def test_attach_contract_pdf(self):
227229
self.assertEqual(mime, "application/pdf")
228230
self.assertEqual(content, expected_content)
229231

232+
def test_attach_contract_docx_if_it_exists(self):
233+
self.assertTrue(self.contract.document_docx.name)
234+
with self.contract.document_docx.open("rb") as fd:
235+
expected_content = fd.read()
236+
self.assertTrue(expected_content)
237+
238+
self.contract.refresh_from_db()
239+
self.notification.notify(contract=self.contract)
240+
email = mail.outbox[0]
241+
242+
self.assertEqual(len(email.attachments), 1)
243+
name, content, mime = email.attachments[0]
244+
self.assertEqual(name, "Contract.docx")
245+
self.assertEqual(mime, "application/msword")
246+
self.assertEqual(content, expected_content)
247+
230248

231249
class SponsorshipApprovalLoggerTests(TestCase):
232250

sponsors/tests/test_pdf.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,37 @@
1-
from unittest.mock import patch, Mock
2-
from model_bakery import baker
1+
from datetime import date
2+
from docxtpl import DocxTemplate
33
from markupfield_helpers.helpers import render_md
4+
from model_bakery import baker
5+
from pathlib import Path
6+
from unittest.mock import patch, Mock
47

8+
from django.conf import settings
59
from django.http import HttpResponse, HttpRequest
610
from django.template.loader import render_to_string
711
from django.test import TestCase
812
from django.utils.html import mark_safe
13+
from django.utils.dateformat import format
914

10-
from sponsors.pdf import render_contract_to_pdf_file, render_contract_to_pdf_response
15+
from sponsors.pdf import render_contract_to_pdf_file, render_contract_to_pdf_response, render_contract_to_docx_response
1116

1217

13-
class TestRenderContractToPDF(TestCase):
18+
class TestRenderContract(TestCase):
1419
def setUp(self):
15-
self.contract = baker.make_recipe("sponsors.tests.empty_contract")
20+
self.contract = baker.make_recipe("sponsors.tests.empty_contract", sponsorship__start_date=date.today())
1621
text = f"{self.contract.benefits_list.raw}\n\n**Legal Clauses**\n{self.contract.legal_clauses.raw}"
1722
html = render_md(text)
1823
self.context = {
1924
"contract": self.contract,
2025
"start_date": self.contract.sponsorship.start_date,
26+
"start_day_english_suffix": format(self.contract.sponsorship.start_date, "S"),
2127
"sponsor": self.contract.sponsorship.sponsor,
2228
"sponsorship": self.contract.sponsorship,
2329
"benefits": [],
2430
"legal_clauses": [],
2531
}
2632
self.template = "sponsors/admin/preview-contract.html"
2733

34+
# PDF unit tests
2835
@patch("sponsors.pdf.render_to_pdf")
2936
def test_render_pdf_using_django_easy_pdf(self, mock_render):
3037
mock_render.return_value = "pdf content"
@@ -44,3 +51,23 @@ def test_render_response_using_django_easy_pdf(self, mock_render):
4451

4552
self.assertEqual(content, response)
4653
mock_render.assert_called_once_with(request, self.template, self.context)
54+
55+
# DOCX unit test
56+
@patch("sponsors.pdf.DocxTemplate")
57+
def test_render_response_with_docx_attachment(self, MockDocxTemplate):
58+
template = Path(settings.TEMPLATES_DIR) / "sponsors" / "admin" / "contract-template.docx"
59+
self.assertTrue(template.exists())
60+
mocked_doc = Mock(DocxTemplate)
61+
MockDocxTemplate.return_value = mocked_doc
62+
63+
request = Mock(HttpRequest)
64+
response = render_contract_to_docx_response(request, self.contract)
65+
66+
MockDocxTemplate.assert_called_once_with(str(template.resolve()))
67+
mocked_doc.render.assert_called_once_with(self.context)
68+
mocked_doc.save.assert_called_once_with(response)
69+
self.assertEqual(response.get("Content-Disposition"), "attachment; filename=contract.docx")
70+
self.assertEqual(
71+
response.get("Content-Type"),
72+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
73+
)

sponsors/tests/test_use_cases.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def test_send_and_update_contract_with_document(self):
141141
self.contract.refresh_from_db()
142142

143143
self.assertTrue(self.contract.document.name)
144+
self.assertTrue(self.contract.document_docx.name)
144145
self.assertTrue(self.contract.awaiting_signature)
145146
for n in self.notifications:
146147
n.notify.assert_called_once_with(

0 commit comments

Comments
 (0)