diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html index f0999f261d0a79375803958472e8751276b24a0f..b0810bc01f9104f9c2c96fdf3beeeeef27ef410a 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html @@ -1,4 +1,5 @@ {% extends "base-apply.html" %} +{% load static %} {% block title %}{{ object.title }}{% endblock %} @@ -18,6 +19,12 @@ <span>{{ object.round }}</span> <span>Lead: {{ object.lead }}</span> </h5> + <a + class="button button--primary simplified__button" + href="{% url "apply:submissions:download" pk=object.pk %}" + > + Download PDF + </a> </div> </div> @@ -34,3 +41,7 @@ </div> </div> {% endblock %} + +{% block extra_js %} + <script src="{% static 'js/apply/submission-text-cleanup.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 706fddede0349dcf01515894b85a659f4e23e46e..122472c123c1d140a63e006fb13dbddedac2c044 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -15,7 +15,8 @@ from .views import ( SubmissionSealedView, SubmissionDeleteView, SubmissionPrivateMediaView, - SubmissionDetailSimplifiedView + SubmissionDetailPDFView, + SubmissionDetailSimplifiedView, ) from .api_views import ( CommentEdit, @@ -45,6 +46,7 @@ submission_urls = ([ path('edit/', SubmissionEditView.as_view(), name="edit"), path('sealed/', SubmissionSealedView.as_view(), name="sealed"), path('simplified/', SubmissionDetailSimplifiedView.as_view(), name="simplified"), + path('download/', SubmissionDetailPDFView.as_view(), name="download"), path('delete/', SubmissionDeleteView.as_view(), name="delete"), path( 'documents/<uuid:field_id>/<str:file_name>', diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index 3347d8621c28eafb0387d2fe77059671e4727d66..c757e048335a60a1954748851c71d341c4d4a027 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -5,13 +5,15 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib import messages from django.core.exceptions import PermissionDenied from django.db.models import Count, F, Q -from django.http import HttpResponseRedirect, Http404 +from django.http import FileResponse, HttpResponseRedirect, Http404 from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ +from django.views import View from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView, DeleteView +from django.views.generic.detail import SingleObjectMixin from django_filters.views import FilterView from django_tables2.views import SingleTableMixin @@ -29,6 +31,7 @@ from opentech.apply.projects.forms import CreateProjectForm from opentech.apply.projects.models import Project from opentech.apply.review.views import ReviewContextMixin from opentech.apply.users.decorators import staff_required +from opentech.apply.utils.pdfs import make_pdf from opentech.apply.utils.storage import PrivateMediaView from opentech.apply.utils.views import DelegateableListView, DelegateableView, ViewDispatcher @@ -947,3 +950,34 @@ class SubmissionDetailSimplifiedView(DetailView): raise Http404 return obj + + +@method_decorator(staff_required, name='dispatch') +class SubmissionDetailPDFView(SingleObjectMixin, View): + model = ApplicationSubmission + + def get_object(self, queryset=None): + obj = super().get_object(queryset) + + if not hasattr(obj, 'project'): + raise Http404 + + return obj + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + pdf = make_pdf( + title=self.object.title, + meta=[ + self.object.stage, + self.object.page, + self.object.round, + f"Lead: { self.object.lead }", + ], + content=self.object.output_text_answers() + ) + return FileResponse( + pdf, + as_attachment=True, + filename=self.object.title + '.pdf', + ) diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_field.html index 01afc0c0ce157203f85b21803c275c113c4fefca..47387630e685bad0de9aa5b70894e96dcdcdfbe4 100644 --- a/opentech/apply/stream_forms/templates/stream_forms/render_field.html +++ b/opentech/apply/stream_forms/templates/stream_forms/render_field.html @@ -1,9 +1,11 @@ {% if include_question %} <section> - <h4>{{ value.field_label }}</h4> + <h4 class="question">{{ value.field_label }}</h4> {% endif %} - {% block data_display %}<span>{{ data }}</span>{% endblock %} +<div class="answer"> +{% block data_display %}<p>{{ data }}</p>{% endblock %} +</div> {% if include_question %} </section> diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html index 04ea0ffc4395d43f00b90a1fe36a9ec95e039e65..2f11036840264deaa73e001b739e680cdff2b849 100644 --- a/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html +++ b/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html @@ -2,7 +2,7 @@ {% load bleach_tags %} {% block data_display %} {% if data %} - {{ data|bleach }} + <p>{{ data|bleach }}</p> {% else %} {{ block.super }} {% endif %} diff --git a/opentech/apply/utils/media/fonts/Montserrat-Bold.ttf b/opentech/apply/utils/media/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..527ff38befb64d492f55cd117027dade2b0d2475 Binary files /dev/null and b/opentech/apply/utils/media/fonts/Montserrat-Bold.ttf differ diff --git a/opentech/apply/utils/media/fonts/Montserrat-BoldItalic.ttf b/opentech/apply/utils/media/fonts/Montserrat-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..79bf375d1790ac5f9e16a4de985c931cf082d5eb Binary files /dev/null and b/opentech/apply/utils/media/fonts/Montserrat-BoldItalic.ttf differ diff --git a/opentech/apply/utils/media/fonts/Montserrat-Italic.ttf b/opentech/apply/utils/media/fonts/Montserrat-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f8e361c19fffb63fbd216bcf1554522bf6f531f6 Binary files /dev/null and b/opentech/apply/utils/media/fonts/Montserrat-Italic.ttf differ diff --git a/opentech/apply/utils/media/fonts/Montserrat-Regular.ttf b/opentech/apply/utils/media/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..17b0ba8663453d75028971e33e30fba0033e6e04 Binary files /dev/null and b/opentech/apply/utils/media/fonts/Montserrat-Regular.ttf differ diff --git a/opentech/apply/utils/media/fonts/NotoSans-Bold.ttf b/opentech/apply/utils/media/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ab11d316397b9652ce96c2675f3b7a46cf6de13e Binary files /dev/null and b/opentech/apply/utils/media/fonts/NotoSans-Bold.ttf differ diff --git a/opentech/apply/utils/media/fonts/NotoSans-BoldItalic.ttf b/opentech/apply/utils/media/fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6dfd1e614fd2af31a86ee1e381ab24e7185b3b1e Binary files /dev/null and b/opentech/apply/utils/media/fonts/NotoSans-BoldItalic.ttf differ diff --git a/opentech/apply/utils/media/fonts/NotoSans-Italic.ttf b/opentech/apply/utils/media/fonts/NotoSans-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1639ad7d402a6df3c944da1688392c0d28f0f1e9 Binary files /dev/null and b/opentech/apply/utils/media/fonts/NotoSans-Italic.ttf differ diff --git a/opentech/apply/utils/media/fonts/NotoSans-Regular.ttf b/opentech/apply/utils/media/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1b8994edeacd70067de843a4691b15a0ce5921b Binary files /dev/null and b/opentech/apply/utils/media/fonts/NotoSans-Regular.ttf differ diff --git a/opentech/apply/utils/pdfs.py b/opentech/apply/utils/pdfs.py new file mode 100644 index 0000000000000000000000000000000000000000..e44e6898eee8eb71832867afd06ee6e6a2e63a10 --- /dev/null +++ b/opentech/apply/utils/pdfs.py @@ -0,0 +1,273 @@ +import os +import io + +from reportlab.lib import pagesizes +from reportlab.lib.colors import Color, white +from reportlab.lib.styles import ParagraphStyle as PS +from reportlab.lib.utils import simpleSplit +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import ( + KeepTogether, + ListFlowable, + ListItem, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + + +from bs4 import BeautifulSoup, NavigableString + +styles = { + 'Question': PS(fontName='MontserratBold', fontSize=14, name='Question', spaceAfter=0, spaceBefore=18, leading=21), + 'Normal': PS(fontName='NotoSans', name='Normal'), + 'Heading1': PS(fontName='NotoSansBold', fontSize=12, name='Heading1', spaceAfter=4, spaceBefore=12, leading=18), + 'Heading2': PS(fontName='NotoSansBold', fontSize=10, name='Heading2', spaceAfter=4, spaceBefore=10, leading=15), + 'Heading3': PS(fontName='NotoSansBold', fontSize=10, name='Heading3', spaceAfter=4, spaceBefore=10, leading=15), + 'Heading4': PS(fontName='NotoSansBold', fontSize=10, name='Heading4', spaceAfter=4, spaceBefore=10, leading=15), + 'Heading5': PS(fontName='NotoSansBold', fontSize=10, name='Heading5', spaceAfter=4, spaceBefore=10, leading=15), +} + +font_location = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'media', 'fonts') + + +def font(font_name): + return os.path.join(font_location, font_name) + + +pdfmetrics.registerFont(TTFont('Montserrat', font('Montserrat-Regular.ttf'))) +pdfmetrics.registerFont(TTFont('MontserratBold', font('Montserrat-Bold.ttf'))) +pdfmetrics.registerFont(TTFont('MontserratItalic', font('Montserrat-Italic.ttf'))) +pdfmetrics.registerFont(TTFont('MontserratBoldItalic', font('Montserrat-BoldItalic.ttf'))) +pdfmetrics.registerFontFamily( + 'Montserrat', + normal='Montserrat', + bold='MontserratBold', + italic='MontserratItalic', + boldItalic='MontserratBoldItalic' +) + +pdfmetrics.registerFont(TTFont('NotoSans', font('NotoSans-Regular.ttf'))) +pdfmetrics.registerFont(TTFont('NotoSansBold', font('NotoSans-Bold.ttf'))) +pdfmetrics.registerFont(TTFont('NotoSansItalic', font('NotoSans-Italic.ttf'))) +pdfmetrics.registerFont(TTFont('NotoSansBoldItalic', font('NotoSans-BoldItalic.ttf'))) +pdfmetrics.registerFontFamily( + 'NotoSans', + normal='NotoSans', + bold='NotoSansBold', + italic='NotoSansItalic', + boldItalic='NotoSansBoldItalic' +) + +DARK_GREY = Color(0.0154, 0.0154, 0, 0.7451) + +PAGE_WIDTH, PAGE_HEIGHT = pagesizes.legal + +# default value from https://github.com/MrBitBucket/reportlab-mirror/blob/58fb7bd37ee956cea45477c8b5aef723f1cb82e5/src/reportlab/platypus/frames.py#L70 +FRAME_PADDING = 6 + + +def make_pdf(title, meta, content): + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=(PAGE_WIDTH, PAGE_HEIGHT), + title=title, + ) + + blocks = [] + extra_content = draw_content(content) + blocks = [*blocks, *extra_content] + + def title_page(canvas, doc): + canvas.saveState() + title_spacer = draw_title_block(canvas, doc, title, meta) + canvas.restoreState() + blocks.insert(0, title_spacer) + + def later_page(canvas, doc): + canvas.saveState() + draw_header(canvas, doc, title) + canvas.restoreState() + + doc.build(blocks, onFirstPage=title_page, onLaterPages=later_page) + + buffer.seek(0) + return buffer + + +def split_text(canvas, text, width): + return simpleSplit(text, canvas._fontname, canvas._fontsize, width) + + +def draw_header(canvas, doc, title): + title_size = 10 + + canvas.setFillColor(DARK_GREY) + canvas.rect( + 0, + PAGE_HEIGHT - doc.topMargin, + PAGE_WIDTH, + doc.topMargin, + stroke=False, + fill=True, + ) + # Set canvas font to correctly calculate the splitting + + canvas.setFont("MontserratBold", title_size) + canvas.setFillColor(white) + + text_width = PAGE_WIDTH - doc.leftMargin - doc.rightMargin - 2 * FRAME_PADDING + split_title = split_text(canvas, title, text_width) + + pos = ( + (PAGE_HEIGHT - doc.topMargin) + # bottom of top margin + 1.5 * len(split_title) * title_size + # text + title_size / 2 # bottom padding + ) + + for line in split_title: + pos -= title_size + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + line, + ) + pos -= title_size / 2 + + +def draw_title_block(canvas, doc, title, meta): + title_size = 30 + meta_size = 10 + + text_width = PAGE_WIDTH - doc.leftMargin - doc.rightMargin - 2 * FRAME_PADDING + + # Set canvas font to correctly calculate the splitting + canvas.setFont("MontserratBold", title_size) + canvas.setFillColor(white) + split_title = split_text(canvas, title, text_width) + + total_height = ( + doc.topMargin + + len(split_title) * (title_size + title_size / 2) + # title + spacing + meta_size * 4 # 1 for text 3 for spacing + ) + + canvas.setFillColor(DARK_GREY) + canvas.rect( + 0, + PAGE_HEIGHT - total_height, + PAGE_WIDTH, + total_height, + stroke=False, + fill=True, + ) + + canvas.setFont("MontserratBold", title_size) + canvas.setFillColor(white) + pos = PAGE_HEIGHT - doc.topMargin + + for line in split_title: + pos -= title_size + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + line, + ) + pos -= title_size / 2 + + canvas.setFont("MontserratBold", meta_size) + canvas.setFillColor(white) + meta_text = ' | '.join(str(text) for text in meta) + + pos -= meta_size * 2 + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + meta_text, + ) + + return Spacer(1, total_height - doc.topMargin) + + +def handle_block(block): + paragraphs = [] + + for tag in block: + if isinstance(tag, NavigableString): + text = tag.strip() + if text: + paragraphs.append(Paragraph(text, styles['Normal'])) + elif tag.name in {'ul', 'ol'}: + style = styles['Normal'] + if tag.name == 'ul': + bullet = 'bullet' + elif tag.name == 'ol': + bullet = '1' + + paragraphs.append( + ListFlowable( + [ + ListItem(Paragraph(bullet_item.get_text(), style)) + for bullet_item in tag.find_all('li') + ], + bulletType=bullet, + ) + ) + elif tag.name in {'table'}: + paragraphs.append( + Table( + [ + [ + Paragraph(cell.get_text(), styles['Normal']) + for cell in row.find_all({'td', 'th'}) + ] + for row in tag.find_all('tr') + ], + colWidths='*', + style=TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LINEABOVE', (0, 0), (-1, -1), 1, DARK_GREY), + ]), + ) + ) + else: + if tag.name in {'p'}: + style = styles['Normal'] + elif tag.name == 'h2': + style = styles['Heading2'] + elif tag.name == 'h3': + style = styles['Heading3'] + elif tag.name == 'h4': + style = styles['Heading4'] + elif tag.name == 'h5': + style = styles['Heading5'] + + text = tag.get_text() + if text: + paragraphs.append(Paragraph(text, style)) + return paragraphs + + +def draw_content(content): + paragraphs = [] + + for section in BeautifulSoup(content, "html5lib").find_all('section'): + question_text = section.select_one('.question').get_text() + question = Paragraph(question_text, styles['Question']) + + # Keep the question and the first block of the answer together + # this keeps 1 line answers tidy and ensures that bigger responses break + # sooner instead of waiting to fill an entire page. There may still be issues + first_answer, *rest = handle_block(section.select_one('.answer')) + paragraphs.extend([ + KeepTogether([ + question, + first_answer, + ]), + *rest + ]) + return paragraphs diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 3a19783090da2fddc9072c8b705a2893538033e4..ac6161eba693c7f379199440c5ae504e6ee0b6a0 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -413,7 +413,7 @@ WAGTAILADMIN_RICH_TEXT_EDITORS = { 'OPTIONS': { 'features': [ 'bold', 'italic', - 'h2', 'h3', 'h4', 'h5', + 'h1', 'h2', 'h3', 'h4', 'h5', 'ol', 'ul', 'link' ] diff --git a/opentech/static_src/src/sass/apply/components/_rich-text.scss b/opentech/static_src/src/sass/apply/components/_rich-text.scss index 256965df61c030bd864de3f4d120b78ae97e2624..8279fcd11051308274b4dc1ae11777b5dde4dc88 100644 --- a/opentech/static_src/src/sass/apply/components/_rich-text.scss +++ b/opentech/static_src/src/sass/apply/components/_rich-text.scss @@ -4,7 +4,7 @@ &--answers { > section { - margin: 0 0 1rem; + margin: 0 0 2rem; p:first-of-type { margin-top: 0; @@ -14,10 +14,28 @@ margin: 0; } } + } - h4 { - margin: 0; - } + h1 { + font-size: 20px; + font-family: $font--primary; + } + + h2 { + font-size: 18px; + font-family: $font--primary; + } + + h3, + h4:not(.question), + h5, + h6 { + font-size: 16px; + font-family: $font--primary; + } + + .question { + margin: 0; } &--hidden { diff --git a/opentech/static_src/src/sass/apply/components/_simplified.scss b/opentech/static_src/src/sass/apply/components/_simplified.scss index 15b31cb1ef1b5e3b4d4c9b3bec8bb53fb9bdfc29..0eda6fa9de19d4999811b0fbd9a404d750e57bb6 100644 --- a/opentech/static_src/src/sass/apply/components/_simplified.scss +++ b/opentech/static_src/src/sass/apply/components/_simplified.scss @@ -97,7 +97,7 @@ word-break: break-word; > section { - margin: 0 0 1rem; + margin: 0 0 2rem; p:first-of-type { margin-top: 0; @@ -108,8 +108,27 @@ } } - h4 { + h1 { + font-size: 20px; + font-family: $font--primary; + } + + h2 { + font-size: 18px; + font-family: $font--primary; + } + + h3, + h4:not(.question), + h5, + h6 { + font-size: 16px; + font-family: $font--primary; + } + + .question { margin: 0; } + } } diff --git a/requirements.txt b/requirements.txt index 99fba24cc43a2b94ab2423117503ce9d14a1642a..baece5f064b879ca37e4a8e0d9736c353d3f7567 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,6 +34,7 @@ mistune==0.8.4 more-itertools==7.2.0 Pillow==5.4.1 psycopg2==2.7.3.1 +reportlab==3.5.31 social_auth_app_django==3.1.0 tomd==0.1.3 wagtail==2.5.1