Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

287 linhas
11KB

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