commit d6505069ce88dbcfee5692113c2316191e6ca10e parent 973ef22345dfce7e9154909e8bc4238564a81176 Author: lash <dev@holbrook.no> Date: Mon, 30 Mar 2026 06:49:20 -0600 Merge branch 'carlos/set_entry_date' into lash/personal-accounting Diffstat:
13 files changed, 212 insertions(+), 330 deletions(-)
diff --git a/dummy/usawa/core/chain_manager.py b/dummy/usawa/core/chain_manager.py @@ -1,158 +0,0 @@ -import os -import logging -from usawa import Ledger, load -from usawa.crypto import ACL, DemoWallet -from usawa.store import LedgerStore -from whee.valkey import ValkeyStore -import os -import shutil -import logging - -from usawa import Ledger, load - -logg = logging.getLogger("core.chain_manager") - - -def default_chain_dir(): - """Return the conventional XDG data directory for usawa ledger files. - - Defaults to ~/.local/share/usawa/ledger/ unless XDG_DATA_HOME is set. - Directory is created if it does not exist. - """ - xdg_data = os.environ.get( - 'XDG_DATA_HOME', - os.path.join(os.path.expanduser('~'), '.local', 'share'), - ) - path = os.path.join(xdg_data, 'usawa', 'ledger') - os.makedirs(path, exist_ok=True) - return path - - -class LedgerChainManager: - - def __init__(self, genesis_path=None): - """Initialise the chain manager. - - On first ever launch, supply genesis_path so it can be copied into - the conventional directory as 0.xml. On subsequent launches, omit - genesis_path — the manager will reconstruct the chain from disk. - - :param genesis_path: Path to the genesis XML file (optional after first run) - :type genesis_path: str or None - """ - self.basedir = default_chain_dir() - logg.debug('chain dir: {}'.format(self.basedir)) - - zero = os.path.join(self.basedir, '0.xml') - if not os.path.exists(zero): - if genesis_path is None: - raise FileNotFoundError( - 'no chain found in {} and no genesis_path provided'.format(self.basedir) - ) - genesis_path = os.path.realpath(genesis_path) - if not os.path.exists(genesis_path): - raise FileNotFoundError('genesis file not found: {}'.format(genesis_path)) - shutil.copy(genesis_path, zero) - logg.debug('copied genesis {} → {}'.format(genesis_path, zero)) - - self.chain = self._reconstruct_chain() - logg.debug('chain reconstructed with depth {}'.format(self.depth())) - - - def _reconstruct_chain(self): - """Rebuild the chain list by scanning sequential xml files on disk. - - Starts at 0.xml and stops at the first missing index. - """ - chain = [] - index = 0 - while True: - path = os.path.join(self.basedir, '{}.xml'.format(index)) - if not os.path.exists(path): - break - chain.append(path) - index += 1 - if not chain: - raise FileNotFoundError('no chain files found in: {}'.format(self.basedir)) - return chain - - - def current(self): - """Return the path of the most recent XML file in the chain.""" - return self.chain[-1] - - - def derive_next(self): - """Derive the next output file path based on the current chain length. - - genesis = index 0, first entry output = index 1, and so on. - """ - next_index = len(self.chain) - basename = '{}.xml'.format(next_index) - return os.path.join(self.basedir, basename) - - - def advance(self, written_path): - """Call this after successfully writing an entry output file. - - Verifies the file exists before appending to the chain. - """ - written_path = os.path.realpath(written_path) - if not os.path.exists(written_path): - raise FileNotFoundError( - 'written file not found, cannot advance chain: {}'.format(written_path) - ) - self.chain.append(written_path) - logg.debug('chain advanced to: {}'.format(written_path)) - - - def load_current(self): - """Load and return a fresh Ledger instance from the current chain tail.""" - ledger_path = self.current() - ledger_tree = load(ledger_path) - ledger = Ledger.from_tree(ledger_tree) - logg.debug('loaded ledger from: {}'.format(ledger_path)) - return ledger - - - def write_entry(self, entry, ledger, store, wallet): - """Sign and write an entry, then advance the chain. - - Encapsulates the full write sequence: - entry.sign → store.add_entry → ledger.truncate → ledger.sign → write file - """ - next_path = self.derive_next() - - raise NotImplementedError('not sure if we need this') - db = ValkeyStore('') - - store = LedgerStore(db, ledger) - pk = store.get_key() - wallet = DemoWallet(privatekey=pk) - logg.debug("wallet pk: %s pubk: %s", wallet.privkey().hex(), wallet.pubkey().hex()) - ledger.set_wallet(wallet) - - - ledger.acl = ACL.from_wallet(wallet) - self.ledger = ledger - - entry.sign(wallet) - store.add_entry(entry, update_ledger=True) - ledger.truncate() - ledger.sign() - - with open(next_path, 'wb') as f: - f.write(ledger.to_string()) - logg.debug('entry written to: {}'.format(next_path)) - - self.advance(next_path) - return next_path - - - def depth(self): - """Return the number of files in the chain including genesis.""" - return len(self.chain) - - - def __repr__(self): - return 'LedgerChainManager(depth={}, current={})'.format(self.depth(), self.current()) diff --git a/dummy/usawa/core/entry_service.py b/dummy/usawa/core/entry_service.py @@ -1,70 +0,0 @@ -import logging -from datetime import datetime -import uuid - -from .models import LedgerEntry -from usawa.storage.ledger_repository import LedgerRepository - -logg = logging.getLogger("core.entry_service") - - -class EntryService: - """Business logic for ledger entries""" - - def __init__(self, repository: LedgerRepository): - self.repository = repository - - def save_entry(self, entry: LedgerEntry) -> tuple[bool, str]: - try: - entry.tx_date = datetime.now() - entry.date_registered = datetime.now() - entry.transaction_ref = self._generate_transaction_ref() - - is_valid, error_msg = entry.validate() - if not is_valid: - logg.error(f"Entry validation failed: {error_msg}") - return False, error_msg - - self.repository.save(entry) - - logg.info(f"Entry saved successfully") - return True, "" - - except FileExistsError as e: - error_msg = ( - "Some file information for this entry is already recorded in the ledger" - ) - return False, error_msg - - except ValueError as e: - error_msg = f"Invalid entry data: {str(e)}" - logg.error(f"Validation error: {e}") - return False, error_msg - - except IOError as e: - error_msg = f"File error: {str(e)}" - logg.error(f"File operation failed: {e}") - return False, error_msg - - except Exception as e: - error_msg = f"Failed to save entry: {str(e)}" - logg.error(f"Unexpected error: {e}", exc_info=True) - return False, error_msg - - def save_wallet(self, wallet, passphrase): - return self.repository.save_wallet(wallet=wallet, passphrase=passphrase) - - def get_all_entries(self): - return self.repository.get_all_entries() - - def _generate_transaction_ref(self) -> str: - return str(uuid.uuid4()) - - def get_asset_bytes(self, digest: bytes) -> bytes: - return self.repository.get_asset_bytes(digest=digest) - - def export_all_entries_to_xml(self, output_path: str) -> tuple[bool, str]: - return self.repository.export_all_entries_to_xml(output_path=output_path) - - def export_entry_to_xml(self, serial: int, output_path: str) -> tuple[bool, str]: - return self.repository.export_entry_to_xml(serial, output_path) diff --git a/dummy/usawa/core/models.py b/dummy/usawa/core/models.py @@ -1,95 +0,0 @@ -from dataclasses import dataclass,field -from typing import Optional,List, Union -from datetime import datetime -from pathlib import Path - -@dataclass -class LedgerEntry: - """Ledger entry data model""" - - # Basic details - external_reference: Optional[str] = None - description: Optional[str] = None - - # Transaction details - amount: float = 0.0 - source_unit: str = "" - source_type: str = "" - source_path: str = "general" - dest_unit: str = "" - dest_type: str = "" - dest_path: str = "general" - - # Attachments - attachments: List[str] = field(default_factory=list) - - # Signers (public keys) - signer_pubkeys: List[str] = field(default_factory=list) - - serial: Optional[int] = None - tx_date: Optional[datetime] = None - tx_reference: Optional[str] = None - date_registered: Optional[datetime] = None - parent_digest: Optional[str] = None - unit_index: Optional[int] = None - - def validate(self) -> tuple[bool, str]: - """Validate entry data""" - if self.amount <= 0: - return False, "Amount must be greater than 0" - - if not self.source_unit or not self.dest_unit: - return False, "Unit/Currency is required for both source and destination" - - if not self.source_type or not self.dest_type: - return False, "Account type is required for both source and destination" - - if not self.source_path or not self.dest_path: - return False, "Account path is required for both source and destination" - - # Validate attachments - if self.attachments: - for filepath in self.attachments: - if not Path(filepath).exists(): - return False, f"Attachment file not found: {filepath}" - - return True, "" - - def __repr__(self): - return ( - f"LedgerEntry(" - f"external_reference={self.external_reference!r}, " - f"description={self.description!r}, " - f"serial={self.serial!r}, " - f"parent_digest={self.parent_digest}, " - f"amount={self.amount}, " - f"source_unit={self.source_unit}, " - f"tx_ref={self.tx_reference}, " - f"source_type={self.source_type}, " - f"dest_unit={self.dest_unit}, " - f"dest_type={self.dest_type})" - f"attachments={self.attachments!r})" - ) - - def add_attachment(self, filepath: Union[str, List[str]]): - """ - Add one or more attachment file paths - """ - if isinstance(filepath, str): - if filepath not in self.attachments: - self.attachments.append(filepath) - elif isinstance(filepath, list): - for path in filepath: - if path not in self.attachments: - self.attachments.append(path) - else: - raise TypeError(f"filepath must be str or List[str], got {type(filepath)}") - - def remove_attachment(self, filepath: str): - """Remove an attachment file path""" - if filepath in self.attachments: - self.attachments.remove(filepath) - - def get_attachment_count(self) -> int: - """Get number of attachments""" - return len(self.attachments) diff --git a/dummy/usawa/gui/components/wallet_setup.py b/dummy/usawa/gui/components/wallet_setup.py @@ -5,7 +5,7 @@ import threading import logging import threading -from usawa.core.state_manager import StateManager +from ..core.state_manager import StateManager from usawa.crypto import DemoWallet diff --git a/dummy/usawa/gui/controllers/entry_controller.py b/dummy/usawa/gui/controllers/entry_controller.py @@ -1,8 +1,9 @@ +from datetime import datetime import logging from typing import Optional -from usawa.core.entry_service import EntryService -from ...core.models import LedgerEntry +from ..core.entry_service import EntryService +from ..core.models import LedgerEntry logg = logging.getLogger("gui.entry_controller") @@ -16,12 +17,27 @@ class EntryController: def collect_entry_data(self, view) -> Optional[LedgerEntry]: """Collect data from the view and create an entry""" + + tx_date_str = view.date_entry.get_text().strip() + tx_time_str = view.time_entry.get_text().strip() + + try: + if tx_time_str: + tx_date = datetime.strptime( + f"{tx_date_str} {tx_time_str}", "%Y-%m-%d %H:%M:%S" + ) + else: + tx_date = datetime.strptime(tx_date_str, "%Y-%m-%d").date() + except ValueError: + logg.error("Invalid date/time format") + return None try: entry = LedgerEntry( external_reference=view.ref_entry.get_text().strip() or None, description=view.desc_entry.get_text().strip() or None, amount=float(view.amount_entry.get_text() or "0"), source_unit="BTC", + tx_date=tx_date, source_type=view.get_source_type(), source_path=view.source_path_entry.get_text().strip(), dest_unit="BTC", diff --git a/dummy/usawa/core/__init__.py b/dummy/usawa/gui/core/__init__.py diff --git a/dummy/usawa/gui/core/entry_service.py b/dummy/usawa/gui/core/entry_service.py @@ -0,0 +1,69 @@ +import logging +from datetime import datetime +import uuid + +from .models import LedgerEntry +from usawa.storage.ledger_repository import LedgerRepository + +logg = logging.getLogger("core.entry_service") + + +class EntryService: + """Business logic for ledger entries""" + + def __init__(self, repository: LedgerRepository): + self.repository = repository + + def save_entry(self, entry: LedgerEntry) -> tuple[bool, str]: + try: + entry.date_registered = datetime.now() + entry.transaction_ref = self._generate_transaction_ref() + + is_valid, error_msg = entry.validate() + if not is_valid: + logg.error(f"Entry validation failed: {error_msg}") + return False, error_msg + + self.repository.save(entry) + + logg.info(f"Entry saved successfully") + return True, "" + + except FileExistsError as e: + error_msg = ( + "Some file information for this entry is already recorded in the ledger" + ) + return False, error_msg + + except ValueError as e: + error_msg = f"Invalid entry data: {str(e)}" + logg.error(f"Validation error: {e}") + return False, error_msg + + except IOError as e: + error_msg = f"File error: {str(e)}" + logg.error(f"File operation failed: {e}") + return False, error_msg + + except Exception as e: + error_msg = f"Failed to save entry: {str(e)}" + logg.error(f"Unexpected error: {e}", exc_info=True) + return False, error_msg + + def save_wallet(self, wallet, passphrase): + return self.repository.save_wallet(wallet=wallet, passphrase=passphrase) + + def get_all_entries(self): + return self.repository.get_all_entries() + + def _generate_transaction_ref(self) -> str: + return str(uuid.uuid4()) + + def get_asset_bytes(self, digest: bytes) -> bytes: + return self.repository.get_asset_bytes(digest=digest) + + def export_all_entries_to_xml(self, output_path: str) -> tuple[bool, str]: + return self.repository.export_all_entries_to_xml(output_path=output_path) + + def export_entry_to_xml(self, serial: int, output_path: str) -> tuple[bool, str]: + return self.repository.export_entry_to_xml(serial, output_path) diff --git a/dummy/usawa/gui/core/models.py b/dummy/usawa/gui/core/models.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass, field +from typing import Optional, List, Union +from datetime import datetime +from pathlib import Path + + +@dataclass +class LedgerEntry: + """DTO for ledger entry input, converted to usawa.Entry before adding to ledger.""" + + # Basic details + external_reference: Optional[str] = None + description: Optional[str] = None + + # Transaction details + amount: float = 0.0 + source_unit: str = "" + source_type: str = "" + source_path: str = "general" + dest_unit: str = "" + dest_type: str = "" + dest_path: str = "general" + + # Attachments + attachments: List[str] = field(default_factory=list) + + # Signers (public keys) + signer_pubkeys: List[str] = field(default_factory=list) + + serial: Optional[int] = None + tx_date: Optional[datetime] = None + tx_reference: Optional[str] = None + date_registered: Optional[datetime] = None + parent_digest: Optional[str] = None + unit_index: Optional[int] = None + + def validate(self) -> tuple[bool, str]: + """Validate entry data""" + if self.amount <= 0: + return False, "Amount must be greater than 0" + + if not self.source_unit or not self.dest_unit: + return False, "Unit/Currency is required for both source and destination" + + if not self.source_type or not self.dest_type: + return False, "Account type is required for both source and destination" + + if not self.source_path or not self.dest_path: + return False, "Account path is required for both source and destination" + + # Validate attachments + if self.attachments: + for filepath in self.attachments: + if not Path(filepath).exists(): + return False, f"Attachment file not found: {filepath}" + + return True, "" + + def add_attachment(self, filepath: Union[str, List[str]]): + """ + Add one or more attachment file paths + """ + if isinstance(filepath, str): + if filepath not in self.attachments: + self.attachments.append(filepath) + elif isinstance(filepath, list): + for path in filepath: + if path not in self.attachments: + self.attachments.append(path) + else: + raise TypeError(f"filepath must be str or List[str], got {type(filepath)}") diff --git a/dummy/usawa/core/state_manager.py b/dummy/usawa/gui/core/state_manager.py diff --git a/dummy/usawa/gui/main_window.py b/dummy/usawa/gui/main_window.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from usawa.core.state_manager import StateManager +from .core.state_manager import StateManager from usawa.crypto import DemoWallet from usawa.gui.components.passphrase_dialog import ( PASSPHRASE_DIALOG_CSS, @@ -9,7 +9,7 @@ from usawa.gui.components.passphrase_dialog import ( from usawa.gui.components.wallet_setup import ImportWalletDialog from usawa.ledger import Ledger from usawa.service import UnixClient -from usawa.core.entry_service import EntryService +from .core.entry_service import EntryService from usawa.storage.ledger_repository import LedgerRepository from gi.repository import Adw, Gtk, Gio, GLib, Gdk from usawa import load diff --git a/dummy/usawa/gui/views/create_entry_view.py b/dummy/usawa/gui/views/create_entry_view.py @@ -1,3 +1,4 @@ +from datetime import datetime import logging from gi.repository import Gtk, Adw, Gio, Pango from pathlib import Path @@ -137,6 +138,38 @@ class CreateEntryView(Gtk.Box): header.add_css_class("heading") section_box.append(header) + # Transaction date + date_label = Gtk.Label(label="Transaction Date") + date_label.set_halign(Gtk.Align.START) + date_label.add_css_class("dim-label") + section_box.append(date_label) + + date_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + self.date_entry = Gtk.Entry() + self.date_entry.set_placeholder_text("YYYY-MM-DD") + self.date_entry.set_text(datetime.now().strftime("%Y-%m-%d")) + self.date_entry.set_hexpand(True) + date_box.append(self.date_entry) + + calendar_btn = Gtk.Button() + calendar_btn.set_icon_name("x-office-calendar-symbolic") + calendar_btn.add_css_class("flat") + calendar_btn.connect("clicked", self._on_show_calendar) + date_box.append(calendar_btn) + section_box.append(date_box) + + # Optional time + time_label = Gtk.Label(label="Transaction Time (optional)") + time_label.set_halign(Gtk.Align.START) + time_label.add_css_class("dim-label") + section_box.append(time_label) + + self.time_entry = Gtk.Entry() + self.time_entry.set_placeholder_text("HH:MM:SS") + section_box.append(self.time_entry) + + # Amount amount_label = Gtk.Label(label="Amount") amount_label.set_halign(Gtk.Align.START) amount_label.add_css_class("dim-label") @@ -146,6 +179,7 @@ class CreateEntryView(Gtk.Box): self.amount_entry.set_placeholder_text("0.00") section_box.append(self.amount_entry) + # Source / Destination cards ledger_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) ledger_box.set_homogeneous(True) @@ -515,3 +549,18 @@ class CreateEntryView(Gtk.Box): if parent is not None: parent.remove(row) logg.debug("Removed attachment: %s", file_path) + + def _on_show_calendar(self, button): + popover = Gtk.Popover() + popover.set_parent(button) + + calendar = Gtk.Calendar() + calendar.connect("day-selected", lambda c: self._on_date_selected(c, popover)) + popover.set_child(calendar) + popover.popup() + + def _on_date_selected(self, calendar, popover): + date = calendar.get_date() + date_str = f"{date.get_year():04d}-{date.get_month():02d}-{date.get_day_of_month():02d}" + self.date_entry.set_text(date_str) + popover.popdown() diff --git a/dummy/usawa/storage/entry_mapper.py b/dummy/usawa/storage/entry_mapper.py @@ -3,7 +3,7 @@ from datetime import datetime, date from usawa.entry import Entry, EntryPart from usawa.unit import UnitIndex -from ..core.models import LedgerEntry +from ..gui.core.models import LedgerEntry from usawa import Entry, EntryPart logg = logging.getLogger("storage.entry_mapper") diff --git a/dummy/usawa/storage/ledger_repository.py b/dummy/usawa/storage/ledger_repository.py @@ -1,5 +1,6 @@ import logging from typing import List +from usawa.gui.core.models import LedgerEntry from usawa.storage.file_utils import path_from_uri from usawa.storage.xml_utils import ( _write_xml_to_file, @@ -10,7 +11,6 @@ from usawa.ledger import Ledger from usawa.resolve.fs import FSResolver from usawa.service import UnixClient from usawa.store import LedgerStore -from ..core.models import LedgerEntry from .entry_mapper import EntryMapper from whee.valkey import ValkeyStore from usawa import Ledger, DemoWallet, load