From 8469d4a80c50f5f33acb53e83e0228e42e2c4b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20=C5=A0imerda?= Date: Thu, 12 Jan 2012 15:50:26 +0100 Subject: [PATCH] Quick and dirty initial working version. --- .gitignore | 1 + README | 61 ++++++++++ invoice | 13 +++ lib/invoice/__init__.py | 0 lib/invoice/cli.py | 215 ++++++++++++++++++++++++++++++++++++ lib/invoice/db/__init__.py | 8 ++ lib/invoice/db/base.py | 193 ++++++++++++++++++++++++++++++++ lib/invoice/db/companies.py | 42 +++++++ lib/invoice/db/invoices.py | 109 ++++++++++++++++++ templates/invoice.tex | 82 ++++++++++++++ 10 files changed, 724 insertions(+) create mode 100644 .gitignore create mode 100644 README create mode 100755 invoice create mode 100644 lib/invoice/__init__.py create mode 100644 lib/invoice/cli.py create mode 100644 lib/invoice/db/__init__.py create mode 100644 lib/invoice/db/base.py create mode 100644 lib/invoice/db/companies.py create mode 100644 lib/invoice/db/invoices.py create mode 100644 templates/invoice.tex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README b/README new file mode 100644 index 0000000..2897c46 --- /dev/null +++ b/README @@ -0,0 +1,61 @@ += Invoice + +This is my quick and dirty invoice system I am going to use for my invoices +in year 2012. + +The program is higly experimental and users are expected to know at least a +bit of Python coding. Feedback is appreciated. + +Pavel Šimerda + +== License + +This version of the source code is released into public domain, future releases +may adopt a BSD-like license. + +== Requirements + + * Python 3.2 + * python3-templite 0.5.1 + * pdflatex + +== Installation + +You don't need to install, just make a symlink to the invoice script +in ~/bin or /usr/bin/local. + +You will need to make the following symlinks or directories in ~/.invoice/ + + * data (the database) + * data/$YEAR (current year) + * tmp + * output + * output/$YEAR + +Use the code or error messages in case this list is incomplete. Use -D for +debugging. + +== User configuration + +For now an empthy ~/.invoice/config should do. + +== Usage + +Export EDITOR and PDF_VIEWER environment variables to choose your favourite +text editor and PDF viewer. Defaults are 'vim' and 'xdg-open'. + +Manage companies: + +invoice new-company +invoice edit-company +invoice delete-company + +Manage invoices: + +invoice new +invoice edit [] +invoice delete [] + +Generate and display invoice PDF: + +invoice show [] diff --git a/invoice b/invoice new file mode 100755 index 0000000..cae7284 --- /dev/null +++ b/invoice @@ -0,0 +1,13 @@ +#!/usr/bin/python3 + +import os, sys +base_dir = sys.path[0] +sys.path[0] = os.path.join(base_dir, "lib") + +print(sys.path) +import invoice.cli + +if __name__ == '__main__': + invoice.cli.Application( + template_path = os.path.join(base_dir, "templates") + ).run() diff --git a/lib/invoice/__init__.py b/lib/invoice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/invoice/cli.py b/lib/invoice/cli.py new file mode 100644 index 0000000..7b82034 --- /dev/null +++ b/lib/invoice/cli.py @@ -0,0 +1,215 @@ +#!/usr/bin/python3 + +import os, sys, argparse, datetime, subprocess +import invoice.db + +import logging +log = logging.getLogger() + +class SanityCheckError(Exception): + pass + +class Application: + my_company = "my-company" + + editor = os.environ.get("EDITOR") or "vim" + viewer = os.environ.get("PAGER") or "less" + tex_program = "pdflatex" + pdf_program = "xdg-open" + + def __init__(self, template_path): + self._parse_args() + self.year = self.args.__dict__.pop("year") + self.user_path = os.path.expanduser(self.args.__dict__.pop("user_data")) + self.method = self.args.__dict__.pop("method") + self.data_path = os.path.join(self.user_path, "data", "{year}", "data", "{directory}") + self.tmp_path = os.path.join(self.user_path, "tmp") + self.output_path = os.path.join(self.user_path, "output", "{year}") + self.template_path = template_path + self.db = invoice.db.Database( + year = self.year, + data_path = self.data_path) + + def _parse_args(self): + parser = argparse.ArgumentParser( + description = "Pavel Šimerda's invoice CLI application.", + conflict_handler = "resolve") + parser.add_argument("--year", "-Y", action="store") + parser.add_argument("--user-data", "-d", action="store") + parser.add_argument("--debug", "-D", action="store_const", dest="log_level", const=logging.DEBUG) + #parser.add_argument("--verbose", "-v", action="store_const", dest="log_level", const=logging.INFO) + #parser.add_argument("--config", "-C", action="store") + parser.set_defaults( + year = datetime.date.today().year, + user_data = "~/.invoice", + log_level = logging.INFO) + + subparsers = parser.add_subparsers(title="subcommands", + description="valid subcommands", + help="additional help") + + for list_ in "invoices", "companies": + for action in "list", "new", "edit", "show", "delete": + suffix = '' + if list_ == "companies": + suffix = "-companies" if action=="list" else "-company" + method = getattr(self, "do_"+(action+suffix).replace("-", "_")) + subparser = subparsers.add_parser(action+suffix, help=method.__doc__) + if action == "new": + subparser.add_argument("name" if suffix else "company_name") + if action in ("show", "edit", "delete"): + subparser.add_argument("selector", nargs="?") + if action == "delete": + subparser.add_argument("--force", "-f", action="store_true") + subparser.set_defaults(method=method) + + self.args = parser.parse_args() + log.setLevel(self.args.__dict__.pop("log_level")) + log.debug("Arguments: {}".format(self.args)) + + def run(self): + try: + self.method(**vars(self.args)) + except (SanityCheckError) as error: + print("Error: {} Use '--force' to suppress this check.".format(error), file=sys.stderr) + if log.isEnabledFor(logging.DEBUG): + raise + except invoice.db.DatabaseError as error: + print("Error: {}".format(error), file=sys.stderr) + if log.isEnabledFor(logging.DEBUG): + raise + + def do_list(self): + """List invoices.""" + for item in sorted(self.db.invoices): + print(item) + + def do_new(self, company_name): + """Create and edit a new invoice.""" + item = self.db.invoices.new(company_name) + self._edit(item._path) + + def do_edit(self, selector): + """Edit invoice in external editor. + + The external editor is determined by EDITOR environment variable + using 'vim' as the default. Item is edited in-place. + """ + if selector: + item = self.db.invoices[selector] + else: + item = self.db.invoices.last() + self._edit(item._path) + + def _edit(self, path): + log.debug("Editing file: {}".format(path)) + assert os.path.exists(path) + subprocess.call((self.editor, path)) + + def do_show(self, selector): + """Generate and view a PDF invoice. + + This requires Tempita 0.5. + """ + import tempita + if selector: + invoice = self.db.invoices[selector] + else: + invoice = self.db.invoices.last() + issuer = self.db.companies[self.my_company] + customer = self.db.companies[invoice.company_name] + + invoice_data = invoice.data() + issuer_data = issuer.data() + customer_data = customer.data() + + tmp_path = self.tmp_path.format(year=self.year) + output_path = self.output_path.format(year=self.year) + log.debug("tmp_path={}".format(tmp_path)) + + tex_template = os.path.join(self.template_path, "invoice.tex") + tex_file = os.path.join(tmp_path, "{}.tex".format(invoice._name)) + tmp_pdf_file = os.path.join(tmp_path, "{}.pdf".format(invoice._name)) + pdf_file = os.path.join(output_path, "{}.pdf".format(invoice._name)) + + log.debug("Invoice: {}".format(invoice_data._data)) + log.debug("Issuer: {}".format(issuer_data._data)) + log.debug("Customer: {}".format(customer_data._data)) + + log.debug("Creating TeX invoice...") + self._check_path(self.tmp_path) + result = tempita.Template(open(tex_template).read()).substitute( + invoice=invoice_data, issuer=issuer_data, customer=customer_data) + open(tex_file, "w").write(str(result)) + assert(os.path.exists(tex_file)) + + log.debug("Creating PDF invoice...") + if subprocess.call((self.tex_program, "{}.tex".format(invoice._name)), cwd=tmp_path) != 0: + raise GenerationError("PDF generation failed.") + assert(os.path.exists(tmp_pdf_file)) + + log.debug("Moving PDF file to the output directory...") + self._check_path(output_path) + os.rename(tmp_pdf_file, pdf_file) + assert(os.path.exists(pdf_file)) + + log.debug("Running PDF viewer...") + subprocess.call((self.pdf_program, pdf_file)) + + def _check_path(self, path): + if not os.path.exists(path): + raise LookupError("Directory doesn't exist: {}".format(path)) + + def do_delete(self, selector, force): + """List invoices.""" + if selector: + invoice = self.db.invoices[selector] + else: + invoice = self.db.invoices.last() + if not force: + raise SanityCheckError("It is not recommended to delete invoices.") + invoice.delete() + + def do_list_companies(self): + """List companies.""" + for item in sorted(self.db.companies): + print(item) + + def do_new_company(self, name): + """Create and edit a new company.""" + item = self.db.companies.new(name) + self._edit(item._path) + + def do_edit_company(self, selector): + """Edit company in external editor. + + The external editor is determined by EDITOR environment variable + using 'vim' as the default. Item is edited in-place. + """ + item = self.db.companies[selector] + self._edit(item._path) + + def do_show_company(self, selector): + """View company in external editor. + + The external viewer is determined by PAGER environment variable + using 'less' as the default. + """ + item = self.db.companies[selector] + self._show(item._path) + + def _show(self, path): + log.debug("Viewing file: {}".format(path)) + assert os.path.exists(path) + subprocess.call((self.viewer, path)) + + def do_delete_company(self, selector, force): + """Delete a company.""" + company = self.db.companies[selector] + if not force: + invoices = self.db.invoices.select({"company_name": company._name}) + if invoices: + for invoice in invoices: + log.info("Dependent invoice: {}".format(invoice)) + raise SanityCheckError("This company is used by some invoices. You should not delete it.") + company.delete() diff --git a/lib/invoice/db/__init__.py b/lib/invoice/db/__init__.py new file mode 100644 index 0000000..c34b344 --- /dev/null +++ b/lib/invoice/db/__init__.py @@ -0,0 +1,8 @@ +from .base import DatabaseError + +class Database: + def __init__(self, **config): + from . import companies + from . import invoices + self.companies = companies.Companies(db=self, **config) + self.invoices = invoices.Invoices(db=self, **config) diff --git a/lib/invoice/db/base.py b/lib/invoice/db/base.py new file mode 100644 index 0000000..62838a4 --- /dev/null +++ b/lib/invoice/db/base.py @@ -0,0 +1,193 @@ +#!/usr/bin/python3 + +import os, sys, re, time, datetime + +import logging +log = logging.getLogger() + +class DatabaseError(Exception): + pass + +class ItemNotFoundError(DatabaseError, LookupError): + pass + +class ItemNameCheckError(DatabaseError, ValueError): + pass + +class ItemExistsError(DatabaseError): + pass + +class List: + """Base class for database lists. + + This class provides a real-time view to a file-based database. It can be + used as an iterable with a little bit of magic (like indexing by a + dictionary of matching attributes). + """ + def __init__(self, year, data_path, db=None): + self._year = year + self._path = os.path.expanduser(data_path.format( + year=year, directory=self._directory)) + self._db = db + log.debug("{}: {}".format(self.__class__.__name__, self._path)) + + def _item_class(self): + """Returns class object used to instantiate items. + + Override in subclasses. + """ + return Item + + def _item_name(self): + return self._item_class().__name__.lower() + + def __iter__(self): + for name in os.listdir(self._path): + match = self._regex.match(name) + if match: + yield self._item_class()(self, year=self._year, **match.groupdict()) + + def last(self): + return max(iter(self), key=lambda item: item._name) + + def __contains__(self, selector): + return bool(self._select(selector)) + + def __getitem__(self, selector): + items = self._select(selector) + assert(len(items) < 2) + if not items: + raise ItemNotFoundError("{} '{}' not found.".format(self._item_class().__name__, selector)) + item = items[0] + log.debug("Found matching item: {}".format(item)) + return item + + def select(self, selector=None): + return sorted(self._select(selector)) + + def _select(self, selector): + """Return a list of items matching 'name', 'number' or other attributes.""" + if selector == None: + selector = {} + if isinstance(selector, str): + selector = {"name": selector} + if isinstance(selector, int): + selector = {"number": number} + log.debug("Selecting: {}".format(selector)) + assert isinstance(selector, dict) + return [item for item in self if all(getattr(item, key) == selector[key] for key in selector)] + + def new(self, name): + """Create a new item in this list. + + Keyword arguments: + name -- filesystem name of the new item + edit -- edit the item after creation + + Returns + """ + log.info("Creating {}: {}".format(self._item_name(), name)) + if not self._regex.match(name): + raise ItemNameCheckError("Name {} doesn't match {} regex.".format(name, self._item_name())) + if name in self: + raise ItemExistsError("Item {} of type {} already exists.".format(name, self._item_name())) + self._new(os.path.join(self._path, name)) + return self[name] + + def _new(self, path): + log.debug("Creating {} file: {}".format(self._item_name(), path)) + stream = os.fdopen(os.open(path, os.O_WRONLY|os.O_EXCL|os.O_CREAT, 0o644), "w") + stream.write(self.data_template) + stream.close() + +class Item: + """Base class for database list items.""" + def __init__(self, list_, **selector): + self._list = list_ + self._selector = selector + self._postprocess() + self._name = self._list._template.format(**selector) + self._path = os.path.join(list_._path, self._name) + log.debug("{!r}".format(self)) + + def _postprocess(self): + """Postprocess the _selector attribute. + + Override in subclasses. + """ + + def __lt__(self, other): + return self._name < other._name + + def __repr__(self): + return "{}({!r}, **{})".format(self.__class__.__name__, self._name, self._selector) + + def __str__(self): + return self._name + + def __getattr__(self, key): + return self._selector[key] + + def delete(self): + log.info("Deleting: {}".format(self)) + path = self._path + newpath = path + "~" + log.debug("Renaming file {} to {}.".format(path, newpath)) + assert os.path.exists(path) + os.rename(path, newpath) + + def data(self): + """Return item's data. + + Override in subclasses. + """ + return self._data_class()(self) + + def _data_class(self): + return Data + +class Data: + """Base class for database list item data objects.""" + _fields = [] + _multivalue_fields = [] + _line_regex = re.compile(r"^([A-Z][a-zA-Z-]*):\s+(.*?)\s+$") + + def __init__(self, item): + self._item = item + self._parse(open(self._item._path)) + self._postprocess() + + def __getattr__(self, key): + return self._data[key] + + def _parse(self, stream): + self._data = self._item._selector.copy() + for f in self._fields: + self._data[f] = None + for f in self._multivalue_fields: + self._data[f] = [] + for n, line in enumerate(stream): + match = self._line_regex.match(line) + if not match: + log.warning("Ignoring {}:{}: {}".format(n, self._item._name, line)) + continue + key, value = match.groups() + key = key.lower().replace("-", "_") + if key in self._fields: + self._data[key] = value + elif key in self._multivalue_fields: + self._data[key].append(value) + else: + log.warning("Key ignored: {}".format(key)) + + def _postprocess(self): + """Postprocess item data. + + Override in subclasses. + """ + def rename_key(self, oldkey, newkey): + """Convenience function mainly intended for subclasses.""" + if not self.__dict__.get(newkey) and oldkey in self._data: + self._data[newkey] = self._data[oldkey] + del self._data[oldkey] + diff --git a/lib/invoice/db/companies.py b/lib/invoice/db/companies.py new file mode 100644 index 0000000..49e7d36 --- /dev/null +++ b/lib/invoice/db/companies.py @@ -0,0 +1,42 @@ +#!/usr/bin/python3 + +import os, sys, re, time, datetime + +import logging +log = logging.getLogger() + +from invoice.db.base import * + +class Companies(List): + """Company list. + + When editing data files, you can use the following directives: + + Name -- full company name + Address -- company address, repeat to get multiple lines + Number -- identification number + Comment -- additional information that you want to see on the invoice + """ + _directory = "companies" + _regex = re.compile("^(?P[a-z0-9-]+)$") + _template = "{name}" + _data_template = """\ +Name: +Address: +Address: +Number: +""" + def _item_class(self): + return Company + +class Company(Item): + def _data_class(self): + return CompanyData + +class CompanyData(Data): + _fields = ["name", "number", "ic", "bank_account"] + _multivalue_fields = ["address", "comment"] + + def _postprocess(self): + self.rename_key("ic", "number") + self.rename_key("comment", "comments") diff --git a/lib/invoice/db/invoices.py b/lib/invoice/db/invoices.py new file mode 100644 index 0000000..3b8abe3 --- /dev/null +++ b/lib/invoice/db/invoices.py @@ -0,0 +1,109 @@ +#!/usr/bin/python3 + +import os, sys, re, time, datetime + +import logging +log = logging.getLogger() + +from invoice.db.base import * + +class Invoices(List): + """Company invoice. + + When editing data files, you can use the following + directives: + + Item -- price, followed by ':' and description + Due -- Due date YYYY-MM-DD + Note -- a note at the and of the invoice + """ + data_template = """\ +Item: 0000: Item summary +""" + _directory = "income" + _regex = re.compile("^(?P[0-9]{8})-(?P[0-9]{3})-(?P[a-z0-9-]+)$") + _template = "{date}-{number:03}-{company_name}" + + def _item_class(self): + return Invoice + + def _select(self, selector): + if isinstance(selector, str): + match = self._regex.match(selector) + if not match: + raise ItemNotFoundError("Item not found: {}".format(selector)) + selector = match.groupdict() + selector["number"] = int(selector["number"]) + return super(Invoices, self)._select(selector) + + def new(self, company_name): + if company_name not in self._db.companies: + raise ItemNotFoundError("Company '{}' not found.".format(company_name)) + try: + number = max(item.number for item in self) + 1 + except ValueError: + number = 1 + date = time.strftime("%Y%m%d") + name = self._template.format(**vars()) + return super(Invoices, self).new(name) + +class Invoice(Item): + def _data_class(self): + return InvoiceData + + def _postprocess(self): + self._selector["number"] = int(self.number) + +class InvoiceData(Data): + _fields = ["payment"] + _multivalue_fields = ["item", "address", "note"] + _date_regex = re.compile(r"^(\d{4})-?(\d{2})-?(\d{2})$") + _item_regex = re.compile(r"^(\d+)[:;]\s*(.*)$") + _number_template = "{year}{number:03}" + + def _parse_date(self, date): + match = self._date_regex.match(self.date) + if not match: + raise ValueError("Bad date format: {}".format(date)) + return datetime.date(*(int(f) for f in match.groups())) + + def _postprocess(self): + log.debug(self._data) + self._postprocess_number() + self._postprocess_items() + self._postprocess_dates() + self.rename_key("note", "notes") + + def _postprocess_number(self): + self._data["number"] = self._number_template.format( + year = self.year, + number = self.number) + + def _postprocess_items(self): + items = [] + for item in self.item: + match = self._item_regex.match(item) + if not match: + raise ValueError("Bad item format: {}".format(item)) + price, description = match.groups() + items.append((description, int(price))) + del self._data["item"] + self._data["items"] = items + self._data["sum"] = sum(item[1] for item in items) + + def _postprocess_dates(self): + date = self._parse_date(self._item.date) + if "due" in self._data: + try: + due = self._parse_date(self.due) + except ValueError: + try: + due = datetime.timedelta(int(re.sub("^+", "", self.due))) + except ValueError: + raise ValueError("Bad due format: {}".format(self.due)) + else: + due = datetime.timedelta(14) + if isinstance(due, datetime.timedelta): + due += date + self._data["date"] = date + self._data["due"] = due diff --git a/templates/invoice.tex b/templates/invoice.tex new file mode 100644 index 0000000..cf72b56 --- /dev/null +++ b/templates/invoice.tex @@ -0,0 +1,82 @@ +\documentclass[10pt]{article} +\usepackage[utf8]{inputenc} +\usepackage[czech]{babel} +\usepackage{a4wide} +\usepackage{tabularx} +\renewcommand{\familydefault}{\sfdefault} +\setlength{\extrarowheight}{3pt} + +\begin{document} +\footnotesize + +\begin{center} +\begin{tabularx}{\textwidth}{|XXXX|} +\cline{3-4} +\multicolumn{2}{X}{} & \multicolumn{2}{|X|}{} \\ +\multicolumn{2}{X}{} & \multicolumn{2}{|l|}{\large Faktura: \hfill {{invoice.number}}} \\ +\multicolumn{2}{X}{} & \multicolumn{2}{|X|}{} \\ +\hline +& & & \\ +\bf Dodavatel: & & \bf Odběratel & \\[1em] +\multicolumn{2}{|l}{\large\bf {{issuer.name}}} & \multicolumn{2}{l|}{\large\bf {{customer.name}}} \\ +{{py: +a1 = issuer.address[:] +a2 = customer.address[:] +a1 += (len(a2)-len(a1))*[""] +a2 += (len(a1)-len(a2))*[""] +}} +{{for f1, f2 in zip(a1, a2)}} +\multicolumn{2}{|l}{\large {{f1}}} & \multicolumn{2}{l|}{\large {{f2}}} \\ +{{endfor}} + +& & & \\ +\multicolumn{2}{|l}{IČ: {{issuer.number}}} & \multicolumn{2}{l|}{IČ: {{customer.number}}} \\ +& & & \\ + +{{py: +a1 = issuer.comments[:] +a2 = customer.comments[:] +a1 += (len(a2)-len(a1))*[""] +a2 += (len(a1)-len(a2))*[""] +}} +{{for f1, f2 in zip(a1, a2)}} +\multicolumn{2}{|l}{ {{f1}}} & \multicolumn{2}{l|}{ {{f2}}} \\ +{{endfor}} + +& & & \\ +\hline +& & & \\ +\bf Platební podmínky: & & & \\[1em] +\large Forma úhrady: & \large {{"hotově" if invoice.payment=="cash" else "převodem"}} & \large Datum vystavení: & \multicolumn{1}{r|}{\large {{invoice.date.strftime("%d.%m.%Y")}}} \\ +\large Číslo účtu: & \large {{issuer.bank_account}} & \multicolumn{2}{l|}{\large\bf Datum splatnosti: \hfill {{invoice.due.strftime("%d.%m.%Y")}}} \\ +\large Variabilní symbol: & \large {{invoice.number}} & & \\ + +{{if invoice.notes}} +& & & \\ +\hline +& & & \\ +\bf Poznámky: & & & \\[1em] +{{for note in invoice.notes}} +\multicolumn{4}{|l|}{\large {{note}}} \\ +{{endfor}} +{{endif}} + +& & & \\ +\hline +& & & \\ +\bf Fakturujeme vám: & & & \\[1em] + +{{for item in invoice.items}} +\multicolumn{4}{|l|}{\normalsize {{item[0]}} \hfill {{item[1]}} Kč} \\ +{{endfor}} + +& & & \\ +\hline +& & & \\ +\large\bf Celkem k úhradě: & & & \multicolumn{1}{r|}{\large\bf {{invoice.sum}} Kč} \\ +& & & \\ +\hline +\end{tabularx} +\end{center} + +\end{document}