25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

245 lines
9.3KB

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