Conditions générales & particulières
+ + {% for provision in draft.provisions %} +Article {{loop.index}} - {{ provision.title|safe }}
+{{ provision.body|safe }}
+diff --git a/back/Dockerfile b/back/Dockerfile index f43f98d6..bfdfbd91 100644 --- a/back/Dockerfile +++ b/back/Dockerfile @@ -1,6 +1,11 @@ FROM python:3.10 -# make the 'app' folder the current working directory +RUN apt update && apt install -y xfonts-base xfonts-75dpi \ + && rm -rf /var/lib/apt/lists/* +RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.bullseye_amd64.deb \ + && dpkg -i wkhtmltox_0.12.6.1-2.bullseye_amd64.deb \ + && rm wkhtmltox_0.12.6.1-2.bullseye_amd64.deb + WORKDIR /code # copy both 'package.json' and 'package-lock.json' (if available) diff --git a/back/app/contract/__init__.py b/back/app/contract/__init__.py index 944807a6..a386e75f 100644 --- a/back/app/contract/__init__.py +++ b/back/app/contract/__init__.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from .routes_draft import draft_router -from .routes_print import print_router +from .print import print_router contract_router = APIRouter() diff --git a/back/app/contract/print/__init__.py b/back/app/contract/print/__init__.py new file mode 100644 index 00000000..65834925 --- /dev/null +++ b/back/app/contract/print/__init__.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.templating import Jinja2Templates + + + +import pdfkit +from PyPDF2 import PdfMerger + +from pathlib import Path + +from app.entity.models import Entity +from app.template.models import ProvisionTemplate +from ..schemas import ContractDraft + + +async def build_model(model): + parties = [] + for p in model.parties: + party = { + "entity": await Entity.get(p.entity_id), + "part": p.part + } + if p.representative_id: + party['representative'] = await Entity.get(p.representative_id) + + parties.append(party) + + + model.parties = parties + + provisions = [] + for p in model.provisions: + if p.provision.type == "template": + provisions.append(await ProvisionTemplate.get(p.provision.provision_template_id)) + else: + provisions.append(p.provision) + model.provisions = provisions + + model.location = "Toulouse" + model.date = "01/01/1970" + return model + + +BASE_PATH = Path(__file__).resolve().parent + +print_router = APIRouter() + + +templates = Jinja2Templates(directory=str(BASE_PATH / "templates")) + +options = { + 'encoding': 'UTF-8', + 'margin-left': '20mm', + 'margin-right': '20mm', + 'margin-bottom': '20mm', + 'margin-top': '20mm' +} +options_content = options.copy() +options_content['footer-html'] = 'footer.html' +options_content['margin-bottom'] = '60mm' + + + +async def render_template(host): + draft = await ContractDraft.get("63e92534aafed8b509f229c4") + lawyer = { + "firstname": "Nathaniel", + "lastname": "Toshi", + } + + template = templates.get_template("print.html") + return template.render({ + "draft": await build_model(draft), + "lawyer": lawyer, + "static_host": host + }) + + +async def render_frontpage(host, draft, lawyer, ): + template = templates.get_template("frontpage.html") + return template.render({ + "draft": draft, + "lawyer": lawyer, + "static_host": host + }) + + +async def render_content(host, draft, ): + template = templates.get_template("content.html") + return template.render({ + "draft": draft, + "static_host": host + }) + + +async def render_footer(host, draft, ): + template = templates.get_template("footer.html") + return template.render({ + "draft": draft, + "static_host": host + }) + + +@print_router.get("/frontpage", response_class=HTMLResponse) +async def frontpage() -> str: + draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) + lawyer = { + "firstname": "Nathaniel", + "lastname": "Toshi", + } + return await render_frontpage('localhost', draft, lawyer) + + +@print_router.get("/content", response_class=HTMLResponse) +async def content() -> str: + draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) + return await render_content('localhost', draft) + + +@print_router.get("/footer", response_class=HTMLResponse) +async def footer() -> str: + draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) + return await render_footer('localhost', draft) + + +@print_router.get("/", response_class=HTMLResponse) +async def create() -> str: + return await render_template('localhost') + + +@print_router.get("/pdf", response_class=FileResponse) +async def create_pdf() -> str: + draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) + lawyer = { + "firstname": "Nathaniel", + "lastname": "Toshi", + } + + merger = PdfMerger() + + pdfkit.from_string(await render_frontpage('nginx', draft, lawyer), 'front.pdf', options=options) + merger.append('front.pdf') + with open('footer.html', 'w') as f: + f.write(await render_footer('nginx', draft)) + pdfkit.from_string(await render_content('nginx', draft), 'content.pdf', options=options_content) + merger.append('content.pdf') + + merger.write("out.pdf") + merger.close() + print(options) + return FileResponse( + "out.pdf", + media_type="application/pdf", + filename=draft.name) diff --git a/back/app/contract/print/pdf_generator.py b/back/app/contract/print/pdf_generator.py new file mode 100644 index 00000000..733b5076 --- /dev/null +++ b/back/app/contract/print/pdf_generator.py @@ -0,0 +1,152 @@ +from weasyprint import HTML, CSS + + +class PdfGenerator: + """ + Generate a PDF out of a rendered template, with the possibility to integrate nicely + a header and a footer if provided. + + Notes: + ------ + - When Weasyprint renders an html into a PDF, it goes though several intermediate steps. + Here, in this class, we deal mostly with a box representation: 1 `Document` have 1 `Page` + or more, each `Page` 1 `Box` or more. Each box can contain other box. Hence the recursive + method `get_element` for example. + For more, see: + https://weasyprint.readthedocs.io/en/stable/hacking.html#dive-into-the-source + https://weasyprint.readthedocs.io/en/stable/hacking.html#formatting-structure + - Warning: the logic of this class relies heavily on the internal Weasyprint API. This + snippet was written at the time of the release 47, it might break in the future. + - This generator draws its inspiration and, also a bit of its implementation, from this + discussion in the library github issues: https://github.com/Kozea/WeasyPrint/issues/92 + """ + OVERLAY_LAYOUT = '@page {size: A4 portrait; margin: 0;}' + + def __init__(self, main_html, header_html=None, footer_html=None, + base_url=None, side_margin=2, extra_vertical_margin=30): + """ + Parameters + ---------- + main_html: str + An HTML file (most of the time a template rendered into a string) which represents + the core of the PDF to generate. + header_html: str + An optional header html. + footer_html: str + An optional footer html. + base_url: str + An absolute url to the page which serves as a reference to Weasyprint to fetch assets, + required to get our media. + side_margin: int, interpreted in cm, by default 2cm + The margin to apply on the core of the rendered PDF (i.e. main_html). + extra_vertical_margin: int, interpreted in pixel, by default 30 pixels + An extra margin to apply between the main content and header and the footer. + The goal is to avoid having the content of `main_html` touching the header or the + footer. + """ + self.main_html = main_html + self.header_html = header_html + self.footer_html = footer_html + self.base_url = base_url + self.side_margin = side_margin + self.extra_vertical_margin = extra_vertical_margin + + def _compute_overlay_element(self, element: str): + """ + Parameters + ---------- + element: str + Either 'header' or 'footer' + + Returns + ------- + element_body: BlockBox + A Weasyprint pre-rendered representation of an html element + element_height: float + The height of this element, which will be then translated in a html height + """ + html = HTML( + string=getattr(self, f'{element}_html'), + base_url=self.base_url, + ) + element_doc = html.render(stylesheets=[CSS(string=self.OVERLAY_LAYOUT)]) + element_page = element_doc.pages[0] + element_body = PdfGenerator.get_element(element_page._page_box.all_children(), 'body') + element_body = element_body.copy_with_children(element_body.all_children()) + element_html = PdfGenerator.get_element(element_page._page_box.all_children(), element) + + if element == 'header': + element_height = element_html.height + if element == 'footer': + element_height = element_page.height - element_html.position_y + + return element_body, element_height + + def _apply_overlay_on_main(self, main_doc, header_body=None, footer_body=None): + """ + Insert the header and the footer in the main document. + + Parameters + ---------- + main_doc: Document + The top level representation for a PDF page in Weasyprint. + header_body: BlockBox + A representation for an html element in Weasyprint. + footer_body: BlockBox + A representation for an html element in Weasyprint. + """ + for page in main_doc.pages: + page_body = PdfGenerator.get_element(page._page_box.all_children(), 'body') + + if header_body: + page_body.children += header_body.all_children() + if footer_body: + page_body.children += footer_body.all_children() + + def render_pdf(self): + """ + Returns + ------- + pdf: a bytes sequence + The rendered PDF. + """ + if self.header_html: + header_body, header_height = self._compute_overlay_element('header') + else: + header_body, header_height = None, 0 + if self.footer_html: + footer_body, footer_height = self._compute_overlay_element('footer') + else: + footer_body, footer_height = None, 0 + + margins = '{header_size}px {side_margin} {footer_size}px {side_margin}'.format( + header_size=header_height + self.extra_vertical_margin, + footer_size=footer_height + self.extra_vertical_margin, + side_margin=f'{self.side_margin}cm', + ) + content_print_layout = '@page {size: A4 portrait; margin: %s;}' % margins + + html = HTML( + string=self.main_html, + base_url=self.base_url, + ) + main_doc = html.render(stylesheets=[CSS(string=content_print_layout)]) + + if self.header_html or self.footer_html: + self._apply_overlay_on_main(main_doc, header_body, footer_body) + pdf = main_doc.write_pdf() + + return pdf + + @staticmethod + def get_element(boxes, element): + """ + Given a set of boxes representing the elements of a PDF page in a DOM-like way, find the + box which is named `element`. + + Look at the notes of the class for more details on Weasyprint insides. + """ + for box in boxes: + if box.element_tag == element: + return box + return PdfGenerator.get_element(box.all_children(), element) \ No newline at end of file diff --git a/back/app/contract/print/templates/content.html b/back/app/contract/print/templates/content.html new file mode 100644 index 00000000..c7fdff47 --- /dev/null +++ b/back/app/contract/print/templates/content.html @@ -0,0 +1,30 @@ + +
+ + + +{{ provision.body|safe }}
+![]() |
+ Cooper, Hillman & Toshi LLP 6834 Innocence Boulevard LOS SANTOS - SA consulting@cht.law.com |
+
Le {{ draft.date }} à {{ draft.location}}
+Entre les soussignés :
+ {% for party in draft.parties %} +ET
+ {% endif %} ++ {% if party.entity.entity_data.type == "corporation" %} + {{ party.entity.entity_data.title }} société de {{ party.entity.entity_data.activity }} enregistrée auprès du gouvernement de San Andreas et domiciliée au {{ party.entity.address }}{% if party.representative %}, représentée par {{ party.representative.entity_data.firstname }} {{ party.representative.entity_data.middlenames }} {{ party.representative.entity_data.lastname }}{% endif %} + {% elif party.entity.entity_data.type == "individual" %} + {{ party.entity.entity_data.firstname }} {{ party.entity.entity_data.middlenames }} {{ party.entity.entity_data.lastname }} + {% if party.entity.entity_data.day_of_birth %} né le {{ party.entity.entity_data.day_of_birth.strftime('%d/%m/%Y') }} {% if true %} à {{ party.entity.entity_data.place_of_birth }}{% endif %},{% endif %} + {% if party.entity.address %} résidant à {{ party.entity.address }}, {% endif %} + {% elif party.entity.entity_data.type == "institution" %} + + {% endif %} +
+Ci-après dénommé {{ party.part|safe }}
+ {% if loop.first %} +d'une part
+ {% endif %} +d'autre part
+Sous la supervision légale de Maître {{ lawyer.firstname }} {{ lawyer.lastname }}
+Il a été convenu l'exécution des prestations ci-dessous, conformément aux conditions générales et particulières ci-après:
+![]() |
+ Cooper, Hillman & Toshi LLP 6834 Innocence Boulevard LOS SANTOS - SA consulting@cht.law.com |
+
Le %DATE_CONTRAT% à %LIEU_CONTRAT%
+Entre les soussignés :
+ {% for party in draft.parties %} +ET
+ {% endif %} ++ {% if party.entity.entity_data.type == "corporation" %} + {{ party.entity.entity_data.title }} sociétéde {{ party.entity.entity_data.activity }} enregistrée auprès du gouvernement de San Andreas et domiciliée au {{ party.entity.address }} représentée par %NOM_REPRESENTANT% + {% elif party.entity.entity_data.type == "individual" %} + {{ party.entity.entity_data.firstname }} {{ party.entity.entity_data.middlenames }} {{ party.entity.entity_data.lastname }} + {% if party.entity.entity_data.day_of_birth %} né le {{ party.entity.entity_data.day_of_birth.strftime('%d/%m/%Y') }} {% if true %} à %BIRTHPLACE% {% endif %},{% endif %} + {% if party.entity.address %} résidant à {{ party.entity.address }}, {% endif %} + {% elif party.entity.entity_data.type == "institution" %} + + {% endif %} +
+Ci-après dénommé {{ party.part|safe }}
+ {% if loop.first %} +d'une part
+ {% endif %} +d'autre part
+Sous la supervision légale de Maître {{ lawyer.firstname }} {{ lawyer.lastname }}
+Il a été convenu l'exécution des prestations ci-dessous, conformément aux conditions générales et particulières ci-après:
+{{ provision.body|safe }}
+