Skip to content

Commit cf7d05e

Browse files
committed
installer script & template packaging
1 parent 616b35d commit cf7d05e

File tree

5 files changed

+175
-55
lines changed

5 files changed

+175
-55
lines changed

app/Tuttle.py

+72-15
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from textwrap import dedent
55
from pathlib import Path
66
import webbrowser
7+
import pkgutil
78

89
import flet
910
from flet import (
@@ -90,13 +91,60 @@ def __init__(
9091
def update(self):
9192
super().update()
9293

93-
def add_demo_data(self, event):
94+
def on_click_load_demo_data(self, event):
9495
"""Install the demo data on button click."""
9596
demo.add_demo_data(self.app.con)
9697
self.main_column.controls.clear()
9798
self.main_column.controls.append(
9899
Text("Demo data installed ☑️"),
99100
)
101+
102+
logger.info("Demo data installed")
103+
self.app.snackbar_message("Demo data installed")
104+
self.update()
105+
106+
self.main_column.controls.append(
107+
views.make_card(
108+
[
109+
Text(
110+
dedent(
111+
"""
112+
2. Navigate the menu on the left to explore the demo data.
113+
"""
114+
)
115+
)
116+
]
117+
)
118+
)
119+
120+
self.main_column.controls.append(
121+
views.make_card(
122+
[
123+
Text(
124+
dedent(
125+
"""
126+
3. Navigate to the "Invoicing" page to generate some invoices.
127+
"""
128+
)
129+
)
130+
]
131+
)
132+
)
133+
134+
self.main_column.controls.append(
135+
views.make_card(
136+
[
137+
Text(
138+
dedent(
139+
"""
140+
4. Please use the help menu on the top right to tell us about issues or suggestions.
141+
"""
142+
)
143+
)
144+
]
145+
)
146+
)
147+
100148
self.update()
101149

102150
def build(self):
@@ -125,7 +173,7 @@ def build(self):
125173
ElevatedButton(
126174
"Install demo data",
127175
icon=icons.TOYS,
128-
on_click=self.add_demo_data,
176+
on_click=self.on_click_load_demo_data,
129177
),
130178
]
131179
),
@@ -247,18 +295,27 @@ def on_click_generate_invoices(self, event):
247295
f"generating invoice and timesheet for {self.project_select.value}"
248296
)
249297
logger.info("Generate invoices clicked")
250-
if not self.calendar_file_path:
298+
if (self.calendar_file_path is None) and (self.calendar_data is None):
251299
self.app.snackbar_message(f"Please select a calendar file")
252300
logger.error("No calendar file selected!")
253301
return
254302
try:
255-
self.app.con.billing(
256-
project_tags=[self.project_select.value],
257-
period_start=str(self.date_from_select.get_date()),
258-
period_end=str(self.date_to_select.get_date()),
259-
timetracking_method="file_calendar",
260-
calendar_file_path=self.calendar_file_path,
261-
)
303+
if self.calendar_file_path:
304+
self.app.con.billing(
305+
project_tags=[self.project_select.value],
306+
period_start=str(self.date_from_select.get_date()),
307+
period_end=str(self.date_to_select.get_date()),
308+
timetracking_method="file_calendar",
309+
calendar_file_path=self.calendar_file_path,
310+
)
311+
elif self.calendar_data:
312+
self.app.con.billing(
313+
project_tags=[self.project_select.value],
314+
period_start=str(self.date_from_select.get_date()),
315+
period_end=str(self.date_to_select.get_date()),
316+
timetracking_method="file_calendar",
317+
calendar_file_content=self.calendar_data,
318+
)
262319
self.app.snackbar_message(
263320
f"created invoice and timesheet for {self.project_select.value} - open the invoice folder to see the result"
264321
)
@@ -285,12 +342,12 @@ def on_click_open_invoice_folder(self, event):
285342

286343
def on_click_load_demo_calendar(self, event):
287344
"""Load the demo calendar file."""
288-
self.calendar_file_path = Path(__file__).parent.parent / Path(
289-
"tuttle_tests/data/TuttleDemo-TimeTracking.ics"
290-
)
291-
self.app.snackbar_message(
292-
f"Loaded demo calendar file: {self.calendar_file_path}"
345+
self.calendar_data = pkgutil.get_data(
346+
"tuttle_tests", "data/TuttleDemo-TimeTracking.ics"
293347
)
348+
assert len(self.calendar_data) > 0
349+
logger.info(f"Loaded demo calendar file")
350+
self.app.snackbar_message(f"Loaded demo calendar file")
294351

295352
def update_content(self):
296353
super().update_content()

requirements_dev.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ black
55
nbdime
66
pre-commit
77
pyinstaller
8+
typer

scripts/build_app.py

+59-20
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
1+
from typing import Optional
2+
13
import os
24
import sys
35
import subprocess
4-
6+
import typer
7+
from pathlib import Path
58
from loguru import logger
69

10+
711
added_files = [
8-
("tuttle_tests/data", "demo_data"),
9-
("templates", "templates"),
12+
("tuttle_tests/data", "./tuttle_tests/data"),
13+
("templates", "./templates"),
1014
]
1115

1216
added_data_options = [f"--add-data={src}:{dst}" for src, dst in added_files]
1317

1418

15-
def build_macos():
19+
def build_macos(
20+
one_file: bool,
21+
):
1622
pyinstaller_options = [
1723
"--noconfirm",
1824
"--windowed",
1925
"--clean",
20-
"--onefile",
21-
# "--onedir",
2226
]
27+
if one_file:
28+
pyinstaller_options += ["--onefile"]
29+
else:
30+
pyinstaller_options += ["--onedir"]
2331

2432
options = pyinstaller_options + added_data_options
2533

@@ -30,15 +38,20 @@ def build_macos():
3038
)
3139

3240

33-
def build_linux():
41+
def build_linux(
42+
one_file: bool,
43+
):
3444
pyinstaller_options = [
3545
"--noconfirm",
3646
"--windowed",
3747
"--clean",
38-
"--onefile",
39-
# "--onedir",
4048
]
4149

50+
if one_file:
51+
pyinstaller_options += ["--onefile"]
52+
else:
53+
pyinstaller_options += ["--onedir"]
54+
4255
options = pyinstaller_options + added_data_options
4356

4457
logger.info(f"calling pyinstaller with options: {' '.join(options)}")
@@ -48,15 +61,21 @@ def build_linux():
4861
)
4962

5063

51-
def build_windows():
64+
def build_windows(
65+
one_file: bool,
66+
):
5267
pyinstaller_options = [
5368
"--noconfirm",
5469
"--windowed",
5570
"--clean",
5671
"--onefile",
57-
# "--onedir",
5872
]
5973

74+
if one_file:
75+
pyinstaller_options += ["--onefile"]
76+
else:
77+
pyinstaller_options += ["--onedir"]
78+
6079
options = pyinstaller_options + added_data_options
6180

6281
logger.info(f"calling pyinstaller with options: {' '.join(options)}")
@@ -66,24 +85,44 @@ def build_windows():
6685
)
6786

6887

69-
def main():
88+
def main(
89+
install_dir: Optional[Path] = typer.Option(
90+
None, "--install-dir", "-i", help="Where to install the app"
91+
),
92+
one_file: bool = typer.Option(
93+
False, "--one-file", "-f", help="Build a single file executable"
94+
),
95+
):
96+
if install_dir:
97+
logger.info(f"removing app from {install_dir}")
98+
subprocess.call(["rm", "-rf", f"{install_dir}/Tuttle.app"], shell=False)
99+
70100
# which operating system?
71101
if sys.platform.startswith("linux"):
72102
logger.info("building for Linux")
73-
build_linux()
103+
build_linux(
104+
one_file=one_file,
105+
)
74106
elif sys.platform.startswith("darwin"):
75107
logger.info("building for macOS")
76-
build_macos()
108+
build_macos(
109+
one_file=one_file,
110+
)
77111
elif sys.platform.startswith("win"):
78112
logger.info("building for Windows")
79-
build_windows()
113+
build_windows(
114+
one_file=one_file,
115+
)
80116
else:
81117
raise RuntimeError("Unsupported operating system")
82118

119+
if install_dir:
120+
logger.info(f"installing to {install_dir}")
121+
subprocess.call(
122+
["cp", "-r", "dist/Tuttle.app", install_dir],
123+
shell=False,
124+
)
83125

84-
if __name__ == "__main__":
85-
main()
86126

87-
# os.system(
88-
# "pyinstaller app/Tuttle.py --onefile --noconsole --noconfirm --add-data 'tuttle_tests/data/TuttleDemo-TimeTracking.ics:.'"
89-
# )
127+
if __name__ == "__main__":
128+
typer.run(main)

tuttle/controller.py

+39-18
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def billing(
213213
period_end=None,
214214
timetracking_method="cloud_calendar",
215215
calendar_file_path=None,
216+
calendar_file_content=None,
216217
):
217218
"""Generate time sheets and invoices for a given period"""
218219
out_dir = self.home / self.preferences.invoice_dir
@@ -224,10 +225,20 @@ def billing(
224225
name="TimeTracking",
225226
)
226227
elif timetracking_method == "file_calendar":
227-
timetracking_calendar = calendar.FileCalendar(
228-
path=calendar_file_path,
229-
name=calendar_file_path.stem,
230-
)
228+
if calendar_file_path:
229+
timetracking_calendar = calendar.FileCalendar(
230+
path=calendar_file_path,
231+
name=calendar_file_path.stem,
232+
)
233+
elif calendar_file_content:
234+
timetracking_calendar = calendar.FileCalendar(
235+
content=calendar_file_content,
236+
name="TimeTracking",
237+
)
238+
else:
239+
raise ValueError(
240+
"either calendar_file_path or calendar_file_content required"
241+
)
231242
if timetracking_calendar.to_data().empty:
232243
raise ValueError(
233244
f"empty calendar loaded from file {calendar_file_path}"
@@ -246,25 +257,35 @@ def billing(
246257
period_end=period_end,
247258
item_description=project.title,
248259
)
249-
rendering.render_timesheet(
250-
user=self.user,
251-
timesheet=timesheet,
252-
style="anvil",
253-
document_format="pdf",
254-
out_dir=out_dir,
255-
)
260+
logger.info(f"✅ timesheet generated for {project.title}")
261+
try:
262+
rendering.render_timesheet(
263+
user=self.user,
264+
timesheet=timesheet,
265+
style="anvil",
266+
document_format="pdf",
267+
out_dir=out_dir,
268+
)
269+
except Exception as ex:
270+
logger.error(f"❌ Error rendering timesheet for {project.title}: {ex}")
271+
logger.exception(ex)
256272
logger.info(f"generating invoice for {project.title}")
257273
invoice = invoicing.generate_invoice(
258274
timesheets=[timesheet],
259275
contract=project.contract,
260276
date=datetime.date.today(),
261277
counter=(i + 1),
262278
)
263-
rendering.render_invoice(
264-
user=self.user,
265-
invoice=invoice,
266-
style="anvil",
267-
document_format="pdf",
268-
out_dir=out_dir,
269-
)
270279
logger.info(f"✅ created invoice for {project.title}")
280+
try:
281+
rendering.render_invoice(
282+
user=self.user,
283+
invoice=invoice,
284+
style="anvil",
285+
document_format="pdf",
286+
out_dir=out_dir,
287+
)
288+
logger.info(f"✅ rendered invoice for {project.title}")
289+
except Exception as ex:
290+
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
291+
logger.exception(ex)

tuttle/rendering.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
from babel.numbers import format_currency
99
import pandas
1010
import pdfkit
11+
from loguru import logger
1112

1213
from .model import User, Invoice, Timesheet, Project
1314
from .view import Timeline
1415

1516

1617
def get_template_path(template_name) -> str:
1718
"""Get the path to an HTML template by name"""
18-
module_path = Path(__file__).parent.resolve()
19-
template_path = module_path / Path(f"../templates/{template_name}")
19+
app_dir = Path(__file__).parent.parent.resolve()
20+
template_path = app_dir / Path(f"templates/{template_name}")
21+
logger.info(f"Template path: {template_path}")
2022
return template_path
2123

2224

0 commit comments

Comments
 (0)