vereinsgwrschtl/tryton-scripts/mailings.py

296 lines
13 KiB
Python
Raw Normal View History

2024-10-03 17:08:59 -05:00
#!/bin/env python3
import argparse
import csv
import datetime
import decimal
import email.message
2024-10-04 06:57:49 -05:00
import functools
2024-10-03 17:08:59 -05:00
import getpass
2024-10-04 06:57:49 -05:00
import operator
2024-10-03 17:08:59 -05:00
import pathlib
import subprocess
import tempfile
2024-10-09 15:44:57 -05:00
import tomllib
2024-10-03 17:08:59 -05:00
2024-10-04 06:57:49 -05:00
import babel.numbers
import dateutil.relativedelta
2024-10-03 17:08:59 -05:00
import jinja2
import triad.client
2024-10-04 06:57:49 -05:00
def strip_iban(iban):
iban = [c for c in iban if not c.isspace()]
return "".join(iban[0:3] + ["X" for c in iban[3:-3]] + iban[-3:])
def format_currency(amount, currency='EUR', locale='de_DE'):
return babel.numbers.format_currency(amount, currency, locale=locale)
def format_quantity(amount, locale='de_DE'):
return babel.numbers.format_decimal(amount, locale=locale)
2024-10-03 17:08:59 -05:00
class Env(object):
jinja2_env: jinja2.Environment
args: argparse.Namespace
session: triad.client.Session
2024-10-04 06:57:49 -05:00
def deduce_payments(client, invoice):
total_amount = invoice.total_amount
remainder = total_amount
for line in invoice.payment_term.lines:
if line.type == 'fixed':
amount = line.amount
elif line.type == 'percent':
amount = remainder * line.ratio
elif line.type =='percent_on_total':
amount = total_amount * line.ratio
elif line.type == 'remainder':
amount = remainder
else:
continue
relativedeltas = (
dateutil.relativedelta.relativedelta(
day=relativedelta.day,
month=int(relativedelta.month.index) if relativedelta.month else None,
days=relativedelta.days,
weeks=relativedelta.weeks,
months=relativedelta.months,
weekday=int(relativedelta.weekday.index) if relativedelta.weekday else None
) for relativedelta in line.relativedeltas
)
date = functools.reduce(operator.add, relativedeltas, invoice.payment_term_date)
remainder -= amount
yield (date, amount)
2024-10-03 17:08:59 -05:00
def fee_invoice(env: Env):
csvreader = csv.DictReader(env.args.csvfile, fieldnames=env.args.csv_fields)
2024-10-04 06:57:49 -05:00
csvwriter = csv.DictWriter(env.args.output, fieldnames=csvreader.fieldnames + (["message_id"] if 'message_id' not in csvreader.fieldnames else []))
2024-10-03 17:08:59 -05:00
csvwriter.writeheader()
invoicing_client = triad.client.InvoicingClient.from_session(env.session)
template = env.jinja2_env.get_template("beitrag.eml")
for member in csvreader:
2024-10-09 15:44:57 -05:00
try:
invoice = invoicing_client.Invoice.find([(env.args.lookup_key, "=", str(member['invoice_id'])), ("type", "=", "out")])[0]
except IndexError:
continue
2024-10-03 17:08:59 -05:00
year = invoice.invoice_date or env.args.year
months = [datetime.date(year.year, month, 1) for month in range(1, 13)]
email_address = member.get('email') or invoice.party.email
try:
sepa_mandate = invoice.party.reception_direct_debits[0]
except IndexError:
sepa_mandate = None
else:
sepa_mandate = sepa_mandate.sepa_mandate
2024-10-04 06:57:49 -05:00
if invoice.state in {"draft", "validated"} and sepa_mandate is not None:
payments = list(deduce_payments(invoicing_client, invoice))
else:
payments = [(move.maturity_date, move.amount) for move in invoice.lines_to_pay if not move.reconciliation and move.payable_receivable_balance != 0]
2024-10-03 17:08:59 -05:00
template_args = {
'monthly_fee': decimal.Decimal(member.get('monthly_fee') or '0.0'),
"yearly_fee": decimal.Decimal(member.get('yearly_fee') or '0.0'),
'invoice': invoice,
2024-10-04 06:57:49 -05:00
"payments": payments,
2024-10-03 17:08:59 -05:00
'sepa_mandate': sepa_mandate,
2024-10-04 06:57:49 -05:00
'payments': payments,
2024-10-03 17:08:59 -05:00
'sender': env.args.email_sender,
"year": year,
"fee_months": [datetime.date(year.year, month, 1) for month in range(1, 13)]
}
2024-10-04 06:57:49 -05:00
member.setdefault('message_id', email.utils.make_msgid(domain='verein.ccchb.de'))
2024-10-03 17:08:59 -05:00
msg = email.message.EmailMessage()
msg["From"] = env.args.email_from
msg["To"] = email_address
msg["Date"] = env.args.email_date or datetime.datetime.now()
msg["Subject"] = f"Beitragsrechnung {year.year} ({invoice.number})" if invoice.number else f"Vorläufige Beitragsrechnung {year.year}"
2024-10-04 06:57:49 -05:00
msg["Message-Id"] = member['message_id']
2024-10-03 17:08:59 -05:00
msg.set_content(template.render(**template_args))
with (env.args.output_dir / member['message_id']).open("wb") as mailfile:
mailfile.write(bytes(msg))
csvwriter.writerow(member)
def sepa_mandate(env: Env):
csvreader = csv.DictReader(env.args.csvfile, fieldnames=env.args.csv_fields)
csvwriter = csv.DictWriter(env.args.output, fieldnames=csvreader.fieldnames + ["message_id"])
csvwriter.writeheader()
client = triad.client.BaseClient.from_session(env.session)
template = env.jinja2_env.get_template("sepa_mandat.eml")
for mandate in csvreader:
email_address= mandate.get('email')
if 'sepa_mandate_ref' in mandate:
sepa_mandate = client.get_model('account.payment.sepa.mandate').find([('identification', '=', mandate['sepa_mandate_ref'])])
party = sepa_mandate.party
elif 'party_id' in mandate:
party = client.get_model('party.party').find([('code', '=', mandate['party_id'])])[0]
email_address = email_address or party.email
sepa_mandate = party.reception_direct_debits[0].sepa_mandate
else:
continue
template_args = {
"party": party,
"sepa_mandate": sepa_mandate
}
msg = email.message.EmailMessage()
msg["From"] = env.args.email_from
msg["To"] = email_address
msg["Date"] = env.args.email_date or datetime.datetime.now()
msg["Subject"] = f"SEPA-Lastschriftmandat MREF: {sepa_mandate.identification}"
msg["Message-Id"] = email.utils.make_msgid(domain="verein.ccchb.de")
mandate['message_id'] = msg["Message-Id"]
msg.set_content(template.render(**template_args))
if env.args.pdf:
msg.add_attachment(sepa_mandate_build_pdf(env, sepa_mandate), 'application', 'pdf', filename='SEPA-Lastschriftmandat.pdf')
with (env.args.output_dir / mandate['message_id']).open("wb") as mailfile:
mailfile.write(bytes(msg))
csvwriter.writerow(mandate)
2024-12-10 11:45:25 -06:00
def newsletter(env: Env):
csvreader = csv.DictReader(env.args.csvfile, fieldnames=env.args.csv_fields)
csvwriter = csv.DictWriter(env.args.output, fieldnames=csvreader.fieldnames + ["message_id"])
csvwriter.writeheader()
template = env.jinja2_env.get_template(env.args.template)
for member in csvreader:
email_address = member.get('email')
if not email_address:
continue
msg = email.message.EmailMessage()
msg["From"] = env.args.email_from
msg["To"] = email_address
msg["Date"] = env.args.email_date or datetime.datetime.now()
msg["Subject"] = env.args.subject
msg["Message-Id"] = member.get("message_id") or email.utils.make_msgid(domain="verein.ccchb.de")
member['message_id'] = msg["Message-Id"]
template_args = {
"name": member.get("name"),
"email": email_address,
"direct_debit": member.get("sepa_mandate"),
"fee_old": decimal.Decimal(member.get("monthly_fee") or '0.0'),
"member": member,
}
msg.set_content(template.render(**template_args))
with (env.args.output_dir / member['message_id']).open("wb") as mailfile:
mailfile.write(bytes(msg))
csvwriter.writerow(member)
2024-10-03 17:08:59 -05:00
def sepa_mandate_build_pdf(env, sepa_mandate):
with tempfile.TemporaryDirectory() as tempdir:
tempdir_path = pathlib.Path(tempdir)
jobname = sepa_mandate.identification
with (tempdir_path / (jobname + "-settings.tex")).open("w") as fh:
fh.write(f'''
\\csdef{{sepa-m001}}{{{sepa_mandate.identification}}}
\\csdef{{sepa-m006}}{{Wiederkehrende Zahlung}}
\\csdef{{sepa-p001}}{{{sepa_mandate.party.name}}}
\\csdef{{sepa-d001}}{{{sepa_mandate.account_number.number}}}
\\csdef{{sepa-m008}}{{{sepa_mandate.signature_date}}}
\\csdef{{sepa-m009}}{{\\emph{{entfällt}}}}
\\csdef{{sepa-d002a}}{{}}
\\csdef{{sepa-d002b}}{{}}
\\csdef{{sepa-p005}}{{}}
\\csdef{{sepa-remarks}}{{Dieses Mandat ist eine Abschrift des dem Zahlungsempfänger vorliegenden, originalen SEPA-Lastschriftmandats.\\\\
Erstellt am {datetime.date.today()}}}
''')
compile_latex(jobname, env.args.latex_template, tempdir)
with (tempdir_path / (jobname + ".pdf")).open("rb") as fh:
return fh.read()
def compile_latex(jobname, texfile, directory, debug=True):
latexmk_args = ["latexmk", "-lualatex", str(texfile), f"-jobname={jobname}"]
if not debug:
latexmk_args.append("-silent")
subprocess.run(latexmk_args, cwd=directory)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-U", "--uri", help="Tryton URI")
parser.add_argument("-u", "--username", help="Tryton username")
parser.add_argument("-p", "--password", help="Tryton password")
parser.add_argument("-O", "--output-dir", help="email output directory", default=".", type=pathlib.Path)
parser.add_argument("-T", "--template-dir", help="email template directory", default="mail_templates")
2024-10-09 15:44:57 -05:00
parser.add_argument("-c", "--config", type=argparse.FileType("rb"))
2024-10-03 17:08:59 -05:00
# Email arguments
parser.add_argument('--email-sender', help='email signature sender')
parser.add_argument("--email-from", help="email from header")
parser.add_argument("--email-date", help="email date header")
2024-12-10 11:45:25 -06:00
2024-10-03 17:08:59 -05:00
subparsers = parser.add_subparsers()
fee_invoice_parser = subparsers.add_parser("fee-invoice")
fee_invoice_parser.add_argument("-o", "--output", type=argparse.FileType("w"), default='-')
fee_invoice_parser.add_argument("-F", "--csv-fields", type=lambda f: f.split(","), help="csv fields")
fee_invoice_parser.add_argument("-y", "--year", type=lambda y: datetime.date(int(y), 1, 1))
fee_invoice_parser.add_argument("--months", help="Beitragsmonate", type=lambda m: m.split(","), default="1,2,3,4,5,6,7,8,9,10,11,12")
2024-10-09 15:44:57 -05:00
fee_invoice_parser.add_argument("-k", "--lookup-key", choices={'number', 'id'}, default='id', help='lookup key for invoice')
2024-10-03 17:08:59 -05:00
fee_invoice_parser.add_argument("csvfile", type=argparse.FileType("r"), default="-", nargs="?")
fee_invoice_parser.set_defaults(func=fee_invoice)
2024-12-10 11:45:25 -06:00
fee_invoice_parser.set_defaults(requires_tryton=True)
2024-10-03 17:08:59 -05:00
sepa_mandate_parser = subparsers.add_parser('sepa-mandate')
sepa_mandate_parser.add_argument('-o', '--output', type=argparse.FileType("w"), default='-')
sepa_mandate_parser.add_argument('-F', '--csv-fields', type=lambda f: f.split(','), help='csv fields')
sepa_mandate_parser.add_argument('--pdf', action='store_true', help='build pdf attachment', default=False)
sepa_mandate_parser.add_argument('--no-pdf', action='store_false', dest='pdf', help='don\'t build pdf attachment')
sepa_mandate_parser.add_argument('--latex-template', help='latex template in $TEXINPUTS')
sepa_mandate_parser.add_argument('csvfile', type=argparse.FileType('r'), default='-', nargs='?')
sepa_mandate_parser.set_defaults(func=sepa_mandate)
2024-12-10 11:45:25 -06:00
sepa_mandate_parser.set_defaults(requires_tryton=True)
newsletter_parser = subparsers.add_parser('newsletter')
newsletter_parser.add_argument('-o', '--output', type=argparse.FileType("w"), default='-')
newsletter_parser.add_argument('-F', '--csv_fields', type=lambda f: f.split(','), help='csv fields')
newsletter_parser.add_argument("-s", "--subject", default="")
newsletter_parser.add_argument('template')
newsletter_parser.add_argument('csvfile', type=argparse.FileType('r'), default='-', nargs='?')
newsletter_parser.set_defaults(func=newsletter)
newsletter_parser.set_defaults(requires_tryton=False)
2024-10-03 17:08:59 -05:00
args = parser.parse_args()
env = Env()
env.jinja2_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(args.template_dir)
)
2024-10-04 06:57:49 -05:00
env.jinja2_env.filters["strip_iban"] = strip_iban
env.jinja2_env.filters["format_currency"] = format_currency
env.jinja2_env.filters["format_quantity"] = format_quantity
2024-10-03 17:08:59 -05:00
env.args = args
2024-10-09 15:44:57 -05:00
if args.config:
env.config = tomllib.load(args.config)
args.config.close()
if "tryton" in env.config:
if "uri" in env.config["tryton"]:
env.args.uri = env.config["tryton"]["uri"]
if "username" in env.config["tryton"]:
env.args.username = env.config["tryton"]["username"]
if "password" in env.config["tryton"]:
env.args.password = env.config["tryton"]["password"]
if "email" in env.config:
if "sender" in env.config["email"]:
env.args.email_sender = env.config["email"]["sender"]
if "from" in env.config["email"]:
env.args.email_from = env.config["email"]["from"]
2024-12-10 11:45:25 -06:00
if not args.uri and args.requires_tryton:
2024-10-03 17:08:59 -05:00
args.uri = input("URI for Tryton: ")
2024-12-10 11:45:25 -06:00
if not args.username and args.requires_tryton:
2024-10-03 17:08:59 -05:00
args.username = input("Username for Tryton: ")
2024-12-10 11:45:25 -06:00
if not args.password and args.requires_tryton:
2024-10-03 17:08:59 -05:00
args.password = getpass.getpass(f"Password for Tryton user `{args.username}': ")
2024-12-10 11:45:25 -06:00
if args.requires_tryton:
with triad.client.Session.start(args.uri, args.username, args.password) as session:
env.session = session
args.func(env)
else:
2024-10-03 17:08:59 -05:00
args.func(env)
2024-12-10 11:45:25 -06:00
if __name__ == '__main__':
main()