feat: extract pdf from mp/pdf — initial libreshop/pdf
Some checks failed
Build and publish / build (push) Failing after 17s
Some checks failed
Build and publish / build (push) Failing after 17s
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.
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.dockerignore
|
||||
*Dockerfile
|
||||
*.log
|
||||
README.md
|
||||
55
.gitea/workflows/build.yml
Normal file
55
.gitea/workflows/build.yml
Normal file
@@ -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-<short>
|
||||
# tag pushes → :<tag> + :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 }}
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -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/
|
||||
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@@ -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.
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -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
|
||||
25
README.md
Normal file
25
README.md
Normal file
@@ -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:<pin>` 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.
|
||||
8
data/details.md
Normal file
8
data/details.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
---
|
||||
|
||||
**Gewählte Zahlungsart:** PayPal
|
||||
|
||||
Die Rechnung wurde per **PayPal** bereits beglichen.
|
||||
|
||||
Vielen Dank für Ihren Einkauf
|
||||
0
data/output/.gitkeep
Normal file
0
data/output/.gitkeep
Normal file
BIN
data/templates/RE.pdf
Normal file
BIN
data/templates/RE.pdf
Normal file
Binary file not shown.
145
data/templates/invoice-scrlttr2.tex
Normal file
145
data/templates/invoice-scrlttr2.tex
Normal file
@@ -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}
|
||||
145
data/templates/shipping-note-scrlttr2.tex
Normal file
145
data/templates/shipping-note-scrlttr2.tex
Normal file
@@ -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}
|
||||
7
docker-entrypoint.sh
Normal file
7
docker-entrypoint.sh
Normal file
@@ -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
|
||||
15
makefile
Normal file
15
makefile
Normal file
@@ -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/*
|
||||
|
||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -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
|
||||
331
src/app.py
Normal file
331
src/app.py
Normal file
@@ -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"<h1>pdf v1.0.0 - {today}</h1><pre>" + html.escape(str(url_map), False) + "</pre>"
|
||||
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user