Skip to content

Commit

Permalink
store invoice data in json datastore
Browse files Browse the repository at this point in the history
  • Loading branch information
dtrai2 committed Jul 26, 2024
1 parent 36e1f11 commit 0f3d132
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 68 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ MANIFEST
config.yaml
invoice_template.html
invoices
datastore.json
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ A suitable example looks like this:

```yaml
paths:
invoices_root_dir: /home/user/invoices/ # must be an absolute path, and it needs to end with /
invoices_dir_paid: paid
invoices_dir_unpaid: open
invoices_dir: /home/user/invoices/ # must be an absolute path, and it needs to end with /
html_template: shiny_invoice/default_invoice_template.html
datastore: datastore.json
company: # here you can specify details of your company
name: Company Name
skills:
Expand Down Expand Up @@ -65,18 +64,18 @@ shiny-invoice run --help

## Workflow

This application manages the invoices as plain html files, which then can be turned into pdfs via the
browsers print functionality.
According to the configuration invoices can be separated into paid/unpaid directories.
Based on that they will be also categorised inside the ui.
The application does not offer ways to move or change files, this has to be done manually.
The filename also needs to follow a specific pattern which is
`<CREATED_AT_DATE>-<INVOICE_NUMBER>-<CUSTOMERNAME>.html`
This application manages the invoices as plain html files, named by the invoice id.
To turn the html file into a pdf just use the browsers print functionality.
All the data corresponding to the invoices is stored inside a json file, which is configured with the key
`paths.datastore`.
The json you can edit as you like, via the gui it is only possible to change the 'Paid At' value.
This value is also used to determine if in invoice is indeed 'Paid' or 'Unpaid'.

## Impressions

### View of creating a new invoice
![new-invoice.png](docs/new-invoice.png)

### View of existing invoices
![existing-invoices.png](docs/existing-invoices.png)

### View of creating a new invoice
![new-invoice.png](docs/new-invoice.png)
Binary file modified docs/existing-invoices.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ shiny-invoice = "shiny_invoice.shiny_invoice:cli"

[tool.black]
line-length = 100
target-version = ['py311']
target-version = ['py312']
4 changes: 2 additions & 2 deletions shiny_invoice/shiny_invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def run(config: Path, host: str, port: int):

# pylint: disable=too-many-function-args
app_ui = ui.page_navbar(
ui.nav_panel("Existing Invoices", existing_invoices_ui("existing_invoices")),
ui.nav_panel("Create Invoice", new_invoice_ui("new_invoice", config)),
ui.nav_panel("Existing Invoices", existing_invoices_ui("existing_invoices")),
ui.nav_panel("Configuration", config_ui("config")),
title="Shiny Invoice",
id="navbar_id",
Expand All @@ -60,7 +60,7 @@ def server(input: Inputs, output: Outputs, session: Session):

# pylint: enable=redefined-builtin, unused-argument, no-value-for-parameter

app = App(app_ui, server, static_assets=config.get("paths").get("invoices_root_dir"))
app = App(app_ui, server, static_assets=config.get("paths").get("invoices_dir"))
app.run(host=host, port=port)


Expand Down
105 changes: 54 additions & 51 deletions shiny_invoice/ui_existing_invoices.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""This module contains the ui and server configurations for the existing invoices."""

import datetime
import glob
from pathlib import Path

import pandas as pd
from shiny import module, ui, render, reactive

from tinydb import TinyDB, Query
from tinydb.operations import set

@module.ui
def existing_invoices_ui():
Expand Down Expand Up @@ -48,75 +48,78 @@ def existing_invoices_ui():
def existing_invoices_server(input, output, session, config):
"""Contains the Shiny Server for existing invoices"""

datastore = TinyDB(config.get("paths").get("datastore"))

@reactive.calc
def get_filtered_invoices() -> pd.DataFrame | str:
"""Retrieve all invoices from the configured directories and parse them into a DataFrame.
"""Retrieve all invoices from the datastore and turn them into a DataFrame.
The input filters will then be applied to the dataframe such that only the desired results
will be returned.
"""
paid_records, unpaid_records = _get_invoice_records()
df = pd.DataFrame.from_records(paid_records + unpaid_records)
selected_invoices = datastore.all()
for invoice in selected_invoices:
invoice["Link"] = ui.a("Download", href=f"{invoice["Id"]}.html", target="_blank")
df = pd.DataFrame.from_records(selected_invoices)
if len(df) == 0:
return df
duplicate_numbers = df[df.duplicated(["Invoice"], keep="last")]
if len(duplicate_numbers) > 0:
duplicate_ids = ", ".join(duplicate_numbers["Invoice"].to_list())
ui.notification_show(
f"Found duplicate invoice ids: {duplicate_ids}", type="warning", duration=2
)
df = _filter_invoices(df)
df["Created At"] = df["Created At"].apply(lambda x: x.strftime("%d.%m.%Y"))
df["Due Date"] = df["Due Date"].apply(lambda x: x.strftime("%d.%m.%Y"))
df["Paid At"] = df["Paid At"].apply(
lambda x: datetime.datetime.strptime(x, "%Y-%m-%d").strftime("%d.%m.%Y") if x != "Unpaid" else "Unpaid")
return df

def _get_invoice_records():
root_dir = Path(config.get("paths").get("invoices_root_dir"))
paid_dir = root_dir / config.get("paths").get("invoices_dir_paid")
unpaid_dir = root_dir / config.get("paths").get("invoices_dir_unpaid")
paid_invoices = glob.glob(f"{paid_dir}/**/*.html", recursive=True)
unpaid_invoices = glob.glob(f"{unpaid_dir}/**/*.html", recursive=True)
paid_records = _create_invoice_records(paid_invoices, status="paid")
unpaid_records = _create_invoice_records(unpaid_invoices, status="unpaid")
return paid_records, unpaid_records

def _filter_invoices(df):
if input.invoice_numbers():
filtered_invoice_ids = input.invoice_numbers().split(",")
df = df.loc[df["Invoice"].isin(filtered_invoice_ids)]
filtered_ids = input.invoice_numbers().split(",")
df = df.loc[df["Id"].isin(filtered_ids)]
if input.paid_status():
df = df.loc[df["Status"].isin(input.paid_status())]
paid, unpaid = pd.DataFrame(), pd.DataFrame()
if "paid" in input.paid_status():
paid = df.loc[df["Paid At"] != "Unpaid"]
if "unpaid" in input.paid_status():
unpaid = df.loc[df["Paid At"] == "Unpaid"]
df = pd.concat([paid, unpaid])
df["Created At"] = pd.to_datetime(df["Created At"]).dt.date
df["Due Date"] = pd.to_datetime(df["Due Date"]).dt.date
start_date = input.daterange()[0]
end_date = input.daterange()[1]
df = df[(df["Date"] >= start_date) & (df["Date"] <= end_date)]
df["Date"] = df["Date"].apply(lambda x: x.strftime("%d.%m.%Y"))
df = df[(df["Created At"] >= start_date) & (df["Created At"] <= end_date)]
return df

def _create_invoice_records(file_paths, status):
records = []
for invoice_path in file_paths:
parts = invoice_path.split("/")
name_parts = parts[-1].split("-")
date = datetime.date(
year=int(name_parts[0]), month=int(name_parts[1]), day=int(name_parts[2])
)
invoice_number = name_parts[3]
customer = name_parts[-1].replace(".html", "")
root_dir = config.get("paths").get("invoices_root_dir")
invoice_path = invoice_path.replace(root_dir, "")
records.append(
{
"Date": date,
"Invoice": invoice_number,
"Status": status,
"Customer": customer,
"Link": ui.a("Download", href=invoice_path, target="_blank"),
}
)
return records

@render.data_frame
def invoice_list():
"""Render a list of filtered invoices"""
df = get_filtered_invoices()
return render.DataGrid(df, selection_mode="rows", width="100%")
table = render.DataGrid(df, selection_mode="rows", width="100%", editable=True)
return table

def patch_table(patch):
"""Apply edits to the table only if the fourth column 'Paid At' was changed"""
table_data = invoice_list.data()
if patch.get("column_index") != 3:
column_index = patch.get("column_index")
edited_column = table_data.columns[int(column_index)]
ui.notification_show(
"You can only edit the column 'Paid At'.",
type="warning",
duration=2,
)
return table_data[edited_column].iloc[patch.get("row_index")]
try:
Invoices = Query()
invoice_id = table_data.iloc[patch.get("row_index")]["Id"]
parsed_date = datetime.datetime.strptime(patch.get("value"), "%d.%m.%Y")
datastore.update(set("paid_at", parsed_date.strftime("%Y-%m-%d")), Invoices.id == invoice_id)
except Exception:
ui.notification_show(
"Error while updating invoice, please only use the date format '%d.%m.%Y'.",
type="error",
duration=6
)
return patch.get("value")

invoice_list.set_patch_fn(patch_table)

@render.ui
def selected_invoice():
Expand All @@ -125,7 +128,7 @@ def selected_invoice():
if len(selection) > 0:
selection = selection[0]
df = get_filtered_invoices().iloc[selection]["Link"]
root_dir = Path(config.get("paths").get("invoices_root_dir"))
root_dir = Path(config.get("paths").get("invoices_dir"))
with open(root_dir / df.attrs.get("href"), "r", encoding="utf8") as file:
html = file.read()
return ui.HTML(html)
Expand Down
41 changes: 39 additions & 2 deletions shiny_invoice/ui_new_invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pandas as pd
from shiny import module, ui, render, reactive
from tinydb import TinyDB, Query


@module.ui
Expand All @@ -17,7 +18,7 @@ def new_invoice_ui(config):
return ui.layout_column_wrap(
ui.card(
ui.card_header("Invoice Details"),
ui.input_text(id="invoice_number", label="Invoice Number", value="1", width="100%"),
ui.output_ui(id="invoice_number_ui", width="100%"),
ui.input_date(id="created_at_date", label="Created At", width="100%"),
ui.output_ui(id="due_date_ui", width="100%"),
ui.input_text(
Expand Down Expand Up @@ -53,6 +54,8 @@ def new_invoice_ui(config):
@module.server
def new_invoice_server(input, output, session, config):

datastore = TinyDB(config.get("paths").get("datastore"))

with open(Path(config.get("paths").get("html_template")), "r", encoding="utf8") as file:
html_template = Template(file.read())

Expand All @@ -73,6 +76,15 @@ def calculate_totals():
)
return items[last_column].sum()

@render.ui
def invoice_number_ui():
invoice_ids = [int(invoice.get("Id")) for invoice in datastore.all()]
next_id = 1
if invoice_ids:
next_id = max(invoice_ids) +1
number_ui = ui.input_text(id="invoice_number", label="Invoice Number", value=str(next_id), width="100%")
return number_ui

@render.ui
def due_date_ui():
payment_terms_days = config.get("company").get("payment_terms_days")
Expand All @@ -83,15 +95,40 @@ def due_date_ui():
def customer_name():
return input.recipient_address().split("\n")[0]

@reactive.effect
@reactive.event(input.invoice_number)
def already_existing_id_modal():
Invoices = Query()
already_existing = datastore.search(Invoices.id == input.invoice_number())
if already_existing:
ui.notification_show(
"This invoice already exists. Please choose another invoice number.",
type="error",
duration=2,
)

@render.download(
filename=lambda: f"{input.created_at_date()}-{input.invoice_number()}-{customer_name()}.html",
filename=lambda: f"{input.invoice_number()}.html",
)
def download_button():
"""Download the currently created invoice"""
datastore.insert({
"Id": input.invoice_number(),
"Created At": str(input.created_at_date()),
"Due Date": str(input.due_date()),
"Paid At": "Unpaid",
"Customer": customer_name(),
})
ui.notification_show(
"Reload page to update 'Existing Invoices' view.",
type="message",
duration=None
)
with io.BytesIO() as buf:
buf.write(render_invoice().encode("utf8"))
yield buf.getvalue()


@reactive.calc
def render_invoice():
total_net = calculate_totals()
Expand Down

0 comments on commit 0f3d132

Please sign in to comment.