Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions rechnung/cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import click
import os
import sys

from .settings import get_settings_from_cwd, copy_assets, create_required_settings_file
from .invoice import create_invoices, render_invoices, send_invoices
from .contract import create_contracts, render_contracts, send_contract, get_contracts
from .contract import render_contracts, send_contract, get_contracts

cwd = os.getcwd()

Expand Down
40 changes: 11 additions & 29 deletions rechnung/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@
from collections import OrderedDict

from pathlib import Path
from .helpers import (
generate_pdf,
get_pdf,
get_template,
generate_email_with_pdf_attachment,
generate_email_with_pdf_attachments,
send_email,
)
from .helpers import generate_pdf, get_template, generate_email, send_email


def get_contracts(settings, year=None, month=None, inactive=False):
Expand Down Expand Up @@ -80,41 +73,30 @@ def send_contract(settings, cid):

contract_pdf_filename = f"{settings.company} {contract_yaml_filename.stem}.pdf"
contract_mail_text = mail_template.render()
contract_pdf = get_pdf(contract_pdf_path)

pdf_documents = [contract_pdf]
pdf_filenames = [contract_pdf_filename]
attachments = [(contract_pdf_path, contract_pdf_filename)]

for item in contract_data["items"]:
item_pdf_file = f"{item['description']}.pdf"
if not item_pdf_file in pdf_filenames:
item_pdf_path = Path(settings.assets_dir / item_pdf_file)
if item_pdf_path.is_file():
item_pdf = get_pdf(item_pdf_path)
pdf_documents.append(item_pdf)
pdf_filenames.append(item_pdf_file)
else:
print(f"Item file {item_pdf_file} not found")
item_pdf_path = Path(settings.assets_dir / item_pdf_file)
if item_pdf_path.is_file():
attachments.append((item_pdf_path, item_pdf_file))
else:
print(f"Item file {item_pdf_file} not found")

if settings.policy_attachment_asset_file:
policy_pdf_file = settings.policy_attachment_asset_file
policy_pdf_path = settings.assets_dir / policy_pdf_file
if policy_pdf_path.is_file():
policy_pdf = get_pdf(policy_pdf_path)
pdf_documents.append(policy_pdf)
pdf_filenames.append(policy_pdf_file)
attachments.append((policy_pdf_path, policy_pdf_file))
else:
print(
f"Policy file {settings.policy_attachment_asset_file.name} not found"
)
print(f"Missing {settings.policy_attachment_asset_file.name}")

contract_email = generate_email_with_pdf_attachments(
contract_email = generate_email(
contract_data["email"],
settings.sender,
settings.contract_mail_subject,
contract_mail_text,
pdf_documents,
pdf_filenames,
attachments,
)

print("Sending contract {}".format(contract_data["cid"]))
Expand Down
116 changes: 43 additions & 73 deletions rechnung/helpers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import yaml
import smtplib
import ssl
import yaml

from email.header import Header
from email.message import EmailMessage
import mimetypes
from jinja2 import Template
from weasyprint import HTML, CSS
from weasyprint.fonts import FontConfiguration
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.utils import formatdate


def get_template(template_filename):
Expand All @@ -23,6 +21,7 @@ def get_template(template_filename):
Returns:
Template: jinja2 Template instance.
"""

with open(template_filename) as template_file:
return Template(template_file.read())

Expand All @@ -34,86 +33,57 @@ def send_email(msg, server, username, password, insecure=True):
Args:
msg (email.MIMEMultipart): The email to be sent.
"""
conn = smtplib.SMTP(server, 587)
context = ssl.create_default_context() if not insecure else None
conn.starttls(context=context)
conn.login(username, password)
conn.send_message(msg)
conn.quit()


def generate_email_with_pdf_attachments(
mail_to, mail_from, mail_subject, mail_text, pdf_documents, attachment_filenames
):
if not len(pdf_documents) == len(attachment_filenames):
raise ValueError(
"pdf_documents and attachment_filenames must be of same length."
)

msg = MIMEMultipart()
msg["Subject"] = mail_subject
msg["From"] = mail_from
msg["To"] = mail_to
msg["Date"] = formatdate(localtime=True)
msg.attach(MIMEText(mail_text, "plain"))

for document, filename in zip(pdf_documents, attachment_filenames):
payload = MIMEBase("application", "pdf")
payload.set_payload(document)
payload.add_header("Content-Disposition", "attachment", filename=filename)

encoders.encode_base64(payload)

msg.attach(payload)

return msg


def generate_email_with_pdf_attachment(
mail_to, mail_from, mail_subject, mail_text, pdf_document, attachment_filename
try:
conn = smtplib.SMTP(server, 587)
context = ssl.create_default_context() if not insecure else None
conn.starttls(context=context)
conn.login(username, password)
conn.send_message(msg)
conn.quit()
return True
except Exception as e:
print(e)
quit(1)


def generate_email(
settings, mail_to: str, mail_subject: str, mail_text: str, files=None
):
"""
Sends the invoice to the recipient.
Generate EmailMessage

Args:
invoice_mail_text (str): Text for the email body
invoice_pdf (bytes): Invoice PDF
invoice_data (dict): Invoice Metadata object
settings
mail_to: receiver mail address
mail_subject: mail subject
mail_text: mail text
files (touple): list of file_path and file_name

Returns:
email.MIMEMultipart: Invoice email message object
email.EmailMessage

"""

msg = MIMEMultipart()
msg = EmailMessage()
msg["To"] = Header(mail_to, "utf-8")
msg["Subject"] = mail_subject
msg["From"] = mail_from
msg["To"] = mail_to
msg["Date"] = formatdate(localtime=True)
msg.attach(MIMEText(mail_text, "plain"))

payload = MIMEBase("application", "pdf")
payload.set_payload(pdf_document)
payload.add_header(
"Content-Disposition", "attachment", filename=attachment_filename
)

encoders.encode_base64(payload)
msg["From"] = settings.sender

msg.attach(payload)

return msg
msg.set_content(mail_text)

for file_path, file_name in files:
ctype, encoding = mimetypes.guess_type(file_path)
if ctype is None or encoding is not None:
ctype = "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
with open(file_path, "rb") as fp:
msg.add_attachment(
fp.read(), maintype=maintype, subtype=subtype, filename=file_name
)

def get_pdf(filename):
"""
Reads a pdf file and returns its contents.
with open("outgoing.msg", "wb") as email_file:
email_file.write(bytes(msg))

Args:
invoice_path (str) full path to the invoice file.
"""
with open(filename, "rb") as infile:
return infile.read()
return msg


def generate_pdf(html_data, css_data, path):
Expand Down
51 changes: 23 additions & 28 deletions rechnung/invoice.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
import datetime
import locale
from pathlib import Path
import yaml

from .settings import get_settings_from_cwd
from .contract import get_contracts
from .helpers import (
generate_pdf,
get_pdf,
get_template,
generate_email_with_pdf_attachment,
send_email,
)
from .helpers import generate_pdf, get_template, generate_email, send_email


def fill_invoice_items(settings, items):
Expand Down Expand Up @@ -77,15 +69,14 @@ def iterate_invoices(settings):

def render_invoices(settings):
template = get_template(settings.invoice_template_file)
logo_path = settings.assets_dir / settings.logo_file

for contract_invoice_dir, filename in iterate_invoices(settings):
invoice_pdf_filename = filename.with_suffix(".pdf")
if not invoice_pdf_filename.is_file():
with open(filename) as yaml_file:
invoice_data = yaml.safe_load(yaml_file.read())
invoice_data["logo_path"] = logo_path
invoice_data["company"] = settings.company
invoice_data.update(settings._asdict())
print(invoice_data)

print(f"Rendering invoice pdf for {invoice_data['id']}")

Expand Down Expand Up @@ -133,43 +124,47 @@ def create_yaml_invoices(settings, contracts, year, month):
save_invoice_yaml(settings, invoice_data)


def send_invoices(settings, year, month):
def send_invoices(settings, year, month, force_resend=False):
mail_template = get_template(settings.invoice_mail_template_file)

for d in settings.invoices_dir.iterdir():
customer_invoice_dir = settings.invoices_dir / d
if customer_invoice_dir.iterdir():
for filename in customer_invoice_dir.glob("*.yaml"):
file_suffix = ".".join(filename.split(".")[-3:-1])

if file_suffix != f"{year}.{month:02}":
if not filename.name.endswith(f"{year}.{month:02}.yaml"):
continue

with open(customer_invoice_dir / filename) as yaml_file:
invoice_data = yaml.safe_load(yaml_file)

# don't send invoices multiple times
if invoice_data.get("sent") and not force_resend:
print(f"Skip previously sent invoice {invoice_data['id']}")
continue

invoice_pdf_filename = (
f"{settings.company} {filename.with_suffix('.pdf').name}"
)
invoice_pdf_path = customer_invoice_dir / filename.with_suffix(".pdf")
invoice_pdf_filename = f"{settings.company} {filename[:-5]}.pdf"
invoice_mail_text = mail_template.render(invoice=invoice_data)
invoice_pdf = get_pdf(invoice_pdf_path)

invoice_receiver = invoice_data["email"]

invoice_email = generate_email_with_pdf_attachment(
invoice_receiver,
settings.sender,
settings.invoice_mail_subject,
invoice_email = generate_email(
settings,
invoice_data["email"],
f"{settings.invoice_mail_subject} {invoice_data['id']}",
invoice_mail_text,
invoice_pdf,
invoice_pdf_filename,
[(invoice_pdf_path, invoice_pdf_filename)],
)

print(f"Sending invoice {invoice_data['id']}")

send_email(
if send_email(
invoice_email,
settings.server,
settings.username,
settings.password,
settings.insecure,
)
):
with open(customer_invoice_dir / filename, "w") as yaml_file:
invoice_data["sent"] = True
yaml_file.write(yaml.dump(invoice_data))
12 changes: 7 additions & 5 deletions rechnung/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@
# in the settings.yaml
required_settings = [
"company",
"company_address",
"company_bank",
"contract_mail_subject",
"insecure",
"invoice_mail_subject",
"locale",
"password",
"sender",
Expand All @@ -30,6 +31,7 @@
"vat",
]
optional_settings = {
"invoice_mail_subject": "Rechnung",
"assets_dir": "assets",
"contract_css_asset_file": "contract.css",
"contract_mail_template_file": "contract_mail_template.j2",
Expand All @@ -40,7 +42,7 @@
"invoice_mail_template_file": "invoice_mail_template.j2",
"invoice_template_file": "invoice_template.j2.html",
"invoices_dir": "invoices",
"logo_file": "logo.svg",
"logo_asset_file": "logo.svg",
"policy_attachment_asset_file": "policy.pdf",
}
possible_settings = set(required_settings + list(optional_settings.keys()))
Expand Down Expand Up @@ -90,7 +92,7 @@ def get_settings_from_file(
Opens a settings.yaml and returns its contents as
a namedtuple "Settings". It checks if all required
settings are found in the settings file, as well
if there are any unknown settings given.
if there are any unknown settings given.
Finally the base_path is prepended to all settings
ending with "_file" or "_dir".
"""
Expand Down Expand Up @@ -135,8 +137,8 @@ def get_settings_from_file(

def copy_assets(target_dir, orig_dir=od):
"""
Copy the original assets, which are shipped with the tool,
from the original directory (where the tool is installed)
Copy the original assets, which are shipped with the tool,
from the original directory (where the tool is installed)
to the cwd (where the data is stored).
"""
for asset in orig_dir.glob("*"):
Expand Down