diff --git a/hypha/apply/funds/models/mixins.py b/hypha/apply/funds/models/mixins.py index 0c6080ff77..7e817e71ba 100644 --- a/hypha/apply/funds/models/mixins.py +++ b/hypha/apply/funds/models/mixins.py @@ -385,3 +385,20 @@ def get_answer_from_label(self, label): if answer and not answer == "N": return answer return None + + def get_text_questions_answers_as_dict(self): + data_dict = {} + for field_id in self.question_text_field_ids: + if field_id not in self.named_blocks: + question_field = self.serialize(field_id) + if isinstance(question_field["answer"], str): + answer = question_field["answer"] + else: + answer = ",".join(question_field["answer"]) + if answer and not answer == "None": + data_dict[question_field["question"]] = answer + elif question_field["type"] == "checkbox": + data_dict[question_field["question"]] = False + else: + data_dict[question_field["question"]] = "-" + return data_dict diff --git a/hypha/apply/funds/templates/funds/submission-pdf.html b/hypha/apply/funds/templates/funds/submission-pdf.html new file mode 100644 index 0000000000..4140755887 --- /dev/null +++ b/hypha/apply/funds/templates/funds/submission-pdf.html @@ -0,0 +1,15 @@ +{% extends "base-pdf.html" %} +{% load i18n %} + +{% block content %} +

{{ title }} (#{{ id }})

+

{{ stage }} : {{ fund }} : {{ round }} : {{ lead }}

+ +
+ {% for question, answer in data.items %} +

{{ question }}

+

{{ answer }}

+ {% endfor %} +
+ +{% endblock %} diff --git a/hypha/apply/funds/views/submission_detail.py b/hypha/apply/funds/views/submission_detail.py index 95782f15a7..d738fa560d 100644 --- a/hypha/apply/funds/views/submission_detail.py +++ b/hypha/apply/funds/views/submission_detail.py @@ -1,15 +1,17 @@ from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import ( - FileResponse, Http404, HttpRequest, HttpResponse, HttpResponseRedirect, ) from django.shortcuts import redirect +from django.templatetags.static import static from django.urls import reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.text import slugify from django.views import View from django.views.generic import ( DetailView, @@ -22,10 +24,11 @@ staff_required, ) from hypha.apply.utils.models import PDFPageSettings -from hypha.apply.utils.pdfs import draw_submission_content, make_pdf +from hypha.apply.utils.pdfs import render_as_pdf from hypha.apply.utils.views import ( ViewDispatcher, ) +from hypha.core.models import SystemSettings from ..models import ( ApplicationSubmission, @@ -275,28 +278,40 @@ def should_redirect(cls, request, submission): class SubmissionDetailPDFView(SingleObjectMixin, View): model = ApplicationSubmission + def get_slugified_file_name(self, export_type): + return f"{timezone.localdate().strftime('%Y%m%d')}-{slugify(self.object.title)}.{export_type}" + def get(self, request, *args, **kwargs): self.object = self.get_object() pdf_page_settings = PDFPageSettings.load(request_or_site=request) - content = draw_submission_content(self.object.output_text_answers()) - pdf = make_pdf( - title=self.object.title, - sections=[ - { - "content": content, - "title": "Submission", - "meta": [ - self.object.stage, - self.object.page, - self.object.round, - f"Lead: {self.object.lead}", - ], - }, - ], - pagesize=pdf_page_settings.download_page_size, + context = {} + context["pagesize"] = pdf_page_settings.download_page_size + context["show_footer"] = True + site_settings = SystemSettings.objects.first() + if site_settings: + if site_settings.site_logo_default: + context["logo"] = request.build_absolute_uri( + site_settings.site_logo_default.file.url + ) + else: + context["logo"] = request.build_absolute_uri(static("images/logo.png")) + + context["link"] = self.request.build_absolute_uri( + self.object.get_absolute_url() ) - return FileResponse( - pdf, - as_attachment=True, - filename=self.object.title + ".pdf", + context["id"] = self.object.application_id + context["data"] = self.object.get_text_questions_answers_as_dict() + context["title"] = self.object.title + context["stage"] = self.object.stage + context["fund"] = self.object.page + context["round"] = self.object.round + context["lead"] = self.object.lead + context["show_header"] = True + context["header_title"] = "Submission details" + template_path = "funds/submission-pdf.html" + return render_as_pdf( + request=request, + template_name=template_path, + context=context, + filename=self.get_slugified_file_name("pdf"), ) diff --git a/hypha/apply/utils/pdfs.py b/hypha/apply/utils/pdfs.py index e5c2ad76f1..74fccafd9f 100644 --- a/hypha/apply/utils/pdfs.py +++ b/hypha/apply/utils/pdfs.py @@ -1,461 +1,14 @@ -import os from io import BytesIO -from itertools import cycle -from bs4 import BeautifulSoup, NavigableString from django.core.files import File from django.http import HttpResponse from django.template.loader import render_to_string from django.utils import timezone from pypdf import PdfReader, PdfWriter -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 ( - BaseDocTemplate, - Frame, - KeepTogether, - ListFlowable, - ListItem, - NextPageTemplate, - PageBreak, - PageTemplate, - Paragraph, - Spacer, - Table, - TableStyle, -) from xhtml2pdf import pisa from hypha.apply.utils.models import PDFPageSettings -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, - ), - "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) - - -PREPARED_FONTS = False - - -def prepare_fonts(): - global PREPARED_FONTS - if PREPARED_FONTS: - return - 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", - ) - PREPARED_FONTS = True - - -DARK_GREY = Color(0.0154, 0.0154, 0, 0.7451) - -# default value from https://github.com/MrBitBucket/reportlab-mirror/blob/58fb7bd37ee956cea45477c8b5aef723f1cb82e5/src/reportlab/platypus/frames.py#L70 -FRAME_PADDING = 6 - - -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, pagesize): - prepare_fonts() - buffer = BytesIO() - page_width, page_height = getattr(pagesizes, pagesize) - - doc = ReportDocTemplate( - buffer, - pagesize=getattr(pagesizes, pagesize), - title=title, - ) - - story = [] - for section in sections: - story.extend(section["content"]) - story.append(NextPageTemplate("Header")) - story.append(PageBreak()) - - current_section = None - sections = cycle(sections) - - def header_page(canvas, doc): - nonlocal current_section - current_section = next(sections) - canvas.saveState() - title_spacer = draw_title_block( - canvas, - doc, - current_section["title"], - title, - current_section["meta"], - page_width, - page_height, - ) - canvas.restoreState() - story.insert(0, title_spacer) - - def main_page(canvas, doc): - nonlocal current_section - canvas.saveState() - spacer = draw_header( - canvas, - doc, - current_section["title"], - title, - page_width, - page_height, - ) - story.insert(0, spacer) - canvas.restoreState() - - doc.build(story, onFirstPage=header_page, onLaterPages=main_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, page_title, title, page_width, page_height): - 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 - total_height, - page_width, - total_height, - stroke=False, - fill=True, - ) - - pos = ( - (page_height - doc.topMargin) - + title_size / 2 # bottom of top margin - + 1.5 * 1 * title_size # spacing below page title # 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( - doc.leftMargin + FRAME_PADDING, - pos, - line, - ) - pos -= title_size / 2 - - return Spacer(1, total_height - doc.topMargin) - - -def draw_title_block(canvas, doc, page_title, title, meta, page_width, page_height): - page_title_size = 20 - 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) - - 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 - + len(split_title) * (title_size + title_size / 2) # page title + spaceing - + (1.5 * len(split_meta) + 3) # title + spacing - * meta_size # 1.5 per text line + 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", 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( - doc.leftMargin + FRAME_PADDING, - pos, - line, - ) - pos -= title_size / 2 - - canvas.setFont("MontserratBold", meta_size) - canvas.setFillColor(white) - - pos -= meta_size * 2 - - for line in split_meta: - pos -= meta_size - canvas.drawString( - doc.leftMargin + FRAME_PADDING, - pos, - line, - ) - pos -= meta_size / 2 - - return Spacer(1, total_height - doc.topMargin) - - -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): - 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: - style = None - 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"] - - if style: - text = tag.get_text() - if text: - paragraphs.append(Paragraph(text, style)) - else: - paragraphs.extend(handle_block(tag)) - return paragraphs - - -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"]) - - # 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 - def html_to_pdf(html_body: str) -> BytesIO: """Convert HTML to PDF. diff --git a/hypha/templates/base-pdf.html b/hypha/templates/base-pdf.html index 8fa7ef3585..55e2fda3bc 100644 --- a/hypha/templates/base-pdf.html +++ b/hypha/templates/base-pdf.html @@ -51,6 +51,22 @@ {% endblock %} + {% if show_header %} + {% block header %} + + + + + + +
+ + +

{{ header_title }}

+
+
+ {% endblock %} + {% endif %}
{% block content %}{% endblock %}