Make open source

This commit is contained in:
Pim Kunis 2025-03-02 14:31:16 +01:00
commit f245d21e0c
6 changed files with 310 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.direnv
*.pdf
*.xlsx

27
README.md Normal file
View file

@ -0,0 +1,27 @@
# Automating a parking expenses report
For the past year, I was consulting at a place where I could request
reimbursement of parking expenses. This had to be claimed using a standard Excel
template.
This project automated a significant part of that:
- Fetch parking costs for a certain month from my Paperless-ngx instance
- Calculate costs after taxes and a grand total
- Fill in an Excel sheet with the details
- Convert the Excel sheet to PDF and append photos of parking costs
Note: I don't expect anybody to have the same requirements as me here, but
hopefully pieces can be useful to some.
## Usage
The project uses Nix for dependency management.
Example usage:
```
$ cd parking-expenses
$ nix develop
$ PAPERLESS_TOKEN=foo ./main.py -y 2025 -m 1 -p ABC-12-D -u https://paperless.griffin-mermaid.ts.net
```

25
flake.lock generated Normal file
View file

@ -0,0 +1,25 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1707268954,
"narHash": "sha256-2en1kvde3cJVc3ZnTy8QeD2oKcseLFjYPLKhIGDanQ0=",
"rev": "f8e2ebd66d097614d51a56a755450d4ae1632df1",
"revCount": 581229,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.581229%2Brev-f8e2ebd66d097614d51a56a755450d4ae1632df1/018d8a37-bd05-79cb-a42f-290c1f0ca0ce/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A.tar.gz"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

21
flake.nix Normal file
View file

@ -0,0 +1,21 @@
{
description = "A Nix-flake-based Python development environment";
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz";
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in
{
devShells = forEachSupportedSystem ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [ python311 virtualenv unoconv ] ++
(with pkgs.python311Packages; [ openpyxl requests pypdf2 ]);
};
});
};
}

233
main.py Executable file
View file

@ -0,0 +1,233 @@
#!/usr/bin/env python3
import PyPDF2
from openpyxl import load_workbook
import requests
import json
from datetime import datetime
import io
import subprocess
import tempfile
import argparse
import os
import calendar
FILTER_TAG_ID = 1
FILTER_CORRESPONDENT_ID = 1
def request_headers():
token = os.environ.get("PAPERLESS_TOKEN")
return {"Authorization": f"Token {token}"}
def get_start_date(year, month):
if month == 1:
year, month = (year - 1, 12)
else:
year, month = (year, month - 1)
day = calendar.monthrange(year, month)[1]
return (year, month, day)
def get_end_date(year, month):
day = calendar.monthrange(year, month)[1]
return (year, month, day)
def retrieve_parking_tickets(paperless_ngx_url, year, month):
(start_year, start_month, start_day) = get_start_date(year, month)
(end_year, end_month, end_day) = get_end_date(year, month)
start_date = f"{start_year}{str(start_month).zfill(2)}{start_day}"
end_date = f"{end_year}{str(end_month).zfill(2)}{end_day}"
print(
f"Fetching parking tickets from {start_day}-{start_month}-{start_year} to {end_day}-{end_month}-{end_year}."
)
response = requests.get(
f"{paperless_ngx_url}/api/documents/?page_size=50&query=created:[{start_date} TO {end_date}]&tags__id__all={FILTER_TAG_ID}&correspondent__id__in={FILTER_CORRESPONDENT_ID}",
headers=request_headers(),
)
if response.status_code != 200:
print(
f"HTTP {response.status_code} error while retrieving parking ticket: {response.text}"
)
exit(1)
data = json.loads(response.text)
print("Successfully retrieved " + str(len(data["results"])) + " parking tickets.")
tickets = list()
for ticket in data["results"]:
amount1 = float(ticket["custom_fields"][0]["value"].removeprefix("EUR"))
amount2 = float(ticket["custom_fields"][1]["value"].removeprefix("EUR"))
if amount1 > amount2:
price = amount1
btw = amount2
else:
price = amount2
btw = amount1
date = datetime.strptime(ticket["created_date"], "%Y-%m-%d")
tickets.append({"date": date, "price": price, "btw": btw, "id": ticket["id"]})
return sorted(tickets, key=lambda t: t["date"])
def find_last_used_row(ws):
for row in range(ws.max_row, 0, -1):
# Iterate through the columns in the current row
for column in range(1, ws.max_column + 1):
# Check if the cell is not empty
if ws.cell(row=row, column=column).value:
# Found the last row with data, print and exit
return row
def insert_declaration(number_plate, ws, row, date, price, btw):
ws.cell(row=row, column=1, value=date.strftime("%d-%m-%Y"))
ws.cell(row=row, column=2, value="Parkeerkosten P+R Noord")
ws.cell(row=row, column=3, value=number_plate)
ws.cell(row=row, column=4, value=(price - btw))
ws.cell(row=row, column=5, value=btw)
ws.cell(row=row, column=6, value=price)
def fill_template(tickets, template, number_plate):
print("Creating expenses overview.")
wb = load_workbook(filename=template)
ws = wb.active
last_row = find_last_used_row(ws)
price_total = 0
btw_total = 0
for ticket in tickets:
price_total += ticket["price"]
btw_total += ticket["btw"]
ws.insert_rows(last_row + 1)
insert_declaration(
number_plate, ws, last_row + 1, ticket["date"], ticket["price"], ticket["btw"]
)
last_row += 1
# Insert empty row
ws.insert_rows(last_row + 1)
last_row += 1
ws.insert_rows(last_row + 1)
ws.cell(row=last_row + 1, column=1, value="Totaal onkosten")
ws.cell(row=last_row + 1, column=4, value=(price_total - btw_total))
ws.cell(row=last_row + 1, column=5, value=btw_total)
ws.cell(row=last_row + 1, column=6, value=price_total)
print("Expenses overview created.")
return wb
def merge_spreadsheet_and_tickets(paperless_ngx_url, spreadsheet, tickets):
print("Creating final PDF.")
pdf_merger = PyPDF2.PdfMerger()
print("Converting expenses report to PDF.")
with tempfile.NamedTemporaryFile(delete=True, suffix=".xlsx") as xlsx_file:
spreadsheet.save(xlsx_file)
with tempfile.NamedTemporaryFile(delete=True, suffix=".pdf") as pdf_file:
subprocess.run(
f"unoconv -f pdf -o {pdf_file.name} {xlsx_file.name}",
shell=True,
capture_output=True,
text=True,
)
pdf_merger.append(pdf_file.name)
print("Adding parking tickets photos to PDF.")
for index, ticket in enumerate(tickets):
id = ticket["id"]
print(
f"Adding parking ticket (ID={id}) photo to PDF ({index+1}/{len(tickets)})."
)
response = requests.get(
f"{paperless_ngx_url}/api/documents/{id}/download/",
headers=request_headers(),
)
pdf_merger.append(io.BytesIO(response.content))
return pdf_merger
def write_pdf_to_file(pdf_merger, output):
print("Writing final PDF to file.")
with open(output, "wb") as output_file:
pdf_merger.write(output_file)
print(f"Written expense report to {output}.")
def main():
parser = argparse.ArgumentParser(
description="Create a monthly travel expense report"
)
parser.add_argument(
"-y",
"--year",
type=int,
required=True,
help="The year of the expenses.",
)
parser.add_argument(
"-m",
"--month",
type=int,
required=True,
help="The month of the expenses (1-12).",
)
parser.add_argument(
"-p",
"--number-plate",
type=str,
required=True,
help="Number plate of the parked vehicle."
)
parser.add_argument(
"-u",
"--paperless-ngx-url",
type=str,
required=True,
help="Base URL of the Paperless-ngx instance."
)
parser.add_argument(
"-o", "--output", help="Output file of resulting PDF", default="expenses.pdf"
)
parser.add_argument(
"-t",
"--template",
help="XLSX template for the expense report",
default="template.xlsx",
)
args = parser.parse_args()
tickets = retrieve_parking_tickets(args.paperless_ngx_url, args.year, args.month)
spreadsheet = fill_template(tickets, args.template, args.number_plate)
pdf_merger = merge_spreadsheet_and_tickets(args.paperless_ngx_url, spreadsheet, tickets)
write_pdf_to_file(pdf_merger, args.output)
if __name__ == "__main__":
main()