Skip to content

Commit f90f8ce

Browse files
authored
Merge pull request #186 from tuttle-dev/dev-feat-timesheets
Timesheets rendered and displayed for invoices
2 parents f5f55cf + efa61dc commit f90f8ce

File tree

15 files changed

+495
-205
lines changed

15 files changed

+495
-205
lines changed

app/auth/intent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def get_user_if_exists(self) -> IntentResult[Optional[User]]:
9494
"""
9595
result = self._data_source.get_user_()
9696
if not result.was_intent_successful:
97-
result.error_msg = "Checking auth status failed! Please restart the app"
97+
result.error_msg = "No user data found."
9898
result.log_message_if_any()
9999
return result
100100

app/core/database_storage_impl.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
from typing import Callable
2+
13
import re
24
from pathlib import Path
3-
from loguru import logger
4-
from typing import Callable
5-
import demo
5+
66
import sqlmodel
7+
from loguru import logger
8+
9+
from tuttle import demo
10+
711
from .abstractions import DatabaseStorage
812

913

app/invoicing/data_source.py

Lines changed: 25 additions & 1 deletion
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

app/invoicing/intent.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ def __init__(self, client_storage: ClientStorage):
4545
self._user_data_source = UserDataSource()
4646
self._auth_intent = AuthIntent()
4747

48-
def get_user(self) -> Optional[User]:
49-
"""Get the current user."""
50-
return self._auth_intent.get_user_if_exists()
48+
def get_user(self) -> IntentResult[User]:
49+
user = self._user_data_source.get_user()
50+
return IntentResult(was_intent_successful=True, data=user)
5151

5252
def get_active_projects_as_map(self) -> Mapping[int, Project]:
5353
return self._projects_intent.get_active_projects_as_map()
@@ -95,11 +95,13 @@ def create_invoice(
9595
render: bool = True,
9696
) -> IntentResult[Invoice]:
9797
"""Create a new invoice from time tracking data."""
98-
98+
logger.info(f"⚙️ Creating invoice for {project.title}...")
99+
user = self._user_data_source.get_user()
99100
try:
100101
# get the time tracking data
101102
timetracking_data = self._timetracking_data_source.get_data_frame()
102-
timesheet: Timesheet = timetracking.create_timesheet(
103+
# generate timesheet
104+
timesheet: Timesheet = timetracking.generate_timesheet(
103105
timetracking_data,
104106
project,
105107
from_date,
@@ -116,10 +118,24 @@ def create_invoice(
116118
)
117119

118120
if render:
119-
# TODO: render timesheet
121+
# render timesheet
122+
try:
123+
logger.info(f"⚙️ Rendering timesheet for {project.title}...")
124+
rendering.render_timesheet(
125+
user=user,
126+
timesheet=timesheet,
127+
out_dir=Path.home() / ".tuttle" / "Timesheets",
128+
only_final=True,
129+
)
130+
logger.info(f"✅ rendered timesheet for {project.title}")
131+
except Exception as ex:
132+
logger.error(
133+
f"❌ Error rendering timesheet for {project.title}: {ex}"
134+
)
135+
logger.exception(ex)
120136
# render invoice
121137
try:
122-
user = self._user_data_source.get_user()
138+
logger.info(f"⚙️ Rendering invoice for {project.title}...")
123139
rendering.render_invoice(
124140
user=user,
125141
invoice=invoice,
@@ -130,7 +146,12 @@ def create_invoice(
130146
except Exception as ex:
131147
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
132148
logger.exception(ex)
133-
# 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)
134155
self._invoicing_data_source.save_invoice(invoice)
135156
return IntentResult(
136157
was_intent_successful=True,
@@ -319,6 +340,29 @@ def view_invoice(self, invoice: Invoice) -> IntentResult[None]:
319340
error_msg=error_message,
320341
)
321342

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+
322366
def generate_invoice_number(
323367
self,
324368
invoice_date: Optional[date] = None,

app/invoicing/view.py

Lines changed: 14 additions & 0 deletions
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",

tuttle/calendar.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
import io
66
import re
7+
import calendar
78

89
from loguru import logger
910
import ics
@@ -180,3 +181,17 @@ class GoogleCalendar(CloudCalendar):
180181

181182
def to_data(self) -> DataFrame:
182183
raise NotImplementedError("TODO")
184+
185+
186+
def get_month_start_end(month_str):
187+
# Parse the string into a datetime object
188+
dt = datetime.datetime.strptime(month_str, "%B %Y")
189+
190+
# Get the date information from the datetime object
191+
year, month = dt.date().year, dt.date().month
192+
193+
# Get the start and end dates of the month
194+
start_date = datetime.date(year, month, 1)
195+
end_date = datetime.date(year, month, calendar.monthrange(year, month)[1])
196+
197+
return start_date, end_date

0 commit comments

Comments
 (0)