Преглед на файлове

Quick and dirty initial working version.

master
Pavel Šimerda преди 9 години
ревизия
8469d4a80c
променени са 10 файла, в които са добавени 724 реда и са изтрити 0 реда
  1. +1
    -0
      .gitignore
  2. +61
    -0
      README
  3. +13
    -0
      invoice
  4. +0
    -0
      lib/invoice/__init__.py
  5. +215
    -0
      lib/invoice/cli.py
  6. +8
    -0
      lib/invoice/db/__init__.py
  7. +193
    -0
      lib/invoice/db/base.py
  8. +42
    -0
      lib/invoice/db/companies.py
  9. +109
    -0
      lib/invoice/db/invoices.py
  10. +82
    -0
      templates/invoice.tex

+ 1
- 0
.gitignore Целия файл

@@ -0,0 +1 @@
__pycache__

+ 61
- 0
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 <pavlix@pavlix.net>

== 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 <id>
invoice edit-company <id>
invoice delete-company <id>

Manage invoices:

invoice new <company-id>
invoice edit [<number>]
invoice delete [<number>]

Generate and display invoice PDF:

invoice show [<number>]

+ 13
- 0
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()

+ 0
- 0
lib/invoice/__init__.py Целия файл


+ 215
- 0
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()

+ 8
- 0
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)

+ 193
- 0
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]


+ 42
- 0
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<name>[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")

+ 109
- 0
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<date>[0-9]{8})-(?P<number>[0-9]{3})-(?P<company_name>[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

+ 82
- 0
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}

Loading…
Отказ
Запис