Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 3 additions & 0 deletions tuttle/app/clients/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand Down
5 changes: 5 additions & 0 deletions tuttle/app/clients/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 4 additions & 11 deletions tuttle/app/contacts/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)
7 changes: 7 additions & 0 deletions tuttle/app/contacts/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions tuttle/app/contracts/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand Down
96 changes: 45 additions & 51 deletions tuttle/app/contracts/view.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Callable, Optional

import datetime
from enum import Enum

from flet import (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -602,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"
)
Expand All @@ -636,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",
Expand All @@ -658,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(
Expand Down Expand Up @@ -747,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,
Expand Down
67 changes: 62 additions & 5 deletions tuttle/app/core/abstractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from flet import AlertDialog, FilePicker

import sqlalchemy
import sqlmodel
from sqlmodel import pool

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading