cotisations.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. #!/usr/bin/env python3
  2. # coding=utf-8
  3. import sys
  4. import os
  5. import time
  6. import odoorpc
  7. import arrow
  8. import logging
  9. import argparse
  10. from tabulate import tabulate
  11. import math
  12. SUBJECT = "FIXME Hackerspace Cotisations"
  13. BODY = """
  14. <html>
  15. <head>
  16. <meta http-equiv="content-type" content="text/html; charset=UTF-8">
  17. </head>
  18. <body>
  19. <pre>
  20. Chèr(e) membre,
  21. Tu trouveras ci-dessous le montant de ta cotisation. Ton association dépend
  22. entièrement du soutien de ses membres pour continuer à louer le local actuel,
  23. c'est pourquoi ton apport nous est précieux.
  24. Le montant de ta cotisation est celui que tu as choisi lors de ton inscription
  25. ou mis à jour par la suite. Il est possible de le modifier sur demande.
  26. Un grand merci pour ton soutien à FIXME !
  27. ======================
  28. A payer: CHF {a_payer}.-
  29. ======================
  30. Montant annuel de ta cotisation : CHF {dues}.-
  31. Montant reçu dans les 12 derniers mois : CHF {encaissees}.-
  32. Ton comité,
  33. P.S: Dans la mesure du possible, merci de payer en une fois dans les 30 jours
  34. ou d'instaurer un ordre permanent équivalent au montant mensuel.
  35. CCP: 12-621716-7
  36. IBAN: CH14 0900 0000 1262 1716 7
  37. BIC: POFICHBEXXX
  38. FIXME Hackerspace, 1004 Lausanne
  39. Commentaire: Prénom et nom si différent du compte bancaire utilisé pour le paiement
  40. Message generated by https://git.fixme.ch/FIXME/cotisations, patches welcome!
  41. </pre>
  42. </body>
  43. </html>
  44. """
  45. SUBJECT_COMITE = "Cotisations: merci de mettre a jour les extraits bancaires :)"
  46. BODY_COMITE = """
  47. <html>
  48. <head>
  49. <meta http-equiv="content-type" content="text/html; charset=UTF-8">
  50. </head>
  51. <body>
  52. <pre>
  53. Chèr(e) membre du comité,
  54. Les entrées d'argent des derniers mois n'ont pas été mise à jour sur Odoo.
  55. Dès que ce sera fait, ce script pourra envoyer les factures aux membres.
  56. Happy Hacking!
  57. Message generated by https://git.fixme.ch/FIXME/cotisations, patches welcome!
  58. </pre>
  59. </body>
  60. </html>
  61. """
  62. def get_members(odoo):
  63. partner = odoo.env["res.partner"]
  64. for p in partner.search_read([("x_status", "!=", False)]):
  65. yield (p)
  66. def get_cotisations_encaissees(p, odoo):
  67. line = odoo.env["account.move.line"]
  68. cotisations_encaissees = 0.0
  69. ecritures = []
  70. thriteen_months_ago = arrow.utcnow().shift(months=-13).format("YYYY-MM-DD")
  71. # Lists all cotisation transactions registered during the last 12 months
  72. for l in line.search_read(
  73. [
  74. ("partner_id", "=", p["id"]),
  75. ("account_id.name", "=", "Cotisations"),
  76. ("date", ">", thriteen_months_ago),
  77. ("credit", ">", 0),
  78. ]
  79. ):
  80. ecritures.append((l["date"], l["display_name"], str(l["credit"])))
  81. cotisations_encaissees += l["credit"]
  82. return cotisations_encaissees
  83. def send_mail(p, cotisations_encaissees, cotisations_dues, odoo):
  84. logging.info(
  85. "Preparing email to {}, cotisation: CHF {}, payé: CHF {}".format(
  86. p["email"], cotisations_dues, cotisations_encaissees
  87. )
  88. )
  89. mail_mail = odoo.env["mail.mail"]
  90. values = {
  91. "email_from": "comite@fixme.ch",
  92. "reply_to": "comite@fixme.ch",
  93. "subject": SUBJECT,
  94. "body_html": BODY.format(
  95. p=p,
  96. encaissees=math.ceil(cotisations_encaissees),
  97. a_payer=math.ceil(p["x_amount"] - cotisations_encaissees),
  98. dues=math.ceil(p["x_amount"]),
  99. ),
  100. "email_to": p["email"],
  101. }
  102. if not args.dryrun:
  103. msg_id = mail_mail.create(values)
  104. mail_mail.send([msg_id], raise_exception=True)
  105. logging.info("Message ID {} sent".format(msg_id))
  106. def send_mail_comite(odoo):
  107. logging.info("Preparing email for the comité")
  108. mail_mail = odoo.env["mail.mail"]
  109. values = {
  110. "email_from": "comite@fixme.ch",
  111. "reply_to": "comite@fixme.ch",
  112. "subject": SUBJECT_COMITE,
  113. "body_html": BODY_COMITE,
  114. "email_to": "comite@fixme.ch",
  115. }
  116. if not args.dryrun:
  117. msg_id = mail_mail.create(values)
  118. mail_mail.send([msg_id], raise_exception=True)
  119. logging.info("Message ID {} sent".format(msg_id))
  120. # Check if the last cotisations update is not too old
  121. def is_bank_statements_up_to_date(odoo):
  122. statement = odoo.env["account.bank.statement"]
  123. one_year_ago = arrow.utcnow().shift(years=-1).format("YYYY-MM-DD")
  124. statements = statement.search_read(
  125. [("state", "=", "confirm"), ("date", ">", one_year_ago)]
  126. )
  127. # If it is not up to date on the last 11 months
  128. if len(statements) < 11:
  129. logging.error(
  130. "Please ensure that bank statements for the last 12 months are up to date, found only {} < 11".format(
  131. len(statements)
  132. )
  133. )
  134. if args.dryrun:
  135. return True
  136. return False
  137. else:
  138. return True
  139. def get_last_run(odoo):
  140. config_parameter = odoo.env["ir.config_parameter"]
  141. params = config_parameter.search_read([("key", "=", "cotisations_last_run")])
  142. # If configuration setting doesn't yet exists, create a dummy one
  143. if params == []:
  144. last_run = arrow.get(0)
  145. config_parameter.create({"key": "cotisations_last_run", "value": "0"})
  146. elif len(params) == 1:
  147. param = params[0]
  148. last_run = arrow.get(int(param["value"]))
  149. else:
  150. raise Exception("Too many parameters found in database: {}".format(params))
  151. logging.info("Last run timestamp: {}".format(last_run))
  152. return last_run
  153. def set_last_run(odoo, timestamp):
  154. config_parameter = odoo.env["ir.config_parameter"]
  155. logging.info("New timestamp: {}".format(timestamp))
  156. params = config_parameter.search_read([("key", "=", "cotisations_last_run")])
  157. assert len(params) == 1
  158. config_parameter.write([params[0]["id"]], {"value": arrow.get().timestamp})
  159. def should_run(odoo):
  160. # Check if the last cotisations update is not too old
  161. # If it is too old, we should send an email to the comité
  162. if not is_bank_statements_up_to_date(odoo):
  163. logging.info("Bank statements are not up to date")
  164. send_mail_comite(odoo)
  165. return False
  166. # Check if the last time this script ran is old enough
  167. now = arrow.utcnow()
  168. if not args.force:
  169. last_run = get_last_run(odoo)
  170. # If the emails where send less than 4 months ago, we quit
  171. if now.shift(months=-4) < last_run:
  172. logging.info("Don't run now")
  173. return False
  174. # We finally run the script and save the timestamp in odoo database
  175. if not args.dryrun:
  176. set_last_run(odoo, now)
  177. return True
  178. def run(args):
  179. odoo = odoorpc.ODOO("odoo.fixme.ch", protocol="jsonrpc+ssl", port=443)
  180. odoo.login(os.environ["DATABASE"], os.environ["USERNAME"], os.environ["PASSWORD"])
  181. if not should_run(odoo):
  182. sys.exit(0)
  183. count = 0
  184. total_amount = 0
  185. up_to_date_table = []
  186. out_of_date_table = []
  187. for p in get_members(odoo):
  188. # On modifie les memberships, on modifie le script says JB le 6 mai 2016
  189. if p["x_status"] == "non_member":
  190. continue
  191. if p["x_status"] == "old_member":
  192. continue
  193. cotisations_encaissees = get_cotisations_encaissees(p, odoo)
  194. # If all cotisation transaction amount if lower than chosen membership, send out a reminder email
  195. if cotisations_encaissees < p["x_amount"]:
  196. out_of_date_table.append(
  197. (
  198. p["id"],
  199. p["display_name"],
  200. p["x_status"],
  201. p["x_amount"],
  202. cotisations_encaissees,
  203. p["email"],
  204. )
  205. )
  206. send_mail(p, cotisations_encaissees, p["x_amount"], odoo)
  207. total_amount += p["x_amount"] - cotisations_encaissees
  208. count += 1
  209. else:
  210. up_to_date_table.append(
  211. (
  212. p["id"],
  213. p["display_name"],
  214. p["x_status"],
  215. p["x_amount"],
  216. cotisations_encaissees,
  217. p["email"],
  218. )
  219. )
  220. print()
  221. print(
  222. "Total payé: {}".format(
  223. sum([a[4] for a in up_to_date_table + out_of_date_table])
  224. )
  225. )
  226. print(
  227. "Total cotisations: {}".format(
  228. sum([a[3] for a in up_to_date_table + out_of_date_table])
  229. )
  230. )
  231. print()
  232. print(
  233. "Up to date: {} (cotisations: CHF {}, payé: CHF {})".format(
  234. len(up_to_date_table),
  235. sum([a[3] for a in up_to_date_table]),
  236. sum([a[4] for a in up_to_date_table]),
  237. )
  238. )
  239. print(tabulate(up_to_date_table))
  240. print()
  241. print(
  242. "Out to date: {} (cotisations: CHF {}, payé: CHF {})".format(
  243. len(out_of_date_table),
  244. sum([a[3] for a in out_of_date_table]),
  245. sum([a[4] for a in out_of_date_table]),
  246. )
  247. )
  248. print(tabulate(out_of_date_table))
  249. print()
  250. if __name__ == "__main__":
  251. logging.basicConfig(level=logging.INFO)
  252. parser = argparse.ArgumentParser(
  253. formatter_class=argparse.RawDescriptionHelpFormatter,
  254. description="FIXME Cotisations",
  255. )
  256. parser.add_argument(
  257. "--dryrun", action="store_true", help="Don't actually send emails"
  258. )
  259. parser.add_argument(
  260. "--debug", action="store_true", help="Show all logging messages"
  261. )
  262. parser.add_argument(
  263. "--force",
  264. action="store_true",
  265. help="Force run the script even if minimum time between run has been yet been reached.",
  266. )
  267. args = parser.parse_args()
  268. if args.dryrun == True:
  269. logging.info("DRYRUN MODE")
  270. if args.debug == True:
  271. logging.getLogger().setLevel(logging.DEBUG)
  272. run(args)