From 5b7623047e82d11075643ab95c0cacd459d52d81 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Mon, 23 Jan 2023 21:09:25 +0100 Subject: [PATCH 1/6] checkpoint: demo: create_fake_timesheet --- app/demo.py | 44 ++++++++++++++++++++++++++++++++++++++++- app/invoicing/intent.py | 15 ++++++++++++-- tuttle/model.py | 4 +--- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/app/demo.py b/app/demo.py index da0bf9c9..6c4a0707 100644 --- a/app/demo.py +++ b/app/demo.py @@ -25,6 +25,8 @@ BankAccount, Invoice, InvoiceItem, + Timesheet, + TimeTrackingItem, ) from tuttle import rendering @@ -129,6 +131,46 @@ def invoice_number_counting(): invoice_number_counter = invoice_number_counting() +def create_fake_timesheet( + project: Project, + fake: faker.Faker, +) -> Timesheet: + """ + Create a fake timesheet object with random values. + + Args: + project (Project): The project associated with the timesheet. + fake (faker.Faker): An instance of the Faker class to generate random values. + + Returns: + Timesheet: A fake timesheet object. + """ + timesheet = Timesheet( + title=fake.bs(), + comment=fake.paragraph(nb_sentences=2), + date=datetime.date.today(), + project=project, + ) + number_of_items = fake.random_int(min=1, max=5) + for _ in range(number_of_items): + unit = fake.random_element(elements=("hours", "days")) + if unit == "hours": + unit_price = abs(round(numpy.random.normal(50, 20), 2)) + elif unit == "days": + unit_price = abs(round(numpy.random.normal(400, 200), 2)) + time_tracking_item = TimeTrackingItem( + timesheet=timesheet, + begin=fake.date_time_this_year(before_now=True, after_now=False), + end=fake.date_time_this_year(before_now=True, after_now=False), + duration=datetime.timedelta(hours=fake.random_int(min=1, max=8)), + title=f"{fake.bs()} for #{project.tag}", + tag=project.tag, + description=fake.paragraph(nb_sentences=2), + ) + timesheet.items.append(time_tracking_item) + return timesheet + + def create_fake_invoice( project: Project, user: User, @@ -146,7 +188,7 @@ def create_fake_invoice( """ invoice_number = next(invoice_number_counter) invoice = Invoice( - number=invoice_number, + number=str(invoice_number), # TODO: replace with generated number date=datetime.date.today(), sent=fake.pybool(), paid=fake.pybool(), diff --git a/app/invoicing/intent.py b/app/invoicing/intent.py index 974ec067..fccc0cbc 100644 --- a/app/invoicing/intent.py +++ b/app/invoicing/intent.py @@ -117,10 +117,21 @@ def create_invoice( if render: # TODO: render timesheet - + user = self._user_data_source.get_user() + try: + rendering.render_timesheet( + user=user, + timesheet=timesheet, + out_dir=Path.home() / ".tuttle" / "Timesheets", + ) + logger.info(f"✅ rendered timesheet for {project.title}") + except Exception as ex: + logger.error( + f"❌ Error rendering timesheet for {project.title}: {ex}" + ) + logger.exception(ex) # render invoice try: - user = self._user_data_source.get_user() rendering.render_invoice( user=user, invoice=invoice, diff --git a/tuttle/model.py b/tuttle/model.py index 70fd8983..3eb27c87 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -439,9 +439,7 @@ class TimeTrackingItem(SQLModel, table=True): timesheet: Optional["Timesheet"] = Relationship(back_populates="items") # begin: datetime.datetime = Field(description="Start time of the time interval.") - end: Optional[datetime.datetime] = Field( - description="End time of the time interval." - ) + end: datetime.datetime = Field(description="End time of the time interval.") duration: datetime.timedelta = Field(description="Duration of the time interval.") title: str = Field(description="A short description of the time interval.") tag: str = Field( From a3d55498aea36ce0433ca732efc64c4299b08439 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Mon, 30 Jan 2023 18:00:27 +0100 Subject: [PATCH 2/6] checkpoint: invoice rendering --- tuttle/model.py | 51 ++++++++++++++++++++++++++++++--------------- tuttle/rendering.py | 7 ++++--- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/tuttle/model.py b/tuttle/model.py index 3eb27c87..1b9a1829 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -1,12 +1,9 @@ """Object model.""" -import email from typing import Optional, List, Dict, Type from pydantic import constr, BaseModel, condecimal from enum import Enum import datetime -import hashlib -import uuid import textwrap import sqlalchemy @@ -50,9 +47,10 @@ def to_dataframe(items: List[Type[BaseModel]]) -> pandas.DataFrame: def OneToOneRelationship(back_populates): + """Define a relationship as one-to-one.""" return Relationship( back_populates=back_populates, - sa_relationship_kwargs={"uselist": False}, + sa_relationship_kwargs={"uselist": False, "lazy": "subquery"}, ) @@ -150,6 +148,11 @@ class User(SQLModel, table=True): ) # TODO: path to logo image logo: Optional[str] + # User 1:n Invoices + # invoices: List["Invoice"] = Relationship( + # back_populates="user", + # sa_relationship_kwargs={"lazy": "subquery"}, + # ) @property def bank_account_not_set(self) -> bool: @@ -454,10 +457,7 @@ class Timesheet(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str date: datetime.date = Field(description="The date of creation of the timesheet") - # period: str - # table: pandas.DataFrame - # TODO: store dataframe as dict - # table: Dict = Field(default={}, sa_column=sqlalchemy.Column(sqlalchemy.JSON)) + # Timesheet n:1 Project project_id: Optional[int] = Field(default=None, foreign_key="project.id") project: Project = Relationship( @@ -469,6 +469,18 @@ class Timesheet(SQLModel, table=True): comment: Optional[str] = Field(description="A comment on the timesheet.") items: List[TimeTrackingItem] = Relationship(back_populates="timesheet") + rendered: bool = Field( + default=False, + description="Whether the Timesheet has been rendered as a PDF.", + ) + + # Timesheet 1:1 Invoice + # FIXME: Could not determine join condition between parent/child tables + # invoice_id: Optional[int] = Field(default=None, foreign_key="invoice.id") + # invoice: Optional["Invoice"] = OneToOneRelationship( + # back_populates="timesheet", + # ) + # class Config: # arbitrary_types_allowed = True @@ -492,16 +504,18 @@ class Invoice(SQLModel, table=True): """An invoice is a bill for a client.""" id: Optional[int] = Field(default=None, primary_key=True) - number: str + number: Optional[str] = Field(description="The invoice number. Auto-generated.") # date and time date: datetime.date = Field( description="The date of the invoice", ) - # due_date: datetime.date - # sent_date: datetime.date - # Invoice 1:n Timesheet ? + + # TODO: sent_date: datetime.datetime = Field(description="The date the invoice was sent.") + # Invoice 1:1 Timesheet # timesheet_id: Optional[int] = Field(default=None, foreign_key="timesheet.id") - # timesheet: Timesheet = Relationship(back_populates="invoice") + # timesheet: Timesheet = OneToOneRelationship( + # back_populates="invoice", + # ) # Invoice n:1 Contract ? contract_id: Optional[int] = Field(default=None, foreign_key="contract.id") contract: Contract = Relationship( @@ -529,7 +543,7 @@ class Invoice(SQLModel, table=True): ) rendered: bool = Field( default=False, - description="If the invoice has been rendered as a PDF.", + description="Whether the invoice has been rendered as a PDF.", ) # @@ -548,7 +562,7 @@ def total(self) -> Decimal: """Total invoiced amount.""" return self.sum + self.VAT_total - def generate_number(self, pattern=None, counter=None): + def generate_number(self, pattern=None, counter=None) -> str: """Generate an invoice number""" date_prefix = self.date.strftime("%Y-%m-%d") # suffix = hashlib.shake_256(str(uuid.uuid4()).encode("utf-8")).hexdigest(2) @@ -559,9 +573,12 @@ def generate_number(self, pattern=None, counter=None): self.number = f"{date_prefix}-{suffix}" @property - def due_date(self): + def due_date(self) -> Optional[datetime.date]: """Date until which payment is due.""" - return self.date + datetime.timedelta(days=self.contract.term_of_payment) + if self.contract.term_of_payment: + return self.date + datetime.timedelta(days=self.contract.term_of_payment) + else: + return None @property def client(self): diff --git a/tuttle/rendering.py b/tuttle/rendering.py index 0799f142..85aa0bea 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -117,8 +117,8 @@ def emit_pdf(finished): def render_invoice( user: User, invoice: Invoice, + out_dir, document_format: str = "pdf", - out_dir: str = None, style: str = "anvil", only_final: bool = False, ) -> str: @@ -212,10 +212,11 @@ def as_percentage(number): def render_timesheet( user: User, timesheet: Timesheet, + out_dir, document_format: str = "html", - out_dir: str = None, style: str = "anvil", -) -> str: + only_final: bool = False, +): """Render a Timeseheet using an HTML template. Args: From 5763268d8b206501f5f83d2f24bd76d2a941fc1b Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 1 Feb 2023 16:39:57 +0100 Subject: [PATCH 3/6] checkpoint: demo and rendering unit tests --- app/auth/intent.py | 2 +- app/invoicing/intent.py | 24 +++++-- {app => tuttle}/demo.py | 69 +++++++++++++------ tuttle/model.py | 2 +- tuttle/rendering.py | 11 +++- tuttle/timetracking.py | 79 +--------------------- tuttle_tests/test_demo.py | 64 ++++++++++++++++++ tuttle_tests/test_rendering.py | 106 ++++++++++++++++++++++++++++++ tuttle_tests/test_timetracking.py | 2 +- 9 files changed, 249 insertions(+), 110 deletions(-) rename {app => tuttle}/demo.py (87%) create mode 100644 tuttle_tests/test_demo.py create mode 100644 tuttle_tests/test_rendering.py diff --git a/app/auth/intent.py b/app/auth/intent.py index 24f1f880..20890af9 100644 --- a/app/auth/intent.py +++ b/app/auth/intent.py @@ -94,7 +94,7 @@ def get_user_if_exists(self) -> IntentResult[Optional[User]]: """ result = self._data_source.get_user_() if not result.was_intent_successful: - result.error_msg = "Checking auth status failed! Please restart the app" + result.error_msg = "No user data found." result.log_message_if_any() return result diff --git a/app/invoicing/intent.py b/app/invoicing/intent.py index 2065245d..c5f272fc 100644 --- a/app/invoicing/intent.py +++ b/app/invoicing/intent.py @@ -45,10 +45,6 @@ def __init__(self, client_storage: ClientStorage): self._user_data_source = UserDataSource() self._auth_intent = AuthIntent() - def get_user(self) -> Optional[User]: - """Get the current user.""" - return self._auth_intent.get_user_if_exists() - def get_active_projects_as_map(self) -> Mapping[int, Project]: return self._projects_intent.get_active_projects_as_map() @@ -95,11 +91,12 @@ def create_invoice( render: bool = True, ) -> IntentResult[Invoice]: """Create a new invoice from time tracking data.""" - + user = self._user_data_source.get_user() try: # get the time tracking data timetracking_data = self._timetracking_data_source.get_data_frame() - timesheet: Timesheet = timetracking.create_timesheet( + # generate timesheet + timesheet: Timesheet = timetracking.generate_timesheet( timetracking_data, project, from_date, @@ -116,7 +113,20 @@ def create_invoice( ) if render: - # TODO: render timesheet + # render timesheet + try: + rendering.render_timesheet( + user=user, + timesheet=timesheet, + out_dir=Path.home() / ".tuttle" / "Timesheets", + only_final=True, + ) + logger.info(f"✅ rendered timesheet for {project.title}") + except Exception as ex: + logger.error( + f"❌ Error rendering timesheet for {project.title}: {ex}" + ) + logger.exception(ex) # render invoice try: rendering.render_invoice( diff --git a/app/demo.py b/tuttle/demo.py similarity index 87% rename from app/demo.py rename to tuttle/demo.py index 3b66aa24..f007a111 100644 --- a/app/demo.py +++ b/tuttle/demo.py @@ -32,9 +32,24 @@ ) +def create_fake_user( + fake: faker.Faker, +) -> User: + """ + Create a fake user. + """ + user = User( + name=fake.name(), + email=fake.email(), + subtitle=fake.job(), + VAT_number=fake.ean8(), + ) + return user + + def create_fake_contact( fake: faker.Faker, -): +) -> Contact: split_address_lines = fake.address().splitlines() street_line = split_address_lines[0] @@ -59,9 +74,11 @@ def create_fake_contact( def create_fake_client( - invoicing_contact: Contact, fake: faker.Faker, -): + invoicing_contact: Optional[Contact] = None, +) -> Client: + if invoicing_contact is None: + invoicing_contact = create_fake_contact(fake) client = Client( name=fake.company(), invoicing_contact=invoicing_contact, @@ -71,12 +88,14 @@ def create_fake_client( def create_fake_contract( - client: Client, fake: faker.Faker, + client: Optional[Client] = None, ) -> Contract: """ Create a fake contract for the given client. """ + if client is None: + client = create_fake_client(fake) unit = random.choice(list(TimeUnit)) if unit == TimeUnit.day: rate = fake.random_int(200, 1000) # realistic distribution for day rates @@ -101,9 +120,12 @@ def create_fake_contract( def create_fake_project( - contract: Contract, fake: faker.Faker, -): + contract: Optional[Contract] = None, +) -> Project: + if contract is None: + contract = create_fake_contract(fake) + project_title = fake.bs() project_tag = f"#{'-'.join(project_title.split(' ')[:2]).lower()}" @@ -130,8 +152,8 @@ def invoice_number_counting(): def create_fake_timesheet( - project: Project, fake: faker.Faker, + project: Optional[Project] = None, ) -> Timesheet: """ Create a fake timesheet object with random values. @@ -143,6 +165,8 @@ def create_fake_timesheet( Returns: Timesheet: A fake timesheet object. """ + if project is None: + project = create_fake_project(fake) timesheet = Timesheet( title=fake.bs(), comment=fake.paragraph(nb_sentences=2), @@ -170,9 +194,10 @@ def create_fake_timesheet( def create_fake_invoice( - project: Project, - user: User, fake: faker.Faker, + project: Optional[Project] = None, + user: Optional[User] = None, + render: bool = True, ) -> Invoice: """ Create a fake invoice object with random values. @@ -184,6 +209,9 @@ def create_fake_invoice( Returns: Invoice: A fake invoice object. """ + if project is None: + project = create_fake_project(fake) + invoice_number = next(invoice_number_counter) invoice = Invoice( number=str(invoice_number), @@ -215,17 +243,18 @@ def create_fake_invoice( invoice=invoice, ) - try: - rendering.render_invoice( - user=user, - invoice=invoice, - out_dir=Path.home() / ".tuttle" / "Invoices", - only_final=True, - ) - logger.info(f"✅ rendered invoice for {project.title}") - except Exception as ex: - logger.error(f"❌ Error rendering invoice for {project.title}: {ex}") - logger.exception(ex) + if render: + try: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=Path.home() / ".tuttle" / "Invoices", + only_final=True, + ) + logger.info(f"✅ rendered invoice for {project.title}") + except Exception as ex: + logger.error(f"❌ Error rendering invoice for {project.title}: {ex}") + logger.exception(ex) return invoice diff --git a/tuttle/model.py b/tuttle/model.py index b833a1a2..e5e3e958 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -614,7 +614,7 @@ def prefix(self): """A string that can be used as the prefix of a file name, or a folder name.""" client_suffix = "" if self.client: - client_suffix = self.client.name.lower().split()[0] + client_suffix = "-".join(self.client.name.lower().split()) prefix = f"{self.number}-{client_suffix}" return prefix diff --git a/tuttle/rendering.py b/tuttle/rendering.py index 73393a1c..2f70d446 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -203,8 +203,6 @@ def as_percentage(number): invoice_dir / Path(f"{invoice.prefix}.html"), final_output_path ) shutil.rmtree(invoice_dir) - # finally set the rendered flag - invoice.rendered = True # finally set the rendered flag invoice.rendered = True @@ -274,6 +272,15 @@ def render_timesheet( css_paths=css_paths, out_path=timesheet_dir / Path(f"{prefix}.pdf"), ) + if only_final: + final_output_path = out_dir / Path(f"{prefix}.{document_format}") + if document_format == "pdf": + shutil.move(timesheet_dir / Path(f"{prefix}.pdf"), final_output_path) + else: + shutil.move(timesheet_dir / Path(f"{prefix}.html"), final_output_path) + shutil.rmtree(timesheet_dir) + # finally set the rendered flag + timesheet.rendered = True def generate_document_thumbnail(pdf_path: str, thumbnail_width: int) -> str: diff --git a/tuttle/timetracking.py b/tuttle/timetracking.py index 6f9db47a..af41d361 100644 --- a/tuttle/timetracking.py +++ b/tuttle/timetracking.py @@ -15,7 +15,7 @@ from .model import Project, Timesheet, TimeTrackingItem, User -def create_timesheet( +def generate_timesheet( timetracking_data: DataFrame, project: Project, period_start: datetime.date, @@ -63,83 +63,6 @@ def create_timesheet( return ts -@deprecated -def generate_timesheet( - source, - project: Project, - period_start: str, - period_end: str = None, - date: datetime.date = datetime.date.today(), - comment: str = "", - group_by: str = None, - item_description: str = None, - as_dataframe: bool = False, -) -> Timesheet: - if period_end: - period = (period_start, period_end) - period_str = f"{period_start} - {period_end}" - else: - period = period_start - period_str = f"{period_start}" - # convert cal to data - timetracking_data = None - if issubclass(type(source), Calendar): - cal = source - timetracking_data = cal.to_data() - elif isinstance(source, pandas.DataFrame): - timetracking_data = source - schema.time_tracking.validate(timetracking_data) - else: - raise ValueError(f"unknown source: {source}") - tag_query = f"tag == '{project.tag}'" - if period_end: - ts_table = ( - timetracking_data.loc[period_start:period_end].query(tag_query).sort_index() - ) - else: - ts_table = timetracking_data.loc[period_start].query(tag_query).sort_index() - # convert all-day entries - ts_table.loc[ts_table["all_day"], "duration"] = ( - project.contract.unit.to_timedelta() * project.contract.units_per_workday - ) - if item_description: - # TODO: extract item description from calendar - ts_table["description"] = item_description - # assert not ts_table.empty - if as_dataframe: - return ts_table - - # TODO: grouping - if group_by is None: - pass - elif group_by == "day": - ts_table = ts_table.reset_index() - ts_table = ts_table.groupby(by=ts_table["begin"].dt.date).agg( - { - "title": "first", - "tag": "first", - "description": "first", - "duration": "sum", - } - ) - elif group_by == "week": - raise NotImplementedError("TODO") - else: - raise ValueError(f"unknown group_by argument: {group_by}") - - ts = Timesheet( - title=f"{project.title} - {period_str}", - period=period, - project=project, - comment=comment, - date=date, - ) - for record in ts_table.reset_index().to_dict("records"): - ts.items.append(TimeTrackingItem(**record)) - - return ts - - def export_timesheet( timesheet: Timesheet, path: str, diff --git a/tuttle_tests/test_demo.py b/tuttle_tests/test_demo.py new file mode 100644 index 00000000..25f598b8 --- /dev/null +++ b/tuttle_tests/test_demo.py @@ -0,0 +1,64 @@ +import faker +import pytest + +from tuttle.model import Contact, Client, Contract, Project, User +from tuttle import demo + + +@pytest.fixture +def fake(): + return faker.Faker() + + +def test_create_fake_user(fake): + user = demo.create_fake_user(fake) + assert user.name is not None + assert user.email is not None + assert user.subtitle is not None + assert user.VAT_number is not None + + +def test_create_fake_contact(fake): + contact = demo.create_fake_contact(fake) + assert isinstance(contact, Contact) + assert contact.first_name is not None + assert contact.last_name is not None + assert contact.email is not None + assert contact.company is not None + assert contact.address is not None + + +def test_create_fake_client(fake): + client = demo.create_fake_client(fake) + assert isinstance(client, Client) + assert client.name is not None + assert client.invoicing_contact is not None + + +def test_create_fake_contract(fake): + contract = demo.create_fake_contract(fake) + assert isinstance(contract, Contract) + assert contract.title is not None + assert contract.client is not None + assert contract.signature_date is not None + assert contract.start_date is not None + assert contract.rate is not None + assert contract.currency is not None + assert contract.VAT_rate is not None + assert contract.unit is not None + assert contract.units_per_workday is not None + assert contract.volume is not None + assert contract.term_of_payment is not None + assert contract.billing_cycle is not None + + +def test_create_fake_project(fake): + project = demo.create_fake_project(fake) + assert isinstance(project, Project) + assert project.title is not None + assert project.tag is not None + assert project.description is not None + assert project.is_completed is not None + assert project.start_date is not None + assert project.end_date is not None + assert project.contract is not None diff --git a/tuttle_tests/test_rendering.py b/tuttle_tests/test_rendering.py new file mode 100644 index 00000000..5c6a0fe8 --- /dev/null +++ b/tuttle_tests/test_rendering.py @@ -0,0 +1,106 @@ +import tempfile +import pytest +from pathlib import Path + +import faker + +from tuttle import rendering, demo + + +@pytest.fixture +def fake(): + return faker.Faker() + + +class TestRenderTimesheet: + """Tests for render_timesheet""" + + def test_returns_html_when_out_dir_is_none(self, fake): + + user = demo.create_fake_user(fake) + timesheet = demo.create_fake_timesheet(fake) + document_format = "html" + style = "anvil" + only_final = False + + result = rendering.render_timesheet( + user=user, + timesheet=timesheet, + out_dir=None, + document_format=document_format, + style=style, + only_final=only_final, + ) + + assert isinstance(result, str) + + def test_creates_only_final_file(self, fake): + user = demo.create_fake_user(fake) + timesheet = demo.create_fake_timesheet(fake) + document_format = "pdf" + style = "anvil" + only_final = True + + with tempfile.TemporaryDirectory() as out_dir: + rendering.render_timesheet( + user=user, + timesheet=timesheet, + out_dir=out_dir, + document_format=document_format, + style=style, + only_final=only_final, + ) + + prefix = f"Timesheet-{timesheet.title}" + pdf_file = Path(out_dir) / Path(f"{prefix}.pdf") + assert pdf_file.is_file() + + dir = Path(out_dir) / Path(prefix) + assert not dir.exists() + + +class TestRenderInvoice: + """Tests for render_invoice""" + + def test_returns_html_when_out_dir_is_none(self, fake): + + user = demo.create_fake_user(fake) + invoice = demo.create_fake_invoice(fake) + document_format = "html" + style = "anvil" + only_final = False + + result = rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=None, + document_format=document_format, + style=style, + only_final=only_final, + ) + + assert isinstance(result, str) + + def test_creates_only_final_file(self, fake): + user = demo.create_fake_user(fake) + invoice = demo.create_fake_invoice(fake) + document_format = "pdf" + style = "anvil" + only_final = True + + with tempfile.TemporaryDirectory() as out_dir: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=out_dir, + document_format=document_format, + style=style, + only_final=only_final, + ) + + prefix = invoice.prefix + pdf_file = Path(out_dir) / Path(f"{prefix}.pdf") + assert pdf_file.is_file() + + dir = Path(out_dir) / Path(prefix) + assert not dir.exists() diff --git a/tuttle_tests/test_timetracking.py b/tuttle_tests/test_timetracking.py index 184a35bb..40f416b1 100644 --- a/tuttle_tests/test_timetracking.py +++ b/tuttle_tests/test_timetracking.py @@ -68,7 +68,7 @@ def test_create_timesheet( # create a timesheet period_start = datetime.date(2022, 1, 1) period_end = datetime.date(2022, 12, 31) - timesheet = timetracking.create_timesheet( + timesheet = timetracking.generate_timesheet( timetracking_data, project, period_start, period_end ) From 3405cccf78523badc4940816c97eb3cc0aed198e Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 1 Feb 2023 18:09:46 +0100 Subject: [PATCH 4/6] checkpoint: before debugging --- app/core/database_storage_impl.py | 10 +++++-- app/invoicing/intent.py | 6 ++++ tuttle/demo.py | 50 ++++++++++++++++++++++++++----- tuttle/invoicing.py | 2 ++ tuttle/model.py | 26 ++++++++-------- 5 files changed, 71 insertions(+), 23 deletions(-) diff --git a/app/core/database_storage_impl.py b/app/core/database_storage_impl.py index 09501abb..6b5ac30b 100644 --- a/app/core/database_storage_impl.py +++ b/app/core/database_storage_impl.py @@ -1,9 +1,13 @@ +from typing import Callable + import re from pathlib import Path -from loguru import logger -from typing import Callable -import demo + import sqlmodel +from loguru import logger + +from tuttle import demo + from .abstractions import DatabaseStorage diff --git a/app/invoicing/intent.py b/app/invoicing/intent.py index c5f272fc..663110e8 100644 --- a/app/invoicing/intent.py +++ b/app/invoicing/intent.py @@ -45,6 +45,10 @@ def __init__(self, client_storage: ClientStorage): self._user_data_source = UserDataSource() self._auth_intent = AuthIntent() + def get_user(self) -> IntentResult[User]: + user = self._user_data_source.get_user() + return IntentResult(was_intent_successful=True, data=user) + def get_active_projects_as_map(self) -> Mapping[int, Project]: return self._projects_intent.get_active_projects_as_map() @@ -115,6 +119,7 @@ def create_invoice( if render: # render timesheet try: + logger.info(f"⚙️ Rendering timesheet for {project.title}...") rendering.render_timesheet( user=user, timesheet=timesheet, @@ -129,6 +134,7 @@ def create_invoice( logger.exception(ex) # render invoice try: + logger.info(f"⚙️ Rendering invoice for {project.title}...") rendering.render_invoice( user=user, invoice=invoice, diff --git a/tuttle/demo.py b/tuttle/demo.py index f007a111..5773e56a 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -54,11 +54,22 @@ def create_fake_contact( split_address_lines = fake.address().splitlines() street_line = split_address_lines[0] city_line = split_address_lines[1] + try: + # TODO: This has a German bias + street = street_line.split(" ", 1)[0] + number = street_line.split(" ", 1)[1] + city = city_line.split(" ")[1] + postal_code = city_line.split(" ")[0] + except IndexError: + street = street_line + number = "" + city = city_line + postal_code = "" a = Address( - street=street_line, - number=city_line, - city=city_line.split(" ")[1], - postal_code=city_line.split(" ")[0], + street=street, + number=number, + city=city, + postal_code=postal_code, country=fake.country(), ) first_name, last_name = fake.name().split(" ", 1) @@ -212,6 +223,9 @@ def create_fake_invoice( if project is None: project = create_fake_project(fake) + if user is None: + user = create_fake_user(fake) + invoice_number = next(invoice_number_counter) invoice = Invoice( number=str(invoice_number), @@ -243,7 +257,11 @@ def create_fake_invoice( invoice=invoice, ) + # an invoice is created together with a timesheet. For the sake of simplicity, timesheet and invoice items are not linked. + timesheeet = create_fake_timesheet(fake, project) + if render: + # render invoice try: rendering.render_invoice( user=user, @@ -255,6 +273,18 @@ def create_fake_invoice( except Exception as ex: logger.error(f"❌ Error rendering invoice for {project.title}: {ex}") logger.exception(ex) + # render timesheet + try: + rendering.render_timesheet( + user=user, + timesheet=timesheeet, + out_dir=Path.home() / ".tuttle" / "Timesheets", + only_final=True, + ) + logger.info(f"✅ rendered timesheet for {project.title}") + except Exception as ex: + logger.error(f"❌ Error rendering timesheet for {project.title}: {ex}") + logger.exception(ex) return invoice @@ -281,11 +311,15 @@ def create_fake_data( fake = faker.Faker(locale=locales) contacts = [create_fake_contact(fake) for _ in range(n)] - clients = [create_fake_client(contact, fake) for contact in contacts] - contracts = [create_fake_contract(client, fake) for client in clients] - projects = [create_fake_project(contract, fake) for contract in contracts] + clients = [ + create_fake_client(fake, invoicing_contact=contact) for contact in contacts + ] + contracts = [create_fake_contract(fake, client=client) for client in clients] + projects = [create_fake_project(fake, contract=contract) for contract in contracts] - invoices = [create_fake_invoice(project, user, fake) for project in projects] + invoices = [ + create_fake_invoice(fake, project=project, user=user) for project in projects + ] return projects, invoices diff --git a/tuttle/invoicing.py b/tuttle/invoicing.py index d33edfb4..dd4f1b51 100644 --- a/tuttle/invoicing.py +++ b/tuttle/invoicing.py @@ -37,6 +37,8 @@ def generate_invoice( VAT_rate=contract.VAT_rate, description=timesheet.title, ) + # attach timesheet to invoice + timesheet.invoice = invoice # TODO: replace with auto-incrementing numbers invoice.generate_number(counter=counter) return invoice diff --git a/tuttle/model.py b/tuttle/model.py index e5e3e958..0959ac81 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -499,12 +499,12 @@ class Timesheet(SQLModel, table=True): description="Whether the Timesheet has been rendered as a PDF.", ) - # Timesheet 1:1 Invoice - # FIXME: Could not determine join condition between parent/child tables - # invoice_id: Optional[int] = Field(default=None, foreign_key="invoice.id") - # invoice: Optional["Invoice"] = OneToOneRelationship( - # back_populates="timesheet", - # ) + # Timesheet n:1 Invoice + invoice_id: Optional[int] = Field(default=None, foreign_key="invoice.id") + invoice: Optional["Invoice"] = Relationship( + back_populates="timesheets", + sa_relationship_kwargs={"lazy": "subquery"}, + ) # class Config: # arbitrary_types_allowed = True @@ -535,12 +535,8 @@ class Invoice(SQLModel, table=True): description="The date of the invoice", ) - # TODO: sent_date: datetime.datetime = Field(description="The date the invoice was sent.") - # Invoice 1:1 Timesheet - # timesheet_id: Optional[int] = Field(default=None, foreign_key="timesheet.id") - # timesheet: Timesheet = OneToOneRelationship( - # back_populates="invoice", - # ) + # RELATIONSHIPTS + # Invoice n:1 Contract ? contract_id: Optional[int] = Field(default=None, foreign_key="contract.id") contract: Contract = Relationship( @@ -553,6 +549,12 @@ class Invoice(SQLModel, table=True): back_populates="invoices", sa_relationship_kwargs={"lazy": "subquery"}, ) + # Invoice 1:n Timesheet + timesheets: List[Timesheet] = Relationship( + back_populates="invoice", + sa_relationship_kwargs={"lazy": "subquery"}, + ) + # status -- corresponds to InvoiceStatus enum above sent: Optional[bool] = Field(default=False) paid: Optional[bool] = Field(default=False) From e9b7f6d028de0d40b98754e672422f940c415954 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 1 Feb 2023 20:42:29 +0100 Subject: [PATCH 5/6] test: unit tests passing --- tuttle/calendar.py | 15 +++++++ tuttle_tests/test_invoice.py | 71 +++---------------------------- tuttle_tests/test_rendering.py | 1 - tuttle_tests/test_timetracking.py | 7 ++- 4 files changed, 25 insertions(+), 69 deletions(-) diff --git a/tuttle/calendar.py b/tuttle/calendar.py index a634e6a8..f01bb5ce 100644 --- a/tuttle/calendar.py +++ b/tuttle/calendar.py @@ -4,6 +4,7 @@ from pathlib import Path import io import re +import calendar from loguru import logger import ics @@ -180,3 +181,17 @@ class GoogleCalendar(CloudCalendar): def to_data(self) -> DataFrame: raise NotImplementedError("TODO") + + +def get_month_start_end(month_str): + # Parse the string into a datetime object + dt = datetime.datetime.strptime(month_str, "%B %Y") + + # Get the date information from the datetime object + year, month = dt.date().year, dt.date().month + + # Get the start and end dates of the month + start_date = datetime.date(year, month, 1) + end_date = datetime.date(year, month, calendar.monthrange(year, month)[1]) + + return start_date, end_date diff --git a/tuttle_tests/test_invoice.py b/tuttle_tests/test_invoice.py index 57e52456..ad87a7e1 100644 --- a/tuttle_tests/test_invoice.py +++ b/tuttle_tests/test_invoice.py @@ -5,6 +5,7 @@ from tuttle import invoicing, timetracking, rendering from tuttle.model import Invoice, InvoiceItem +from tuttle.calendar import get_month_start_end def test_invoice(): @@ -50,10 +51,12 @@ def test_generate_invoice( for project in demo_projects: timesheets = [] for period in ["January 2022", "February 2022"]: + (period_start, period_end) = get_month_start_end(period) timesheet = timetracking.generate_timesheet( - source=demo_calendar_timetracking, + timetracking_data=demo_calendar_timetracking.to_data(), project=project, - period_start=period, + period_start=period_start, + period_end=period_end, item_description=project.title, ) if not timesheet.empty: @@ -65,67 +68,3 @@ def test_generate_invoice( date=datetime.date.today(), ) # assert invoice.total > 0 - - -def test_render_invoice_to_html( - demo_user, - demo_projects, - demo_calendar_timetracking, -): - for project in demo_projects: - timesheets = [] - for period in ["January 2022", "February 2022"]: - timesheet = timetracking.generate_timesheet( - source=demo_calendar_timetracking, - project=project, - period_start=period, - item_description=project.title, - ) - if not timesheet.empty: - timesheets.append(timesheet) - invoice = invoicing.generate_invoice( - timesheets=timesheets, - contract=project.contract, - project=project, - date=datetime.date.today(), - ) - # RENDERING - rendering.render_invoice( - user=demo_user, - invoice=invoice, - style="anvil", - document_format="html", - out_dir=Path("tuttle_tests/tmp"), - ) - - -def test_render_invoice_to_pdf( - demo_user, - demo_projects, - demo_calendar_timetracking, -): - for project in demo_projects: - timesheets = [] - for period in ["January 2022", "February 2022"]: - timesheet = timetracking.generate_timesheet( - source=demo_calendar_timetracking, - project=project, - period_start=period, - item_description=project.title, - ) - if not timesheet.empty: - timesheets.append(timesheet) - invoice = invoicing.generate_invoice( - timesheets=timesheets, - contract=project.contract, - project=project, - date=datetime.date.today(), - ) - # RENDERING - rendering.render_invoice( - user=demo_user, - invoice=invoice, - style="anvil", - document_format="pdf", - out_dir=Path("tuttle_tests/tmp"), - ) diff --git a/tuttle_tests/test_rendering.py b/tuttle_tests/test_rendering.py index 5c6a0fe8..c36f6637 100644 --- a/tuttle_tests/test_rendering.py +++ b/tuttle_tests/test_rendering.py @@ -16,7 +16,6 @@ class TestRenderTimesheet: """Tests for render_timesheet""" def test_returns_html_when_out_dir_is_none(self, fake): - user = demo.create_fake_user(fake) timesheet = demo.create_fake_timesheet(fake) document_format = "html" diff --git a/tuttle_tests/test_timetracking.py b/tuttle_tests/test_timetracking.py index 40f416b1..69aaa1bf 100644 --- a/tuttle_tests/test_timetracking.py +++ b/tuttle_tests/test_timetracking.py @@ -4,6 +4,7 @@ import datetime from tuttle import timetracking +from tuttle.calendar import get_month_start_end def test_timetracking_import_toggl(): @@ -27,11 +28,13 @@ def test_generate_timesheet_from_demo_calendar( demo_calendar_timetracking, ): for period in ["January 2022", "February 2022"]: + (period_start, period_end) = get_month_start_end(period) for project in demo_projects: timesheet = timetracking.generate_timesheet( - source=demo_calendar_timetracking, + timetracking_data=demo_calendar_timetracking.to_data(), project=project, - period_start=period, + period_start=period_start, + period_end=period_end, item_description=project.title, ) assert (timesheet.empty) or (timesheet.total >= pandas.Timedelta("0 hours")) From efa61dcaf1a2d7f2d333490c714fa30a5731b9a2 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 1 Feb 2023 21:56:59 +0100 Subject: [PATCH 6/6] feat: view timesheet associates with invoice --- app/invoicing/data_source.py | 26 +++++++++++++- app/invoicing/intent.py | 31 +++++++++++++++- app/invoicing/view.py | 14 ++++++++ tuttle/demo.py | 65 ++++++++++++++++++---------------- tuttle/invoicing.py | 3 +- tuttle/model.py | 16 +++++++++ tuttle/rendering.py | 4 +-- tuttle/timetracking.py | 3 +- tuttle_tests/test_rendering.py | 2 +- 9 files changed, 126 insertions(+), 38 deletions(-) diff --git a/app/invoicing/data_source.py b/app/invoicing/data_source.py index c2e1eaca..f42f69dd 100644 --- a/app/invoicing/data_source.py +++ b/app/invoicing/data_source.py @@ -5,7 +5,7 @@ from core.abstractions import SQLModelDataSourceMixin from core.intent_result import IntentResult -from tuttle.model import Invoice, Project +from tuttle.model import Invoice, Project, Timesheet class InvoicingDataSource(SQLModelDataSourceMixin): @@ -74,6 +74,10 @@ def save_invoice( """Creates or updates an invoice with given invoice and project info""" self.store(invoice) + def save_timesheet(self, timesheet: Timesheet): + """Creates or updates a timesheet""" + self.store(timesheet) + def get_last_invoice(self) -> IntentResult[Invoice]: """Get the last invoice. @@ -100,3 +104,23 @@ def get_last_invoice(self) -> IntentResult[Invoice]: log_message=f"Exception raised @InvoicingDataSource.get_last_invoice_number {e.__class__.__name__}", exception=e, ) + + def get_timesheet_for_invoice(self, invoice: Invoice) -> Timesheet: + """Get the timesheet associated with an invoice + + Args: + invoice (Invoice): the invoice to get the timesheet for + + Returns: + Optional[Timesheet]: the timesheet associated with the invoice + """ + if not len(invoice.timesheets) > 0: + raise ValueError( + f"invoice {invoice.id} has no timesheets associated with it" + ) + if len(invoice.timesheets) > 1: + raise ValueError( + f"invoice {invoice.id} has more than one timesheet associated with it: {invoice.timesheets}" + ) + timesheet = invoice.timesheets[0] + return timesheet diff --git a/app/invoicing/intent.py b/app/invoicing/intent.py index 663110e8..4f8500ad 100644 --- a/app/invoicing/intent.py +++ b/app/invoicing/intent.py @@ -95,6 +95,7 @@ def create_invoice( render: bool = True, ) -> IntentResult[Invoice]: """Create a new invoice from time tracking data.""" + logger.info(f"⚙️ Creating invoice for {project.title}...") user = self._user_data_source.get_user() try: # get the time tracking data @@ -145,7 +146,12 @@ def create_invoice( except Exception as ex: logger.error(f"❌ Error rendering invoice for {project.title}: {ex}") logger.exception(ex) - # save invoice + + # save invoice and timesheet + timesheet.invoice = invoice + assert timesheet.invoice is not None + assert len(invoice.timesheets) == 1 + # self._invoicing_data_source.save_timesheet(timesheet) self._invoicing_data_source.save_invoice(invoice) return IntentResult( was_intent_successful=True, @@ -334,6 +340,29 @@ def view_invoice(self, invoice: Invoice) -> IntentResult[None]: error_msg=error_message, ) + def view_timesheet_for_invoice(self, invoice: Invoice) -> IntentResult[None]: + """Attempts to open the timesheet for the invoice in the default pdf viewer""" + try: + timesheet = self._invoicing_data_source.get_timesheet_for_invoice(invoice) + timesheet_path = ( + Path().home() / ".tuttle" / "Timesheets" / f"{timesheet.prefix}.pdf" + ) + preview_pdf(timesheet_path) + return IntentResult(was_intent_successful=True) + except ValueError as ve: + logger.error(f"❌ Error getting timesheet for invoice: {ve}") + logger.exception(ve) + return IntentResult(was_intent_successful=False, error_msg=str(ve)) + except Exception as ex: + # display the execption name in the error message + error_message = f"❌ Failed to open the timesheet: {ex.__class__.__name__}" + logger.error(error_message) + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + error_msg=error_message, + ) + def generate_invoice_number( self, invoice_date: Optional[date] = None, diff --git a/app/invoicing/view.py b/app/invoicing/view.py index a6bf99dc..95db9dec 100644 --- a/app/invoicing/view.py +++ b/app/invoicing/view.py @@ -216,6 +216,7 @@ def refresh_invoices(self): on_delete_clicked=self.on_delete_invoice_clicked, on_mail_invoice=self.on_mail_invoice, on_view_invoice=self.on_view_invoice, + on_view_timesheet=self.on_view_timesheet, toggle_paid_status=self.toggle_paid_status, toggle_cancelled_status=self.toggle_cancelled_status, toggle_sent_status=self.toggle_sent_status, @@ -241,6 +242,12 @@ def on_view_invoice(self, invoice: Invoice): if not result.was_intent_successful: self.show_snack(result.error_msg, is_error=True) + def on_view_timesheet(self, invoice: Invoice): + """Called when the user clicks view in the context menu of an invoice""" + result = self.intent.view_timesheet_for_invoice(invoice) + if not result.was_intent_successful: + self.show_snack(result.error_msg, is_error=True) + def on_delete_invoice_clicked(self, invoice: Invoice): """Called when the user clicks delete in the context menu of an invoice""" if self.editor is not None: @@ -425,6 +432,7 @@ def __init__( on_delete_clicked, on_mail_invoice, on_view_invoice, + on_view_timesheet, toggle_paid_status, toggle_sent_status, toggle_cancelled_status, @@ -433,6 +441,7 @@ def __init__( self.invoice = invoice self.on_delete_clicked = on_delete_clicked self.on_view_invoice = on_view_invoice + self.on_view_timesheet = on_view_timesheet self.on_mail_invoice = on_mail_invoice self.toggle_paid_status = toggle_paid_status self.toggle_sent_status = toggle_sent_status @@ -504,6 +513,11 @@ def build(self): txt="View", on_click=lambda e: self.on_view_invoice(self.invoice), ), + views.TPopUpMenuItem( + icon=icons.VISIBILITY_OUTLINED, + txt="View Timesheet ", + on_click=lambda e: self.on_view_timesheet(self.invoice), + ), views.TPopUpMenuItem( icon=icons.OUTGOING_MAIL, txt="Send", diff --git a/tuttle/demo.py b/tuttle/demo.py index 5773e56a..dedfaffa 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -137,7 +137,7 @@ def create_fake_project( if contract is None: contract = create_fake_contract(fake) - project_title = fake.bs() + project_title = fake.bs().replace("/", "-") project_tag = f"#{'-'.join(project_title.split(' ')[:2]).lower()}" project = Project( @@ -179,9 +179,11 @@ def create_fake_timesheet( if project is None: project = create_fake_project(fake) timesheet = Timesheet( - title=fake.bs(), + title=fake.bs().replace("/", "-"), comment=fake.paragraph(nb_sentences=2), date=datetime.date.today(), + period_start=datetime.date.today() - datetime.timedelta(days=30), + period_end=datetime.date.today(), project=project, ) number_of_items = fake.random_int(min=1, max=5) @@ -257,34 +259,37 @@ def create_fake_invoice( invoice=invoice, ) - # an invoice is created together with a timesheet. For the sake of simplicity, timesheet and invoice items are not linked. - timesheeet = create_fake_timesheet(fake, project) - - if render: - # render invoice - try: - rendering.render_invoice( - user=user, - invoice=invoice, - out_dir=Path.home() / ".tuttle" / "Invoices", - only_final=True, - ) - logger.info(f"✅ rendered invoice for {project.title}") - except Exception as ex: - logger.error(f"❌ Error rendering invoice for {project.title}: {ex}") - logger.exception(ex) - # render timesheet - try: - rendering.render_timesheet( - user=user, - timesheet=timesheeet, - out_dir=Path.home() / ".tuttle" / "Timesheets", - only_final=True, - ) - logger.info(f"✅ rendered timesheet for {project.title}") - except Exception as ex: - logger.error(f"❌ Error rendering timesheet for {project.title}: {ex}") - logger.exception(ex) + # an invoice is created together with a timesheet. For the sake of simplicity, timesheet and invoice items are not linked. + timesheet = create_fake_timesheet(fake, project) + # attach timesheet to invoice + timesheet.invoice = invoice + assert len(invoice.timesheets) == 1 + + if render: + # render invoice + try: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=Path.home() / ".tuttle" / "Invoices", + only_final=True, + ) + logger.info(f"✅ rendered invoice for {project.title}") + except Exception as ex: + logger.error(f"❌ Error rendering invoice for {project.title}: {ex}") + logger.exception(ex) + # render timesheet + try: + rendering.render_timesheet( + user=user, + timesheet=timesheet, + out_dir=Path.home() / ".tuttle" / "Timesheets", + only_final=True, + ) + logger.info(f"✅ rendered timesheet for {project.title}") + except Exception as ex: + logger.error(f"❌ Error rendering timesheet for {project.title}: {ex}") + logger.exception(ex) return invoice diff --git a/tuttle/invoicing.py b/tuttle/invoicing.py index dd4f1b51..98baa455 100644 --- a/tuttle/invoicing.py +++ b/tuttle/invoicing.py @@ -37,8 +37,7 @@ def generate_invoice( VAT_rate=contract.VAT_rate, description=timesheet.title, ) - # attach timesheet to invoice - timesheet.invoice = invoice + # TODO: replace with auto-incrementing numbers invoice.generate_number(counter=counter) return invoice diff --git a/tuttle/model.py b/tuttle/model.py index 0959ac81..ad08a474 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -409,6 +409,9 @@ class Project(SQLModel, table=True): sa_relationship_kwargs={"lazy": "subquery"}, ) + def __repr__(self): + return f"Project(id={self.id}, title={self.title}, tag={self.tag})" + # PROPERTIES @property def client(self) -> Optional[Client]: @@ -482,6 +485,12 @@ class Timesheet(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str date: datetime.date = Field(description="The date of creation of the timesheet") + period_start: datetime.date = Field( + description="The start date of the period covered by the timesheet." + ) + period_end: datetime.date = Field( + description="The end date of the period covered by the timesheet." + ) # Timesheet n:1 Project project_id: Optional[int] = Field(default=None, foreign_key="project.id") @@ -509,6 +518,13 @@ class Timesheet(SQLModel, table=True): # class Config: # arbitrary_types_allowed = True + def __repr__(self): + return f"Timesheet(id={self.id}, tag={self.project.tag}, period_start={self.period_start}, period_end={self.period_end})" + + @property + def prefix(self) -> str: + return f"{self.project.tag[1:]}-{self.period_start.strftime('%Y-%m-%d')}-{self.period_end.strftime('%Y-%m-%d')}" + @property def total(self) -> datetime.timedelta: """Sum of time in timesheet.""" diff --git a/tuttle/rendering.py b/tuttle/rendering.py index 2f70d446..342def2b 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -211,7 +211,7 @@ def render_timesheet( user: User, timesheet: Timesheet, out_dir, - document_format: str = "html", + document_format: str = "pdf", style: str = "anvil", only_final: bool = False, ): @@ -238,7 +238,7 @@ def render_timesheet( return html else: # write invoice html - prefix = f"Timesheet-{timesheet.title}" + prefix = timesheet.prefix timesheet_dir = Path(out_dir) / Path(prefix) timesheet_dir.mkdir(parents=True, exist_ok=True) timesheet_path = timesheet_dir / Path(f"{prefix}.html") diff --git a/tuttle/timetracking.py b/tuttle/timetracking.py index af41d361..787eb554 100644 --- a/tuttle/timetracking.py +++ b/tuttle/timetracking.py @@ -52,7 +52,8 @@ def generate_timesheet( period_str = f"{period_start} - {period_end}" ts = Timesheet( title=f"{project.title} - {period_str}", - # period=period, + period_start=period_start, + period_end=period_end, project=project, comment=comment, date=date, diff --git a/tuttle_tests/test_rendering.py b/tuttle_tests/test_rendering.py index c36f6637..17dc7fd8 100644 --- a/tuttle_tests/test_rendering.py +++ b/tuttle_tests/test_rendering.py @@ -50,7 +50,7 @@ def test_creates_only_final_file(self, fake): only_final=only_final, ) - prefix = f"Timesheet-{timesheet.title}" + prefix = timesheet.prefix pdf_file = Path(out_dir) / Path(f"{prefix}.pdf") assert pdf_file.is_file()