Skip to content

Commit bd66964

Browse files
committed
compose an email with an invoice on macOS
1 parent 8a4d31d commit bd66964

File tree

11 files changed

+252
-25
lines changed

11 files changed

+252
-25
lines changed

Diff for: app/components/view_user.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ def demo_user():
3232
phone_number="+55555555555",
3333
VAT_number="27B-6",
3434
address=Address(
35-
name="Harry Tuttle",
35+
first_name="Harry",
36+
last_name="Tuttle",
3637
street="Main Street",
3738
number="450",
3839
city="Sao Paolo",

Diff for: app/views.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -401,17 +401,24 @@ def make_invoice_view(
401401
trailing=PopupMenuButton(
402402
icon=icons.MORE_VERT,
403403
items=[
404+
# PopupMenuItem(
405+
# icon=icons.VISIBILITY,
406+
# text="View",
407+
# on_click=lambda _: app_page.app.con.quicklook_invoice(
408+
# invoice
409+
# ),
410+
# ),
404411
PopupMenuItem(
405-
icon=icons.VISIBILITY,
406-
text="View",
407-
on_click=lambda _: app_page.app.con.quicklook_invoice(
412+
icon=icons.PREVIEW,
413+
text="Open",
414+
on_click=lambda _: app_page.app.con.open_invoice(
408415
invoice
409416
),
410417
),
411418
PopupMenuItem(
412-
icon=icons.PREVIEW,
413-
text="Open",
414-
on_click=lambda _: app_page.app.con.open_invoice(
419+
icon=icons.OUTGOING_MAIL,
420+
text="Send",
421+
on_click=lambda _: app_page.app.con.send_invoice(
415422
invoice
416423
),
417424
),

Diff for: notebooks/test-compose-email.ipynb

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": 1,
6+
"metadata": {},
7+
"outputs": [],
8+
"source": [
9+
"from tuttle import os_functions"
10+
]
11+
},
12+
{
13+
"cell_type": "code",
14+
"execution_count": 2,
15+
"metadata": {},
16+
"outputs": [
17+
{
18+
"name": "stderr",
19+
"output_type": "stream",
20+
"text": [
21+
"message_attachment = 0\n"
22+
]
23+
}
24+
],
25+
"source": [
26+
"os_functions.compose_email_in_app(\n",
27+
" recipient=\"[email protected]\",\n",
28+
" subject=\"Test\",\n",
29+
" body=\"Test\",\n",
30+
" attachment_path=\"/Users/cls/Stack/monalisa.jpeg\"\n",
31+
")"
32+
]
33+
},
34+
{
35+
"cell_type": "code",
36+
"execution_count": null,
37+
"metadata": {},
38+
"outputs": [],
39+
"source": []
40+
}
41+
],
42+
"metadata": {
43+
"kernelspec": {
44+
"display_name": "Python 3.10.5 ('tuttle-flet')",
45+
"language": "python",
46+
"name": "python3"
47+
},
48+
"language_info": {
49+
"codemirror_mode": {
50+
"name": "ipython",
51+
"version": 3
52+
},
53+
"file_extension": ".py",
54+
"mimetype": "text/x-python",
55+
"name": "python",
56+
"nbconvert_exporter": "python",
57+
"pygments_lexer": "ipython3",
58+
"version": "3.10.5"
59+
},
60+
"orig_nbformat": 4,
61+
"vscode": {
62+
"interpreter": {
63+
"hash": "5727a89f8f4826e6ce3fb9da18a08727b274ba76880924157270e222bba4a1ac"
64+
}
65+
}
66+
},
67+
"nbformat": 4,
68+
"nbformat_minor": 2
69+
}

Diff for: tuttle/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
time,
1818
rendering,
1919
view,
20+
os_functions,
2021
)
2122

2223

Diff for: tuttle/controller.py

+51-5
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,23 @@
1313

1414
from loguru import logger
1515

16-
from . import model, timetracking, dataviz, rendering, invoicing, calendar, cloud
16+
from . import (
17+
model,
18+
timetracking,
19+
dataviz,
20+
rendering,
21+
invoicing,
22+
calendar,
23+
cloud,
24+
os_functions,
25+
)
1726
from .preferences import Preferences
27+
from .model import (
28+
User,
29+
Project,
30+
Contract,
31+
Invoice,
32+
)
1833

1934

2035
class Controller:
@@ -296,13 +311,16 @@ def billing(
296311
# finally store invoice
297312
self.store(invoice)
298313

299-
def open_invoice(self, invoice):
314+
def open_invoice(
315+
self,
316+
invoice: Invoice,
317+
):
300318
"""Open an invoice in the default application for PDF files"""
301319
invoice_file_path = (
302320
self.home
303321
/ self.preferences.invoice_dir
304322
/ Path(invoice.prefix)
305-
/ Path(f"{invoice.prefix}.pdf")
323+
/ Path(invoice.file_name)
306324
)
307325
if invoice_file_path.exists():
308326
if platform.system() == "Darwin": # macOS
@@ -315,13 +333,16 @@ def open_invoice(self, invoice):
315333
else:
316334
logger.error(f"invoice file {invoice_file_path} not found")
317335

318-
def quicklook_invoice(self, invoice):
336+
def quicklook_invoice(
337+
self,
338+
invoice: Invoice,
339+
):
319340
"""Open an invoice in the preview application for PDF files"""
320341
invoice_file_path = (
321342
self.home
322343
/ self.preferences.invoice_dir
323344
/ Path(invoice.prefix)
324-
/ Path(f"{invoice.prefix}.pdf")
345+
/ Path(invoice.file_name)
325346
)
326347
if invoice_file_path.exists():
327348
if platform.system() == "Darwin": # macOS
@@ -330,3 +351,28 @@ def quicklook_invoice(self, invoice):
330351
logger.error(f"quicklook not supported on {platform.system()}")
331352
else:
332353
logger.error(f"invoice file {invoice_file_path} not found")
354+
355+
def send_invoice(self, invoice: Invoice):
356+
"""Compose an email to the client with the invoice attached"""
357+
invoice_file_path = (
358+
self.home
359+
/ self.preferences.invoice_dir
360+
/ Path(invoice.prefix)
361+
/ Path(invoice.file_name)
362+
)
363+
if invoice_file_path.exists():
364+
if platform.system() == "Darwin":
365+
email = invoicing.generate_invoice_email(
366+
invoice,
367+
self.user,
368+
)
369+
os_functions.compose_email(
370+
recipient=email["recipient"],
371+
subject=email["subject"],
372+
body=email["body"],
373+
attachment_path=invoice_file_path,
374+
)
375+
else:
376+
logger.error(f"emailing not yet supported on {platform.system()}")
377+
else:
378+
logger.error(f"invoice file {invoice_file_path} not found")

Diff for: tuttle/invoicing.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Invoicing."""
22

3-
from typing import List
3+
from typing import List, Optional, Dict
44
import datetime
55
from pathlib import Path
66
import shutil
@@ -53,3 +53,25 @@ def send_invoice(
5353
body=None, #
5454
attachments=None, # TODO: inovice as PDF
5555
)
56+
57+
58+
def generate_invoice_email(
59+
invoice: Invoice,
60+
user: User,
61+
) -> Dict:
62+
"""Generate an email with the invoice attached."""
63+
body = f"""
64+
Dear {invoice.client.invoicing_contact.first_name}
65+
66+
Please find attached the invoice number {invoice.number}.
67+
68+
Best regards
69+
{user.name}
70+
"""
71+
72+
email = {
73+
"subject": f"Invoice {invoice.number}",
74+
"body": body,
75+
"recipient": invoice.client.invoicing_contact.email,
76+
}
77+
return email

Diff for: tuttle/model.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ class Contact(SQLModel, table=True):
155155
"""An entry in the address book."""
156156

157157
id: Optional[int] = Field(default=None, primary_key=True)
158-
name: str
158+
first_name: str
159+
last_name: str
159160
company: Optional[str]
160161
email: Optional[str]
161162
address_id: Optional[int] = Field(default=None, foreign_key="address.id")
@@ -383,6 +384,11 @@ def prefix(self):
383384
prefix = f"{self.number}-{client_suffix}"
384385
return prefix
385386

387+
@property
388+
def file_name(self):
389+
"""A string that can be used as a file name."""
390+
return f"{self.prefix}.pdf"
391+
386392

387393
class InvoiceItem(SQLModel, table=True):
388394
id: Optional[int] = Field(default=None, primary_key=True)

Diff for: tuttle/os_functions.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""OS-level function"""
2+
from typing import Optional
3+
import subprocess
4+
import platform
5+
from pathlib import Path
6+
7+
8+
def open_application(app_name):
9+
"""Open an application by name."""
10+
if platform.system() == "Darwin":
11+
subprocess.call(["open", "-a", app_name])
12+
elif platform.system() == "Windows":
13+
subprocess.call(["start", app_name], shell=True)
14+
elif platform.system() == "Linux":
15+
subprocess.call(["xdg-open", app_name])
16+
17+
18+
def run_applescript(script):
19+
"""Run an AppleScript."""
20+
subprocess.call(["osascript", "-e", script])
21+
22+
23+
def compose_email(
24+
recipient: str,
25+
subject: str,
26+
body: str,
27+
attachment_path: Optional[Path],
28+
):
29+
"""Compose an email in the default email application."""
30+
if platform.system() == "Darwin":
31+
script_set_attachment = f"""
32+
set theAttachmentPath to "{attachment_path}"
33+
set theAttachmentFile to (theAttachmentPath as POSIX file)
34+
"""
35+
36+
script_set_vars = f"""
37+
set theSubject to "{subject}"
38+
set theBody to "{body}"
39+
set theAddress to "{recipient}"
40+
"""
41+
42+
script_compose_email = """
43+
tell application "Mail" to activate
44+
tell application "Mail"
45+
set theNewMessage to make new outgoing message with properties {subject:theSubject, content:theBody & return & return, visible:true}
46+
tell theNewMessage
47+
set visibile to true
48+
#set sender to theSender
49+
make new recipient at end of to recipients with properties {address:theAddress}
50+
try
51+
make new attachment with properties {file name:theAttachmentFile} at after the last word of the last paragraph
52+
set message_attachment to 0
53+
on error errmess -- oops
54+
log errmess -- log the error
55+
set message_attachment to 1
56+
end try
57+
end tell
58+
end tell
59+
"""
60+
61+
script = script_set_attachment + script_set_vars + script_compose_email
62+
run_applescript(script)
63+
else:
64+
raise ValueError("Unsupported OS")

Diff for: tuttle_tests/conftest.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
@pytest.fixture
1212
def demo_contact():
1313
return Contact(
14-
name="Sam Lowry",
14+
fist_name="Sam",
15+
last_name="Lowry",
1516
1617
address=Address(
1718
street="Main Street",
@@ -26,7 +27,8 @@ def demo_contact():
2627
@pytest.fixture
2728
def demo_user():
2829
user = User(
29-
name="Harry Tuttle",
30+
first_name="Harry",
31+
last_name="Tuttle",
3032
subtitle="Heating Engineer",
3133
website="https://tuttle-dev.github.io/tuttle/",
3234
@@ -52,9 +54,11 @@ def demo_user():
5254
@pytest.fixture
5355
def demo_clients():
5456
central_services = Client(
55-
name="Central Services",
57+
first_name="Central",
58+
last_name="Services",
5659
invoicing_contact=Contact(
57-
name="Central Services",
60+
first_name="Central",
61+
last_name="Services",
5862
5963
address=Address(
6064
street="Main Street",
@@ -69,7 +73,8 @@ def demo_clients():
6973
sam_lowry = Client(
7074
name="Sam Lowry",
7175
invoicing_contact=Contact(
72-
name="Sam Lowry",
76+
first_name="Sam",
77+
last_name="Lowry",
7378
7479
address=Address(
7580
street="Main Street",

0 commit comments

Comments
 (0)