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