Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit743b53b

Browse files
Contract as docx files (python#1847)
* Remove unecessary code* Implement backend code to generate contract as a docxThis PR introduces python-docx-template which is a library whichreads 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 importCo-authored-by: Ee Durbin <ernest@python.org>
1 parenta256e96 commit743b53b

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+
fromdjango.dbimportmigrations,models
4+
5+
6+
classMigration(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
importuuid
22
fromabcimportABC
3-
frompathlibimportPath
43
fromitertoolsimportchain
54
fromnum2wordsimportnum2words
65
fromdjango.confimportsettings
7-
fromdjango.core.files.storageimportdefault_storage
86
fromdjango.core.exceptionsimportObjectDoesNotExist
97
fromdjango.dbimportmodels,transaction
108
fromdjango.db.modelsimportSum,Subquery
@@ -16,6 +14,7 @@
1614
fromordered_model.modelsimportOrderedModel
1715
fromallauth.account.adminimportEmailAddress
1816
fromdjango_countries.fieldsimportCountryField
17+
frompathlibimportPath
1918
frompolymorphic.modelsimportPolymorphicModel
2019

2120
fromcms.modelsimportContentManageable
@@ -30,6 +29,7 @@
3029
InvalidStatusException,
3130
SponsorshipInvalidDateRangeException,
3231
)
32+
from .utilsimportfile_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
returnsuper().save(**kwargs)
857863

858-
defset_final_version(self,pdf_file):
864+
defset_final_version(self,pdf_file,docx_file=None):
859865
ifself.AWAITING_SIGNATUREnotinself.next_status:
860866
msg=f"Can't send a{self.get_status_display()} contract."
861867
raiseInvalidStatusException(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-
exceptFileNotFoundErrorase:
872-
# local env, not using S3
873-
path=Path(e.filename).parent
874-
ifnotpath.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+
ifdocx_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
returncontext["contract"].sponsorship.verified_emails
103103

104104
defget_attachments(self,context):
105+
contract=context["contract"]
106+
ifcontract.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
withdocument.open("rb")asfd:
107115
content=fd.read()
108-
return [("Contract.pdf",content,"application/pdf")]
116+
return [(f"Contract.{ext}",content,f"application/{app_type}")]
109117

110118

111119
classSponsorshipApprovalLogger():

‎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+
importio
5+
importos
6+
fromdjango.confimportsettings
7+
fromdjango.httpimportHttpResponse
8+
fromdjango.utils.dateformatimportformat
9+
10+
fromdocxtplimportDocxTemplate
411
fromeasy_pdf.renderingimportrender_to_pdf_response,render_to_pdf
512

613
frommarkupfield_helpers.helpersimportrender_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
defrender_contract_to_pdf_response(request,contract,**context):
3140
template="sponsors/admin/preview-contract.html"
3241
context=_contract_context(contract,**context)
33-
fromdjango.shortcutsimportrender
34-
#return render(request, template, context)
3542
returnrender_to_pdf_response(request,template,context)
3643

3744

3845
defrender_contract_to_pdf_file(contract,**context):
3946
template="sponsors/admin/preview-contract.html"
4047
context=_contract_context(contract,**context)
4148
returnrender_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+
returnoutput
58+
59+
60+
defrender_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+
defrender_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+
returnfp.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+
deftest_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
deftest_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-
deftest_attach_contract_pdf(self):
214+
deftest_attach_contract_pdf_by_default(self):
215215
self.assertTrue(self.contract.document.name)
216216
withself.contract.document.open("rb")asfd:
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+
deftest_attach_contract_docx_if_it_exists(self):
233+
self.assertTrue(self.contract.document_docx.name)
234+
withself.contract.document_docx.open("rb")asfd:
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
classSponsorshipApprovalLoggerTests(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-
fromunittest.mockimportpatch,Mock
2-
frommodel_bakeryimportbaker
1+
fromdatetimeimportdate
2+
fromdocxtplimportDocxTemplate
33
frommarkupfield_helpers.helpersimportrender_md
4+
frommodel_bakeryimportbaker
5+
frompathlibimportPath
6+
fromunittest.mockimportpatch,Mock
47

8+
fromdjango.confimportsettings
59
fromdjango.httpimportHttpResponse,HttpRequest
610
fromdjango.template.loaderimportrender_to_string
711
fromdjango.testimportTestCase
812
fromdjango.utils.htmlimportmark_safe
13+
fromdjango.utils.dateformatimportformat
914

10-
fromsponsors.pdfimportrender_contract_to_pdf_file,render_contract_to_pdf_response
15+
fromsponsors.pdfimportrender_contract_to_pdf_file,render_contract_to_pdf_response,render_contract_to_docx_response
1116

1217

13-
classTestRenderContractToPDF(TestCase):
18+
classTestRenderContract(TestCase):
1419
defsetUp(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
deftest_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+
deftest_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
forninself.notifications:
146147
n.notify.assert_called_once_with(

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp