commit c40bf8f8a5241cef6a4f0de1d26a68f25743634f Author: Michael Czechowski Date: Wed Apr 29 17:48:35 2026 +0200 feat: extract pdf from mp/pdf — initial libreshop/pdf Source moved verbatim from mp/pdf/ on 2026-04-29; mp was the first concrete adapter consuming the libreshop toolkit. Builds and publishes git.librete.ch/libreshop/pdf on every main / v* push via the standard .gitea/workflows/build.yml shared across libreshop components. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de4ddf7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.dockerignore +*Dockerfile +*.log +README.md diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..3382b45 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,55 @@ +name: Build and publish + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +# Required secrets: +# REGISTRY git.librete.ch +# REGISTRY_USER libretech-bot +# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team) +# Required variable: +# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs) +# +# Image: git.librete.ch/libreshop/pdf +# main pushes → :main + :sha- +# tag pushes → : + :latest + +jobs: + build: + runs-on: ubuntu-latest + container: + image: git.librete.ch/libretech/runner-image:v1 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login (only when publishing) + if: ${{ vars.PUBLISH_ENABLED == 'true' }} + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASS }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.REGISTRY }}/libreshop/pdf + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95b452a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +data/output/*.pdf +.venv +# libreshop additions +.env +.env.local +.env.*.local +node_modules/ +.nuxt/ +.output/ +.cache/ +.parcel-cache/ +dist/ +build/ +coverage/ +logs/ +tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71ed62f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to libreshop/pdf are documented here. + +## Unreleased + +- Extracted from `mp/pdf/` (2026-04-29). The component history before + the extraction lives in the `muellerprints` repository. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd1eae6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM rstropek/pandoc-latex:3.1 + +RUN apk add --no-cache --update \ + make \ + python3 py-pip \ + fontconfig ttf-freefont font-noto + +WORKDIR /app + +RUN tlmgr update --self +RUN tlmgr install ragged2e xltxtra realscripts wallpaper eso-pic \ + titlesec arydshln spreadtab enumitem xstring + +COPY ./requirements.txt /app/requirements.txt + +RUN pip install -r requirements.txt + +COPY ./data /app/data +COPY ./src /app/src +COPY ./makefile /app/makefile +COPY ./docker-entrypoint.sh /app/docker-entrypoint.sh + +RUN chmod +x /app/docker-entrypoint.sh + +ENTRYPOINT ["/app/docker-entrypoint.sh"] + +EXPOSE 1111 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a55c4d --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# libreshop/pdf + +Pandoc + LaTeX renderer for invoices and shop documents. + +Part of the [libreshop](https://git.librete.ch/libreshop) toolkit. Image +published at `git.librete.ch/libreshop/pdf` on every push to `main` +and on `v*` tags. + +## Source + +This repo was extracted from `mp/pdf/` on 2026-04-29; mp was the +first concrete adapter consuming the toolkit. mp's `compose.yml` now +pulls `git.librete.ch/libreshop/pdf:` instead of building locally. + +## Build locally + +``` +docker build -t libreshop/pdf:dev . +``` + +## Adapter contract + +See `docker-entrypoint.sh` and `Dockerfile` for the runtime surface. +Adapters configure the component via env vars and bind-mounted volumes; +do not patch the running container or rely on internal paths. diff --git a/data/details.md b/data/details.md new file mode 100644 index 0000000..936ccb1 --- /dev/null +++ b/data/details.md @@ -0,0 +1,8 @@ +--- +--- + +**Gewählte Zahlungsart:** PayPal + +Die Rechnung wurde per **PayPal** bereits beglichen. + +Vielen Dank für Ihren Einkauf diff --git a/data/output/.gitkeep b/data/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/templates/RE.pdf b/data/templates/RE.pdf new file mode 100644 index 0000000..6320b52 Binary files /dev/null and b/data/templates/RE.pdf differ diff --git a/data/templates/invoice-scrlttr2.tex b/data/templates/invoice-scrlttr2.tex new file mode 100644 index 0000000..f486257 --- /dev/null +++ b/data/templates/invoice-scrlttr2.tex @@ -0,0 +1,145 @@ +\documentclass[ + fontsize=10pt, + parskip=full, + paper=A4, + fromalign=off, + fromphone=false, + fromfax=false, + fromemail=false, + fromurl=false, + foldmarks=true, + version=last, + refline=wide +]{scrlttr2} + +% Layout +\usepackage{geometry} +\geometry{a4paper, left=20mm, right=0mm, top=20mm, bottom=17mm} + +% Typography +\usepackage[utf8]{inputenc} +\usepackage{fontspec} +\usepackage{eurosym} +\usepackage[hidelinks]{hyperref} + +\defaultfontfeatures{Mapping=tex-text} +\setsansfont[Scale=0.9]{Noto Sans Regular} +\setmainfont[SmallCapsFeatures={LetterSpace=5,Letters=SmallCaps}]{Noto Sans Regular} + +% Language +\usepackage[ngerman]{babel} + +% Table Customization +\usepackage{spreadtab} +\usepackage{arydshln} +\usepackage{hhline} +\usepackage{enumitem} +\renewcommand{\arraystretch}{1.5} % Apply vertical padding to table cells +% \usepackage[table]{xcolor} +% \definecolor{gr}{rgb}{0.95,0.95,1} + +% Letterhead +$if(letterhead)$ + \usepackage{wallpaper} + \ULCornerWallPaper{1}{$letterhead$} +$endif$ + +% \setplength{refwidth}{10cm} +% \setplength{refhpos}{0pt} +\setplength{locwidth}{6cm} +\setplength{locvpos}{6cm} + +% \showfields{test} +\begin{document} + +\date{} +\setkomavar{subject}[left]{\LARGE \textit{\textcolor{red}{$subject$}}} + +% Additional Information +\setkomavar{location}{\raggedright + $if(nr.invoice)$ + \small\textit{\textbf{RECHNUNG Nr.} $nr.invoice$}\\ + \scriptsize\textit{(Bitte bei Bezahlungen stets angeben)} + $endif$ + + $if(nr.shipping)$ + \small \textit{\textbf{LIEFERSCHEIN Nr.} $nr.shipping$}\\ + $endif$ + + $if(nr.customer)$ + \small \textit{Kundennummer: $nr.customer$}\\ + $endif$ + + $if(nr.order)$ + \small \textit{Bestellnummer: $nr.order$}\\ + $endif$ + + $if(date)$ + \small \textit{Datum: $date$} + $endif$ +} + +% Letter +\begin{letter}{ + \scriptsize{\textcolor{red}{Rechnungsadresse:}} \\ + + $if(to.name)$ + $to.name$\\ + $endif$ + + $if(to.address)$ + $for(to.address)$ + $to.address$\\ + $endfor$ + $endif$ +} + +\opening{} + +% Table +% \footnotesize +\newcounter{pos} +\setcounter{pos}{0} +\STautoround*{2} +\STsetdecimalsep{,} + +\begin{spreadtab}{{tabular}[t t t t t t t]{lp{7.6cm}rrrr}} + \hdashline[1pt/1pt] +% \rowcolor{red} +% \rowcolor{gray!25} +% \rowcolor{gr} + @ \textbf{Pos.} & @ \textbf{Artikel} & @ \textbf{Art-Nr.} & @ \textbf{Menge} & @ \textbf{Einzelpreis} & @ \textbf{Gesamt} \\ + \hline + $for(service)$ + @ \refstepcounter{pos} \thepos & + @ $service.description$ & + @ $service.nr$ & + @ $service.count$ & + :={$service.price.per_unit$} $currency$ & + :={$service.price.total$} $currency$ \\ + \hline + $endfor$ + @ \noalign{\vskip 1.2cm} & @ & @ & @ & @ & @\\ + $if(subtotal)$ + @ & @ & @ \multicolumn{3}{l}{\textbf{Zwischensumme:}} & :={$subtotal$} $currency$ \\ + $endif$ + $if(shipping)$ + @ & @ & @ \multicolumn{3}{l}{\textbf{Versandkosten:}} & :={$shipping$} $currency$ \\ + $endif$ + $if(VAT)$ + @ & @ & @ \multicolumn{3}{l}{\textbf{USt. $VAT.rate$\%}} & :={$VAT.amount$} $currency$ \\ + \noalign{\vskip 2mm} \hhline{~~----} + $endif$ + \noalign{\vskip 2mm} & @ & @ \multicolumn{3}{l}{\textbf{Gesamtbetrag:}} & \textbf{:={$total$} $currency$} \\ + \noalign{\vskip 2mm}\hhline{~~----} +\end{spreadtab} +\vspace{15mm} + +% \useplength{toaddrhpos} +% \useplength{refvpos} +% Body +$body$ + +% Closing +\end{letter} +\end{document} diff --git a/data/templates/shipping-note-scrlttr2.tex b/data/templates/shipping-note-scrlttr2.tex new file mode 100644 index 0000000..a95d1d0 --- /dev/null +++ b/data/templates/shipping-note-scrlttr2.tex @@ -0,0 +1,145 @@ +\documentclass[ + fontsize=10pt, + parskip=full, + paper=A4, + fromalign=off, + fromphone=false, + fromfax=false, + fromemail=false, + fromurl=false, + foldmarks=true, + version=last, + refline=wide +]{scrlttr2} + +% Layout +\usepackage{geometry} +\geometry{a4paper, left=20mm, right=0mm, top=20mm, bottom=17mm} + +% Typography +\usepackage[utf8]{inputenc} +\usepackage{fontspec} +\usepackage{eurosym} +\usepackage[hidelinks]{hyperref} + +\defaultfontfeatures{Mapping=tex-text} +\setsansfont[Scale=0.9]{Noto Sans Regular} +\setmainfont[SmallCapsFeatures={LetterSpace=5,Letters=SmallCaps}]{Noto Sans Regular} + +% Language +\usepackage[ngerman]{babel} + +% Table Customization +\usepackage{spreadtab} +\usepackage{arydshln} +\usepackage{hhline} +\usepackage{enumitem} +\renewcommand{\arraystretch}{1.5} % Apply vertical padding to table cells +% \usepackage[table]{xcolor} +% \definecolor{gr}{rgb}{0.95,0.95,1} + +% Letterhead +$if(letterhead)$ + \usepackage{wallpaper} + \ULCornerWallPaper{1}{$letterhead$} +$endif$ + +% \setplength{refwidth}{10cm} +% \setplength{refhpos}{0pt} +\setplength{locwidth}{6cm} +\setplength{locvpos}{6cm} + +% \showfields{test} +\begin{document} + +\date{} +\setkomavar{subject}[left]{\LARGE \textit{\textcolor{red}{$subject$}}} + +% Additional Information +\setkomavar{location}{\raggedright + $if(nr.invoice)$ + \small\textit{\textbf{RECHNUNG Nr.} $nr.invoice$}\\ + \scriptsize\textit{(Bitte bei Bezahlungen stets angeben)} + $endif$ + + $if(nr.shipping)$ + \small \textit{\textbf{LIEFERSCHEIN Nr.} $nr.shipping$}\\ + $endif$ + + $if(nr.customer)$ + \small \textit{Kundennummer: $nr.customer$}\\ + $endif$ + + $if(nr.order)$ + \small \textit{Bestellnummer: $nr.order$}\\ + $endif$ + + $if(date)$ + \small \textit{Datum: $date$} + $endif$ +} + +% Letter +\begin{letter}{ + \scriptsize{\textcolor{red}{Lieferadresse:}} \\ + + $if(to.name)$ + $to.name$\\ + $endif$ + + $if(to.address)$ + $for(to.address)$ + $to.address$\\ + $endfor$ + $endif$ +} + +\opening{} + +% Table +% \footnotesize +\newcounter{pos} +\setcounter{pos}{0} +\STautoround*{2} +\STsetdecimalsep{,} + +\begin{spreadtab}{{tabular}[t t t t t t t]{lp{7.6cm}rrrr}} + \hdashline[1pt/1pt] +% \rowcolor{red} +% \rowcolor{gray!25} +% \rowcolor{gr} + @ \textbf{Pos.} & @ \textbf{Artikel} & @ \textbf{Art-Nr.} & @ \textbf{Menge} & @ \textbf{Einzelpreis} & @ \textbf{Gesamt} \\ + \hline + $for(service)$ + @ \refstepcounter{pos} \thepos & + @ $service.description$ & + @ $service.nr$ & + @ $service.count$ & + :={$service.price.per_unit$} $currency$ & + :={$service.price.total$} $currency$ \\ + \hline + $endfor$ + @ \noalign{\vskip 1.2cm} & @ & @ & @ & @ & @\\ + $if(subtotal)$ + @ & @ & @ \multicolumn{3}{l}{\textbf{Zwischensumme:}} & :={$subtotal$} $currency$ \\ + $endif$ + $if(shipping)$ + @ & @ & @ \multicolumn{3}{l}{\textbf{Versandkosten:}} & :={$shipping$} $currency$ \\ + $endif$ + $if(VAT)$ + @ & @ & @ \multicolumn{3}{l}{\textbf{USt. $VAT.rate$\%}} & :={$VAT.amount$} $currency$ \\ + \noalign{\vskip 2mm} \hhline{~~----} + $endif$ + \noalign{\vskip 2mm} & @ & @ \multicolumn{3}{l}{\textbf{Gesamtbetrag:}} & \textbf{:={$total$} $currency$} \\ + \noalign{\vskip 2mm}\hhline{~~----} +\end{spreadtab} +\vspace{15mm} + +% \useplength{toaddrhpos} +% \useplength{refvpos} +% Body +$body$ + +% Closing +\end{letter} +\end{document} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..0ebf5c9 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if [ "$ENVIRONMENT" = "production" ]; then + exec gunicorn -w 4 -b 0.0.0.0:1111 src.app:app +else + exec python3 src/app.py +fi diff --git a/makefile b/makefile new file mode 100644 index 0000000..91e48e0 --- /dev/null +++ b/makefile @@ -0,0 +1,15 @@ +TEX = pandoc +FLAGS = --pdf-engine=xelatex + +# Use the values of src, template, and output passed as arguments +$(output) : $(src) + $(TEX) $(src) -o $@ --template=$(template) $(FLAGS) + +.PHONY: clean +clean : + rm -f /app/data/output/*.pdf + +.PHONY: cleanall +cleanall : clean + rm -rf /app/data/output/* + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0eab48b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +blinker==1.6.2 +click==8.1.7 +Flask==2.3.3 +flask-prometheus-metrics==1.0.0 +gunicorn==21.2.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +prometheus-client==0.19.0 +prometheus-flask-exporter==0.23.0 +PyYAML==6.0.1 +Werkzeug==2.3.7 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..0169774 --- /dev/null +++ b/src/app.py @@ -0,0 +1,331 @@ +import html +import time +from enum import Enum +from flask import Flask, request, send_file, jsonify +from prometheus_flask_exporter import PrometheusMetrics +import subprocess +import tempfile +import os +import re +import json +import yaml +from typing import List, NamedTuple +import logging + + +class FilterRemoveDate(logging.Filter): + # '192.168.0.102 - - [30/Jun/2024 01:14:03] "%s" %s %s' -> '192.168.0.102 - "%s" %s %s' + pattern: re.Pattern = re.compile(r' - \[.+?]') + + def filter(self, record: logging.LogRecord) -> bool: + record.msg = self.pattern.sub('', record.msg) + return True + +class FilterReplaceWerkzeug(logging.Filter): + # 'werkzeug:' -> 'app:flask:' + pattern: re.Pattern = re.compile(r'werkzeug:') + + def filter(self, record: logging.LogRecord) -> bool: + record.msg = self.pattern.sub('app:flask:', record.msg) + return True + +class FilterReplaceLowercaseI(logging.Filter): + # 'I:' -> 'i:' + pattern: re.Pattern = re.compile(r'I:') + + def filter(self, record: logging.LogRecord) -> bool: + record.msg = self.pattern.sub('i:', record.msg) + return True + +# Setup logger +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s.%(msecs)03dZ %(name)s:%(levelname).1s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +logger = logging.getLogger('app') +logger_werkzeug = logging.getLogger('werkzeug') +logger_werkzeug.addFilter(FilterRemoveDate()) +logger_werkzeug.addFilter(FilterReplaceWerkzeug()) +logger_werkzeug.addFilter(FilterReplaceLowercaseI()) + +app = Flask(__name__, static_folder=None) + +metrics = PrometheusMetrics(app, defaults_prefix="pdf") + +# Define a counter for successful pdf generation +pdf_generation_counter = metrics.counter( + 'pdf_generation_total', + 'Total number of pdfs generated', + labels={'endpoint': lambda: request.endpoint} +) + + +class Template(Enum): + INVOICE = "/app/data/templates/invoice-scrlttr2.tex" + SHIPPING = "/app/data/templates/shipping-note-scrlttr2.tex" + ORDER_CONFIRMATION = "/app/data/templates/order-confirmation.tex" + LETTERHEAD = "/app/data/templates/RE.pdf" + DETAILS = "" + +template_to_label = { + Template.INVOICE: "invoice", + Template.SHIPPING: "shipping", + Template.ORDER_CONFIRMATION: "order_confirmation" +} + +class SenderAddress(NamedTuple): + companyname: str + name: str + street: str + city: str + email: str + url: str + + +class RecipientAddress(NamedTuple): + name: str + address: List[str] + + +class Services(NamedTuple): + description: str + price: float + details: List[str] + + +class Details(NamedTuple): + subject: str + date: str + me: SenderAddress + to: RecipientAddress + invoice_nr: str + author: str + city: str + VAT: int + service: List[Services] + closingnote: str + + +def normalize(string: str): + return ( + string + .lower() + .replace('ö', 'oe') + .replace('ä', 'ae') + .replace('ß', 'ss') + .replace('ü', 'ue') + .replace(',', '') + .replace('.', '') + .replace(' ', '') + ) + + +def generate_pdf(template_path: Template, details_json: Details): + try: + logger = logging.getLogger('app:generate-pdf') + logger.info("Generating pdf") + logger.debug(details_json) + details_dict = json.loads(json.dumps(details_json)) + details_yaml = yaml.dump(details_dict) + date = time.strftime("%Y%m%d") + label = template_to_label[template_path] + + logger.debug(details_yaml) + + with tempfile.TemporaryDirectory() as temp_dir: + # Write the details JSON to a YAML file + details_yaml_path = os.path.join(temp_dir, 'details.md') + + with open(details_yaml_path, 'w') as yaml_file: + yaml_file.write("---\n") + yaml_file.write("letterhead: /app/data/templates/RE.pdf\n") + yaml_file.write(details_yaml) + yaml_file.write("...\n") + yaml_file.write(details_dict["body"]) + + normalized_recipient = normalize(details_dict["to"]["name"] or details_dict["to"]["address"][0]) + output_base_path = os.path.join("/", "app", "data", "output", f'{date}-{label}-{normalized_recipient}') + + logger.debug(normalized_recipient) + + counter = 0 + if os.path.exists(output_base_path + '.pdf'): + counter = 1 + while os.path.exists(f'{output_base_path}-{counter}.pdf'): + counter += 1 + output_path = f'{output_base_path}-{counter}.pdf' + else: + output_path = output_base_path + '.pdf' + + logger.debug(f"Starting Pandoc") + logger.debug(f"Source YAML: {details_yaml_path}") + logger.debug(f"Template: {template_path.value}") + logger.debug(f"Output pdf: {output_path}") + + result = subprocess.run([ + 'make', + '-e', + '-B', + f'src={details_yaml_path}', + f'template={template_path.value}', + f'output={output_path}' + ]) + + if result.returncode != 0: + logger.error(f"Pdf generation failed: {result.stderr}") + return None + + logger.info("Pdf generated successfully") + return output_path + + except Exception as e: + logger.error(f"Error generating pdf: {str(e)}") + return None + + +@app.route('/v1') +@metrics.do_not_track() +def info(): + today = time.strftime("%Y-%m-%d") + url_map = app.url_map + return f"

pdf v1.0.0 - {today}

" + html.escape(str(url_map), False) + "
" + + +@app.route('/v1/invoice', methods=['POST']) +@pdf_generation_counter +def generate_invoice(): + logger = logging.getLogger('app:generate-invoice') + try: + details_json = request.json + logger.info("Generating invoice") + logger.debug(details_json) + template = Template.INVOICE + pdf_content = generate_pdf(template, details_json) + if pdf_content: + logger.info(f"Sending invoice pdf: {pdf_content}") + return send_file(pdf_content, mimetype='application/pdf') + else: + logger.error("Sending pdf failed") + return jsonify({"status": "failed"}), 500 + except Exception as e: + logger.error(f"Error in generate_invoice: {str(e)}") + return jsonify({"status": "failed"}), 500 + + +@app.route('/v1/shipping', methods=['POST']) +@pdf_generation_counter +def generate_shipping(): + logger = logging.getLogger('app:generate-shipping-note') + try: + details_json = request.json + logger.info("Generating shipping note") + logger.debug(details_json) + template = Template.SHIPPING + pdf_content = generate_pdf(template, details_json) + if pdf_content: + logger.info(f"Sending shipping pdf: {pdf_content}") + return send_file(pdf_content, mimetype='application/pdf') + else: + logger.error("Sending pdf failed, generated pdf is empty") + return jsonify({"status": "failed"}), 500 + except Exception as e: + logger.error(f"Error in generate_shipping: {str(e)}") + return jsonify({"status": "failed"}), 500 + + +@app.route('/v1/order-confirmation', methods=['POST']) +@pdf_generation_counter +def generate_order_confirmation(): + logger = logging.getLogger('app:generate-order-confirmation') + try: + details_json = request.json + logger.info("Generating order confirmation") + logger.debug(details_json) + template = Template.ORDER_CONFIRMATION + pdf_content = generate_pdf(template, details_json) + if pdf_content: + logger.info(f"Sending order confirmation pdf: {pdf_content}") + return send_file(pdf_content, mimetype='application/pdf') + else: + logger.error("pdf generation failed") + return "pdf generation failed", 500 + except Exception as e: + logger.error(f"Error in generate_order_confirmation: {str(e)}") + return str(e), 400 + + +@app.route('/v1/delete/pdf', methods=['DELETE']) +def delete_pdf(): + logger = logging.getLogger('app:delete-pdf') + try: + subprocess.run([ + 'make', + '-e', + 'clean' + ]) + logger.info("Cleaned up pdf files in /app/data/output directory.") + return jsonify({"status": "success"}), 204 + except Exception as e: + logger.error(f"Failed to clean pdf files: {str(e)}") + return jsonify({"status": "failed"}), 500 + + +@app.route('/v1/delete/all', methods=['DELETE']) +def delete_all(): + logger = logging.getLogger('app:delete-all-pdf') + try: + subprocess.run([ + 'make', + '-e', + 'cleanall' + ]) + logger.info("Cleaned up ALL files in /app/data/output directory.") + return jsonify({"status": "success"}), 204 + except Exception as e: + logger.error(f"Failed to clean all files: {str(e)}") + return jsonify({"status": "failed"}), 500 + + +@app.route('/health', methods=['GET']) +def health_check(): + logger = logging.getLogger('app:health-check') + health_status = {'status': 'ok'} + + # Check write access to output directory + output_dir = "/app/data/output" + try: + test_file = os.path.join(output_dir, "test_write.txt") + with open(test_file, "w") as f: + f.write("test") + os.remove(test_file) + health_status['output_dir_writable'] = True + except Exception as e: + logger.error(f"Output directory not writable: {str(e)}") + health_status['output_dir_writable'] = False + health_status['status'] = 'error' + + # Check if Pandoc is responding + try: + result = subprocess.run(['pandoc', '--version'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + health_status['pandoc_responding'] = True + else: + health_status['pandoc_responding'] = False + health_status['status'] = 'error' + except subprocess.TimeoutExpired: + logger.error("Pandoc check timed out") + health_status['pandoc_responding'] = False + health_status['status'] = 'error' + except Exception as e: + logger.error(f"Error checking Pandoc: {str(e)}") + health_status['pandoc_responding'] = False + health_status['status'] = 'error' + + logger.debug(f"Health check performed: {health_status}") + return jsonify(health_status) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=1111, debug=False)