From 5200a22020a3070f6a7538e70a59a3ad95fb1d91 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 28 Feb 2026 20:27:47 +0100 Subject: [PATCH 1/5] Add integration tests for application startup and update pytest configuration - Introduced a new test file for integration tests that verify application startup, including module import checks, database schema creation, and process stability. - Updated pytest configuration in pyproject.toml to include a marker for GUI tests and set default options to exclude them from the test run. --- pyproject.toml | 2 + tuttle_tests/test_app_start.py | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 tuttle_tests/test_app_start.py diff --git a/pyproject.toml b/pyproject.toml index 20cb85c..8a71968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,8 @@ packages = ["tuttle"] [tool.pytest.ini_options] collect_ignore = ["setup.py"] +markers = ["gui: tests that open a GUI window (deselected by default)"] +addopts = "-m 'not gui'" [tool.flake8] exclude = ["docs", ".venv"] diff --git a/tuttle_tests/test_app_start.py b/tuttle_tests/test_app_start.py new file mode 100644 index 0000000..27b3d14 --- /dev/null +++ b/tuttle_tests/test_app_start.py @@ -0,0 +1,129 @@ +"""Integration tests for application startup. + +Verifies that: +- all critical modules can be imported (catches dependency issues), +- the database schema can be materialised in memory, +- the app process starts without an immediate crash. +""" + +import importlib +import os +import signal +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from sqlmodel import SQLModel, create_engine + + +# --------------------------------------------------------------------------- +# 1. Import smoke tests +# --------------------------------------------------------------------------- + +CORE_MODULES = [ + "tuttle", + "tuttle.model", + "tuttle.calendar", + "tuttle.invoicing", + "tuttle.timetracking", + "tuttle.rendering", + "tuttle.tax", + "tuttle.banking", + "tuttle.cloud", + "tuttle.time", + "tuttle.dataviz", + "tuttle.os_functions", + "tuttle.mail", +] + +APP_MODULES = [ + "tuttle.app", + "tuttle.app.core.abstractions", + "tuttle.app.core.database_storage_impl", + "tuttle.app.core.views", + "tuttle.app.auth.view", + "tuttle.app.home.view", + "tuttle.app.projects.view", + "tuttle.app.contracts.view", + "tuttle.app.invoicing.view", + "tuttle.app.timetracking.view", + "tuttle.app.preferences.view", +] + + +@pytest.mark.parametrize("module_name", CORE_MODULES) +def test_import_core_module(module_name): + """Every core library module must be importable without error.""" + mod = importlib.import_module(module_name) + assert mod is not None + + +@pytest.mark.parametrize("module_name", APP_MODULES) +def test_import_app_module(module_name): + """Every application UI module must be importable without error.""" + mod = importlib.import_module(module_name) + assert mod is not None + + +# --------------------------------------------------------------------------- +# 2. Database schema creation +# --------------------------------------------------------------------------- + + +def test_database_schema_creation(tmp_path): + """The full SQLModel schema must materialise as SQLite tables.""" + import tuttle.model # noqa: F401 — registers all tables + + db_path = tmp_path / "test_startup.db" + engine = create_engine(f"sqlite:///{db_path}") + SQLModel.metadata.create_all(engine) + + conn = sqlite3.connect(db_path) + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + conn.close() + + expected_tables = {"user", "contact", "address", "client", "contract", "project"} + missing = expected_tables - {t.lower() for t in tables} + assert not missing, f"Missing tables: {missing}" + + +# --------------------------------------------------------------------------- +# 3. Application process smoke test +# --------------------------------------------------------------------------- + +APP_ENTRY_POINT = Path(__file__).resolve().parent.parent / "app.py" +STARTUP_WAIT_SECONDS = 5 + + +@pytest.mark.gui +def test_app_process_starts(): + """The application process must survive initial startup without crashing.""" + # start_new_session gives the app its own process group so we can + # kill it together with any child processes (e.g. the Flet desktop client). + proc = subprocess.Popen( + [sys.executable, str(APP_ENTRY_POINT)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + try: + time.sleep(STARTUP_WAIT_SECONDS) + exit_code = proc.poll() + if exit_code is not None: + _, stderr = proc.communicate(timeout=2) + pytest.fail( + f"App exited prematurely with code {exit_code}.\n" + f"stderr:\n{stderr.decode(errors='replace')}" + ) + finally: + pgid = os.getpgid(proc.pid) + os.killpg(pgid, signal.SIGKILL) + proc.wait(timeout=5) From e4a221f54de64118a313c40f0783ec0451308c4c Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 28 Feb 2026 20:33:57 +0100 Subject: [PATCH 2/5] Add date presets functionality to InvoicingEditorPopUp - Introduced a new row of date presets for quick selection of date ranges: "This Month", "Last Month", and "2 Months Ago". - Implemented a handler method to set the date range based on the selected preset, enhancing user experience in date selection. --- tuttle/app/invoicing/view.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tuttle/app/invoicing/view.py b/tuttle/app/invoicing/view.py index da80775..72eae34 100644 --- a/tuttle/app/invoicing/view.py +++ b/tuttle/app/invoicing/view.py @@ -1,5 +1,6 @@ from typing import Callable, List, Optional +import calendar as _calendar import datetime as _dt from datetime import datetime, timedelta, date from decimal import Decimal @@ -284,6 +285,19 @@ def __init__( size=fonts.BODY_2_SIZE, visible=False, ) + date_presets_row = Row( + spacing=dimens.SPACE_XS, + controls=[ + _FilterChip( + label=label, on_click=self._make_date_preset_handler(months_back) + ) + for label, months_back in [ + ("This Month", 0), + ("Last Month", 1), + ("2 Months Ago", 2), + ] + ], + ) dialog = AlertDialog( bgcolor=colors.bg_surface, content=Container( @@ -307,6 +321,7 @@ def __init__( self.projects_dropdown, views.Spacer(), views.SectionLabel("Date Range"), + date_presets_row, self.from_date_field, self.to_date_field, views.Spacer(xs_space=True), @@ -324,6 +339,24 @@ def __init__( self.project = self.invoice.project if is_editing else None self.on_submit = on_submit + def _make_date_preset_handler(self, months_back: int): + """Return a click handler that sets the date range to a full month.""" + + def _handler(e): + today = date.today() + year = today.year + month = today.month - months_back + while month < 1: + month += 12 + year -= 1 + first_day = date(year, month, 1) + last_day = date(year, month, _calendar.monthrange(year, month)[1]) + self.from_date_field.set_date(first_day) + self.to_date_field.set_date(last_day) + self.dialog.update() + + return _handler + def _show_error(self, message: str): """Display an inline error message inside the dialog.""" self.error_text.value = message From 43aef90d97055059b29b538c9317fa833c1fc045 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 28 Feb 2026 20:42:42 +0100 Subject: [PATCH 3/5] Add sortable fields to list views for clients, contacts, contracts, and projects - Implemented `get_sortable_fields` method in `ClientsListView`, `ContactsListView`, `ContractsListView`, and `ProjectsListView` to allow sorting by relevant fields such as name, last name, title, and dates. - Enhanced the `CrudListView` class to support sorting functionality, including dropdown selection and sorting direction toggle. - Improved the refresh logic to sort entities based on the selected field and direction, enhancing user experience in managing lists. --- tuttle/app/clients/view.py | 5 ++ tuttle/app/contacts/view.py | 7 +++ tuttle/app/contracts/view.py | 12 +++++ tuttle/app/core/views.py | 89 +++++++++++++++++++++++++++++++++--- tuttle/app/projects/view.py | 11 +++++ 5 files changed, 118 insertions(+), 6 deletions(-) diff --git a/tuttle/app/clients/view.py b/tuttle/app/clients/view.py index 0fdf763..92732dc 100644 --- a/tuttle/app/clients/view.py +++ b/tuttle/app/clients/view.py @@ -421,6 +421,11 @@ class ClientsListView(views.CrudListView): entity_name_plural = "clients" on_add_intent_key = res_utils.ADD_CLIENT_INTENT + def get_sortable_fields(self): + return [ + ("Name", lambda c: (c.name or "").lower()), + ] + def __init__(self, params: TViewParams): self.intent = ClientsIntent() super().__init__(params=params) diff --git a/tuttle/app/contacts/view.py b/tuttle/app/contacts/view.py index 35e9376..e0b7a1d 100644 --- a/tuttle/app/contacts/view.py +++ b/tuttle/app/contacts/view.py @@ -283,6 +283,13 @@ class ContactsListView(views.CrudListView): entity_name_plural = "contacts" on_add_intent_key = res_utils.ADD_CONTACT_INTENT + def get_sortable_fields(self): + return [ + ("Last Name", lambda c: (c.last_name or "").lower()), + ("First Name", lambda c: (c.first_name or "").lower()), + ("Company", lambda c: (c.company or "").lower()), + ] + def __init__(self, params: TViewParams): self.intent = ContactsIntent() super().__init__(params) diff --git a/tuttle/app/contracts/view.py b/tuttle/app/contracts/view.py index 7356930..d868c6c 100644 --- a/tuttle/app/contracts/view.py +++ b/tuttle/app/contracts/view.py @@ -1,5 +1,6 @@ from typing import Callable, Optional +import datetime from enum import Enum from flet import ( @@ -559,6 +560,17 @@ class ContractsListView(views.CrudListView): entity_name = "contract" entity_name_plural = "contracts" + def get_sortable_fields(self): + return [ + ("Title", lambda c: (c.title or "").lower()), + ( + "Start Date", + lambda c: c.start_date if c.start_date else datetime.date.min, + ), + ("End Date", lambda c: c.end_date if c.end_date else datetime.date.min), + ("Client", lambda c: (c.client.name if c.client else "").lower()), + ] + def __init__(self, params: TViewParams): self.intent = ContractsIntent() super().__init__(params) diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index 4811a31..db56670 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -1099,24 +1099,78 @@ class CrudListView(TView, Column): entity_name_plural: str = "" on_add_intent_key: Optional[str] = None + def get_sortable_fields(self) -> list[tuple[str, Callable]]: + """Return a list of (label, key_func) for fields the user can sort by. + + Override in subclasses. Each key_func receives an entity and returns + a comparable value. Sorting direction is toggled via a separate button. + """ + return [] + def __init__(self, params: TViewParams): TView.__init__(self, params) Column.__init__(self) + self._sort_ascending: bool = True + self._sort_field_index: int = 0 self.loading_indicator = TProgressBar() self.no_items_control = TBodyText( txt=f"You have not added any {self.entity_name_plural} yet", color=colors.text_muted, show=False, ) + + sortable = self.get_sortable_fields() + sort_control = None + if sortable: + self._sort_dropdown = Dropdown( + value="0", + options=[ + DropdownOption(key=str(i), text=label) + for i, (label, _) in enumerate(sortable) + ], + on_select=self._on_sort_field_changed, + width=180, + text_size=fonts.BODY_2_SIZE, + content_padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS + ), + dense=True, + ) + self._sort_dir_btn = IconButton( + icon=Icons.ARROW_UPWARD, + icon_size=dimens.ICON_SIZE, + icon_color=colors.text_secondary, + tooltip="Ascending", + on_click=self._on_sort_dir_toggled, + ) + sort_control = Row( + spacing=0, + vertical_alignment=CrossAxisAlignment.CENTER, + controls=[ + Icon(Icons.SORT, size=dimens.ICON_SIZE, color=colors.text_muted), + self._sort_dropdown, + self._sort_dir_btn, + ], + ) + + heading_row = Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=CrossAxisAlignment.CENTER, + controls=[ + THeading( + f"My {self.entity_name_plural.title()}", + size=fonts.HEADLINE_3_SIZE, + ), + ] + + ([sort_control] if sort_control else []), + ) + self.title_control = ResponsiveRow( controls=[ Column( col={"xs": 12}, controls=[ - THeading( - f"My {self.entity_name_plural.title()}", - size=fonts.HEADLINE_3_SIZE, - ), + heading_row, self.loading_indicator, self.no_items_control, ], @@ -1157,13 +1211,36 @@ def on_save_entity(self, entity): """Override for inline save handling (contacts, clients).""" pass + def _on_sort_field_changed(self, e): + self._sort_field_index = int(e.control.value) + self.refresh_list() + self.update_self() + + def _on_sort_dir_toggled(self, e): + self._sort_ascending = not self._sort_ascending + self._sort_dir_btn.icon = ( + Icons.ARROW_UPWARD if self._sort_ascending else Icons.ARROW_DOWNWARD + ) + self._sort_dir_btn.tooltip = ( + "Ascending" if self._sort_ascending else "Descending" + ) + self.refresh_list() + self.update_self() + # -- Lifecycle methods (generic) ------------------------------------------- def refresh_list(self): """Clears and rebuilds the items container from items_to_display.""" self.items_container.controls.clear() - for key in self.items_to_display: - entity = self.items_to_display[key] + entities = list(self.items_to_display.values()) + sortable = self.get_sortable_fields() + if sortable and 0 <= self._sort_field_index < len(sortable): + _, key_func = sortable[self._sort_field_index] + entities.sort( + key=lambda ent: (key_func(ent) is None, key_func(ent)), + reverse=not self._sort_ascending, + ) + for entity in entities: card = self.make_card(entity) self.items_container.controls.append(card) diff --git a/tuttle/app/projects/view.py b/tuttle/app/projects/view.py index 1b14166..6d69c54 100644 --- a/tuttle/app/projects/view.py +++ b/tuttle/app/projects/view.py @@ -1,5 +1,6 @@ from typing import Callable, Optional +import datetime from enum import Enum from flet import ( @@ -388,6 +389,16 @@ class ProjectsListView(views.CrudListView): entity_name = "project" entity_name_plural = "projects" + def get_sortable_fields(self): + return [ + ("Title", lambda p: (p.title or "").lower()), + ( + "Start Date", + lambda p: p.start_date if p.start_date else datetime.date.min, + ), + ("End Date", lambda p: p.end_date if p.end_date else datetime.date.min), + ] + def __init__(self, params): self.intent = ProjectsIntent() super().__init__(params) From c3ab50ba7490d3819de111f3f9b89eda54441443 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 28 Feb 2026 20:53:50 +0100 Subject: [PATCH 4/5] Enhance referential integrity and deletion handling across models - Added `ondelete` constraints to various foreign key fields in the `Contact`, `Client`, `Contract`, `Project`, `Timesheet`, and `Invoice` models to enforce referential integrity. - Implemented `deletion_guards` in `ClientsIntent`, `ContactsIntent`, `ContractsIntent`, and `ProjectsIntent` to prevent deletion of entities that are still referenced by related records, providing user-friendly error messages. - Updated the `delete` method in `CrudIntent` to check for related records before deletion, ensuring that integrity constraints are respected and raising appropriate exceptions when necessary. - Added tests to verify that entities cannot be deleted if they are referenced by others, ensuring robust data integrity in the application. --- tuttle/app/clients/intent.py | 3 + tuttle/app/contacts/intent.py | 15 ++-- tuttle/app/contracts/intent.py | 4 ++ tuttle/app/core/abstractions.py | 67 ++++++++++++++++-- tuttle/app/invoicing/intent.py | 4 +- tuttle/app/projects/intent.py | 4 ++ tuttle/model.py | 51 ++++++++++---- tuttle_tests/test_model.py | 119 ++++++++++++++++++++++++++++++++ 8 files changed, 234 insertions(+), 33 deletions(-) diff --git a/tuttle/app/clients/intent.py b/tuttle/app/clients/intent.py index a7e88c2..12b9a34 100644 --- a/tuttle/app/clients/intent.py +++ b/tuttle/app/clients/intent.py @@ -9,6 +9,9 @@ class ClientsIntent(CrudIntent): entity_type = Client entity_name = "client" + deletion_guards = [ + ("contracts", "contracts", lambda c: c.title), + ] def __init__(self): super().__init__() diff --git a/tuttle/app/contacts/intent.py b/tuttle/app/contacts/intent.py index 2ae7523..300010d 100644 --- a/tuttle/app/contacts/intent.py +++ b/tuttle/app/contacts/intent.py @@ -8,6 +8,9 @@ class ContactsIntent(CrudIntent): entity_type = Contact entity_name = "contact" + deletion_guards = [ + ("invoicing_contact_of", "clients", lambda c: c.name), + ] def save_contact(self, contact: Contact) -> IntentResult: """Validate and save a contact.""" @@ -24,15 +27,5 @@ def save_contact(self, contact: Contact) -> IntentResult: return self.save(contact) def delete_contact(self, contact_id) -> IntentResult: - """Delete only if the contact is not an invoicing contact of any client.""" - result = self.get_by_id(contact_id) - if not result.was_intent_successful: - return result - contact: Contact = result.data - if len(contact.invoicing_contact_of) > 0: - client_names = ", ".join(c.name for c in contact.invoicing_contact_of) - return IntentResult( - was_intent_successful=False, - error_msg=f"Contact {contact.name} cannot be deleted because it is invoicing contact of clients: {client_names}", - ) + """Alias kept for backward compatibility.""" return self.delete(contact_id) diff --git a/tuttle/app/contracts/intent.py b/tuttle/app/contracts/intent.py index e4230ec..1135ce9 100644 --- a/tuttle/app/contracts/intent.py +++ b/tuttle/app/contracts/intent.py @@ -12,6 +12,10 @@ class ContractsIntent(CrudIntent): """Handles Contract CRUD intents.""" entity_type = Contract + deletion_guards = [ + ("projects", "projects", lambda p: p.title), + ("invoices", "invoices", lambda i: i.number or f"#{i.id}"), + ] def __init__(self): super().__init__() diff --git a/tuttle/app/core/abstractions.py b/tuttle/app/core/abstractions.py index 5a75506..7bcbf51 100644 --- a/tuttle/app/core/abstractions.py +++ b/tuttle/app/core/abstractions.py @@ -7,6 +7,7 @@ from flet import AlertDialog, FilePicker +import sqlalchemy import sqlmodel from sqlmodel import pool @@ -191,6 +192,11 @@ def __init__( connect_args={"check_same_thread": False}, poolclass=pool.StaticPool, ) + sqlalchemy.event.listen( + self.db_engine, + "connect", + lambda dbapi_conn, _: dbapi_conn.execute("PRAGMA foreign_keys = ON"), + ) def create_session(self): return sqlmodel.Session( @@ -265,12 +271,20 @@ def store(self, entity: sqlmodel.SQLModel): session.refresh(entity) def delete_by_id(self, entity_type: Type[sqlmodel.SQLModel], entity_id: int): - """Deletes the entity of the given type with the given id from the database""" + """Deletes the entity of the given type with the given id from the database. + + Uses ORM-level delete so that cascade relationships are honoured. + Raises ``sqlalchemy.exc.IntegrityError`` when a foreign-key + constraint prevents the deletion (e.g. entity is still referenced). + """ logger.debug(f"deleting {entity_type} with id={entity_id}") with self.create_session() as session: - session.exec( - sqlmodel.delete(entity_type).where(entity_type.id == entity_id) - ) + entity = session.get(entity_type, entity_id) + if entity is None: + raise ValueError( + f"{entity_type.__name__} with id={entity_id} not found" + ) + session.delete(entity) session.commit() @@ -301,10 +315,18 @@ class CrudIntent(SQLModelDataSourceMixin, Intent): Subclasses must set `entity_type` to the SQLModel class they manage. Optionally set `entity_name` for human-readable error messages. + + To prevent deletion when related records exist, set ``deletion_guards`` + to a list of ``(relationship_attr, label, display_func)`` tuples. + *relationship_attr* is the attribute name on the entity that holds the + referencing collection, *label* is a human-readable noun (plural) for + the error message, and *display_func* extracts a short display string + from each related object. """ entity_type: Type[sqlmodel.SQLModel] entity_name: str = "" + deletion_guards: List[tuple] = [] def __init__(self): SQLModelDataSourceMixin.__init__(self) @@ -361,10 +383,45 @@ def save(self, entity) -> IntentResult: ) def delete(self, entity_id) -> IntentResult: - """Delete an entity by its id.""" + """Delete an entity by its id. + + Checks ``deletion_guards`` first to produce a user-friendly message + when related records still reference this entity. Falls back to the + database-level ``IntegrityError`` as a safety net. + """ + if self.deletion_guards: + result = self.get_by_id(entity_id) + if not result.was_intent_successful: + return result + entity = result.data + for attr, label, display_fn in self.deletion_guards: + related = getattr(entity, attr, None) or [] + if related: + names = ", ".join(display_fn(r) for r in related) + entity_desc = getattr(entity, "name", None) or getattr( + entity, "title", f"#{entity_id}" + ) + return IntentResult( + was_intent_successful=False, + error_msg=( + f"Cannot delete {self.entity_name} " + f"'{entity_desc}' because it is " + f"referenced by {label}: {names}" + ), + ) try: self.delete_by_id(self.entity_type, entity_id) return IntentResult(was_intent_successful=True) + except sqlalchemy.exc.IntegrityError as e: + return IntentResult( + was_intent_successful=False, + error_msg=( + f"Cannot delete this {self.entity_name} because it is " + f"still referenced by other records." + ), + log_message=f"{self.__class__.__name__}.delete({entity_id}): {e}", + exception=e, + ) except Exception as e: return IntentResult( was_intent_successful=False, diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 7daf4f5..9815c24 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -72,7 +72,7 @@ def get_all_invoices_as_map(self) -> Mapping[int, Invoice]: return {} def delete_invoice_by_id(self, invoice_id) -> IntentResult[None]: - """Delete an invoice by id.""" + """Delete an invoice by id (cascades to timesheets and invoice items).""" try: self._invoicing_data_source.delete_invoice_by_id(invoice_id) return IntentResult(was_intent_successful=True) @@ -81,7 +81,7 @@ def delete_invoice_by_id(self, invoice_id) -> IntentResult[None]: logger.exception(ex) return IntentResult( was_intent_successful=False, - error_msg="Could not delete invoice. ", + error_msg="Could not delete invoice.", ) def create_invoice( diff --git a/tuttle/app/projects/intent.py b/tuttle/app/projects/intent.py index 90e447a..c29e81a 100644 --- a/tuttle/app/projects/intent.py +++ b/tuttle/app/projects/intent.py @@ -12,6 +12,10 @@ class ProjectsIntent(CrudIntent): """Handles intents related to the projects data UI.""" entity_type = Project + deletion_guards = [ + ("invoices", "invoices", lambda i: i.number or f"#{i.id}"), + ("timesheets", "timesheets", lambda t: t.title), + ] def __init__(self): super().__init__() diff --git a/tuttle/model.py b/tuttle/model.py index af97e2c..5143e65 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -210,7 +210,8 @@ class Contact(SQLModel, table=True): back_populates="contacts", sa_relationship_kwargs={"lazy": "subquery"} ) invoicing_contact_of: List["Client"] = Relationship( - back_populates="invoicing_contact", sa_relationship_kwargs={"lazy": "subquery"} + back_populates="invoicing_contact", + sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) # post address @@ -267,13 +268,16 @@ class Client(SQLModel, table=True): description="Name of the client.", ) # Client 1:1 invoicing Contact - invoicing_contact_id: int = Field(default=None, foreign_key="contact.id") + invoicing_contact_id: int = Field( + default=None, foreign_key="contact.id", ondelete="RESTRICT" + ) invoicing_contact: Contact = Relationship( back_populates="invoicing_contact_of", sa_relationship_kwargs={"lazy": "subquery"}, ) contracts: List["Contract"] = Relationship( - back_populates="client", sa_relationship_kwargs={"lazy": "subquery"} + back_populates="client", + sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) # non-invoice related contact person? @@ -303,6 +307,7 @@ class Contract(SQLModel, table=True): client_id: Optional[int] = Field( default=None, foreign_key="client.id", + ondelete="RESTRICT", ) rate: condecimal(decimal_places=2) = Field( description="Rate of remuneration", @@ -336,10 +341,12 @@ class Contract(SQLModel, table=True): description="How often is an invoice sent?", ) projects: List["Project"] = Relationship( - back_populates="contract", sa_relationship_kwargs={"lazy": "subquery"} + back_populates="contract", + sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) invoices: List["Invoice"] = Relationship( - back_populates="contract", sa_relationship_kwargs={"lazy": "subquery"} + back_populates="contract", + sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) # TODO: model contractual promises like "at least 2 days per week" @@ -396,7 +403,9 @@ class Project(SQLModel, table=True): default=False, description="marks if the project is completed" ) # Project m:n Contract - contract_id: Optional[int] = Field(default=None, foreign_key="contract.id") + contract_id: Optional[int] = Field( + default=None, foreign_key="contract.id", ondelete="RESTRICT" + ) contract: Contract = Relationship( back_populates="projects", sa_relationship_kwargs={"lazy": "subquery"}, @@ -404,12 +413,12 @@ class Project(SQLModel, table=True): # Project 1:n Timesheet timesheets: List["Timesheet"] = Relationship( back_populates="project", - sa_relationship_kwargs={"lazy": "subquery"}, + sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) # Project 1:n Invoice invoices: List["Invoice"] = Relationship( back_populates="project", - sa_relationship_kwargs={"lazy": "subquery"}, + sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) def __repr__(self): @@ -471,7 +480,9 @@ def get_status(self, default: str = "") -> str: class TimeTrackingItem(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) # TimeTrackingItem n : 1 TimeSheet - timesheet_id: Optional[int] = Field(default=None, foreign_key="timesheet.id") + timesheet_id: Optional[int] = Field( + default=None, foreign_key="timesheet.id", ondelete="CASCADE" + ) timesheet: Optional["Timesheet"] = Relationship(back_populates="items") # begin: datetime.datetime = Field(description="Start time of the time interval.") @@ -498,7 +509,9 @@ class Timesheet(SQLModel, table=True): ) # Timesheet n:1 Project - project_id: Optional[int] = Field(default=None, foreign_key="project.id") + project_id: Optional[int] = Field( + default=None, foreign_key="project.id", ondelete="RESTRICT" + ) project: Project = Relationship( back_populates="timesheets", sa_relationship_kwargs={"lazy": "subquery"}, @@ -520,7 +533,9 @@ class Timesheet(SQLModel, table=True): ) # Timesheet n:1 Invoice - invoice_id: Optional[int] = Field(default=None, foreign_key="invoice.id") + invoice_id: Optional[int] = Field( + default=None, foreign_key="invoice.id", ondelete="CASCADE" + ) invoice: Optional["Invoice"] = Relationship( back_populates="timesheets", sa_relationship_kwargs={"lazy": "subquery"}, @@ -564,14 +579,18 @@ class Invoice(SQLModel, table=True): # RELATIONSHIPTS - # Invoice n:1 Contract ? - contract_id: Optional[int] = Field(default=None, foreign_key="contract.id") + # Invoice n:1 Contract + contract_id: Optional[int] = Field( + default=None, foreign_key="contract.id", ondelete="RESTRICT" + ) contract: Contract = Relationship( back_populates="invoices", sa_relationship_kwargs={"lazy": "subquery"}, ) # Invoice n:1 Project - project_id: Optional[int] = Field(default=None, foreign_key="project.id") + project_id: Optional[int] = Field( + default=None, foreign_key="project.id", ondelete="RESTRICT" + ) project: Project = Relationship( back_populates="invoices", sa_relationship_kwargs={"lazy": "subquery"}, @@ -669,7 +688,9 @@ class InvoiceItem(SQLModel, table=True): description: str VAT_rate: Decimal # invoice - invoice_id: Optional[int] = Field(default=None, foreign_key="invoice.id") + invoice_id: Optional[int] = Field( + default=None, foreign_key="invoice.id", ondelete="CASCADE" + ) invoice: Invoice = Relationship( back_populates="items", sa_relationship_kwargs={"lazy": "subquery"}, diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index ca5e8cc..3143676 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -222,3 +222,122 @@ def test_invalid_tag_instantiation(self): end_date=datetime.date(2022, 12, 31), ) ) + + +# --------------------------------------------------------------------------- +# Deletion guard / referential integrity tests +# --------------------------------------------------------------------------- + + +def _make_engine_with_fk(tmp_path): + """Create an in-memory SQLite engine with FK enforcement enabled.""" + import sqlalchemy as sa + + db_path = tmp_path / "integrity_test.db" + engine = create_engine(f"sqlite:///{db_path}") + sa.event.listen( + engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys = ON") + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _seed(session): + """Insert a minimal entity chain: Address -> Contact -> Client -> Contract -> Project.""" + from tuttle.model import Cycle, TimeUnit + + addr = Address( + street="1st St", number="1", city="C", postal_code="00000", country="US" + ) + contact = Contact( + first_name="Jane", last_name="Doe", email="jane@example.com", address=addr + ) + client = Client(name="Acme", invoicing_contact=contact) + contract = Contract( + title="Support", + client=client, + signature_date=datetime.date(2024, 1, 1), + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 12, 31), + rate=100, + currency="EUR", + billing_cycle=Cycle.monthly, + unit=TimeUnit.hour, + ) + project = Project( + title="Website", + description="Build a website", + tag="#website", + start_date=datetime.date(2024, 1, 1), + end_date=datetime.date(2024, 6, 30), + contract=contract, + ) + session.add(project) + session.commit() + session.refresh(contact) + session.refresh(client) + session.refresh(contract) + session.refresh(project) + return contact, client, contract, project + + +class TestDeletionGuards: + """Verify that entities referenced by others cannot be deleted.""" + + def test_cannot_delete_contact_used_by_client(self, tmp_path): + engine = _make_engine_with_fk(tmp_path) + with Session(engine, expire_on_commit=False) as s: + contact, client, _, _ = _seed(s) + with Session(engine) as s: + c = s.get(Contact, contact.id) + s.delete(c) + with pytest.raises(Exception): + s.commit() + + def test_cannot_delete_client_used_by_contract(self, tmp_path): + engine = _make_engine_with_fk(tmp_path) + with Session(engine, expire_on_commit=False) as s: + _, client, _, _ = _seed(s) + with Session(engine) as s: + c = s.get(Client, client.id) + s.delete(c) + with pytest.raises(Exception): + s.commit() + + def test_cannot_delete_contract_used_by_project(self, tmp_path): + engine = _make_engine_with_fk(tmp_path) + with Session(engine, expire_on_commit=False) as s: + _, _, contract, _ = _seed(s) + with Session(engine) as s: + c = s.get(Contract, contract.id) + s.delete(c) + with pytest.raises(Exception): + s.commit() + + def test_can_delete_project_without_references(self, tmp_path): + engine = _make_engine_with_fk(tmp_path) + with Session(engine, expire_on_commit=False) as s: + _, _, _, project = _seed(s) + with Session(engine) as s: + p = s.get(Project, project.id) + s.delete(p) + s.commit() + assert s.get(Project, project.id) is None + + def test_can_delete_leaf_to_root_sequentially(self, tmp_path): + """Deleting in reverse dependency order must succeed.""" + engine = _make_engine_with_fk(tmp_path) + with Session(engine, expire_on_commit=False) as s: + contact, client, contract, project = _seed(s) + with Session(engine) as s: + s.delete(s.get(Project, project.id)) + s.commit() + with Session(engine) as s: + s.delete(s.get(Contract, contract.id)) + s.commit() + with Session(engine) as s: + s.delete(s.get(Client, client.id)) + s.commit() + with Session(engine) as s: + s.delete(s.get(Contact, contact.id)) + s.commit() From 854da3faac31b5c1936a084f4639a2f634ae6480 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 28 Feb 2026 21:28:38 +0100 Subject: [PATCH 5/5] Refactor contract and project display logic in view components - Simplified the display of contract and project data in `ViewContractScreen` and `ViewProjectScreen` by introducing a new method for building field rows, enhancing code readability and maintainability. - Removed redundant control initializations and streamlined the visibility logic for various fields. - Updated the invoicing process to support manual quantity entry, allowing users to create invoices directly from specified quantities without relying solely on time-tracking data. - Enhanced the `InvoicingEditorPopUp` to include a mode toggle for selecting between time tracking and manual entry, improving user experience in invoice creation. --- tuttle/app/contracts/view.py | 84 ++++++++++--------------- tuttle/app/core/views.py | 44 +++++++++++++ tuttle/app/invoicing/intent.py | 112 +++++++++++++++++++-------------- tuttle/app/invoicing/view.py | 105 ++++++++++++++++++++++++++----- tuttle/app/projects/view.py | 51 ++++++--------- 5 files changed, 248 insertions(+), 148 deletions(-) diff --git a/tuttle/app/contracts/view.py b/tuttle/app/contracts/view.py index d868c6c..198dcc7 100644 --- a/tuttle/app/contracts/view.py +++ b/tuttle/app/contracts/view.py @@ -614,30 +614,16 @@ def __init__(self, params: TViewParams, contract_id: str): super().__init__(params, contract_id, ContractsIntent()) def display_entity_data(self): - """Displays the data for the contract.""" c = self.entity self.contract_title_control.value = c.title self.client_control.value = c.client.name if c.client else "Unknown" - self.start_date_control.value = c.start_date - self.end_date_control.value = c.end_date + self.update_field_rows(c) _status = c.get_status(default="") if _status: self.status_control.value = f"Status {_status}" self.status_control.visible = True else: self.status_control.visible = False - self.billing_cycle_control.value = ( - c.billing_cycle.value if c.billing_cycle else "" - ) - self.rate_control.value = c.rate - self.currency_control.value = c.currency - self.vat_rate_control.value = f"{(c.VAT_rate) * 100:.0f} %" - time_unit = c.unit.value if c.unit else "" - self.unit_control.value = time_unit - self.units_per_workday_control.value = f"{c.units_per_workday} {time_unit}" - self.volume_control.value = f"{c.volume} {time_unit}" - self.term_of_payment_control.value = f"{c.term_of_payment} days" - self.signature_date_control.value = c.signature_date self.toggle_compete_status_btn.tooltip = ( "Mark as incomplete" if c.is_completed else "Mark as completed" ) @@ -648,7 +634,6 @@ def display_entity_data(self): ) def build(self): - """Called when page is built""" self.edit_contract_btn = IconButton( icon=Icons.EDIT_OUTLINED, tooltip="Edit contract", @@ -670,25 +655,42 @@ def build(self): icon_size=dimens.ICON_SIZE, ) - self.client_control = views.THeading() self.contract_title_control = views.THeading() - self.billing_cycle_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.rate_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.currency_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.vat_rate_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.unit_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.units_per_workday_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.volume_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.term_of_payment_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - - self.signature_date_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.start_date_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - self.end_date_control = views.TBodyText(align=utils.TXT_ALIGN_JUSTIFY) - + self.client_control = views.THeading() self.status_control = views.TBodyText( size=fonts.BUTTON_SIZE, color=colors.accent ) + _unit = lambda c: c.unit.value if c.unit else "" + field_rows = self.build_field_rows( + [ + ("Billing Cycle", "billing_cycle"), + ("Rate", "rate"), + ("Currency", "currency"), + ( + "VAT Rate", + lambda c: f"{float(c.VAT_rate) * 100:.0f} %" + if c.VAT_rate is not None + else "", + ), + ("Time Unit", "unit"), + ("Units per Workday", lambda c: f"{c.units_per_workday} {_unit(c)}"), + ( + "Volume", + lambda c: f"{c.volume} {_unit(c)}" if c.volume is not None else "", + ), + ( + "Term of Payment", + lambda c: f"{c.term_of_payment} days" + if c.term_of_payment is not None + else "", + ), + ("Signed on Date", "signature_date"), + ("Start Date", "start_date"), + ("End Date", "end_date"), + ] + ) + self.content = Row( [ Container( @@ -759,27 +761,7 @@ def build(self): ], ), views.Spacer(md_space=True), - self.get_body_element( - "Billing Cycle", self.billing_cycle_control - ), - self.get_body_element("Rate", self.rate_control), - self.get_body_element("Currency", self.currency_control), - self.get_body_element("Vat Rate", self.vat_rate_control), - self.get_body_element("Time Unit", self.unit_control), - self.get_body_element( - "Units per Workday", self.units_per_workday_control - ), - self.get_body_element("Volume", self.volume_control), - self.get_body_element( - "Term of Payment (days)", self.term_of_payment_control - ), - self.get_body_element( - "Signed on Date", self.signature_date_control - ), - self.get_body_element( - "Start Date", self.start_date_control - ), - self.get_body_element("End Date", self.end_date_control), + *field_rows, views.Spacer(md_space=True), Row( spacing=dimens.SPACE_STD, diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index db56670..cb7dad4 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -1455,5 +1455,49 @@ def get_body_element(self, label: str, control: Control) -> ResponsiveRow: ] ) + # -- Declarative field binding --------------------------------------------- + + @staticmethod + def _resolve_field_value(entity, accessor) -> str: + """Resolve *accessor* against *entity* to a Flet-safe string. + + *accessor* is either a callable ``(entity) -> str`` or an attribute + name. For attribute names the value is auto-converted: + ``None`` -> ``""``, ``Enum`` -> ``.value``, everything else -> ``str()``. + """ + if callable(accessor): + val = accessor(entity) + return str(val) if val is not None else "" + val = getattr(entity, accessor, None) + if val is None: + return "" + if isinstance(val, Enum): + return str(val.value) + if isinstance(val, str): + return val + return str(val) + + def build_field_rows(self, specs: list[tuple]) -> list[ResponsiveRow]: + """Create controls and layout rows from a list of field specs. + + Each spec is ``(label, accessor)`` where *accessor* is a string + attribute name or a callable ``(entity) -> str``. + + Returns a list of ``ResponsiveRow`` controls ready to be placed in the + layout. Call :meth:`update_field_rows` later to populate them. + """ + self._field_specs: list[tuple[str, typing.Any, TBodyText]] = [] + rows: list[ResponsiveRow] = [] + for label, accessor in specs: + control = TBodyText(align=utils.TXT_ALIGN_JUSTIFY) + self._field_specs.append((label, accessor, control)) + rows.append(self.get_body_element(label, control)) + return rows + + def update_field_rows(self, entity) -> None: + """Refresh all controls created by :meth:`build_field_rows`.""" + for _label, accessor, control in self._field_specs: + control.value = self._resolve_field_value(entity, accessor) + def will_unmount(self): self.mounted = False diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 9815c24..664a52c 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -14,7 +14,7 @@ from ..timetracking.intent import TimeTrackingIntent from ... import invoicing, mail, os_functions, rendering, timetracking -from ...model import Invoice, Project, Timesheet, User +from ...model import Invoice, InvoiceItem, Project, Timesheet, User from .data_source import InvoicingDataSource from ..auth.intent import AuthIntent @@ -91,84 +91,100 @@ def create_invoice( from_date: date, to_date: date, render: bool = True, + manual_quantity: Optional[float] = None, ) -> IntentResult[Invoice]: - """Create a new invoice from time tracking data.""" - logger.info(f"⚙️ Creating invoice for {project.title}...") + """Create a new invoice. + + When *manual_quantity* is provided the invoice is built directly from + the given quantity and the contract rate, without requiring imported + time-tracking data. Otherwise the existing time-tracking flow is + used. + """ + logger.info(f"Creating invoice for {project.title}...") user = self._user_data_source.get_user() try: - # get the time tracking data - timetracking_data = self._timetracking_data_source.get_data_frame() - # generate timesheet - timesheet: Timesheet = timetracking.generate_timesheet( - timetracking_data, - project, - from_date, - to_date, - ) - invoice_number = self._invoicing_data_source.generate_invoice_number( invoice_date ) - invoice: Invoice = invoicing.generate_invoice( - date=invoice_date, - number=invoice_number, - timesheets=[ - timesheet, - ], - contract=project.contract, - project=project, - ) + if manual_quantity is not None: + contract = project.contract + item = InvoiceItem( + start_date=from_date, + end_date=to_date, + quantity=manual_quantity, + unit=contract.unit.value if contract.unit else "hour", + unit_price=contract.rate, + description=project.title, + VAT_rate=contract.VAT_rate, + ) + invoice = Invoice( + date=invoice_date, + number=invoice_number, + contract=contract, + project=project, + items=[item], + ) + else: + # ── Time-tracking path (existing) ───────────────── + timetracking_data = self._timetracking_data_source.get_data_frame() + timesheet: Timesheet = timetracking.generate_timesheet( + timetracking_data, + project, + from_date, + to_date, + ) + invoice: Invoice = invoicing.generate_invoice( + date=invoice_date, + number=invoice_number, + timesheets=[timesheet], + contract=project.contract, + project=project, + ) + timesheet.invoice = invoice if render: - # render timesheet + if manual_quantity is None and "timesheet" in locals(): + try: + rendering.render_timesheet( + user=user, + timesheet=timesheet, + out_dir=Path.home() / ".tuttle" / "Timesheets", + only_final=True, + ) + except Exception as ex: + logger.error( + f"Error rendering timesheet for {project.title}: {ex}" + ) + logger.exception(ex) try: - logger.info(f"⚙️ Rendering timesheet for {project.title}...") - 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: - logger.info(f"⚙️ Rendering invoice for {project.title}...") 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.error(f"Error rendering invoice for {project.title}: {ex}") logger.exception(ex) - # 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, data=invoice, ) except ValueError: - error_message = f"No time tracking data found for project '{project.title}' between {from_date} and {to_date}." + error_message = ( + f"No time tracking data found for project " + f"'{project.title}' between {from_date} and {to_date}." + ) logger.error(error_message) return IntentResult( was_intent_successful=False, error_msg=error_message, ) except Exception as ex: - error_message = "Failed to create invoice. " + error_message = "Failed to create invoice." logger.error(error_message) logger.exception(ex) return IntentResult( diff --git a/tuttle/app/invoicing/view.py b/tuttle/app/invoicing/view.py index 72eae34..274b831 100644 --- a/tuttle/app/invoicing/view.py +++ b/tuttle/app/invoicing/view.py @@ -241,18 +241,20 @@ class InvoicingEditorPopUp(DialogHandler, Column): an invoice object to edit, defaults to None if a new one is to be created """ + _MODE_TIMETRACKING = "timetracking" + _MODE_MANUAL = "manual" + def __init__( self, dialog_controller: Callable[[any, utils.AlertDialogControls], None], on_submit: Callable, projects_map, invoice: Optional[Invoice] = None, + has_timetracking_data: bool = True, ): - # set the dimensions of the pop up pop_up_height = dimens.MIN_WINDOW_HEIGHT * 0.9 pop_up_width = int(dimens.MIN_WINDOW_WIDTH * 0.8) - # initialize the data today = datetime.today() yesterday = datetime.now() - timedelta(1) is_editing = invoice is not None @@ -263,6 +265,12 @@ def __init__( for id, project in self.projects_as_map.items() ] title = "Edit Invoice" if is_editing else "New Invoice" + + self._has_timetracking_data = has_timetracking_data + self._mode = ( + self._MODE_TIMETRACKING if has_timetracking_data else self._MODE_MANUAL + ) + self.date_field = views.DateSelector( label="Invoice Date", initial_date=self.invoice.date, @@ -285,6 +293,21 @@ def __init__( size=fonts.BODY_2_SIZE, visible=False, ) + + # Mode toggle + self._mode_row = Row( + spacing=dimens.SPACE_XS, + controls=self._build_mode_chips(), + ) + + # Quantity field (manual mode only); label adapts to the contract unit + self.quantity_field = views.TTextField( + label="Quantity (hours)", + hint="e.g. 40", + keyboard_type=utils.KEYBOARD_NUMBER, + show=self._is_manual, + ) + date_presets_row = Row( spacing=dimens.SPACE_XS, controls=[ @@ -316,6 +339,8 @@ def __init__( show=is_editing, ), views.Spacer(xs_space=True), + self._mode_row, + views.Spacer(xs_space=True), self.date_field, views.Spacer(xs_space=True), self.projects_dropdown, @@ -325,6 +350,8 @@ def __init__( self.from_date_field, self.to_date_field, views.Spacer(xs_space=True), + self.quantity_field, + views.Spacer(xs_space=True), self.error_text, ], ), @@ -339,6 +366,35 @@ def __init__( self.project = self.invoice.project if is_editing else None self.on_submit = on_submit + @property + def _is_manual(self) -> bool: + return self._mode == self._MODE_MANUAL + + def _build_mode_chips(self): + tt_click = ( + (lambda e: self._set_mode(self._MODE_TIMETRACKING)) + if self._has_timetracking_data + else None + ) + return [ + _FilterChip( + label="From Time Tracking", + active=not self._is_manual, + on_click=tt_click, + ), + _FilterChip( + label="Manual Entry", + active=self._is_manual, + on_click=lambda e: self._set_mode(self._MODE_MANUAL), + ), + ] + + def _set_mode(self, mode: str): + self._mode = mode + self.quantity_field.visible = self._is_manual + self._mode_row.controls = self._build_mode_chips() + self.dialog.update() + def _make_date_preset_handler(self, months_back: int): """Return a click handler that sets the date range to a full month.""" @@ -363,12 +419,19 @@ def _show_error(self, message: str): self.error_text.visible = True self.dialog.update() + def _unit_label(self) -> str: + """Return a human-readable unit label from the selected project's contract.""" + if self.project and self.project.contract and self.project.contract.unit: + return f"{self.project.contract.unit.value}s" + return "hours" + def on_project_selected(self, e): selected_project = e.control.value - # extract id from selected text id_ = int(selected_project.split(" ")[0]) if id_ in self.projects_as_map: self.project = self.projects_as_map[id_] + self.quantity_field.label = f"Quantity ({self._unit_label()})" + self.dialog.update() def on_submit_btn_clicked(self, e): """Called when the "Done" button is clicked""" @@ -388,8 +451,24 @@ def on_submit_btn_clicked(self, e): self._show_error("The start date cannot be after the end date.") return + manual_quantity: Optional[float] = None + if self._is_manual: + raw = (self.quantity_field.value or "").strip() + unit = self._unit_label() + if not raw: + self._show_error(f"Please enter the number of {unit}.") + return + try: + manual_quantity = float(raw) + except ValueError: + self._show_error(f"The number of {unit} must be a valid number.") + return + if manual_quantity <= 0: + self._show_error(f"The number of {unit} must be greater than zero.") + return + self.close_dialog() - self.on_submit(self.invoice, self.project, from_date, to_date) + self.on_submit(self.invoice, self.project, from_date, to_date, manual_quantity) class InvoicingListView(TView, Column): @@ -461,21 +540,15 @@ def is_user_missing_payment_info( def parent_intent_listener(self, intent: str, data: any): """Handles the intent from the parent view""" if intent == res_utils.CREATE_INVOICE_INTENT: - # create a new invoice if self.is_user_missing_payment_info(): - return # can't create invoice without payment info - if self.time_tracking_data is None: - self.show_snack( - "You need to import time tracking data before invoices can be created.", - is_error=True, - ) - return # can't create invoice without time tracking data + return if self.editor is not None: self.editor.close_dialog() self.editor = InvoicingEditorPopUp( dialog_controller=self.dialog_controller, on_submit=self.on_save_invoice, projects_map=self.active_projects, + has_timetracking_data=self.time_tracking_data is not None, ) self.editor.open_dialog() @@ -686,10 +759,11 @@ def on_save_invoice( project: Project, from_date: Optional[datetime.date], to_date: Optional[datetime.date], + manual_quantity: Optional[float] = None, ): """Called when the user clicks on the submit button in the editor""" if not invoice: - return # this should never happen + return if not project: self.show_snack("Please specify the project") @@ -707,15 +781,14 @@ def on_save_invoice( self.loading_indicator.visible = True self.update_self() if is_updating: - # update the invoice result: IntentResult = self.intent.update_invoice(invoice=invoice) else: - # create a new invoice result: IntentResult = self.intent.create_invoice( invoice_date=invoice.date, project=project, from_date=from_date, to_date=to_date, + manual_quantity=manual_quantity, ) if not result.was_intent_successful: @@ -824,7 +897,7 @@ def build(self): color=colors.text_secondary, ), views.TBodyText( - "Create your first invoice from a tracked project.", + "Create your first invoice from time tracking or manual entry.", size=fonts.BODY_2_SIZE, color=colors.text_muted, ), diff --git a/tuttle/app/projects/view.py b/tuttle/app/projects/view.py index 6d69c54..6140a09 100644 --- a/tuttle/app/projects/view.py +++ b/tuttle/app/projects/view.py @@ -170,38 +170,32 @@ def __init__(self, params: TViewParams, project_id: str): super().__init__(params, project_id, ProjectsIntent()) def display_entity_data(self): - """displays the project data on the screen""" p = self.entity - has_contract = True if p.contract else False - has_client = True if has_contract and p.contract.client else False + has_contract = p.contract is not None + has_client = has_contract and p.contract.client is not None self.project_title_control.value = p.title self.client_control.value = ( - f"Client {p.contract.client.name}" if has_client else "Client not specified" + p.contract.client.name if has_client else "Client not specified" ) self.contract_control.value = ( - f"Contract Title: {p.contract.title}" - if has_contract - else "Contract not specified" + p.contract.title if has_contract else "Contract not specified" ) - self.project_description_control.value = p.description - self.project_start_date_control.value = f"Start Date: {p.start_date}" - self.project_end_date_control.value = f"End Date: {p.end_date}" + self.update_field_rows(p) _status = p.get_status(default="") if _status: self.project_status_control.value = f"Status {_status}" self.project_status_control.visible = True else: self.project_status_control.visible = False - self.project_tagline_control.value = f"{p.tag}" - is_project_completed = p.is_completed + self.project_tagline_control.value = str(p.tag) if p.tag else "" self.toggle_complete_status_btn.icon = ( Icons.RADIO_BUTTON_CHECKED_OUTLINED - if is_project_completed + if p.is_completed else Icons.RADIO_BUTTON_UNCHECKED_OUTLINED ) self.toggle_complete_status_btn.tooltip = ( - "Mark as incomplete" if is_project_completed else "Mark as complete" + "Mark as incomplete" if p.is_completed else "Mark as complete" ) def on_view_client_clicked(self, e): @@ -248,19 +242,8 @@ def build(self): ) self.project_title_control = views.THeading() - self.client_control = views.TSubHeading(color=colors.text_secondary) self.contract_control = views.TSubHeading(color=colors.text_secondary) - self.project_description_control = views.TBodyText( - align=utils.TXT_ALIGN_JUSTIFY - ) - - self.project_start_date_control = views.TSubHeading( - size=fonts.BUTTON_SIZE, color=colors.text_secondary - ) - self.project_end_date_control = views.TSubHeading( - size=fonts.BUTTON_SIZE, color=colors.text_secondary - ) self.project_status_control = views.TSubHeading( size=fonts.BUTTON_SIZE, color=colors.accent @@ -269,7 +252,15 @@ def build(self): size=fonts.BUTTON_SIZE, color=colors.accent ) - page_view = Row( + field_rows = self.build_field_rows( + [ + ("Description", "description"), + ("Start Date", "start_date"), + ("End Date", "end_date"), + ] + ) + + self.content = Row( [ Container( padding=Padding.all(dimens.SPACE_STD), @@ -341,12 +332,7 @@ def build(self): ], ), views.Spacer(md_space=True), - views.TSubHeading( - subtitle="Project Description", - ), - self.project_description_control, - self.project_start_date_control, - self.project_end_date_control, + *field_rows, views.Spacer(md_space=True), Row( spacing=dimens.SPACE_STD, @@ -380,7 +366,6 @@ def build(self): vertical_alignment=utils.START_ALIGNMENT, expand=True, ) - self.content = page_view class ProjectsListView(views.CrudListView):