You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

8 jaren geleden
8 jaren geleden
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. #!/usr/bin/python3
  2. # encoding: utf-8
  3. from __future__ import print_function
  4. import os, sys, argparse, datetime, subprocess
  5. import logging
  6. logging.basicConfig(level=logging.DEBUG)
  7. log = logging.getLogger()
  8. import invoice.db
  9. class SanityCheckError(Exception):
  10. pass
  11. class Application:
  12. my_company = "my-company"
  13. editor = os.environ.get("EDITOR") or "vim"
  14. viewer = os.environ.get("PAGER") or "less"
  15. tex_program = "pdflatex"
  16. pdf_program = "xdg-open"
  17. def __init__(self, template_path):
  18. self._parse_args()
  19. exec(open(os.path.expanduser(os.path.join(self.args.user_data, "config"))).read(),
  20. {"__builtins__": None}, self.__dict__)
  21. self.year = self.args.__dict__.pop("year")
  22. self.user_path = os.path.expanduser(self.args.__dict__.pop("user_data"))
  23. self.method = self.args.__dict__.pop("method")
  24. self.data_path = os.path.join(self.user_path, "{year}", "data", "{directory}")
  25. self.tmp_path = os.path.join(self.user_path, "tmp")
  26. self.output_path = os.path.join(self.user_path, "{year}", "output")
  27. self.template_path = template_path
  28. self.db = invoice.db.Database(
  29. year = self.year,
  30. data_path = self.data_path)
  31. def _parse_args(self):
  32. parser = argparse.ArgumentParser(
  33. description = "Pavel Šimerda's invoice CLI application.",
  34. conflict_handler = "resolve")
  35. parser.add_argument("--year", "-y", action="store")
  36. parser.add_argument("--user-data", "-d", action="store")
  37. parser.add_argument("--debug", "-D", action="store_const", dest="log_level", const=logging.DEBUG)
  38. #parser.add_argument("--verbose", "-v", action="store_const", dest="log_level", const=logging.INFO)
  39. #parser.add_argument("--config", "-C", action="store")
  40. parser.set_defaults(
  41. year = datetime.date.today().year,
  42. user_data = "~/.invoice",
  43. log_level = logging.INFO)
  44. subparsers = parser.add_subparsers(title="subcommands",
  45. description="valid subcommands",
  46. help="additional help")
  47. for list_ in "invoices", "companies":
  48. for action in "list", "summary", "new", "edit", "show", "pdf", "delete":
  49. if action in ("pdf", "summary") and list_ != "invoices":
  50. continue
  51. suffix = ''
  52. if list_ == "companies":
  53. suffix = "-companies" if action=="list" else "-company"
  54. method = getattr(self, "do_"+(action+suffix).replace("-", "_"))
  55. subparser = subparsers.add_parser(action+suffix, help=method.__doc__)
  56. if method == self.do_pdf:
  57. subparser.add_argument("--generate", "-g", action="store_true")
  58. if action == "delete":
  59. subparser.add_argument("--force", "-f", action="store_true")
  60. if action == "new":
  61. subparser.add_argument("name" if suffix else "company_name")
  62. if action in ("show", "pdf", "edit", "delete"):
  63. subparser.add_argument("selector", nargs="?")
  64. subparser.set_defaults(method=method)
  65. self.args = parser.parse_args()
  66. log.setLevel(self.args.__dict__.pop("log_level"))
  67. log.debug("Arguments: {0}".format(self.args))
  68. def run(self):
  69. try:
  70. self.method(**vars(self.args))
  71. except (SanityCheckError) as error:
  72. print("Error: {0} Use '--force' to suppress this check.".format(error), file=sys.stderr)
  73. if log.isEnabledFor(logging.DEBUG):
  74. raise
  75. except invoice.db.DatabaseError as error:
  76. print("Error: {0}".format(error), file=sys.stderr)
  77. if log.isEnabledFor(logging.DEBUG):
  78. raise
  79. def do_list(self):
  80. """List invoices."""
  81. for item in sorted(self.db.invoices):
  82. print(item)
  83. def do_summary(self):
  84. """Show invoice summary."""
  85. total = paid = 0
  86. for invoice in sorted(self.db.invoices):
  87. data = invoice.data()
  88. log.debug(data._data)
  89. print("{number:7} {due!s:10} {paid!s:10} {sum:>6} {company_name}"
  90. .format(**data._data))
  91. total += data.sum
  92. if data.paid:
  93. paid += data.sum
  94. print()
  95. print("Total: {0:6}".format(total))
  96. print("Paid: {0:6}".format(paid))
  97. print("Unpaid: {0:6}".format(total-paid))
  98. def do_new(self, company_name):
  99. """Create and edit a new invoice."""
  100. item = self.db.invoices.new(company_name)
  101. self._edit(item._path)
  102. def do_edit(self, selector):
  103. """Edit invoice in external editor.
  104. The external editor is determined by EDITOR environment variable
  105. using 'vim' as the default. Item is edited in-place.
  106. """
  107. self._edit(self.db.invoices[selector]._path)
  108. def _edit(self, path):
  109. log.debug("Editing file: {0}".format(path))
  110. assert os.path.exists(path)
  111. subprocess.call((self.editor, path))
  112. def do_show(self, selector):
  113. """View invoice in external viewer.
  114. The external viewer is determined by PAGER environment variable
  115. using 'less' as the default.
  116. """
  117. item = self.db.invoices[selector]
  118. self._show(item._path)
  119. def do_pdf(self, selector, generate):
  120. """Generate and view a PDF invoice.
  121. This requires Tempita 0.5.
  122. """
  123. import tempita
  124. invoice = self.db.invoices[selector]
  125. tmp_path = self.tmp_path.format(year=self.year)
  126. output_path = self.output_path.format(year=self.year)
  127. log.debug("tmp_path={0}".format(tmp_path))
  128. tex_template = os.path.join(self.template_path, "invoice.tex")
  129. tex_file = os.path.join(tmp_path, "{0}.tex".format(invoice._name))
  130. tmp_pdf_file = os.path.join(tmp_path, "{0}.pdf".format(invoice._name))
  131. pdf_file = os.path.join(output_path, "{0}.pdf".format(invoice._name))
  132. if generate:
  133. #if(not os.path.exists(pdf_file) or
  134. # os.path.getmtime(invoice._path) > os.path.getmtime(pdf_file)):
  135. issuer = self.db.companies[self.my_company]
  136. customer = self.db.companies[invoice.company_name]
  137. invoice_data = invoice.data()
  138. issuer_data = issuer.data()
  139. customer_data = customer.data()
  140. log.debug("Invoice: {0}".format(invoice_data._data))
  141. log.debug("Issuer: {0}".format(issuer_data._data))
  142. log.debug("Customer: {0}".format(customer_data._data))
  143. log.debug("Creating TeX invoice...")
  144. self._check_path(self.tmp_path)
  145. result = tempita.Template(open(tex_template).read()).substitute(
  146. invoice=invoice_data, issuer=issuer_data, customer=customer_data)
  147. open(tex_file, "w").write(str(result))
  148. assert(os.path.exists(tex_file))
  149. log.debug("Creating PDF invoice...")
  150. if subprocess.call((self.tex_program, "{0}.tex".format(invoice._name)), cwd=tmp_path) != 0:
  151. raise GenerationError("PDF generation failed.")
  152. assert(os.path.exists(tmp_pdf_file))
  153. log.debug("Moving PDF file to the output directory...")
  154. self._check_path(output_path)
  155. os.rename(tmp_pdf_file, pdf_file)
  156. assert(os.path.exists(pdf_file))
  157. log.debug("Running PDF viewer...")
  158. subprocess.call((self.pdf_program, pdf_file))
  159. def _check_path(self, path):
  160. if not os.path.exists(path):
  161. raise LookupError("Directory doesn't exist: {0}".format(path))
  162. def do_delete(self, selector, force):
  163. """List invoices."""
  164. if selector:
  165. invoice = self.db.invoices[selector]
  166. else:
  167. invoice = self.db.invoices.last()
  168. if not force:
  169. raise SanityCheckError("It is not recommended to delete invoices.")
  170. invoice.delete()
  171. def do_list_companies(self):
  172. """List companies."""
  173. for item in sorted(self.db.companies):
  174. print(item)
  175. def do_new_company(self, name):
  176. """Create and edit a new company."""
  177. item = self.db.companies.new(name)
  178. self._edit(item._path)
  179. def do_edit_company(self, selector):
  180. """Edit company in external editor.
  181. The external editor is determined by EDITOR environment variable
  182. using 'vim' as the default. Item is edited in-place.
  183. """
  184. item = self.db.companies[selector]
  185. self._edit(item._path)
  186. def do_show_company(self, selector):
  187. """View company in external viewer.
  188. The external viewer is determined by PAGER environment variable
  189. using 'less' as the default.
  190. """
  191. item = self.db.companies[selector]
  192. self._show(item._path)
  193. def _show(self, path):
  194. log.debug("Viewing file: {0}".format(path))
  195. assert os.path.exists(path)
  196. subprocess.call((self.viewer, path))
  197. def do_delete_company(self, selector, force):
  198. """Delete a company."""
  199. company = self.db.companies[selector]
  200. if not force:
  201. invoices = self.db.invoices.select({"company_name": company._name})
  202. if invoices:
  203. for invoice in invoices:
  204. log.info("Dependent invoice: {0}".format(invoice))
  205. raise SanityCheckError("This company is used by some invoices. You should not delete it.")
  206. company.delete()