Skip to content

Commit efa61dc

Browse files
committed
feat: view timesheet associates with invoice
1 parent e9b7f6d commit efa61dc

File tree

9 files changed

+126
-38
lines changed

9 files changed

+126
-38
lines changed

Diff for: app/invoicing/data_source.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from core.abstractions import SQLModelDataSourceMixin
66
from core.intent_result import IntentResult
77

8-
from tuttle.model import Invoice, Project
8+
from tuttle.model import Invoice, Project, Timesheet
99

1010

1111
class InvoicingDataSource(SQLModelDataSourceMixin):
@@ -74,6 +74,10 @@ def save_invoice(
7474
"""Creates or updates an invoice with given invoice and project info"""
7575
self.store(invoice)
7676

77+
def save_timesheet(self, timesheet: Timesheet):
78+
"""Creates or updates a timesheet"""
79+
self.store(timesheet)
80+
7781
def get_last_invoice(self) -> IntentResult[Invoice]:
7882
"""Get the last invoice.
7983
@@ -100,3 +104,23 @@ def get_last_invoice(self) -> IntentResult[Invoice]:
100104
log_message=f"Exception raised @InvoicingDataSource.get_last_invoice_number {e.__class__.__name__}",
101105
exception=e,
102106
)
107+
108+
def get_timesheet_for_invoice(self, invoice: Invoice) -> Timesheet:
109+
"""Get the timesheet associated with an invoice
110+
111+
Args:
112+
invoice (Invoice): the invoice to get the timesheet for
113+
114+
Returns:
115+
Optional[Timesheet]: the timesheet associated with the invoice
116+
"""
117+
if not len(invoice.timesheets) > 0:
118+
raise ValueError(
119+
f"invoice {invoice.id} has no timesheets associated with it"
120+
)
121+
if len(invoice.timesheets) > 1:
122+
raise ValueError(
123+
f"invoice {invoice.id} has more than one timesheet associated with it: {invoice.timesheets}"
124+
)
125+
timesheet = invoice.timesheets[0]
126+
return timesheet

Diff for: app/invoicing/intent.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def create_invoice(
9595
render: bool = True,
9696
) -> IntentResult[Invoice]:
9797
"""Create a new invoice from time tracking data."""
98+
logger.info(f"⚙️ Creating invoice for {project.title}...")
9899
user = self._user_data_source.get_user()
99100
try:
100101
# get the time tracking data
@@ -145,7 +146,12 @@ def create_invoice(
145146
except Exception as ex:
146147
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
147148
logger.exception(ex)
148-
# save invoice
149+
150+
# save invoice and timesheet
151+
timesheet.invoice = invoice
152+
assert timesheet.invoice is not None
153+
assert len(invoice.timesheets) == 1
154+
# self._invoicing_data_source.save_timesheet(timesheet)
149155
self._invoicing_data_source.save_invoice(invoice)
150156
return IntentResult(
151157
was_intent_successful=True,
@@ -334,6 +340,29 @@ def view_invoice(self, invoice: Invoice) -> IntentResult[None]:
334340
error_msg=error_message,
335341
)
336342

343+
def view_timesheet_for_invoice(self, invoice: Invoice) -> IntentResult[None]:
344+
"""Attempts to open the timesheet for the invoice in the default pdf viewer"""
345+
try:
346+
timesheet = self._invoicing_data_source.get_timesheet_for_invoice(invoice)
347+
timesheet_path = (
348+
Path().home() / ".tuttle" / "Timesheets" / f"{timesheet.prefix}.pdf"
349+
)
350+
preview_pdf(timesheet_path)
351+
return IntentResult(was_intent_successful=True)
352+
except ValueError as ve:
353+
logger.error(f"❌ Error getting timesheet for invoice: {ve}")
354+
logger.exception(ve)
355+
return IntentResult(was_intent_successful=False, error_msg=str(ve))
356+
except Exception as ex:
357+
# display the execption name in the error message
358+
error_message = f"❌ Failed to open the timesheet: {ex.__class__.__name__}"
359+
logger.error(error_message)
360+
logger.exception(ex)
361+
return IntentResult(
362+
was_intent_successful=False,
363+
error_msg=error_message,
364+
)
365+
337366
def generate_invoice_number(
338367
self,
339368
invoice_date: Optional[date] = None,

Diff for: app/invoicing/view.py

+14
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ def refresh_invoices(self):
216216
on_delete_clicked=self.on_delete_invoice_clicked,
217217
on_mail_invoice=self.on_mail_invoice,
218218
on_view_invoice=self.on_view_invoice,
219+
on_view_timesheet=self.on_view_timesheet,
219220
toggle_paid_status=self.toggle_paid_status,
220221
toggle_cancelled_status=self.toggle_cancelled_status,
221222
toggle_sent_status=self.toggle_sent_status,
@@ -241,6 +242,12 @@ def on_view_invoice(self, invoice: Invoice):
241242
if not result.was_intent_successful:
242243
self.show_snack(result.error_msg, is_error=True)
243244

245+
def on_view_timesheet(self, invoice: Invoice):
246+
"""Called when the user clicks view in the context menu of an invoice"""
247+
result = self.intent.view_timesheet_for_invoice(invoice)
248+
if not result.was_intent_successful:
249+
self.show_snack(result.error_msg, is_error=True)
250+
244251
def on_delete_invoice_clicked(self, invoice: Invoice):
245252
"""Called when the user clicks delete in the context menu of an invoice"""
246253
if self.editor is not None:
@@ -425,6 +432,7 @@ def __init__(
425432
on_delete_clicked,
426433
on_mail_invoice,
427434
on_view_invoice,
435+
on_view_timesheet,
428436
toggle_paid_status,
429437
toggle_sent_status,
430438
toggle_cancelled_status,
@@ -433,6 +441,7 @@ def __init__(
433441
self.invoice = invoice
434442
self.on_delete_clicked = on_delete_clicked
435443
self.on_view_invoice = on_view_invoice
444+
self.on_view_timesheet = on_view_timesheet
436445
self.on_mail_invoice = on_mail_invoice
437446
self.toggle_paid_status = toggle_paid_status
438447
self.toggle_sent_status = toggle_sent_status
@@ -504,6 +513,11 @@ def build(self):
504513
txt="View",
505514
on_click=lambda e: self.on_view_invoice(self.invoice),
506515
),
516+
views.TPopUpMenuItem(
517+
icon=icons.VISIBILITY_OUTLINED,
518+
txt="View Timesheet ",
519+
on_click=lambda e: self.on_view_timesheet(self.invoice),
520+
),
507521
views.TPopUpMenuItem(
508522
icon=icons.OUTGOING_MAIL,
509523
txt="Send",

Diff for: tuttle/demo.py

+35-30
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def create_fake_project(
137137
if contract is None:
138138
contract = create_fake_contract(fake)
139139

140-
project_title = fake.bs()
140+
project_title = fake.bs().replace("/", "-")
141141
project_tag = f"#{'-'.join(project_title.split(' ')[:2]).lower()}"
142142

143143
project = Project(
@@ -179,9 +179,11 @@ def create_fake_timesheet(
179179
if project is None:
180180
project = create_fake_project(fake)
181181
timesheet = Timesheet(
182-
title=fake.bs(),
182+
title=fake.bs().replace("/", "-"),
183183
comment=fake.paragraph(nb_sentences=2),
184184
date=datetime.date.today(),
185+
period_start=datetime.date.today() - datetime.timedelta(days=30),
186+
period_end=datetime.date.today(),
185187
project=project,
186188
)
187189
number_of_items = fake.random_int(min=1, max=5)
@@ -257,34 +259,37 @@ def create_fake_invoice(
257259
invoice=invoice,
258260
)
259261

260-
# an invoice is created together with a timesheet. For the sake of simplicity, timesheet and invoice items are not linked.
261-
timesheeet = create_fake_timesheet(fake, project)
262-
263-
if render:
264-
# render invoice
265-
try:
266-
rendering.render_invoice(
267-
user=user,
268-
invoice=invoice,
269-
out_dir=Path.home() / ".tuttle" / "Invoices",
270-
only_final=True,
271-
)
272-
logger.info(f"✅ rendered invoice for {project.title}")
273-
except Exception as ex:
274-
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
275-
logger.exception(ex)
276-
# render timesheet
277-
try:
278-
rendering.render_timesheet(
279-
user=user,
280-
timesheet=timesheeet,
281-
out_dir=Path.home() / ".tuttle" / "Timesheets",
282-
only_final=True,
283-
)
284-
logger.info(f"✅ rendered timesheet for {project.title}")
285-
except Exception as ex:
286-
logger.error(f"❌ Error rendering timesheet for {project.title}: {ex}")
287-
logger.exception(ex)
262+
# an invoice is created together with a timesheet. For the sake of simplicity, timesheet and invoice items are not linked.
263+
timesheet = create_fake_timesheet(fake, project)
264+
# attach timesheet to invoice
265+
timesheet.invoice = invoice
266+
assert len(invoice.timesheets) == 1
267+
268+
if render:
269+
# render invoice
270+
try:
271+
rendering.render_invoice(
272+
user=user,
273+
invoice=invoice,
274+
out_dir=Path.home() / ".tuttle" / "Invoices",
275+
only_final=True,
276+
)
277+
logger.info(f"✅ rendered invoice for {project.title}")
278+
except Exception as ex:
279+
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
280+
logger.exception(ex)
281+
# render timesheet
282+
try:
283+
rendering.render_timesheet(
284+
user=user,
285+
timesheet=timesheet,
286+
out_dir=Path.home() / ".tuttle" / "Timesheets",
287+
only_final=True,
288+
)
289+
logger.info(f"✅ rendered timesheet for {project.title}")
290+
except Exception as ex:
291+
logger.error(f"❌ Error rendering timesheet for {project.title}: {ex}")
292+
logger.exception(ex)
288293

289294
return invoice
290295

Diff for: tuttle/invoicing.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ def generate_invoice(
3737
VAT_rate=contract.VAT_rate,
3838
description=timesheet.title,
3939
)
40-
# attach timesheet to invoice
41-
timesheet.invoice = invoice
40+
4241
# TODO: replace with auto-incrementing numbers
4342
invoice.generate_number(counter=counter)
4443
return invoice

Diff for: tuttle/model.py

+16
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,9 @@ class Project(SQLModel, table=True):
409409
sa_relationship_kwargs={"lazy": "subquery"},
410410
)
411411

412+
def __repr__(self):
413+
return f"Project(id={self.id}, title={self.title}, tag={self.tag})"
414+
412415
# PROPERTIES
413416
@property
414417
def client(self) -> Optional[Client]:
@@ -482,6 +485,12 @@ class Timesheet(SQLModel, table=True):
482485
id: Optional[int] = Field(default=None, primary_key=True)
483486
title: str
484487
date: datetime.date = Field(description="The date of creation of the timesheet")
488+
period_start: datetime.date = Field(
489+
description="The start date of the period covered by the timesheet."
490+
)
491+
period_end: datetime.date = Field(
492+
description="The end date of the period covered by the timesheet."
493+
)
485494

486495
# Timesheet n:1 Project
487496
project_id: Optional[int] = Field(default=None, foreign_key="project.id")
@@ -509,6 +518,13 @@ class Timesheet(SQLModel, table=True):
509518
# class Config:
510519
# arbitrary_types_allowed = True
511520

521+
def __repr__(self):
522+
return f"Timesheet(id={self.id}, tag={self.project.tag}, period_start={self.period_start}, period_end={self.period_end})"
523+
524+
@property
525+
def prefix(self) -> str:
526+
return f"{self.project.tag[1:]}-{self.period_start.strftime('%Y-%m-%d')}-{self.period_end.strftime('%Y-%m-%d')}"
527+
512528
@property
513529
def total(self) -> datetime.timedelta:
514530
"""Sum of time in timesheet."""

Diff for: tuttle/rendering.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def render_timesheet(
211211
user: User,
212212
timesheet: Timesheet,
213213
out_dir,
214-
document_format: str = "html",
214+
document_format: str = "pdf",
215215
style: str = "anvil",
216216
only_final: bool = False,
217217
):
@@ -238,7 +238,7 @@ def render_timesheet(
238238
return html
239239
else:
240240
# write invoice html
241-
prefix = f"Timesheet-{timesheet.title}"
241+
prefix = timesheet.prefix
242242
timesheet_dir = Path(out_dir) / Path(prefix)
243243
timesheet_dir.mkdir(parents=True, exist_ok=True)
244244
timesheet_path = timesheet_dir / Path(f"{prefix}.html")

Diff for: tuttle/timetracking.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ def generate_timesheet(
5252
period_str = f"{period_start} - {period_end}"
5353
ts = Timesheet(
5454
title=f"{project.title} - {period_str}",
55-
# period=period,
55+
period_start=period_start,
56+
period_end=period_end,
5657
project=project,
5758
comment=comment,
5859
date=date,

Diff for: tuttle_tests/test_rendering.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def test_creates_only_final_file(self, fake):
5050
only_final=only_final,
5151
)
5252

53-
prefix = f"Timesheet-{timesheet.title}"
53+
prefix = timesheet.prefix
5454
pdf_file = Path(out_dir) / Path(f"{prefix}.pdf")
5555
assert pdf_file.is_file()
5656

0 commit comments

Comments
 (0)