diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index 011928529e05385d6e0afb973e4b69f4e74f9d74..96bb65f9f746f80d27bc7c319720d5fbd7ce9c1e 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -39,7 +39,7 @@ from hypha.apply.projects.forms import CreateProjectForm from hypha.apply.projects.models import Project from hypha.apply.review.views import ReviewContextMixin from hypha.apply.users.decorators import staff_required -from hypha.apply.utils.pdfs import make_pdf +from hypha.apply.utils.pdfs import draw_submission_content, make_pdf from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import ( DelegateableListView, @@ -1016,15 +1016,23 @@ class SubmissionDetailPDFView(SingleObjectMixin, View): def get(self, request, *args, **kwargs): self.object = self.get_object() + content = draw_submission_content( + self.object.output_text_answers() + ) 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() + sections=[ + { + 'content': content, + 'title': 'Submission', + 'meta': [ + self.object.stage, + self.object.page, + self.object.round, + f"Lead: { self.object.lead }", + ], + }, + ] ) return FileResponse( pdf, diff --git a/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html b/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html index 6994f1a1f649a8d35958301a9842aaa7d74225e3..fef33c9dd193e04a3d94d68cb59044c379226551 100644 --- a/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -15,7 +15,7 @@ <div class="docs-block__row-inner"> <a class="docs-block__link" href="{% url 'apply:submissions:simplified' pk=project.submission.pk %}">View</a> {% if not user.is_applicant %} - <a class="docs-block__link" href="#">Download</a> + <a class="docs-block__link" href="{% url "apply:submissions:download" pk=project.submission.pk %}">Download</a> {% endif %} </div> </li> diff --git a/hypha/apply/projects/templates/application_projects/project_simplified_detail.html b/hypha/apply/projects/templates/application_projects/project_simplified_detail.html index 40cc585fb66f2dad1f7cc7fb406a96e15130dbd5..675d2228695479c6558592a3b35db070b460ac8c 100644 --- a/hypha/apply/projects/templates/application_projects/project_simplified_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_simplified_detail.html @@ -17,6 +17,12 @@ <span>{{ object.submission.round }}</span> <span>Lead: {{ object.lead }}</span> </h5> + <a + class="button button--primary simplified__button" + href="{% url "apply:projects:download" pk=object.pk %}" + > + Download PDF + </a> </div> </div> diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py index c095677bc38d6980994e314a8cc2225c88f3dbdf..e26cbcd3d50ec052daaf753faf3f98b7a3ac8fdc 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -1529,3 +1529,40 @@ class TestSkipReport(BaseViewTestCase): self.assertEqual(response.status_code, 200) report.refresh_from_db() self.assertTrue(report.skipped) + + +class TestStaffProjectPDFExport(BaseViewTestCase): + base_view_name = 'download' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can_access(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.status_code, 200) + + def test_reponse_object_is_pdf(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.filename, project.title + '.pdf') + + +class ApplicantStaffProjectPDFExport(BaseViewTestCase): + base_view_name = 'download' + url_name = 'funds:projects:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_cant_access(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.status_code, 403) diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index 41d28497532b41fe68ee4e7d142d6ebc1ffb30a1..5e6784e6da160a5fe9d142f434427a7cfcb23a47 100644 --- a/hypha/apply/projects/urls.py +++ b/hypha/apply/projects/urls.py @@ -8,6 +8,7 @@ from .views import ( PaymentRequestListView, PaymentRequestPrivateMedia, PaymentRequestView, + ProjectDetailPDFView, ProjectDetailSimplifiedView, ProjectDetailView, ProjectEditView, @@ -31,6 +32,7 @@ urlpatterns = [ path('edit/', ProjectEditView.as_view(), name="edit"), path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"), path('contract/<int:file_pk>/', ContractPrivateMediaView.as_view(), name="contract"), + path('download/', ProjectDetailPDFView.as_view(), name='download'), path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'), path('request/', CreatePaymentRequestView.as_view(), name='request'), ])), diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index f90774d2f882a253a9442c3383056243d4aca24a..d9bc8fd3ef129f9846e1a64efcdfcca8d5b7650b 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -6,7 +6,7 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Count -from django.http import Http404 +from django.http import FileResponse, Http404 from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy from django.utils import timezone @@ -14,6 +14,7 @@ from django.utils.decorators import method_decorator from django.utils.functional import cached_property 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, @@ -21,12 +22,18 @@ from django.views.generic import ( TemplateView, UpdateView, ) +from django.views.generic.detail import SingleObjectMixin from django_filters.views import FilterView from django_tables2 import SingleTableMixin from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.views import ActivityContextMixin, CommentFormView from hypha.apply.users.decorators import approver_required, staff_required +from hypha.apply.utils.pdfs import ( + draw_project_content, + draw_submission_content, + make_pdf, +) from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import DelegateableView, DelegatedViewMixin, ViewDispatcher @@ -518,6 +525,52 @@ class ProjectDetailSimplifiedView(DetailView): template_name_suffix = '_simplified_detail' +@method_decorator(staff_required, name='dispatch') +class ProjectDetailPDFView(SingleObjectMixin, View): + model = Project + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + response = ProjectDetailSimplifiedView.as_view()( + request=self.request, + pk=self.object.pk, + ) + project = draw_project_content( + response.render().content + ) + submission = draw_submission_content( + self.object.submission.output_text_answers() + ) + pdf = make_pdf( + title=self.object.title, + sections=[ + { + 'content': project, + 'title': 'Project Approval Form', + 'meta': [ + self.object.submission.page, + self.object.submission.round, + f"Lead: { self.object.lead }", + ], + }, { + 'content': submission, + 'title': 'Submission', + 'meta': [ + self.object.submission.stage, + self.object.submission.page, + self.object.submission.round, + f"Lead: { self.object.submission.lead }", + ], + }, + ], + ) + return FileResponse( + pdf, + as_attachment=True, + filename=self.object.title + '.pdf', + ) + + class ProjectApprovalEditView(UpdateView): form_class = ProjectApprovalForm model = Project diff --git a/hypha/apply/utils/pdfs.py b/hypha/apply/utils/pdfs.py index 3e63b38f23ce98a85c754a757af5bf83acde0cc4..1c007f6a97d869220847e46616d87926c00d8258 100644 --- a/hypha/apply/utils/pdfs.py +++ b/hypha/apply/utils/pdfs.py @@ -1,5 +1,6 @@ import io import os +from itertools import cycle from bs4 import BeautifulSoup, NavigableString from reportlab.lib import pagesizes @@ -9,18 +10,23 @@ from reportlab.lib.utils import simpleSplit from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import ( + BaseDocTemplate, + Frame, KeepTogether, ListFlowable, ListItem, + NextPageTemplate, + PageBreak, + PageTemplate, Paragraph, - SimpleDocTemplate, Spacer, Table, TableStyle, ) -styles = { +STYLES = { 'Question': PS(fontName='MontserratBold', fontSize=14, name='Question', spaceAfter=0, spaceBefore=18, leading=21), + 'QuestionSmall': PS(fontName='MontserratBold', fontSize=12, name='QuestionSmall', spaceAfter=0, spaceBefore=16, leading=18), '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), @@ -77,31 +83,61 @@ PAGE_WIDTH, PAGE_HEIGHT = pagesizes.legal FRAME_PADDING = 6 -def make_pdf(title, meta, content): +def do_nothing(doc, canvas): + pass + + +class ReportDocTemplate(BaseDocTemplate): + def build(self, flowables, onFirstPage=do_nothing, onLaterPages=do_nothing): + frame = Frame(self.leftMargin, self.bottomMargin, self.width, self.height, id='normal') + self.addPageTemplates([ + PageTemplate(id='Header', autoNextPageTemplate='Main', frames=frame, onPage=onFirstPage, pagesize=self.pagesize), + PageTemplate(id='Main', frames=frame, onPage=onLaterPages, pagesize=self.pagesize), + ]) + super().build(flowables) + + +def make_pdf(title, sections): prepare_fonts() buffer = io.BytesIO() - doc = SimpleDocTemplate( + + doc = ReportDocTemplate( buffer, pagesize=(PAGE_WIDTH, PAGE_HEIGHT), title=title, ) - blocks = [] - extra_content = draw_content(content) - blocks = [*blocks, *extra_content] + story = [] + for section in sections: + story.extend(section['content']) + story.append(NextPageTemplate('Header')) + story.append(PageBreak()) + + current_section = None + sections = cycle(sections) - def title_page(canvas, doc): + def header_page(canvas, doc): + nonlocal current_section + current_section = next(sections) canvas.saveState() - title_spacer = draw_title_block(canvas, doc, title, meta) + title_spacer = draw_title_block( + canvas, + doc, + current_section['title'], + title, + current_section['meta'], + ) canvas.restoreState() - blocks.insert(0, title_spacer) + story.insert(0, title_spacer) - def later_page(canvas, doc): + def main_page(canvas, doc): + nonlocal current_section canvas.saveState() - draw_header(canvas, doc, title) + spacer = draw_header(canvas, doc, current_section['title'], title) + story.insert(0, spacer) canvas.restoreState() - doc.build(blocks, onFirstPage=title_page, onLaterPages=later_page) + doc.build(story, onFirstPage=header_page, onLaterPages=main_page) buffer.seek(0) return buffer @@ -111,32 +147,48 @@ def split_text(canvas, text, width): return simpleSplit(text, canvas._fontname, canvas._fontsize, width) -def draw_header(canvas, doc, title): +def draw_header(canvas, doc, page_title, title): title_size = 10 + # Set canvas font to correctly calculate the splitting + canvas.setFont("MontserratBold", title_size) + + text_width = PAGE_WIDTH - doc.leftMargin - doc.rightMargin - 2 * FRAME_PADDING + split_title = split_text(canvas, title, text_width) + + # only count title - assume 1 line of title in header + total_height = ( + doc.topMargin + + 1.5 * (len(split_title) - 1) * title_size + + title_size / 2 # bottom padding + ) + canvas.setFillColor(DARK_GREY) canvas.rect( 0, - PAGE_HEIGHT - doc.topMargin, + PAGE_HEIGHT - total_height, PAGE_WIDTH, - doc.topMargin, + total_height, 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 + title_size / 2 + # spacing below page title + 1.5 * 1 * title_size # text + ) + + canvas.setFillColor(white) + + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + page_title, ) + pos -= title_size / 2 + for line in split_title: pos -= title_size canvas.drawString( @@ -146,8 +198,11 @@ def draw_header(canvas, doc, title): ) pos -= title_size / 2 + return Spacer(1, total_height - doc.topMargin) + -def draw_title_block(canvas, doc, title, meta): +def draw_title_block(canvas, doc, page_title, title, meta): + page_title_size = 20 title_size = 30 meta_size = 10 @@ -158,10 +213,16 @@ def draw_title_block(canvas, doc, title, meta): canvas.setFillColor(white) split_title = split_text(canvas, title, text_width) + canvas.setFont("MontserratBold", meta_size) + canvas.setFillColor(white) + meta_text = ' | '.join(str(text) for text in meta) + split_meta = split_text(canvas, meta_text, text_width) + total_height = ( doc.topMargin + + page_title_size + page_title_size * 3 / 4 + # page title + spaceing len(split_title) * (title_size + title_size / 2) + # title + spacing - meta_size * 4 # 1 for text 3 for spacing + (1.5 * len(split_meta) + 3) * meta_size # 1.5 per text line + 3 for spacing ) canvas.setFillColor(DARK_GREY) @@ -174,10 +235,20 @@ def draw_title_block(canvas, doc, title, meta): fill=True, ) - canvas.setFont("MontserratBold", title_size) + canvas.setFont("MontserratBold", page_title_size) canvas.setFillColor(white) pos = PAGE_HEIGHT - doc.topMargin + pos -= page_title_size + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + page_title, + ) + pos -= page_title_size * 3 / 4 + + canvas.setFont("MontserratBold", title_size) + canvas.setFillColor(white) for line in split_title: pos -= title_size canvas.drawString( @@ -189,20 +260,28 @@ def draw_title_block(canvas, doc, title, meta): 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, - ) + + for line in split_meta: + canvas.drawString( + doc.leftMargin + FRAME_PADDING, + pos, + line, + ) + pos -= meta_size / 2 return Spacer(1, total_height - doc.topMargin) -def handle_block(block): +def handle_block(block, custom_style=None): paragraphs = [] + if not custom_style: + custom_style = {} + + styles = {**STYLES} + for style, overwrite in custom_style.items(): + styles[style] = STYLES[overwrite] for tag in block: if isinstance(tag, NavigableString): @@ -243,6 +322,7 @@ def handle_block(block): ) ) else: + style = None if tag.name in {'p'}: style = styles['Normal'] elif tag.name == 'h2': @@ -254,18 +334,22 @@ def handle_block(block): elif tag.name == 'h5': style = styles['Heading5'] - text = tag.get_text() - if text: - paragraphs.append(Paragraph(text, style)) + if style: + text = tag.get_text() + if text: + paragraphs.append(Paragraph(text, style)) + else: + paragraphs.extend(handle_block(tag)) return paragraphs -def draw_content(content): +def draw_submission_content(content): + prepare_fonts() paragraphs = [] for section in BeautifulSoup(content, "html5lib").find_all('section'): question_text = section.select_one('.question').get_text() - question = Paragraph(question_text, styles['Question']) + 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 @@ -279,3 +363,13 @@ def draw_content(content): *rest ]) return paragraphs + + +def draw_project_content(content): + prepare_fonts() + paragraphs = [] + for section in BeautifulSoup(content, "html5lib").find_all(class_='simplified__wrapper'): + flowables = handle_block(section, custom_style={"Heading3": "Question", "Heading5": "QuestionSmall"}) + paragraphs.extend(flowables) + + return paragraphs