From 81878c63d9e49e57e0d8b9021e55bfc2a78fd071 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 8 Dec 2025 20:34:11 +1100 Subject: [PATCH 1/6] Invoicing --- .gitignore | 3 + CHANGELOG.md | 5 + README.md | 2 - bouquin/db.py | 546 ++++++++ bouquin/invoices.py | 1450 ++++++++++++++++++++++ bouquin/locales/en.json | 55 +- bouquin/main_window.py | 4 + bouquin/settings.py | 3 + bouquin/settings_dialog.py | 123 ++ bouquin/time_log.py | 120 +- tests/test_code_block_editor_dialog.py | 2 +- tests/test_invoices.py | 1348 ++++++++++++++++++++ tests/test_key_prompt.py | 6 +- tests/test_markdown_editor.py | 7 +- tests/test_markdown_editor_additional.py | 34 - tests/test_statistics_dialog.py | 2 +- 16 files changed, 3656 insertions(+), 54 deletions(-) create mode 100644 bouquin/invoices.py create mode 100644 tests/test_invoices.py diff --git a/.gitignore b/.gitignore index 851b242..07c956d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__ dist .coverage *.db +*.pdf +*.csv +*.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee1413..26e9853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.7.0 + + * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. + * Add 'Last week' to Time Report dialog range option + # 0.6.4 * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. diff --git a/README.md b/README.md index e2c5297..da87442 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,6 @@ report from within the app, or optionally to check for new versions to upgrade t Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). -It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes. - If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc). ### From PyPi/pip diff --git a/bouquin/db.py b/bouquin/db.py index 2ebfa4c..46f72b1 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -41,6 +41,26 @@ DocumentRow = Tuple[ int, # size_bytes str, # uploaded_at (ISO) ] +ProjectBillingRow = Tuple[ + int, # project_id + int, # hourly_rate_cents + str, # currency + str | None, # tax_label + float | None, # tax_rate_percent + str | None, # client_name + str | None, # client_company + str | None, # client_address + str | None, # client_email +] +CompanyProfileRow = Tuple[ + str | None, # name + str | None, # address + str | None, # phone + str | None, # email + str | None, # tax_id + str | None, # payment_details + bytes | None, # logo +] _TAG_COLORS = [ "#FFB3BA", # soft red @@ -77,11 +97,31 @@ class DBConfig: time_log: bool = True reminders: bool = True documents: bool = True + invoicing: bool = False locale: str = "en" font_size: int = 11 class DBManager: + # Allow list of invoice columns allowed for dynamic field helpers + _INVOICE_COLUMN_ALLOWLIST = frozenset( + { + "invoice_number", + "issue_date", + "due_date", + "currency", + "tax_label", + "tax_rate_percent", + "subtotal_cents", + "tax_cents", + "total_cents", + "detail_mode", + "paid_at", + "payment_note", + "document_id", + } + ) + def __init__(self, cfg: DBConfig): self.cfg = cfg self.conn: sqlite.Connection | None = None @@ -252,6 +292,76 @@ class DBManager: CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id ON document_tags(tag_id); + + CREATE TABLE IF NOT EXISTS project_billing ( + project_id INTEGER PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + hourly_rate_cents INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'AUD', + tax_label TEXT, + tax_rate_percent REAL, + client_name TEXT, -- contact person + client_company TEXT, -- business name + client_address TEXT, + client_email TEXT + ); + + CREATE TABLE IF NOT EXISTS company_profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT, + address TEXT, + phone TEXT, + email TEXT, + tax_id TEXT, + payment_details TEXT, + logo BLOB + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL + REFERENCES projects(id) ON DELETE RESTRICT, + invoice_number TEXT NOT NULL, + issue_date TEXT NOT NULL, -- yyyy-MM-dd + due_date TEXT, + currency TEXT NOT NULL, + tax_label TEXT, + tax_rate_percent REAL, + subtotal_cents INTEGER NOT NULL, + tax_cents INTEGER NOT NULL, + total_cents INTEGER NOT NULL, + detail_mode TEXT NOT NULL, -- 'detailed' | 'summary' + paid_at TEXT, + payment_note TEXT, + document_id INTEGER, + FOREIGN KEY(document_id) REFERENCES project_documents(id) + ON DELETE SET NULL, + UNIQUE(project_id, invoice_number) + ); + + CREATE INDEX IF NOT EXISTS ix_invoices_project + ON invoices(project_id); + + CREATE TABLE IF NOT EXISTS invoice_line_items ( + id INTEGER PRIMARY KEY, + invoice_id INTEGER NOT NULL + REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + hours REAL NOT NULL, + rate_cents INTEGER NOT NULL, + amount_cents INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_invoice_line_items_invoice + ON invoice_line_items(invoice_id); + + CREATE TABLE IF NOT EXISTS invoice_time_log ( + invoice_id INTEGER NOT NULL + REFERENCES invoices(id) ON DELETE CASCADE, + time_log_id INTEGER NOT NULL + REFERENCES time_log(id) ON DELETE RESTRICT, + PRIMARY KEY (invoice_id, time_log_id) + ); """ ) self.conn.commit() @@ -942,6 +1052,14 @@ class DBManager: ).fetchall() return [(r["id"], r["name"]) for r in rows] + def list_projects_by_id(self, project_id: int) -> str: + cur = self.conn.cursor() + row = cur.execute( + "SELECT name FROM projects WHERE id = ?;", + (project_id,), + ).fetchone() + return row["name"] if row else "" + def add_project(self, name: str) -> int: name = name.strip() if not name: @@ -1718,3 +1836,431 @@ class DBManager: (tag_name,), ).fetchall() return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows] + + # ------------------------- Billing settings ------------------------# + + def get_project_billing(self, project_id: int) -> ProjectBillingRow | None: + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email + FROM project_billing + WHERE project_id = ? + """, + (project_id,), + ).fetchone() + if not row: + return None + return ( + row["project_id"], + row["hourly_rate_cents"], + row["currency"], + row["tax_label"], + row["tax_rate_percent"], + row["client_name"], + row["client_company"], + row["client_address"], + row["client_email"], + ) + + def upsert_project_billing( + self, + project_id: int, + hourly_rate_cents: int, + currency: str, + tax_label: str | None, + tax_rate_percent: float | None, + client_name: str | None, + client_company: str | None, + client_address: str | None, + client_email: str | None, + ) -> None: + with self.conn: + self.conn.execute( + """ + INSERT INTO project_billing ( + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + hourly_rate_cents = excluded.hourly_rate_cents, + currency = excluded.currency, + tax_label = excluded.tax_label, + tax_rate_percent = excluded.tax_rate_percent, + client_name = excluded.client_name, + client_company = excluded.client_company, + client_address = excluded.client_address, + client_email = excluded.client_email; + """, + ( + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email, + ), + ) + + def list_client_companies(self) -> list[str]: + """Return distinct client display names from project_billing.""" + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT DISTINCT client_company + FROM project_billing + WHERE client_company IS NOT NULL + AND TRIM(client_company) <> '' + ORDER BY LOWER(client_company); + """ + ).fetchall() + return [r["client_company"] for r in rows] + + def get_client_by_company( + self, client_company: str + ) -> tuple[str | None, str | None, str | None, str | None] | None: + """ + Return (contact_name, client_display_name, address, email) + for a given client display name, based on the most recent project using it. + """ + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT client_name, client_company, client_address, client_email + FROM project_billing + WHERE client_company = ? + AND client_company IS NOT NULL + AND TRIM(client_company) <> '' + ORDER BY project_id DESC + LIMIT 1 + """, + (client_company,), + ).fetchone() + if not row: + return None + return ( + row["client_name"], + row["client_company"], + row["client_address"], + row["client_email"], + ) + + # ------------------------- Company profile ------------------------# + + def get_company_profile(self) -> CompanyProfileRow | None: + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT name, address, phone, email, tax_id, payment_details, logo + FROM company_profile + WHERE id = 1 + """ + ).fetchone() + if not row: + return None + return ( + row["name"], + row["address"], + row["phone"], + row["email"], + row["tax_id"], + row["payment_details"], + row["logo"], + ) + + def save_company_profile( + self, + name: str | None, + address: str | None, + phone: str | None, + email: str | None, + tax_id: str | None, + payment_details: str | None, + logo: bytes | None, + ) -> None: + with self.conn: + self.conn.execute( + """ + INSERT INTO company_profile (id, name, address, phone, email, tax_id, payment_details, logo) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + address = excluded.address, + phone = excluded.phone, + email = excluded.email, + tax_id = excluded.tax_id, + payment_details = excluded.payment_details, + logo = excluded.logo; + """, + ( + name, + address, + phone, + email, + tax_id, + payment_details, + Binary(logo) if logo else None, + ), + ) + + # ------------------------- Invoices -------------------------------# + + def create_invoice( + self, + project_id: int, + invoice_number: str, + issue_date: str, + due_date: str | None, + currency: str, + tax_label: str | None, + tax_rate_percent: float | None, + detail_mode: str, # 'detailed' or 'summary' + line_items: list[tuple[str, float, int]], # (description, hours, rate_cents) + time_log_ids: list[int], + ) -> int: + """ + Create invoice + line items + link time logs. + Returns invoice ID. + """ + if line_items: + first_rate_cents = line_items[0][2] + else: + first_rate_cents = 0 + + total_hours = sum(hours for _desc, hours, _rate in line_items) + subtotal_cents = int(round(total_hours * first_rate_cents)) + tax_cents = int(round(subtotal_cents * (tax_rate_percent or 0) / 100.0)) + total_cents = subtotal_cents + tax_cents + + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + INSERT INTO invoices ( + project_id, + invoice_number, + issue_date, + due_date, + currency, + tax_label, + tax_rate_percent, + subtotal_cents, + tax_cents, + total_cents, + detail_mode + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + invoice_number, + issue_date, + due_date, + currency, + tax_label, + tax_rate_percent, + subtotal_cents, + tax_cents, + total_cents, + detail_mode, + ), + ) + invoice_id = cur.lastrowid + + # Line items + for desc, hours, rate_cents in line_items: + amount_cents = int(round(hours * rate_cents)) + cur.execute( + """ + INSERT INTO invoice_line_items ( + invoice_id, description, hours, rate_cents, amount_cents + ) + VALUES (?, ?, ?, ?, ?) + """, + (invoice_id, desc, hours, rate_cents, amount_cents), + ) + + # Link time logs + for tl_id in time_log_ids: + cur.execute( + "INSERT INTO invoice_time_log (invoice_id, time_log_id) VALUES (?, ?)", + (invoice_id, tl_id), + ) + + return invoice_id + + def get_invoice_count_by_project_id_and_year( + self, project_id: int, year: str + ) -> None: + with self.conn: + row = self.conn.execute( + "SELECT COUNT(*) AS c FROM invoices WHERE project_id = ? AND issue_date LIKE ?", + (project_id, year), + ).fetchone() + return row["c"] + + def get_all_invoices(self, project_id: int | None = None) -> None: + with self.conn: + if project_id is None: + rows = self.conn.execute( + """ + SELECT + i.id, + i.project_id, + p.name AS project_name, + i.invoice_number, + i.issue_date, + i.due_date, + i.currency, + i.tax_label, + i.tax_rate_percent, + i.subtotal_cents, + i.tax_cents, + i.total_cents, + i.paid_at, + i.payment_note + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """ + ).fetchall() + else: + rows = self.conn.execute( + """ + SELECT + i.id, + i.project_id, + p.name AS project_name, + i.invoice_number, + i.issue_date, + i.due_date, + i.currency, + i.tax_label, + i.tax_rate_percent, + i.subtotal_cents, + i.tax_cents, + i.total_cents, + i.paid_at, + i.payment_note + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + WHERE i.project_id = ? + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """, + (project_id,), + ).fetchall() + return rows + + def _validate_invoice_field(self, field: str) -> str: + if field not in self._INVOICE_COLUMN_ALLOWLIST: + raise ValueError(f"Invalid invoice field name: {field!r}") + return field + + def get_invoice_field_by_id(self, invoice_id: int, field: str) -> None: + field = self._validate_invoice_field(field) + + with self.conn: + row = self.conn.execute( + f"SELECT {field} FROM invoices WHERE id = ?", # nosec B608 + (invoice_id,), + ).fetchone() + return row + + def set_invoice_field_by_id( + self, invoice_id: int, field: str, value: str | None = None + ) -> None: + field = self._validate_invoice_field(field) + + with self.conn: + self.conn.execute( + f"UPDATE invoices SET {field} = ? WHERE id = ?", # nosec B608 + ( + value, + invoice_id, + ), + ) + + def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None: + with self.conn: + self.conn.execute( + "UPDATE invoices SET invoice_number = ? WHERE id = ?", + (invoice_number, invoice_id), + ) + + def set_invoice_document(self, invoice_id: int, document_id: int) -> None: + with self.conn: + self.conn.execute( + "UPDATE invoices SET document_id = ? WHERE id = ?", + (document_id, invoice_id), + ) + + def time_logs_for_range( + self, + project_id: int, + start_date_iso: str, + end_date_iso: str, + ) -> list[TimeLogRow]: + """ + Return raw time log rows for a project/date range. + + Shape matches time_log_for_date: TimeLogRow. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT + t.id, + t.page_date, + t.project_id, + p.name AS project_name, + t.activity_id, + a.name AS activity_name, + t.minutes, + t.note, + t.created_at AS created_at + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.project_id = ? + AND t.page_date BETWEEN ? AND ? + ORDER BY t.page_date, LOWER(a.name), t.id; + """, + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + result: list[TimeLogRow] = [] + for r in rows: + result.append( + ( + r["id"], + r["page_date"], + r["project_id"], + r["project_name"], + r["activity_id"], + r["activity_name"], + r["minutes"], + r["note"], + r["created_at"], + ) + ) + return result diff --git a/bouquin/invoices.py b/bouquin/invoices.py new file mode 100644 index 0000000..ee8d3a4 --- /dev/null +++ b/bouquin/invoices.py @@ -0,0 +1,1450 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from sqlcipher3 import dbapi2 as sqlite3 + +from PySide6.QtCore import Qt, QDate, QUrl, Signal +from PySide6.QtGui import ( + QImage, + QTextDocument, + QPageLayout, + QDesktopServices, +) +from PySide6.QtPrintSupport import QPrinter +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QFormLayout, + QLabel, + QLineEdit, + QComboBox, + QDateEdit, + QCheckBox, + QTextEdit, + QTableWidget, + QTableWidgetItem, + QAbstractItemView, + QHeaderView, + QPushButton, + QRadioButton, + QButtonGroup, + QDoubleSpinBox, + QFileDialog, + QMessageBox, + QWidget, +) + +from .db import DBManager, TimeLogRow +from .reminders import Reminder, ReminderType +from .settings import load_db_config +from . import strings + + +class InvoiceDetailMode(str, Enum): + DETAILED = "detailed" + SUMMARY = "summary" + + +@dataclass +class InvoiceLineItem: + description: str + hours: float + rate_cents: int + amount_cents: int + + +# Default time of day for automatically created invoice reminders (HH:MM) +_INVOICE_REMINDER_TIME = "09:00" + + +def _invoice_due_reminder_text(project_name: str, invoice_number: str) -> str: + """Build the human-readable text for an invoice due-date reminder. + + Using a single helper keeps the text consistent between creation and + removal of reminders. + """ + project = project_name.strip() or "(no project)" + number = invoice_number.strip() or "?" + return f"Invoice {number} for {project} is due" + + +class InvoiceDialog(QDialog): + """ + Create an invoice for a project + date range from time logs. + """ + + COL_INCLUDE = 0 + COL_DATE = 1 + COL_ACTIVITY = 2 + COL_NOTE = 3 + COL_HOURS = 4 + COL_AMOUNT = 5 + + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + project_id: int, + start_date_iso: str, + end_date_iso: str, + time_rows: list[TimeLogRow] | None = None, + parent=None, + ): + super().__init__(parent) + self._db = db + self._project_id = project_id + self._start = start_date_iso + self._end = end_date_iso + + self.cfg = load_db_config() + + if time_rows is not None: + self._time_rows = time_rows + else: + # Fallback if dialog is ever used standalone + self._time_rows = db.time_logs_for_range( + project_id, start_date_iso, end_date_iso + ) + + self.setWindowTitle(strings._("invoice_dialog_title")) + + layout = QVBoxLayout(self) + + # -------- Header / metadata -------- + form = QFormLayout() + + # Project label + proj_name = self._project_name() + self.project_label = QLabel(proj_name) + form.addRow(strings._("project") + ":", self.project_label) + + # Invoice number + self.invoice_number_edit = QLineEdit(self._suggest_invoice_number()) + form.addRow(strings._("invoice_number") + ":", self.invoice_number_edit) + + # Issue + due dates + today = QDate.currentDate() + self.issue_date_edit = QDateEdit(today) + self.issue_date_edit.setDisplayFormat("yyyy-MM-dd") + self.issue_date_edit.setCalendarPopup(True) + form.addRow(strings._("invoice_issue_date") + ":", self.issue_date_edit) + + self.due_date_edit = QDateEdit(today.addDays(14)) + self.due_date_edit.setDisplayFormat("yyyy-MM-dd") + self.due_date_edit.setCalendarPopup(True) + form.addRow(strings._("invoice_due_date") + ":", self.due_date_edit) + + # Billing defaults from project_billing + pb = db.get_project_billing(project_id) + if pb: + ( + _pid, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email, + ) = pb + else: + hourly_rate_cents = 0 + currency = "AUD" + tax_label = "GST" + tax_rate_percent = None + client_name = client_company = client_address = client_email = "" + + # Currency + self.currency_edit = QLineEdit(currency) + form.addRow(strings._("invoice_currency") + ":", self.currency_edit) + + # Hourly rate + self.rate_spin = QDoubleSpinBox() + self.rate_spin.setRange(0, 1_000_000) + self.rate_spin.setDecimals(2) + self.rate_spin.setValue(hourly_rate_cents / 100.0) + self.rate_spin.valueChanged.connect(self._recalc_amounts) + form.addRow(strings._("invoice_hourly_rate") + ":", self.rate_spin) + + # Tax + self.tax_checkbox = QCheckBox(strings._("invoice_apply_tax")) + self.tax_label = QLabel(strings._("invoice_tax_label") + ":") + self.tax_label_edit = QLineEdit(tax_label or "") + + self.tax_rate_label = QLabel(strings._("invoice_tax_rate") + " %:") + self.tax_rate_spin = QDoubleSpinBox() + self.tax_rate_spin.setRange(0, 100) + self.tax_rate_spin.setDecimals(2) + + tax_row = QHBoxLayout() + tax_row.addWidget(self.tax_checkbox) + tax_row.addWidget(self.tax_label) + tax_row.addWidget(self.tax_label_edit) + tax_row.addWidget(self.tax_rate_label) + tax_row.addWidget(self.tax_rate_spin) + form.addRow(strings._("invoice_tax") + ":", tax_row) + + if tax_rate_percent is None: + self.tax_rate_spin.setValue(10.0) + self.tax_checkbox.setChecked(False) + self.tax_label.hide() + self.tax_label_edit.hide() + self.tax_rate_label.hide() + self.tax_rate_spin.hide() + else: + self.tax_rate_spin.setValue(tax_rate_percent) + self.tax_checkbox.setChecked(True) + self.tax_label.show() + self.tax_label_edit.show() + self.tax_rate_label.show() + self.tax_rate_spin.show() + + # When tax settings change, recalc totals + self.tax_checkbox.toggled.connect(self._on_tax_toggled) + self.tax_rate_spin.valueChanged.connect(self._recalc_totals) + + # Client info + self.client_name_edit = QLineEdit(client_name or "") + + # Client company as an editable combo box with existing clients + self.client_company_combo = QComboBox() + self.client_company_combo.setEditable(True) + + companies = self._db.list_client_companies() + # Add existing companies + for comp in companies: + if comp: + self.client_company_combo.addItem(comp) + + # If this project already has a client_company, select it or set as text + if client_company: + idx = self.client_company_combo.findText( + client_company, Qt.MatchFixedString + ) + if idx >= 0: + self.client_company_combo.setCurrentIndex(idx) + else: + self.client_company_combo.setEditText(client_company) + + # When the company text changes (selection or typed), try autofill + self.client_company_combo.currentTextChanged.connect( + self._on_client_company_changed + ) + + self.client_addr_edit = QTextEdit() + self.client_addr_edit.setPlainText(client_address or "") + self.client_email_edit = QLineEdit(client_email or "") + + form.addRow(strings._("invoice_client_name") + ":", self.client_name_edit) + form.addRow( + strings._("invoice_client_company") + ":", self.client_company_combo + ) + form.addRow(strings._("invoice_client_address") + ":", self.client_addr_edit) + form.addRow(strings._("invoice_client_email") + ":", self.client_email_edit) + + layout.addLayout(form) + + # -------- Detail mode + table -------- + mode_row = QHBoxLayout() + self.rb_detailed = QRadioButton(strings._("invoice_mode_detailed")) + self.rb_summary = QRadioButton(strings._("invoice_mode_summary")) + self.rb_detailed.setChecked(True) + self.mode_group = QButtonGroup(self) + self.mode_group.addButton(self.rb_detailed) + self.mode_group.addButton(self.rb_summary) + self.rb_detailed.toggled.connect(self._update_mode_enabled) + mode_row.addWidget(self.rb_detailed) + mode_row.addWidget(self.rb_summary) + mode_row.addStretch() + layout.addLayout(mode_row) + + # Detailed table (time entries) + self.table = QTableWidget() + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels( + [ + "", # include checkbox + strings._("date"), + strings._("activity"), + strings._("note"), + strings._("invoice_hours"), + strings._("invoice_amount"), + ] + ) + self.table.setSelectionMode(QAbstractItemView.NoSelection) + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_INCLUDE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_ACTIVITY, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_NOTE, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_HOURS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_AMOUNT, QHeaderView.ResizeToContents) + layout.addWidget(self.table) + + self._populate_detailed_rows(hourly_rate_cents) + self.table.itemChanged.connect(self._on_table_item_changed) + + # Summary line + self.summary_desc_label = QLabel(strings._("invoice_summary_desc") + ":") + self.summary_desc_edit = QLineEdit(strings._("invoice_summary_default_desc")) + self.summary_hours_label = QLabel(strings._("invoice_summary_hours") + ":") + self.summary_hours_spin = QDoubleSpinBox() + self.summary_hours_spin.setRange(0, 10_000) + self.summary_hours_spin.setDecimals(2) + self.summary_hours_spin.setValue(self._total_hours_from_table()) + self.summary_hours_spin.valueChanged.connect(self._recalc_totals) + + summary_row = QHBoxLayout() + summary_row.addWidget(self.summary_desc_label) + summary_row.addWidget(self.summary_desc_edit) + summary_row.addWidget(self.summary_hours_label) + summary_row.addWidget(self.summary_hours_spin) + layout.addLayout(summary_row) + + # -------- Totals -------- + totals_row = QHBoxLayout() + self.subtotal_label = QLabel("0.00") + self.tax_label_total = QLabel("0.00") + self.total_label = QLabel("0.00") + totals_row.addStretch() + totals_row.addWidget(QLabel(strings._("invoice_subtotal") + ":")) + totals_row.addWidget(self.subtotal_label) + totals_row.addWidget(QLabel(strings._("invoice_tax_total") + ":")) + totals_row.addWidget(self.tax_label_total) + totals_row.addWidget(QLabel(strings._("invoice_total") + ":")) + totals_row.addWidget(self.total_label) + layout.addLayout(totals_row) + + # -------- Buttons -------- + btn_row = QHBoxLayout() + btn_row.addStretch() + self.btn_save = QPushButton(strings._("invoice_save_and_export")) + self.btn_save.clicked.connect(self._on_save_clicked) + btn_row.addWidget(self.btn_save) + + cancel_btn = QPushButton(strings._("cancel")) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + self._update_mode_enabled() + self._recalc_totals() + + def _project_name(self) -> str: + # relies on TimeLogRow including project_name + if self._time_rows: + return self._time_rows[0][3] + # fallback: query projects table + return self._db.list_projects_by_id(self._project_id) + + def _suggest_invoice_number(self) -> str: + # Very simple example: YYYY-XXX based on count + today = QDate.currentDate() + year = today.toString("yyyy") + last = self._db.get_invoice_count_by_project_id_and_year( + self._project_id, f"{year}-%" + ) + seq = int(last) + 1 + return f"{year}-{seq:03d}" + + def _create_due_date_reminder( + self, invoice_id: int, invoice_number: str, due_date_iso: str + ) -> None: + """Create a one-off reminder on the invoice's due date. + + The reminder is purely informational and is keyed by its text so + that it can be found and deleted later when the invoice is paid. + """ + # No due date, nothing to remind about. + if not due_date_iso: + return + + # Build consistent text and create a Reminder dataclass instance. + project_name = self._project_name() + text = _invoice_due_reminder_text(project_name, invoice_number) + + reminder = Reminder( + id=None, + text=text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + weekday=None, + active=True, + date_iso=due_date_iso, + ) + + try: + # Save without failing the invoice flow if something goes wrong. + self._db.save_reminder(reminder) + self.remindersChanged.emit() + except Exception: + pass + + def _populate_detailed_rows(self, hourly_rate_cents: int) -> None: + self.table.blockSignals(True) + try: + self.table.setRowCount(len(self._time_rows)) + rate = hourly_rate_cents / 100.0 if hourly_rate_cents else 0.0 + + for row_idx, tl in enumerate(self._time_rows): + ( + tl_id, + page_date, + _proj_id, + _proj_name, + _act_id, + activity_name, + minutes, + note, + _created_at, + ) = tl + + # include checkbox + chk_item = QTableWidgetItem() + chk_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + chk_item.setCheckState(Qt.Checked) + chk_item.setData(Qt.UserRole, tl_id) + self.table.setItem(row_idx, self.COL_INCLUDE, chk_item) + + self.table.setItem(row_idx, self.COL_DATE, QTableWidgetItem(page_date)) + self.table.setItem( + row_idx, self.COL_ACTIVITY, QTableWidgetItem(activity_name) + ) + self.table.setItem(row_idx, self.COL_NOTE, QTableWidgetItem(note or "")) + + hours = minutes / 60.0 + + # Hours – editable via spin box (override allowed) + hours_spin = QDoubleSpinBox() + hours_spin.setRange(0, 24) + hours_spin.setDecimals(2) + hours_spin.setValue(hours) + hours_spin.valueChanged.connect(self._recalc_totals) + self.table.setCellWidget(row_idx, self.COL_HOURS, hours_spin) + + amount = hours * rate + amount_item = QTableWidgetItem(f"{amount:.2f}") + amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + amount_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row_idx, self.COL_AMOUNT, amount_item) + finally: + self.table.blockSignals(False) + + def _total_hours_from_table(self) -> float: + total = 0.0 + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + if isinstance(hours_widget, QDoubleSpinBox): + total += hours_widget.value() + return total + + def _detail_line_items(self) -> list[InvoiceLineItem]: + rate_cents = int(round(self.rate_spin.value() * 100)) + items: list[InvoiceLineItem] = [] + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + date_str = self.table.item(r, self.COL_DATE).text() + activity = self.table.item(r, self.COL_ACTIVITY).text() + note = self.table.item(r, self.COL_NOTE).text() + + descr_parts = [date_str, activity] + if note: + descr_parts.append(note) + descr = " – ".join(descr_parts) + + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + hours = ( + hours_widget.value() + if isinstance(hours_widget, QDoubleSpinBox) + else 0.0 + ) + amount_cents = int(round(hours * rate_cents)) + items.append( + InvoiceLineItem( + description=descr, + hours=hours, + rate_cents=rate_cents, + amount_cents=amount_cents, + ) + ) + return items + + def _summary_line_items(self) -> list[InvoiceLineItem]: + rate_cents = int(round(self.rate_spin.value() * 100)) + hours = self.summary_hours_spin.value() + amount_cents = int(round(hours * rate_cents)) + return [ + InvoiceLineItem( + description=self.summary_desc_edit.text().strip() or "Services", + hours=hours, + rate_cents=rate_cents, + amount_cents=amount_cents, + ) + ] + + def _update_mode_enabled(self) -> None: + detailed = self.rb_detailed.isChecked() + self.table.setEnabled(detailed) + if not detailed: + self.summary_desc_label.show() + self.summary_desc_edit.show() + self.summary_hours_label.show() + self.summary_hours_spin.show() + else: + self.summary_desc_label.hide() + self.summary_desc_edit.hide() + self.summary_hours_label.hide() + self.summary_hours_spin.hide() + self.resize(self.sizeHint().width(), self.sizeHint().height()) + self._recalc_totals() + + def _recalc_amounts(self) -> None: + # Called when rate changes + rate = self.rate_spin.value() + for r in range(self.table.rowCount()): + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + if isinstance(hours_widget, QDoubleSpinBox): + hours = hours_widget.value() + amount = hours * rate + amount_item = self.table.item(r, self.COL_AMOUNT) + if amount_item: + amount_item.setText(f"{amount:.2f}") + self._recalc_totals() + + def _recalc_totals(self) -> None: + if self.rb_detailed.isChecked(): + items = self._detail_line_items() + else: + items = self._summary_line_items() + + rate_cents = int(round(self.rate_spin.value() * 100)) + total_hours = sum(li.hours for li in items) + subtotal_cents = int(round(total_hours * rate_cents)) + + tax_rate = self.tax_rate_spin.value() if self.tax_checkbox.isChecked() else 0.0 + tax_cents = int(round(subtotal_cents * (tax_rate / 100.0))) + total_cents = subtotal_cents + tax_cents + + self.subtotal_label.setText(f"{subtotal_cents / 100.0:.2f}") + self.tax_label_total.setText(f"{tax_cents / 100.0:.2f}") + self.total_label.setText(f"{total_cents / 100.0:.2f}") + + def _on_table_item_changed(self, item: QTableWidgetItem) -> None: + """Handle changes to table items, particularly checkbox toggles.""" + if item and item.column() == self.COL_INCLUDE: + self._recalc_totals() + + def _on_tax_toggled(self, checked: bool) -> None: + # if on, show the other tax fields + if checked: + self.tax_label.show() + self.tax_label_edit.show() + self.tax_rate_label.show() + self.tax_rate_spin.show() + else: + self.tax_label.hide() + self.tax_label_edit.hide() + self.tax_rate_label.hide() + self.tax_rate_spin.hide() + + # If user just turned tax ON and the rate is 0, give a sensible default + if checked and self.tax_rate_spin.value() == 0.0: + self.tax_rate_spin.setValue(10.0) + self.resize(self.sizeHint().width(), self.sizeHint().height()) + self._recalc_totals() + + def _on_client_company_changed(self, text: str) -> None: + text = text.strip() + if not text: + return + + details = self._db.get_client_by_company(text) + if not details: + # New client – leave other fields as-is + return + + # We don't touch the company combo text – user already chose/typed it. + client_name, client_company, client_address, client_email = details + if client_name: + self.client_name_edit.setText(client_name) + if client_address: + self.client_addr_edit.setPlainText(client_address) + if client_email: + self.client_email_edit.setText(client_email) + + def _on_save_clicked(self) -> None: + invoice_number = self.invoice_number_edit.text().strip() + if not invoice_number: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_required"), + ) + return + + issue_date = self.issue_date_edit.date() + due_date = self.due_date_edit.date() + issue_date_iso = issue_date.toString("yyyy-MM-dd") + due_date_iso = due_date.toString("yyyy-MM-dd") + + # Guard against due date before issue date + if due_date.isValid() and issue_date.isValid() and due_date < issue_date: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_due_before_issue"), + ) + return + + detail_mode = ( + InvoiceDetailMode.DETAILED + if self.rb_detailed.isChecked() + else InvoiceDetailMode.SUMMARY + ) + + # Build line items + collect time_log_ids + if detail_mode == InvoiceDetailMode.DETAILED: + items = self._detail_line_items() + time_log_ids: list[int] = [] + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + tl_id = int(include_item.data(Qt.UserRole)) + time_log_ids.append(tl_id) + else: + items = self._summary_line_items() + # In summary mode we still link all rows used for the report + time_log_ids = [tl[0] for tl in self._time_rows] + + if not items: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_no_items"), + ) + return + + # Rate + tax info + rate_cents = int(round(self.rate_spin.value() * 100)) + currency = self.currency_edit.text().strip() + tax_label = self.tax_label_edit.text().strip() or None + tax_rate_percent = ( + self.tax_rate_spin.value() if self.tax_checkbox.isChecked() else None + ) + + # Persist billing settings for this project (fills project_billing) + self._db.upsert_project_billing( + project_id=self._project_id, + hourly_rate_cents=rate_cents, + currency=currency, + tax_label=tax_label, + tax_rate_percent=tax_rate_percent, + client_name=self.client_name_edit.text().strip() or None, + client_company=self.client_company_combo.currentText().strip() or None, + client_address=self.client_addr_edit.toPlainText().strip() or None, + client_email=self.client_email_edit.text().strip() or None, + ) + + try: + # Create invoice in DB + invoice_id = self._db.create_invoice( + project_id=self._project_id, + invoice_number=invoice_number, + issue_date=issue_date_iso, + due_date=due_date_iso, + currency=currency, + tax_label=tax_label, + tax_rate_percent=tax_rate_percent, + detail_mode=detail_mode.value, + line_items=[(li.description, li.hours, li.rate_cents) for li in items], + time_log_ids=time_log_ids, + ) + + # Automatically create a reminder for the invoice due date + if self.cfg.reminders: + self._create_due_date_reminder(invoice_id, invoice_number, due_date_iso) + + except sqlite3.IntegrityError: + # (project_id, invoice_number) must be unique + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_unique"), + ) + return + + # Generate PDF + pdf_path = self._export_pdf(invoice_id, items) + # Save to Documents if the Documents feature is enabled + if pdf_path and self.cfg.documents: + doc_id = self._db.add_document_from_path( + self._project_id, + pdf_path, + description=f"Invoice {invoice_number}", + ) + self._db.set_invoice_document(invoice_id, doc_id) + + self.accept() + + def _export_pdf(self, invoice_id: int, items: list[InvoiceLineItem]) -> str | None: + proj_name = self._project_name() + safe_proj = proj_name.replace(" ", "_") or "project" + invoice_number = self.invoice_number_edit.text().strip() + filename = f"{safe_proj}_invoice_{invoice_number}.pdf" + + path, _ = QFileDialog.getSaveFileName( + self, + strings._("invoice_save_pdf_title"), + filename, + "PDF (*.pdf)", + ) + if not path: + return None + + printer = QPrinter(QPrinter.HighResolution) + printer.setOutputFormat(QPrinter.PdfFormat) + printer.setOutputFileName(path) + printer.setPageOrientation(QPageLayout.Portrait) + + doc = QTextDocument() + + # 🔹 Load company profile *before* building HTML + profile = self._db.get_company_profile() + self._company_profile = None + if profile: + name, address, phone, email, tax_id, payment_details, logo_bytes = profile + self._company_profile = { + "name": name, + "address": address, + "phone": phone, + "email": email, + "tax_id": tax_id, + "payment_details": payment_details, + } + if logo_bytes: + img = QImage.fromData(logo_bytes) + if not img.isNull(): + doc.addResource( + QTextDocument.ImageResource, QUrl("company_logo"), img + ) + + html = self._build_invoice_html(items) + doc.setHtml(html) + doc.print_(printer) + + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + return path + + def _build_invoice_html(self, items: list[InvoiceLineItem]) -> str: + # Monetary values based on current labels (these are kept in sync by _recalc_totals) + try: + subtotal = float(self.subtotal_label.text()) + except ValueError: + subtotal = 0.0 + try: + tax_total = float(self.tax_label_total.text()) + except ValueError: + tax_total = 0.0 + total = subtotal + tax_total + + currency = self.currency_edit.text().strip() + issue = self.issue_date_edit.date().toString("yyyy-MM-dd") + due = self.due_date_edit.date().toString("yyyy-MM-dd") + inv_no = self.invoice_number_edit.text().strip() or "-" + proj = self._project_name() + + # --- Client block (Bill to) ------------------------------------- + client_lines = [ + self.client_company_combo.currentText().strip(), + self.client_name_edit.text().strip(), + self.client_addr_edit.toPlainText().strip(), + self.client_email_edit.text().strip(), + ] + client_lines = [ln for ln in client_lines if ln] + client_block = "
".join( + line.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + for line in client_lines + ) + + # --- Company block (From) --------------------------------------- + company_html = "" + if self._company_profile: + cp = self._company_profile + lines = [ + cp.get("name"), + cp.get("address"), + cp.get("phone"), + cp.get("email"), + "Tax ID/Business No: " + cp.get("tax_id"), + ] + lines = [ln for ln in lines if ln] + company_html = "
".join( + line.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + for line in lines + ) + + logo_html = "" + if self._company_profile: + # "company_logo" resource is registered in _export_pdf + logo_html = ( + '' + ) + + # --- Items table ------------------------------------------------- + item_rows_html = "" + for idx, li in enumerate(items, start=1): + desc = li.description or "" + desc = ( + desc.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + ) + hours_str = f"{li.hours:.2f}".rstrip("0").rstrip(".") + price = li.rate_cents / 100.0 + amount = li.amount_cents / 100.0 + item_rows_html += f""" + + + {desc} + + + {hours_str} + + + {price:,.2f} {currency} + + + {amount:,.2f} {currency} + + + """ + + if not item_rows_html: + item_rows_html = """ + + + (No items) + + + """ + + # --- Tax summary line ------------------------------------------- + if tax_total > 0.0: + tax_label = self.tax_label_edit.text().strip() or "Tax" + tax_summary_text = f"{tax_label} has been added." + tax_line_label = tax_label + invoice_title = "TAX INVOICE" + else: + tax_summary_text = "No tax has been charged." + tax_line_label = "Tax" + invoice_title = "INVOICE" + + # --- Optional payment / terms text ----------------------------- + if self._company_profile and self._company_profile.get("payment_details"): + raw_payment = self._company_profile["payment_details"] + else: + raw_payment = "Please pay by the due date. Thank you!" + + lines = [ln.strip() for ln in raw_payment.splitlines()] + payment_text = "\n".join(lines).strip() + + # --- Build final HTML ------------------------------------------- + html = f""" + + + + + + + + + + + + +
+ {logo_html} +
+ {company_html} +
+
+
{invoice_title}
+ + + + + + + + + + + + + + + + + +
Invoice no:{inv_no}
Invoice date:{issue}
Reference:{proj}
Due date:{due}
+
+ + + + + + + + + +
+
BILL TO
+
{client_block}
+
+ + + + + + + + + + + + + +
Subtotal{subtotal:,.2f} {currency}
{tax_line_label}{tax_total:,.2f} {currency}
TOTAL{total:,.2f} {currency}
+
{tax_summary_text}
+
+ + + + + + + + + + {item_rows_html} +
ITEMS AND DESCRIPTIONQTY/HRSPRICEAMOUNT ({currency})
+ + + + + + + +
+
PAYMENT DETAILS
+
+{payment_text} +
+
+ + + + + + +
AMOUNT DUE{total:,.2f} {currency}
+
+ + + + """ + + return html + + +class InvoicesDialog(QDialog): + """Manager for viewing and editing existing invoices.""" + + COL_NUMBER = 0 + COL_PROJECT = 1 + COL_ISSUE_DATE = 2 + COL_DUE_DATE = 3 + COL_CURRENCY = 4 + COL_TAX_LABEL = 5 + COL_TAX_RATE = 6 + COL_SUBTOTAL = 7 + COL_TAX = 8 + COL_TOTAL = 9 + COL_PAID_AT = 10 + COL_PAYMENT_NOTE = 11 + + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + parent: QWidget | None = None, + initial_project_id: int | None = None, + ) -> None: + super().__init__(parent) + self._db = db + self._reloading_invoices = False + self.cfg = load_db_config() + self.setWindowTitle(strings._("manage_invoices")) + self.resize(1100, 500) + + root = QVBoxLayout(self) + + # --- Project selector ------------------------------------------------- + form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + root.addLayout(form) + + proj_row = QHBoxLayout() + self.project_combo = QComboBox() + proj_row.addWidget(self.project_combo, 1) + form.addRow(strings._("project"), proj_row) + + self._reload_projects() + self._select_initial_project(initial_project_id) + + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + + # --- Table of invoices ----------------------------------------------- + self.table = QTableWidget() + self.table.setColumnCount(12) + self.table.setHorizontalHeaderLabels( + [ + strings._("invoice_number"), # COL_NUMBER + strings._("project"), # COL_PROJECT + strings._("invoice_issue_date"), # COL_ISSUE_DATE + strings._("invoice_due_date"), # COL_DUE_DATE + strings._("invoice_currency"), # COL_CURRENCY + strings._("invoice_tax_label"), # COL_TAX_LABEL + strings._("invoice_tax_rate"), # COL_TAX_RATE + strings._("invoice_subtotal"), # COL_SUBTOTAL + strings._("invoice_tax_total"), # COL_TAX + strings._("invoice_total"), # COL_TOTAL + strings._("invoice_paid_at"), # COL_PAID_AT + strings._("invoice_payment_note"), # COL_PAYMENT_NOTE + ] + ) + + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_NUMBER, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PROJECT, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_ISSUE_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_DUE_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_CURRENCY, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX_LABEL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX_RATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_SUBTOTAL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TOTAL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PAID_AT, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PAYMENT_NOTE, QHeaderView.Stretch) + + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.SelectedClicked + ) + + root.addWidget(self.table, 1) + + # Connect after constructing the table + self.table.itemChanged.connect(self._on_item_changed) + + # --- Buttons ---------------------------------------------------------- + btn_row = QHBoxLayout() + btn_row.addStretch(1) + + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + + root.addLayout(btn_row) + + self._reload_invoices() + + # ------------------------------------------------------------------ helpers + + def _reload_projects(self) -> None: + """Populate the project combo box.""" + self.project_combo.blockSignals(True) + try: + self.project_combo.clear() + for proj_id, name in self._db.list_projects(): + self.project_combo.addItem(name, proj_id) + finally: + self.project_combo.blockSignals(False) + + def _select_initial_project(self, project_id: int | None) -> None: + if project_id is None: + if self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + return + + idx = self.project_combo.findData(project_id) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + elif self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + + def _current_project(self) -> int | None: + idx = self.project_combo.currentIndex() + if idx < 0: + return None + data = self.project_combo.itemData(idx) + return int(data) if data is not None else None + + # ----------------------------------------------------------------- reloading + + def _on_project_changed(self, idx: int) -> None: + _ = idx + self._reload_invoices() + + def _reload_invoices(self) -> None: + """Load invoices for the current project into the table.""" + self._reloading_invoices = True + try: + self.table.setRowCount(0) + project_id = self._current_project() + rows = self._db.get_all_invoices(project_id) + + self.table.setRowCount(len(rows) or 0) + + for row_idx, r in enumerate(rows): + inv_id = int(r["id"]) + proj_name = r["project_name"] or "" + invoice_number = r["invoice_number"] or "" + issue_date = r["issue_date"] or "" + due_date = r["due_date"] or "" + currency = r["currency"] or "" + tax_label = r["tax_label"] or "" + tax_rate = ( + r["tax_rate_percent"] if r["tax_rate_percent"] is not None else None + ) + subtotal_cents = r["subtotal_cents"] or 0 + tax_cents = r["tax_cents"] or 0 + total_cents = r["total_cents"] or 0 + paid_at = r["paid_at"] or "" + payment_note = r["payment_note"] or "" + + # Column 0: invoice number (store invoice_id in UserRole) + num_item = QTableWidgetItem(invoice_number) + num_item.setData(Qt.ItemDataRole.UserRole, inv_id) + self.table.setItem(row_idx, self.COL_NUMBER, num_item) + + # Column 1: project name (read-only) + proj_item = QTableWidgetItem(proj_name) + proj_item.setFlags(proj_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row_idx, self.COL_PROJECT, proj_item) + + # Column 2: issue date + self.table.setItem( + row_idx, self.COL_ISSUE_DATE, QTableWidgetItem(issue_date) + ) + + # Column 3: due date + self.table.setItem( + row_idx, self.COL_DUE_DATE, QTableWidgetItem(due_date or "") + ) + + # Column 4: currency + self.table.setItem( + row_idx, self.COL_CURRENCY, QTableWidgetItem(currency) + ) + + # Column 5: tax label + self.table.setItem( + row_idx, self.COL_TAX_LABEL, QTableWidgetItem(tax_label or "") + ) + + # Column 6: tax rate + tax_rate_text = "" if tax_rate is None else f"{tax_rate:.2f}" + self.table.setItem( + row_idx, self.COL_TAX_RATE, QTableWidgetItem(tax_rate_text) + ) + + # Column 7–9: amounts (cents → dollars) + self.table.setItem( + row_idx, + self.COL_SUBTOTAL, + QTableWidgetItem(f"{subtotal_cents / 100.0:.2f}"), + ) + self.table.setItem( + row_idx, + self.COL_TAX, + QTableWidgetItem(f"{tax_cents / 100.0:.2f}"), + ) + self.table.setItem( + row_idx, + self.COL_TOTAL, + QTableWidgetItem(f"{total_cents / 100.0:.2f}"), + ) + + # Column 10: paid_at + self.table.setItem( + row_idx, self.COL_PAID_AT, QTableWidgetItem(paid_at or "") + ) + + # Column 11: payment note + self.table.setItem( + row_idx, + self.COL_PAYMENT_NOTE, + QTableWidgetItem(payment_note or ""), + ) + + finally: + self._reloading_invoices = False + + # ----------------------------------------------------------------- editing + + def _remove_invoice_due_reminder(self, row: int, inv_id: int) -> None: + """Delete any one-off reminder created for this invoice's due date. + + We look up reminders by the same text we used when creating them + to avoid needing extra schema just for this linkage. + """ + proj_item = self.table.item(row, self.COL_PROJECT) + num_item = self.table.item(row, self.COL_NUMBER) + if proj_item is None or num_item is None: + return + + project_name = proj_item.text().strip() + invoice_number = num_item.text().strip() + if not project_name or not invoice_number: + return + + target_text = _invoice_due_reminder_text(project_name, invoice_number) + + removed_any = False + + try: + reminders = self._db.get_all_reminders() + except Exception: + return + + for reminder in reminders: + if ( + reminder.id is not None + and reminder.reminder_type == ReminderType.ONCE + and reminder.text == target_text + ): + try: + self._db.delete_reminder(reminder.id) + removed_any = True + except Exception: + # Best effort; if deletion fails we silently continue. + pass + + if removed_any: + # Tell Reminders that reminders have changed + self.remindersChanged.emit() + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + """Handle inline edits and write them back to the database.""" + if self._reloading_invoices: + return + + row = item.row() + col = item.column() + + base_item = self.table.item(row, self.COL_NUMBER) + if base_item is None: + return + + inv_id = base_item.data(Qt.ItemDataRole.UserRole) + if not inv_id: + return + + text = item.text().strip() + + def _reset_from_db(field: str, formatter=lambda v: v) -> None: + """Reload a single field from DB and reset the cell.""" + self._reloading_invoices = True + try: + row_db = self._db.get_invoice_field_by_id(inv_id, field) + + if row_db is None: + return + value = row_db[field] + item.setText("" if value is None else formatter(value)) + finally: + self._reloading_invoices = False + + # ---- Invoice number (unique per project) ---------------------------- + if col == self.COL_NUMBER: + if not text: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_required"), + ) + _reset_from_db("invoice_number", lambda v: v or "") + return + try: + self._db.update_invoice_number(inv_id, text) + except sqlite3.IntegrityError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_unique"), + ) + _reset_from_db("invoice_number", lambda v: v or "") + return + + # ---- Dates: issue, due, paid_at (YYYY-MM-DD) ------------------------ + if col in (self.COL_ISSUE_DATE, self.COL_DUE_DATE, self.COL_PAID_AT): + new_date: QDate | None = None + if text: + new_date = QDate.fromString(text, "yyyy-MM-dd") + if not new_date.isValid(): + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_date_format"), + ) + field = { + self.COL_ISSUE_DATE: "issue_date", + self.COL_DUE_DATE: "due_date", + self.COL_PAID_AT: "paid_at", + }[col] + _reset_from_db(field, lambda v: v or "") + return + + # Cross-field validation: due/paid must not be before issue date + issue_item = self.table.item(row, self.COL_ISSUE_DATE) + issue_qd: QDate | None = None + if issue_item is not None: + issue_text = issue_item.text().strip() + if issue_text: + issue_qd = QDate.fromString(issue_text, "yyyy-MM-dd") + if not issue_qd.isValid(): + issue_qd = None + + if issue_qd is not None and new_date is not None: + if col == self.COL_DUE_DATE and new_date < issue_qd: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_due_before_issue"), + ) + _reset_from_db("due_date", lambda v: v or "") + return + if col == self.COL_PAID_AT and new_date < issue_qd: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_paid_before_issue"), + ) + _reset_from_db("paid_at", lambda v: v or "") + return + + field = { + self.COL_ISSUE_DATE: "issue_date", + self.COL_DUE_DATE: "due_date", + self.COL_PAID_AT: "paid_at", + }[col] + + self._db.set_invoice_field_by_id(inv_id, field, text or None) + + # If the invoice has just been marked as paid, remove any + # auto-created reminder for its due date. + if col == self.COL_PAID_AT and text and self.cfg.reminders: + self._remove_invoice_due_reminder(row, inv_id) + + return + + # ---- Simple text fields: currency, tax label, payment_note --- + if col in ( + self.COL_CURRENCY, + self.COL_TAX_LABEL, + self.COL_PAYMENT_NOTE, + ): + field = { + self.COL_CURRENCY: "currency", + self.COL_TAX_LABEL: "tax_label", + self.COL_PAYMENT_NOTE: "payment_note", + }[col] + + self._db.set_invoice_field_by_id(inv_id, field, text or None) + + if col == self.COL_CURRENCY and text: + # Normalize currency code display + self._reloading_invoices = True + try: + item.setText(text.upper()) + finally: + self._reloading_invoices = False + return + + # ---- Tax rate percent (float) --------------------------------------- + if col == self.COL_TAX_RATE: + if text: + try: + rate = float(text) + except ValueError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_tax_rate"), + ) + _reset_from_db( + "tax_rate_percent", + lambda v: "" if v is None else f"{v:.2f}", + ) + return + value = rate + else: + value = None + + self._db.set_invoice_field_by_id(inv_id, "tax_rate_percent", value) + return + + # ---- Monetary fields (subtotal, tax, total) in dollars -------------- + if col in (self.COL_SUBTOTAL, self.COL_TAX, self.COL_TOTAL): + field = { + self.COL_SUBTOTAL: "subtotal_cents", + self.COL_TAX: "tax_cents", + self.COL_TOTAL: "total_cents", + }[col] + if not text: + cents = 0 + else: + try: + value = float(text.replace(",", "")) + except ValueError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_amount"), + ) + _reset_from_db( + field, + lambda v: f"{(v or 0) / 100.0:.2f}", + ) + return + cents = int(round(value * 100)) + + self._db.set_invoice_field_by_id(inv_id, field, cents) + + # Normalize formatting in the table + self._reloading_invoices = True + try: + item.setText(f"{cents / 100.0:.2f}") + finally: + self._reloading_invoices = False + return diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index b8c56f5..2a7baea 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -52,7 +52,6 @@ "backup_failed": "Backup failed", "quit": "Quit", "cancel": "Cancel", - "close": "Close", "save": "Save", "help": "Help", "saved": "Saved", @@ -202,6 +201,7 @@ "by_week": "by week", "date_range": "Date range", "custom_range": "Custom", + "last_week": "Last week", "this_week": "This week", "this_month": "This month", "this_year": "This year", @@ -234,6 +234,8 @@ "projects": "Projects", "rename_activity": "Rename activity", "rename_project": "Rename project", + "reporting": "Reporting", + "reporting_and_invoicing": "Reporting and Invoicing", "run_report": "Run report", "add_activity_title": "Add activity", "add_activity_label": "Add an activity", @@ -359,5 +361,54 @@ "documents_search_label": "Search", "documents_search_placeholder": "Type to search documents (all projects)", "todays_documents": "Documents from this day", - "todays_documents_none": "No documents yet." + "todays_documents_none": "No documents yet.", + "manage_invoices": "Manage Invoices", + "create_invoice": "Create Invoice", + "invoice_amount": "Amount", + "invoice_apply_tax": "Apply Tax", + "invoice_client_address": "Client Address", + "invoice_client_company": "Client Company", + "invoice_client_email": "Client E-mail", + "invoice_client_name": "Client Contact", + "invoice_currency": "Currency", + "invoice_dialog_title": "Create Invoice", + "invoice_due_date": "Due Date", + "invoice_hourly_rate": "Hourly Rate", + "invoice_hours": "Hours", + "invoice_issue_date": "Issue Date", + "invoice_mode_detailed": "Detailed mode", + "invoice_mode_summary": "Summary mode", + "invoice_number": "Invoice Number", + "invoice_save_and_export": "Save and export", + "invoice_save_pdf_title": "Save PDF", + "invoice_subtotal": "Subtotal", + "invoice_summary_default_desc": "Consultant services for the month of", + "invoice_summary_desc": "Summary description", + "invoice_summary_hours": "Summary hours", + "invoice_tax": "Tax details", + "invoice_tax_label": "Tax type", + "invoice_tax_rate": "Tax rate", + "invoice_tax_total": "Tax total", + "invoice_total": "Total", + "invoice_paid_at": "Paid on", + "invoice_payment_note": "Payment notes", + "invoice_project_required_title": "Project required", + "invoice_project_required_message": "Please select a specific project before trying to create an invoice.", + "invoice_need_report_title": "Report required", + "invoice_need_report_message": "Please run a time report before trying to create an invoice from it.", + "invoice_due_before_issue": "Due date cannot be earlier than the issue date.", + "invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.", + "enable_invoicing_feature": "Enable Invoicing (requires Time Logging)", + "invoice_company_profile": "Business Profile", + "invoice_company_name": "Business Name", + "invoice_company_address": "Address", + "invoice_company_phone": "Phone", + "invoice_company_email": "E-mail", + "invoice_company_tax_id": "Tax number", + "invoice_company_payment_details": "Payment details", + "invoice_company_logo": "Logo", + "invoice_company_logo_choose": "Choose logo", + "invoice_company_logo_set": "Logo has been set", + "invoice_company_logo_not_set": "Logo not set", + "invoice_number_unique": "Invoice number must be unique. This invoice number already exists." } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index aab7bbb..737b11a 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -117,6 +117,9 @@ class MainWindow(QMainWindow): self.upcoming_reminders = UpcomingRemindersWidget(self.db) self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) + # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget + self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh) + self.pomodoro_manager = PomodoroManager(self.db, self) # Lock the calendar to the left panel at the top to stop it stretching @@ -1448,6 +1451,7 @@ class MainWindow(QMainWindow): self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) + self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) diff --git a/bouquin/settings.py b/bouquin/settings.py index cfd8939..91a6074 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -45,6 +45,7 @@ def load_db_config() -> DBConfig: time_log = s.value("ui/time_log", True, type=bool) reminders = s.value("ui/reminders", True, type=bool) documents = s.value("ui/documents", True, type=bool) + invoicing = s.value("ui/invoicing", True, type=bool) locale = s.value("ui/locale", "en", type=str) font_size = s.value("ui/font_size", 11, type=int) return DBConfig( @@ -57,6 +58,7 @@ def load_db_config() -> DBConfig: time_log=time_log, reminders=reminders, documents=documents, + invoicing=invoicing, locale=locale, font_size=font_size, ) @@ -73,5 +75,6 @@ def save_db_config(cfg: DBConfig) -> None: s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/reminders", str(cfg.reminders)) s.setValue("ui/documents", str(cfg.documents)) + s.setValue("ui/invoicing", str(cfg.invoicing)) s.setValue("ui/locale", str(cfg.locale)) s.setValue("ui/font_size", str(cfg.font_size)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 68599ca..e209e9e 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -7,8 +7,11 @@ from PySide6.QtWidgets import ( QComboBox, QDialog, QFrame, + QFileDialog, QGroupBox, QLabel, + QLineEdit, + QFormLayout, QHBoxLayout, QVBoxLayout, QPushButton, @@ -19,6 +22,7 @@ from PySide6.QtWidgets import ( QMessageBox, QWidget, QTabWidget, + QTextEdit, ) from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QPalette @@ -176,6 +180,17 @@ class SettingsDialog(QDialog): self.time_log.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.time_log) + self.invoicing = QCheckBox(strings._("enable_invoicing_feature")) + invoicing_enabled = getattr(self.current_settings, "invoicing", False) + self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log) + self.invoicing.setCursor(Qt.PointingHandCursor) + features_layout.addWidget(self.invoicing) + # Invoicing only if time_log is enabled + if not self.current_settings.time_log: + self.invoicing.setChecked(False) + self.invoicing.setEnabled(False) + self.time_log.toggled.connect(self._on_time_log_toggled) + self.reminders = QCheckBox(strings._("enable_reminders_feature")) self.reminders.setChecked(self.current_settings.reminders) self.reminders.setCursor(Qt.PointingHandCursor) @@ -187,6 +202,68 @@ class SettingsDialog(QDialog): features_layout.addWidget(self.documents) layout.addWidget(features_group) + + # --- Invoicing / company profile section ------------------------- + self.invoicing_group = QGroupBox(strings._("invoice_company_profile")) + invoicing_layout = QFormLayout(self.invoicing_group) + + profile = self._db.get_company_profile() or ( + None, + None, + None, + None, + None, + None, + None, + ) + name, address, phone, email, tax_id, payment_details, logo_bytes = profile + + self.company_name_edit = QLineEdit(name or "") + self.company_address_edit = QTextEdit(address or "") + self.company_phone_edit = QLineEdit(phone or "") + self.company_email_edit = QLineEdit(email or "") + self.company_tax_id_edit = QLineEdit(tax_id or "") + self.company_payment_details_edit = QTextEdit() + self.company_payment_details_edit.setPlainText(payment_details or "") + + invoicing_layout.addRow( + strings._("invoice_company_name") + ":", self.company_name_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_address") + ":", self.company_address_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_phone") + ":", self.company_phone_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_email") + ":", self.company_email_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_payment_details") + ":", + self.company_payment_details_edit, + ) + + # Logo picker – store bytes on self._logo_bytes + self._logo_bytes = logo_bytes + logo_row = QHBoxLayout() + self.logo_label = QLabel(strings._("invoice_company_logo_not_set")) + if logo_bytes: + self.logo_label.setText(strings._("invoice_company_logo_set")) + logo_btn = QPushButton(strings._("invoice_company_logo_choose")) + logo_btn.clicked.connect(self._on_choose_logo) + logo_row.addWidget(self.logo_label) + logo_row.addWidget(logo_btn) + invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row) + + # Show/hide this whole block based on invoicing checkbox + self.invoicing_group.setVisible(self.invoicing.isChecked()) + self.invoicing.toggled.connect(self.invoicing_group.setVisible) + + layout.addWidget(self.invoicing_group) + layout.addStretch() return page @@ -314,14 +391,60 @@ class SettingsDialog(QDialog): time_log=self.time_log.isChecked(), reminders=self.reminders.isChecked(), documents=self.documents.isChecked(), + invoicing=( + self.invoicing.isChecked() if self.time_log.isChecked() else False + ), locale=self.locale_combobox.currentText(), font_size=self.font_size.value(), ) save_db_config(self._cfg) + + # Save company profile only if invoicing is enabled + if self.invoicing.isChecked() and self.time_log.isChecked(): + self._db.save_company_profile( + name=self.company_name_edit.text().strip() or None, + address=self.company_address_edit.toPlainText().strip() or None, + phone=self.company_phone_edit.text().strip() or None, + email=self.company_email_edit.text().strip() or None, + tax_id=self.company_tax_id_edit.text().strip() or None, + payment_details=self.company_payment_details_edit.toPlainText().strip() + or None, + logo=getattr(self, "_logo_bytes", None), + ) + self.parent().themes.set(selected_theme) self.accept() + def _on_time_log_toggled(self, checked: bool) -> None: + """ + Enforce 'invoicing depends on time logging'. + """ + if not checked: + # Turn off + disable invoicing if time logging is disabled + self.invoicing.setChecked(False) + self.invoicing.setEnabled(False) + else: + # Let the user enable invoicing when time logging is enabled + self.invoicing.setEnabled(True) + + def _on_choose_logo(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + strings._("company_logo_choose"), + "", + "Images (*.png *.jpg *.jpeg *.bmp)", + ) + if not path: + return + + try: + with open(path, "rb") as f: + self._logo_bytes = f.read() + self.logo_label.setText(Path(path).name) + except OSError as exc: + QMessageBox.warning(self, strings._("error"), str(exc)) + def _change_key(self): p1 = KeyPrompt( self, diff --git a/bouquin/time_log.py b/bouquin/time_log.py index e5e9b64..c8aaa14 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -8,7 +8,7 @@ from datetime import datetime from sqlcipher3.dbapi2 import IntegrityError from typing import Optional -from PySide6.QtCore import Qt, QDate, QUrl +from PySide6.QtCore import Qt, QDate, QUrl, Signal from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout from PySide6.QtPrintSupport import QPrinter from PySide6.QtWidgets import ( @@ -43,6 +43,7 @@ from PySide6.QtWidgets import ( ) from .db import DBManager +from .settings import load_db_config from .theme import ThemeManager from . import strings @@ -53,6 +54,8 @@ class TimeLogWidget(QFrame): Shown in the left sidebar above the Tags widget. """ + remindersChanged = Signal() + def __init__( self, db: DBManager, @@ -61,6 +64,7 @@ class TimeLogWidget(QFrame): ): super().__init__(parent) self._db = db + self.cfg = load_db_config() self._themes = themes self._current_date: Optional[str] = None @@ -82,6 +86,15 @@ class TimeLogWidget(QFrame): self.log_btn.setAutoRaise(True) self.log_btn.clicked.connect(self._open_dialog_log_only) + self.report_btn = QToolButton() + self.report_btn.setText("📈") + self.report_btn.setAutoRaise(True) + self.report_btn.clicked.connect(self._on_run_report) + if self.cfg.invoicing: + self.report_btn.setToolTip(strings._("reporting_and_invoicing")) + else: + self.report_btn.setToolTip(strings._("reporting")) + self.open_btn = QToolButton() self.open_btn.setIcon( self.style().standardIcon(QStyle.SP_FileDialogDetailedView) @@ -95,6 +108,7 @@ class TimeLogWidget(QFrame): header.addWidget(self.toggle_btn) header.addStretch(1) header.addWidget(self.log_btn) + header.addWidget(self.report_btn) header.addWidget(self.open_btn) # Body: simple summary label for the day @@ -149,6 +163,14 @@ class TimeLogWidget(QFrame): # ----- internals --------------------------------------------------- + def _on_run_report(self) -> None: + dlg = TimeReportDialog(self._db, self) + + # Bubble the remindersChanged signal further up + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + def _on_toggle(self, checked: bool) -> None: self.body.setVisible(checked) self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) @@ -247,6 +269,7 @@ class TimeLogDialog(QDialog): self._themes = themes self._date_iso = date_iso self._current_entry_id: Optional[int] = None + self.cfg = load_db_config() # Guard flag used when repopulating the table so we don’t treat # programmatic item changes as user edits. self._reloading_entries: bool = False @@ -320,13 +343,9 @@ class TimeLogDialog(QDialog): self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.setEnabled(False) - self.report_btn = QPushButton("&" + strings._("run_report")) - self.report_btn.clicked.connect(self._on_run_report) - btn_row.addStretch(1) btn_row.addWidget(self.add_update_btn) btn_row.addWidget(self.delete_btn) - btn_row.addWidget(self.report_btn) root.addLayout(btn_row) # --- Table of entries for this date @@ -355,12 +374,19 @@ class TimeLogDialog(QDialog): self.table.itemChanged.connect(self._on_table_item_changed) root.addWidget(self.table, 1) - # --- Total time and Close button + # --- Total time, Reporting and Close button close_row = QHBoxLayout() self.total_label = QLabel( strings._("time_log_total_hours").format(hours=self.total_hours) ) + if self.cfg.invoicing: + self.report_btn = QPushButton("&" + strings._("reporting_and_invoicing")) + else: + self.report_btn = QPushButton("&" + strings._("reporting")) + self.report_btn.clicked.connect(self._on_run_report) + close_row.addWidget(self.total_label) + close_row.addWidget(self.report_btn) close_row.addStretch(1) close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) @@ -981,9 +1007,12 @@ class TimeReportDialog(QDialog): Shows decimal hours per time period. """ + remindersChanged = Signal() + def __init__(self, db: DBManager, parent=None): super().__init__(parent) self._db = db + self.cfg = load_db_config() # state for last run self._last_rows: list[tuple[str, str, str, str, int]] = [] @@ -992,6 +1021,7 @@ class TimeReportDialog(QDialog): self._last_start: str = "" self._last_end: str = "" self._last_gran_label: str = "" + self._last_time_logs: list = [] self.setWindowTitle(strings._("time_log_report")) self.resize(600, 400) @@ -999,9 +1029,20 @@ class TimeReportDialog(QDialog): root = QVBoxLayout(self) form = QFormLayout() + + self.invoice_btn = QPushButton(strings._("create_invoice")) + self.invoice_btn.clicked.connect(self._on_create_invoice) + + self.manage_invoices_btn = QPushButton(strings._("manage_invoices")) + self.manage_invoices_btn.clicked.connect(self._on_manage_invoices) + # Project self.project_combo = QComboBox() self.project_combo.addItem(strings._("all_projects"), None) + self.project_combo.currentIndexChanged.connect( + self._update_invoice_button_state + ) + self._update_invoice_button_state() for proj_id, name in self._db.list_projects(): self.project_combo.addItem(name, proj_id) form.addRow(strings._("project"), self.project_combo) @@ -1013,6 +1054,7 @@ class TimeReportDialog(QDialog): self.range_preset = QComboBox() self.range_preset.addItem(strings._("custom_range"), "custom") self.range_preset.addItem(strings._("today"), "today") + self.range_preset.addItem(strings._("last_week"), "last_week") self.range_preset.addItem(strings._("this_week"), "this_week") self.range_preset.addItem(strings._("this_month"), "this_month") self.range_preset.addItem(strings._("this_year"), "this_year") @@ -1061,6 +1103,10 @@ class TimeReportDialog(QDialog): run_row.addWidget(run_btn) run_row.addWidget(export_btn) run_row.addWidget(pdf_btn) + # Only show invoicing if the feature is enabled + if getattr(self._db.cfg, "invoicing", False): + run_row.addWidget(self.invoice_btn) + run_row.addWidget(self.manage_invoices_btn) root.addLayout(run_row) # Table @@ -1146,6 +1192,14 @@ class TimeReportDialog(QDialog): start = today.addDays(1 - today.dayOfWeek()) end = today + elif preset == "last_week": + # Compute Monday–Sunday of the previous week (Monday-based weeks) + # 1. Monday of this week: + start_of_this_week = today.addDays(1 - today.dayOfWeek()) + # 2. Last week is 7 days before that: + start = start_of_this_week.addDays(-7) # last week's Monday + end = start_of_this_week.addDays(-1) # last week's Sunday + elif preset == "this_month": start = QDate(today.year(), today.month(), 1) end = today @@ -1187,11 +1241,13 @@ class TimeReportDialog(QDialog): if proj_data is None: # All projects self._last_all_projects = True + self._last_time_logs = [] self._last_project_name = strings._("all_projects") rows_for_table = self._db.time_report_all(start, end, gran) else: self._last_all_projects = False proj_id = int(proj_data) + self._last_time_logs = self._db.time_logs_for_range(proj_id, start, end) project_name = self.project_combo.currentText() self._last_project_name = project_name @@ -1525,3 +1581,55 @@ class TimeReportDialog(QDialog): strings._("export_pdf_error_title"), strings._("export_pdf_error_message").format(error=str(exc)), ) + + def _update_invoice_button_state(self) -> None: + data = self.project_combo.currentData() + if data is not None: + self.invoice_btn.show() + else: + self.invoice_btn.hide() + + def _on_manage_invoices(self) -> None: + from .invoices import InvoicesDialog + + dlg = InvoicesDialog(self._db, parent=self) + + # When the dialog says "reminders changed", forward that outward + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + + def _on_create_invoice(self) -> None: + idx = self.project_combo.currentIndex() + if idx < 0: + return + + project_id_data = self.project_combo.itemData(idx) + if project_id_data is None: + # Currently invoices are per-project, not cross-project + QMessageBox.information( + self, + strings._("invoice_project_required_title"), + strings._("invoice_project_required_message"), + ) + return + + proj_id = int(project_id_data) + + # Ensure we have a recent run to base this on + if not self._last_time_logs: + QMessageBox.information( + self, + strings._("invoice_need_report_title"), + strings._("invoice_need_report_message"), + ) + return + + start = self.from_date.date().toString("yyyy-MM-dd") + end = self.to_date.date().toString("yyyy-MM-dd") + + from .invoices import InvoiceDialog + + dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self) + dlg.remindersChanged.connect(self.remindersChanged.emit) + dlg.exec() diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py index 9a59aa8..6779bca 100644 --- a/tests/test_code_block_editor_dialog.py +++ b/tests/test_code_block_editor_dialog.py @@ -159,7 +159,7 @@ def test_line_number_area_paint_with_multiple_blocks(qtbot, app): rect = QRect(0, 0, line_area.width(), line_area.height()) paint_event = QPaintEvent(rect) - # This should exercise the painting loop (lines 87-130) + # This should exercise the painting loop editor.line_number_area_paint_event(paint_event) # Should not crash diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..80f1a90 --- /dev/null +++ b/tests/test_invoices.py @@ -0,0 +1,1348 @@ +import pytest +from datetime import date, timedelta + +from PySide6.QtCore import Qt, QDate +from PySide6.QtWidgets import QMessageBox + +from bouquin.invoices import ( + InvoiceDetailMode, + InvoiceLineItem, + _invoice_due_reminder_text, + InvoiceDialog, + InvoicesDialog, + _INVOICE_REMINDER_TIME, +) +from bouquin.reminders import Reminder, ReminderType + + +# ============================================================================ +# Tests for InvoiceDetailMode enum +# ============================================================================ + + +def test_invoice_detail_mode_enum_values(app): + """Test InvoiceDetailMode enum has expected values.""" + assert InvoiceDetailMode.DETAILED == "detailed" + assert InvoiceDetailMode.SUMMARY == "summary" + + +def test_invoice_detail_mode_is_string(app): + """Test InvoiceDetailMode enum inherits from str.""" + assert isinstance(InvoiceDetailMode.DETAILED, str) + assert isinstance(InvoiceDetailMode.SUMMARY, str) + + +# ============================================================================ +# Tests for InvoiceLineItem dataclass +# ============================================================================ + + +def test_invoice_line_item_creation(app): + """Test creating an InvoiceLineItem instance.""" + item = InvoiceLineItem( + description="Development work", + hours=5.5, + rate_cents=10000, + amount_cents=55000, + ) + + assert item.description == "Development work" + assert item.hours == 5.5 + assert item.rate_cents == 10000 + assert item.amount_cents == 55000 + + +def test_invoice_line_item_with_zero_values(app): + """Test InvoiceLineItem with zero values.""" + item = InvoiceLineItem( + description="", + hours=0.0, + rate_cents=0, + amount_cents=0, + ) + + assert item.description == "" + assert item.hours == 0.0 + assert item.rate_cents == 0 + assert item.amount_cents == 0 + + +# ============================================================================ +# Tests for _invoice_due_reminder_text helper function +# ============================================================================ + + +def test_invoice_due_reminder_text_normal(app): + """Test reminder text generation with normal inputs.""" + result = _invoice_due_reminder_text("Project Alpha", "INV-001") + assert result == "Invoice INV-001 for Project Alpha is due" + + +def test_invoice_due_reminder_text_with_whitespace(app): + """Test reminder text strips whitespace from inputs.""" + result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ") + assert result == "Invoice INV-002 for Project Beta is due" + + +def test_invoice_due_reminder_text_empty_project(app): + """Test reminder text with empty project name.""" + result = _invoice_due_reminder_text("", "INV-003") + assert result == "Invoice INV-003 for (no project) is due" + + +def test_invoice_due_reminder_text_empty_invoice_number(app): + """Test reminder text with empty invoice number.""" + result = _invoice_due_reminder_text("Project Gamma", "") + assert result == "Invoice ? for Project Gamma is due" + + +def test_invoice_due_reminder_text_both_empty(app): + """Test reminder text with both inputs empty.""" + result = _invoice_due_reminder_text("", "") + assert result == "Invoice ? for (no project) is due" + + +# ============================================================================ +# Tests for InvoiceDialog +# ============================================================================ + + +@pytest.fixture +def invoice_dialog_setup(qtbot, fresh_db): + """Set up a project with time logs for InvoiceDialog testing.""" + # Create a project + proj_id = fresh_db.add_project("Test Project") + + # Create an activity + act_id = fresh_db.add_activity("Development") + + # Set billing info + fresh_db.upsert_project_billing( + proj_id, + hourly_rate_cents=15000, # $150/hr + currency="USD", + tax_label="VAT", + tax_rate_percent=20.0, + client_name="John Doe", + client_company="Acme Corp", + client_address="123 Main St", + client_email="john@acme.com", + ) + + # Create some time logs + today = date.today() + start_date = (today - timedelta(days=7)).isoformat() + end_date = today.isoformat() + + # Add time logs for testing (2.5 hours = 150 minutes) + for i in range(3): + log_date = (today - timedelta(days=i)).isoformat() + fresh_db.add_time_log( + log_date, + proj_id, + act_id, + 150, # 2.5 hours in minutes + f"Note {i}", + ) + + time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date) + + return { + "db": fresh_db, + "proj_id": proj_id, + "act_id": act_id, + "start_date": start_date, + "end_date": end_date, + "time_rows": time_rows, + } + + +def test_invoice_dialog_init(qtbot, invoice_dialog_setup): + """Test InvoiceDialog initialization.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + assert dialog._db is setup["db"] + assert dialog._project_id == setup["proj_id"] + assert dialog._start == setup["start_date"] + assert dialog._end == setup["end_date"] + assert len(dialog._time_rows) == 3 + + +def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup): + """Test InvoiceDialog initialization without explicit time_rows.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + ) + qtbot.addWidget(dialog) + + # Should fetch time rows from DB + assert len(dialog._time_rows) == 3 + + +def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup): + """Test that InvoiceDialog loads billing defaults from project.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + assert dialog.currency_edit.text() == "USD" + assert dialog.rate_spin.value() == 150.0 + assert dialog.client_name_edit.text() == "John Doe" + assert dialog.client_company_combo.currentText() == "Acme Corp" + + +def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db): + """Test InvoiceDialog with project that has no billing info.""" + proj_id = fresh_db.add_project("Test Project No Billing") + today = date.today() + start = (today - timedelta(days=1)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + + # Should use defaults + assert dialog.currency_edit.text() == "AUD" + assert dialog.rate_spin.value() == 0.0 + assert dialog.client_name_edit.text() == "" + + +def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup): + """Test _project_name method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + project_name = dialog._project_name() + assert project_name == "Test Project" + + +def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup): + """Test _suggest_invoice_number method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + invoice_number = dialog._suggest_invoice_number() + # Should be in format YYYY-001 for first invoice (3 digits) + current_year = date.today().year + assert invoice_number.startswith(str(current_year)) + assert invoice_number.endswith("-001") + + +def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup): + """Test that invoice number suggestions increment.""" + setup = invoice_dialog_setup + + # Create an invoice first + dialog1 = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog1) + + # Save an invoice to increment the counter + invoice_number_1 = dialog1._suggest_invoice_number() + setup["db"].create_invoice( + project_id=setup["proj_id"], + invoice_number=invoice_number_1, + issue_date=date.today().isoformat(), + due_date=(date.today() + timedelta(days=14)).isoformat(), + currency="USD", + tax_label=None, + tax_rate_percent=None, + detail_mode=InvoiceDetailMode.DETAILED, + line_items=[], + time_log_ids=[], + ) + + # Create another dialog and check the number increments + dialog2 = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog2) + + invoice_number_2 = dialog2._suggest_invoice_number() + current_year = date.today().year + assert invoice_number_2 == f"{current_year}-002" + + +def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup): + """Test _populate_detailed_rows method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) # $150/hr in cents + + # Check that table has rows + assert dialog.table.rowCount() == 3 + + # Check that hours are displayed (COL_HOURS uses cellWidget, not item) + for row in range(3): + hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS) + assert hours_widget is not None + assert hours_widget.value() == 2.5 + + +def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup): + """Test _total_hours_from_table method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) + + total_hours = dialog._total_hours_from_table() + # 3 rows * 2.5 hours = 7.5 hours + assert total_hours == 7.5 + + +def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup): + """Test _detail_line_items method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(150.0) + dialog._populate_detailed_rows(15000) + + line_items = dialog._detail_line_items() + assert len(line_items) == 3 + + for item in line_items: + assert isinstance(item, InvoiceLineItem) + assert item.hours == 2.5 + assert item.rate_cents == 15000 + assert item.amount_cents == 37500 # 2.5 * 15000 + + +def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup): + """Test _summary_line_items method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(150.0) + dialog._populate_detailed_rows(15000) + + line_items = dialog._summary_line_items() + assert len(line_items) == 1 # Summary should have one line + + item = line_items[0] + assert isinstance(item, InvoiceLineItem) + # The description comes from summary_desc_edit which has a localized default + # Just check it's not empty + assert len(item.description) > 0 + assert item.hours == 7.5 # Total of 3 * 2.5 + assert item.rate_cents == 15000 + assert item.amount_cents == 112500 # 7.5 * 15000 + + +def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup): + """Test _recalc_amounts method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) + dialog.rate_spin.setValue(200.0) # Change rate to $200/hr + + dialog._recalc_amounts() + + # Check that amounts were recalculated + for row in range(3): + amount_item = dialog.table.item(row, dialog.COL_AMOUNT) + assert amount_item is not None + # 2.5 hours * $200 = $500 + assert amount_item.text() == "500.00" + + +def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup): + """Test _recalc_totals method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + + # Enable tax + dialog.tax_checkbox.setChecked(True) + dialog.tax_rate_spin.setValue(10.0) + + dialog._recalc_totals() + + # 7.5 hours * $100 = $750 + # Tax: $750 * 10% = $75 + # Total: $750 + $75 = $825 + assert "750.00" in dialog.subtotal_label.text() + assert "75.00" in dialog.tax_label_total.text() + assert "825.00" in dialog.total_label.text() + + +def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup): + """Test _on_tax_toggled method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + dialog.show() + + # Initially unchecked (from fixture setup with tax) + dialog.tax_checkbox.setChecked(False) + dialog._on_tax_toggled(False) + + # Tax fields should be hidden + assert not dialog.tax_label.isVisible() + assert not dialog.tax_label_edit.isVisible() + assert not dialog.tax_rate_label.isVisible() + assert not dialog.tax_rate_spin.isVisible() + + # Check the box + dialog.tax_checkbox.setChecked(True) + dialog._on_tax_toggled(True) + + # Tax fields should be visible + assert dialog.tax_label.isVisible() + assert dialog.tax_label_edit.isVisible() + assert dialog.tax_rate_label.isVisible() + assert dialog.tax_rate_spin.isVisible() + + +def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup): + """Test _on_client_company_changed method for autofill.""" + setup = invoice_dialog_setup + + # Create another project with different client + proj_id_2 = setup["db"].add_project("Project 2") + setup["db"].upsert_project_billing( + proj_id_2, + hourly_rate_cents=20000, + currency="EUR", + tax_label="GST", + tax_rate_percent=15.0, + client_name="Jane Smith", + client_company="Tech Industries", + client_address="456 Oak Ave", + client_email="jane@tech.com", + ) + + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Initially should have first project's client + assert dialog.client_name_edit.text() == "John Doe" + + # Change to second company + dialog.client_company_combo.setCurrentText("Tech Industries") + dialog._on_client_company_changed("Tech Industries") + + # Should autofill with second client's info + assert dialog.client_name_edit.text() == "Jane Smith" + assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave" + assert dialog.client_email_edit.text() == "jane@tech.com" + + +def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup): + """Test _create_due_date_reminder method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + due_date = (date.today() + timedelta(days=14)).isoformat() + invoice_number = "INV-TEST-001" + invoice_id = 999 # Fake invoice ID for testing + + dialog._create_due_date_reminder(invoice_id, invoice_number, due_date) + + # Check that reminder was created + reminders = setup["db"].get_all_reminders() + assert len(reminders) > 0 + + # Find our reminder + expected_text = _invoice_due_reminder_text("Test Project", invoice_number) + matching_reminders = [r for r in reminders if r.text == expected_text] + assert len(matching_reminders) == 1 + + reminder = matching_reminders[0] + assert reminder.reminder_type == ReminderType.ONCE + assert reminder.date_iso == due_date + assert reminder.time_str == _INVOICE_REMINDER_TIME + + +# ============================================================================ +# Tests for InvoicesDialog +# ============================================================================ + + +@pytest.fixture +def invoices_dialog_setup(qtbot, fresh_db): + """Set up projects with invoices for InvoicesDialog testing.""" + # Create projects + proj_id_1 = fresh_db.add_project("Project Alpha") + proj_id_2 = fresh_db.add_project("Project Beta") + + # Create invoices for project 1 + today = date.today() + for i in range(3): + issue_date = (today - timedelta(days=i * 7)).isoformat() + due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat() + paid_at = today.isoformat() if i == 0 else None # First one is paid + + fresh_db.create_invoice( + project_id=proj_id_1, + invoice_number=f"ALPHA-{i+1}", + issue_date=issue_date, + due_date=due_date, + currency="USD", + tax_label="VAT", + tax_rate_percent=20.0, + detail_mode=InvoiceDetailMode.DETAILED, + line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr + time_log_ids=[], + ) + + # Update paid_at separately if needed + if paid_at: + invoice_rows = fresh_db.get_all_invoices(proj_id_1) + if invoice_rows: + inv_id = invoice_rows[0]["id"] + fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at) + + # Create invoices for project 2 + for i in range(2): + issue_date = (today - timedelta(days=i * 10)).isoformat() + due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat() + + fresh_db.create_invoice( + project_id=proj_id_2, + invoice_number=f"BETA-{i+1}", + issue_date=issue_date, + due_date=due_date, + currency="EUR", + tax_label=None, + tax_rate_percent=None, + detail_mode=InvoiceDetailMode.SUMMARY, + line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr + time_log_ids=[], + ) + + return { + "db": fresh_db, + "proj_id_1": proj_id_1, + "proj_id_2": proj_id_2, + } + + +def test_invoices_dialog_init(qtbot, invoices_dialog_setup): + """Test InvoicesDialog initialization.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + assert dialog._db is setup["db"] + assert dialog.project_combo.count() >= 2 # 2 projects + + +def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup): + """Test InvoicesDialog initialization with specific project.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Should select the specified project + current_proj = dialog._current_project() + assert current_proj == setup["proj_id_1"] + + +def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup): + """Test _reload_projects method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + initial_count = dialog.project_combo.count() + assert initial_count >= 2 # Should have 2 projects from setup + + # Create a new project + setup["db"].add_project("Project Gamma") + + # Reload projects + dialog._reload_projects() + + # Should have one more project + assert dialog.project_combo.count() == initial_count + 1 + + +def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup): + """Test _current_project method when specific project is selected.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + current_proj = dialog._current_project() + assert current_proj == setup["proj_id_1"] + + +def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup): + """Test _reload_invoices with first project selected by default.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + # First project should be selected by default (Project Alpha with 3 invoices) + # The exact project depends on creation order, so just check we have some invoices + assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2) + + +def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup): + """Test _reload_invoices with single project selected.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + dialog._reload_invoices() + + # Should show only 3 invoices from proj1 + assert dialog.table.rowCount() == 3 + + +def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup): + """Test _on_project_changed method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"]) + qtbot.addWidget(dialog) + + # Start with project 2 (2 invoices) + assert dialog.table.rowCount() == 2 + + # Find the index of project 1 + for i in range(dialog.project_combo.count()): + if dialog.project_combo.itemData(i) == setup["proj_id_1"]: + dialog.project_combo.setCurrentIndex(i) + break + + dialog._on_project_changed(dialog.project_combo.currentIndex()) + + # Should now show 3 invoices from proj1 + assert dialog.table.rowCount() == 3 + + +def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup): + """Test _remove_invoice_due_reminder method.""" + setup = invoices_dialog_setup + + # Create a reminder for an invoice + due_date = (date.today() + timedelta(days=7)).isoformat() + invoice_number = "TEST-REMINDER-001" + project_name = "Project Alpha" + + reminder_text = _invoice_due_reminder_text(project_name, invoice_number) + reminder = Reminder( + id=None, + text=reminder_text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + date_iso=due_date, + active=True, + ) + reminder.id = setup["db"].save_reminder(reminder) + + # Verify reminder exists + reminders = setup["db"].get_all_reminders() + assert len(reminders) == 1 + + # Create dialog and populate with invoices + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Manually add a row to test the removal (simulating the invoice row) + row = dialog.table.rowCount() + dialog.table.insertRow(row) + + # Set the project and invoice number items + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem(project_name) + num_item = QTableWidgetItem(invoice_number) + dialog.table.setItem(row, dialog.COL_PROJECT, proj_item) + dialog.table.setItem(row, dialog.COL_NUMBER, num_item) + + # Mock invoice_id + num_item.setData(Qt.ItemDataRole.UserRole, 999) + + # Call the removal method + dialog._remove_invoice_due_reminder(row, 999) + + # Reminder should be deleted + reminders_after = setup["db"].get_all_reminders() + assert len(reminders_after) == 0 + + +def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup): + """Test _on_item_changed for invoice number editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row's invoice ID + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + # Change the invoice number + num_item.setText("ALPHA-MODIFIED") + + # Trigger the change handler + dialog._on_item_changed(num_item) + + # Verify the change was saved to DB + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number") + assert invoice_data["invoice_number"] == "ALPHA-MODIFIED" + + +def test_invoices_dialog_on_item_changed_empty_invoice_number( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects empty invoice number.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox to auto-close + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Get the first row's invoice number item + num_item = dialog.table.item(0, dialog.COL_NUMBER) + original_number = num_item.text() + + # Try to set empty invoice number + num_item.setText("") + dialog._on_item_changed(num_item) + + # Should be reset to original + assert num_item.text() == original_number + + +def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup): + """Test _on_item_changed for issue date editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + new_date = "2024-01-15" + issue_item.setText(new_date) + + dialog._on_item_changed(issue_item) + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date") + assert invoice_data["issue_date"] == new_date + + +def test_invoices_dialog_on_item_changed_invalid_date( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid date format.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + original_date = issue_item.text() + + # Try to set invalid date + issue_item.setText("not-a-date") + dialog._on_item_changed(issue_item) + + # Should be reset to original + assert issue_item.text() == original_date + + +def test_invoices_dialog_on_item_changed_due_before_issue( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects due date before issue date.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Set issue date + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + issue_item.setText("2024-02-01") + dialog._on_item_changed(issue_item) + + # Try to set due date before issue date + due_item = dialog.table.item(0, dialog.COL_DUE_DATE) + original_due = due_item.text() + due_item.setText("2024-01-01") + dialog._on_item_changed(due_item) + + # Should be reset + assert due_item.text() == original_due + + +def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup): + """Test _on_item_changed for currency editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + currency_item = dialog.table.item(0, dialog.COL_CURRENCY) + currency_item.setText("gbp") # lowercase + + dialog._on_item_changed(currency_item) + + # Should be normalized to uppercase + assert currency_item.text() == "GBP" + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency") + assert invoice_data["currency"] == "GBP" + + +def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup): + """Test _on_item_changed for tax rate editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE) + tax_rate_item.setText("15.5") + + dialog._on_item_changed(tax_rate_item) + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent") + assert invoice_data["tax_rate_percent"] == 15.5 + + +def test_invoices_dialog_on_item_changed_invalid_tax_rate( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid tax rate.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE) + original_rate = tax_rate_item.text() + + # Try to set invalid tax rate + tax_rate_item.setText("not-a-number") + dialog._on_item_changed(tax_rate_item) + + # Should be reset to original + assert tax_rate_item.text() == original_rate + + +def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup): + """Test _on_item_changed for subtotal editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL) + subtotal_item.setText("1234.56") + + dialog._on_item_changed(subtotal_item) + + # Verify change was saved (in cents) + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents") + assert invoice_data["subtotal_cents"] == 123456 + + # Should be normalized to 2 decimals + assert subtotal_item.text() == "1234.56" + + +def test_invoices_dialog_on_item_changed_invalid_amount( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid amount.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL) + original_subtotal = subtotal_item.text() + + # Try to set invalid amount + subtotal_item.setText("not-a-number") + dialog._on_item_changed(subtotal_item) + + # Should be reset to original + assert subtotal_item.text() == original_subtotal + + +def test_invoices_dialog_on_item_changed_paid_at_removes_reminder( + qtbot, invoices_dialog_setup +): + """Test that marking invoice as paid removes due date reminder.""" + setup = invoices_dialog_setup + + # Create a reminder for an invoice + due_date = (date.today() + timedelta(days=7)).isoformat() + invoice_number = "ALPHA-1" + project_name = "Project Alpha" + + reminder_text = _invoice_due_reminder_text(project_name, invoice_number) + reminder = Reminder( + id=None, + text=reminder_text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + date_iso=due_date, + active=True, + ) + reminder.id = setup["db"].save_reminder(reminder) + + # Verify reminder exists + reminders = setup["db"].get_all_reminders() + assert any(r.text == reminder_text for r in reminders) + + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Find the ALPHA-1 invoice row + for row in range(dialog.table.rowCount()): + num_item = dialog.table.item(row, dialog.COL_NUMBER) + if num_item and num_item.text() == "ALPHA-1": + # Mark as paid + paid_item = dialog.table.item(row, dialog.COL_PAID_AT) + paid_item.setText(date.today().isoformat()) + dialog._on_item_changed(paid_item) + break + + # Reminder should be removed + reminders_after = setup["db"].get_all_reminders() + assert not any(r.text == reminder_text for r in reminders_after) + + +def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup): + """Test that _on_item_changed is ignored during reload.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Set reloading flag + dialog._reloading_invoices = True + + # Try to change an item + num_item = dialog.table.item(0, dialog.COL_NUMBER) + original_number = num_item.text() + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + num_item.setText("SHOULD-BE-IGNORED") + dialog._on_item_changed(num_item) + + # Change should not be saved to DB + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number") + assert invoice_data["invoice_number"] == original_number + + +def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup): + """Test _update_mode_enabled method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + dialog.show() + + # Initially detailed mode should be selected + assert dialog.rb_detailed.isChecked() + + # Table should be enabled in detailed mode + assert dialog.table.isEnabled() + + # Switch to summary mode + dialog.rb_summary.setChecked(True) + dialog._update_mode_enabled() + + # Table should be disabled in summary mode + assert not dialog.table.isEnabled() + + +def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db): + """Test InvoiceDialog with project that has no time logs.""" + proj_id = fresh_db.add_project("Empty Project") + today = date.today() + start = (today - timedelta(days=7)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + + # Should handle empty time logs gracefully + assert len(dialog._time_rows) == 0 + assert dialog.table.rowCount() == 0 + + +def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup): + """Test that InvoiceDialog loads existing client companies.""" + setup = invoice_dialog_setup + + # Create another project with a different client company + proj_id_2 = setup["db"].add_project("Project 2") + setup["db"].upsert_project_billing( + proj_id_2, + hourly_rate_cents=10000, + currency="EUR", + tax_label="VAT", + tax_rate_percent=19.0, + client_name="Jane Doe", + client_company="Beta Corp", + client_address="456 Main St", + client_email="jane@beta.com", + ) + + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Should have both companies in the combo + companies = [ + dialog.client_company_combo.itemText(i) + for i in range(dialog.client_company_combo.count()) + ] + assert "Acme Corp" in companies + assert "Beta Corp" in companies + + +def test_invoice_line_item_equality(app): + """Test InvoiceLineItem equality.""" + item1 = InvoiceLineItem("Work", 5.0, 10000, 50000) + item2 = InvoiceLineItem("Work", 5.0, 10000, 50000) + item3 = InvoiceLineItem("Other", 5.0, 10000, 50000) + + assert item1 == item2 + assert item1 != item3 + + +def test_invoices_dialog_empty_database(qtbot, fresh_db): + """Test InvoicesDialog with no projects or invoices.""" + dialog = InvoicesDialog(fresh_db) + qtbot.addWidget(dialog) + + # Should have no projects in combo + assert dialog.project_combo.count() == 0 + assert dialog.table.rowCount() == 0 + + +def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db): + """Test that tax fields are hidden when tax_rate_percent is None.""" + proj_id = fresh_db.add_project("No Tax Project") + fresh_db.upsert_project_billing( + proj_id, + hourly_rate_cents=10000, + currency="USD", + tax_label="Tax", + tax_rate_percent=None, # No tax + client_name="Client", + client_company="Company", + client_address="Address", + client_email="email@test.com", + ) + + today = date.today() + start = (today - timedelta(days=1)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + dialog.show() + + # Tax checkbox should be unchecked + assert not dialog.tax_checkbox.isChecked() + + # Tax fields should be hidden + assert not dialog.tax_label.isVisible() + assert not dialog.tax_label_edit.isVisible() + assert not dialog.tax_rate_label.isVisible() + assert not dialog.tax_rate_spin.isVisible() + + +def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup): + """Test that issue and due dates have correct default values.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Issue date should be today + assert dialog.issue_date_edit.date() == QDate.currentDate() + + # Due date should be 14 days from today + QDate.currentDate().addDays(14) + assert dialog.issue_date_edit.date() == QDate.currentDate() + + +def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup): + """Test that unchecking a line item updates the total cost.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(False) + + # Initial total: 3 rows * 2.5 hours * $100 = $750 + dialog._recalc_totals() + assert "750.00" in dialog.subtotal_label.text() + assert "750.00" in dialog.total_label.text() + + # Uncheck the first row + include_item = dialog.table.item(0, dialog.COL_INCLUDE) + include_item.setCheckState(Qt.Unchecked) + + # Wait for signal processing + qtbot.wait(10) + + # New total: 2 rows * 2.5 hours * $100 = $500 + assert "500.00" in dialog.subtotal_label.text() + assert "500.00" in dialog.total_label.text() + + +def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup): + """Test that checkbox toggling works correctly with tax enabled.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(True) + dialog.tax_rate_spin.setValue(10.0) + + # Initial: 3 rows * 2.5 hours * $100 = $750 + # Tax: $750 * 10% = $75 + # Total: $825 + dialog._recalc_totals() + assert "750.00" in dialog.subtotal_label.text() + assert "75.00" in dialog.tax_label_total.text() + assert "825.00" in dialog.total_label.text() + + # Uncheck two rows + dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + + # Wait for signal processing + qtbot.wait(10) + + # New total: 1 row * 2.5 hours * $100 = $250 + # Tax: $250 * 10% = $25 + # Total: $275 + assert "250.00" in dialog.subtotal_label.text() + assert "25.00" in dialog.tax_label_total.text() + assert "275.00" in dialog.total_label.text() + + +def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup): + """Test that rechecking a previously unchecked item updates totals.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(False) + + # Uncheck all items + for row in range(dialog.table.rowCount()): + dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + + qtbot.wait(10) + + # Total should be 0 + assert "0.00" in dialog.total_label.text() + + # Re-check first item + dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked) + qtbot.wait(10) + + # Total should be 1 row * 2.5 hours * $100 = $250 + assert "250.00" in dialog.total_label.text() + + +def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup): + """Test _select_initial_project method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + # Initially should have first project selected (either proj1 or proj2) + initial_proj = dialog._current_project() + assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]] + + # Select specific project + dialog._select_initial_project(setup["proj_id_2"]) + + # Should now have proj_id_2 selected + assert dialog._current_project() == setup["proj_id_2"] diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py index f044fac..70ad1da 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -97,7 +97,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path): def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app): - """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57""" + """Test KeyPrompt with show_db_change but no initial_db_path""" prompt = KeyPrompt(show_db_change=True, initial_db_path=None) qtbot.addWidget(prompt) @@ -168,7 +168,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path): def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): - """Test browsing when initial_db_path is set - covers line 57 with non-None path""" + """Test browsing when initial_db_path is set""" initial_db = tmp_path / "initial.db" initial_db.touch() @@ -180,7 +180,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): # Mock the file dialog to return a different file def mock_get_open_filename(*args, **kwargs): - # Verify that start_dir was passed correctly (line 57) + # Verify that start_dir was passed correctly return str(new_db), "SQLCipher DB (*.db)" monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index a4025ea..8d869a9 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1928,7 +1928,7 @@ def test_editor_delete_operations(qtbot, app): def test_markdown_highlighter_dark_theme(qtbot, app): - """Test markdown highlighter with dark theme - covers lines 74-75""" + """Test markdown highlighter with dark theme""" # Create theme manager with dark theme themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) @@ -2293,7 +2293,7 @@ def test_highlighter_code_block_with_language(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # Verify syntax highlighting was applied (lines 186-193) + # Verify syntax highlighting was applied # We can't easily verify the exact formatting, but we ensure no crash @@ -2305,13 +2305,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # The overlap detection (lines 252, 264) should prevent issues - def test_highlighter_italic_edge_cases(editor, qtbot): """Test italic formatting edge cases.""" # Test edge case: avoiding stealing markers that are part of double - # This tests lines 267-270 editor.setPlainText("**not italic* text**") # Force rehighlight diff --git a/tests/test_markdown_editor_additional.py b/tests/test_markdown_editor_additional.py index 070d954..2584baa 100644 --- a/tests/test_markdown_editor_additional.py +++ b/tests/test_markdown_editor_additional.py @@ -44,7 +44,6 @@ def editor(app, qtbot): return ed -# Test for line 215: document is None guard def test_update_code_block_backgrounds_with_no_document(app, qtbot): """Test _update_code_block_row_backgrounds when document is None.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -60,7 +59,6 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot): editor._update_code_block_row_backgrounds() -# Test for lines 295, 309, 313-319, 324, 326, 334: _find_code_block_bounds edge cases def test_find_code_block_bounds_invalid_block(editor): """Test _find_code_block_bounds with invalid block.""" editor.setPlainText("some text") @@ -124,7 +122,6 @@ def test_find_code_block_bounds_no_opening_fence(editor): assert result is None -# Test for lines 356, 413, 417-418, 428-434: code block editing edge cases def test_edit_code_block_checks_document(app, qtbot): """Test _edit_code_block when editor has no document.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -249,7 +246,6 @@ def test_edit_code_block_language_change(editor, qtbot, monkeypatch): assert lang == "javascript" -# Test for lines 443-490: _delete_code_block def test_delete_code_block_no_bounds(editor): """Test _delete_code_block when bounds can't be found.""" editor.setPlainText("not a code block") @@ -307,7 +303,6 @@ def test_delete_code_block_with_text_after(editor): assert "text after" in new_text -# Test for line 496: _apply_line_spacing with no document def test_apply_line_spacing_no_document(app): """Test _apply_line_spacing when document is None.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -319,7 +314,6 @@ def test_apply_line_spacing_no_document(app): editor._apply_line_spacing(125.0) -# Test for line 517: _apply_code_block_spacing def test_apply_code_block_spacing(editor): """Test _apply_code_block_spacing applies correct spacing.""" editor.setPlainText("```\nline1\nline2\n```") @@ -334,7 +328,6 @@ def test_apply_code_block_spacing(editor): assert block.isValid() -# Test for line 604: to_markdown with metadata def test_to_markdown_with_code_metadata(editor): """Test to_markdown includes code block metadata.""" editor.setPlainText("```python\ncode\n```") @@ -348,7 +341,6 @@ def test_to_markdown_with_code_metadata(editor): assert "code-langs" in md or "code" in md -# Test for line 648: from_markdown without _code_metadata attribute def test_from_markdown_creates_code_metadata(app): """Test from_markdown creates _code_metadata if missing.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -364,7 +356,6 @@ def test_from_markdown_creates_code_metadata(app): assert hasattr(editor, "_code_metadata") -# Test for lines 718-736: image embedding with original size def test_embed_images_preserves_original_size(editor, tmp_path): """Test that embedded images preserve their original dimensions.""" # Create a test image @@ -387,7 +378,6 @@ def test_embed_images_preserves_original_size(editor, tmp_path): assert doc is not None -# Test for lines 782, 791, 813-834: _maybe_trim_list_prefix_from_line_selection def test_trim_list_prefix_no_selection(editor): """Test _maybe_trim_list_prefix_from_line_selection with no selection.""" editor.setPlainText("- item") @@ -447,7 +437,6 @@ def test_trim_list_prefix_during_adjustment(editor): editor._adjusting_selection = False -# Test for lines 848, 860-866: _detect_list_type def test_detect_list_type_checkbox_checked(editor): """Test _detect_list_type with checked checkbox.""" list_type, prefix = editor._detect_list_type( @@ -478,7 +467,6 @@ def test_detect_list_type_not_a_list(editor): assert prefix == "" -# Test for lines 876, 884-886: list prefix length calculation def test_list_prefix_length_numbered(editor): """Test _list_prefix_length_for_block with numbered list.""" editor.setPlainText("123. item") @@ -489,7 +477,6 @@ def test_list_prefix_length_numbered(editor): assert length > 0 -# Test for lines 948-949: keyPressEvent with Ctrl+Home def test_key_press_ctrl_home(editor, qtbot): """Test Ctrl+Home key combination.""" editor.setPlainText("line1\nline2\nline3") @@ -504,7 +491,6 @@ def test_key_press_ctrl_home(editor, qtbot): assert editor.textCursor().position() == 0 -# Test for lines 957-960: keyPressEvent with Ctrl+Left def test_key_press_ctrl_left(editor, qtbot): """Test Ctrl+Left key combination.""" editor.setPlainText("word1 word2 word3") @@ -518,7 +504,6 @@ def test_key_press_ctrl_left(editor, qtbot): # Should move left by word -# Test for lines 984-988, 1044: Home key in list def test_key_press_home_in_list(editor, qtbot): """Test Home key in list item.""" editor.setPlainText("- item text") @@ -534,7 +519,6 @@ def test_key_press_home_in_list(editor, qtbot): assert pos > 0 -# Test for lines 1067-1073: Left key in list prefix def test_key_press_left_in_list_prefix(editor, qtbot): """Test Left key when in list prefix region.""" editor.setPlainText("- item") @@ -549,7 +533,6 @@ def test_key_press_left_in_list_prefix(editor, qtbot): # Should snap to after prefix -# Test for lines 1088, 1095-1104: Up/Down in code blocks def test_key_press_up_in_code_block(editor, qtbot): """Test Up key inside code block.""" editor.setPlainText("```\ncode line 1\ncode line 2\n```") @@ -579,7 +562,6 @@ def test_key_press_down_in_list_item(editor, qtbot): # Should snap to after prefix on next line -# Test for lines 1127-1130, 1134-1137: Enter key with markers def test_key_press_enter_after_markers(editor, qtbot): """Test Enter key after style markers.""" editor.setPlainText("text **") @@ -593,7 +575,6 @@ def test_key_press_enter_after_markers(editor, qtbot): # Should handle markers -# Test for lines 1146-1164: Enter on fence line def test_key_press_enter_on_closing_fence(editor, qtbot): """Test Enter key on closing fence line.""" editor.setPlainText("```\ncode\n```") @@ -608,7 +589,6 @@ def test_key_press_enter_on_closing_fence(editor, qtbot): # Should create new line after fence -# Test for lines 1185-1189: Backspace in empty checkbox def test_key_press_backspace_empty_checkbox(editor, qtbot): """Test Backspace in empty checkbox item.""" editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ") @@ -622,7 +602,6 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot): # Should remove checkbox -# Test for lines 1205, 1215-1221: Backspace in numbered list def test_key_press_backspace_numbered_list(editor, qtbot): """Test Backspace at start of numbered list item.""" editor.setPlainText("1. ") @@ -634,7 +613,6 @@ def test_key_press_backspace_numbered_list(editor, qtbot): editor.keyPressEvent(event) -# Test for lines 1228, 1232, 1238-1242: Tab/Shift+Tab in lists def test_key_press_tab_in_bullet_list(editor, qtbot): """Test Tab key in bullet list.""" editor.setPlainText("- item") @@ -672,7 +650,6 @@ def test_key_press_tab_in_checkbox(editor, qtbot): editor.keyPressEvent(event) -# Test for lines 1282-1283: Auto-pairing skip def test_apply_weight_to_selection(editor, qtbot): """Test apply_weight makes text bold.""" editor.setPlainText("text to bold") @@ -712,7 +689,6 @@ def test_apply_strikethrough_to_selection(editor, qtbot): assert "~~" in md -# Test for line 1358: apply_code - it opens a dialog, not just wraps in backticks def test_apply_code_on_selection(editor, qtbot): """Test apply_code with selected text.""" editor.setPlainText("some code") @@ -728,7 +704,6 @@ def test_apply_code_on_selection(editor, qtbot): # May contain code block elements depending on dialog behavior -# Test for line 1386: toggle_numbers def test_toggle_numbers_on_plain_text(editor, qtbot): """Test toggle_numbers converts text to numbered list.""" editor.setPlainText("item 1") @@ -742,7 +717,6 @@ def test_toggle_numbers_on_plain_text(editor, qtbot): assert "1." in text -# Test for lines 1402-1407: toggle_bullets def test_toggle_bullets_on_plain_text(editor, qtbot): """Test toggle_bullets converts text to bullet list.""" editor.setPlainText("item 1") @@ -771,7 +745,6 @@ def test_toggle_bullets_removes_bullets(editor, qtbot): assert text.strip() == "item 1" -# Test for line 1429: toggle_checkboxes def test_toggle_checkboxes_on_bullets(editor, qtbot): """Test toggle_checkboxes converts bullets to checkboxes.""" editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1") @@ -786,7 +759,6 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot): assert editor._CHECK_UNCHECKED_DISPLAY in text -# Test for line 1452: apply_heading def test_apply_heading_various_levels(editor, qtbot): """Test apply_heading with different levels.""" test_cases = [ @@ -809,7 +781,6 @@ def test_apply_heading_various_levels(editor, qtbot): assert text.startswith(expected_marker) -# Test for lines 1501-1505: insert_image_from_path def test_insert_image_from_path_invalid_extension(editor, tmp_path): """Test insert_image_from_path with invalid extension.""" invalid_file = tmp_path / "file.txt" @@ -827,7 +798,6 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path): editor.insert_image_from_path(nonexistent) -# Test for lines 1578-1579: mousePressEvent checkbox toggle def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot): """Test clicking checkbox toggles it from unchecked to checked.""" editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") @@ -872,7 +842,6 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot): assert editor._CHECK_UNCHECKED_DISPLAY in text -# Test for line 1602: mouseDoubleClickEvent def test_mouse_double_click_suppression(editor, qtbot): """Test double-click suppression for checkboxes.""" editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") @@ -895,7 +864,6 @@ def test_mouse_double_click_suppression(editor, qtbot): assert not editor._suppress_next_checkbox_double_click -# Test for lines 1692-1738: Context menu (lines 1670 was the image loading, not link handling) def test_context_menu_in_code_block(editor, qtbot): """Test context menu when in code block.""" editor.setPlainText("```python\ncode\n```") @@ -915,7 +883,6 @@ def test_context_menu_in_code_block(editor, qtbot): # Note: actual menu exec is blocked in tests, but we verify it doesn't crash -# Test for lines 1742-1757: _set_code_block_language def test_set_code_block_language(editor, qtbot): """Test _set_code_block_language sets metadata.""" editor.setPlainText("```\ncode\n```") @@ -929,7 +896,6 @@ def test_set_code_block_language(editor, qtbot): assert lang == "python" -# Test for lines 1770-1783: get_current_line_task_text def test_get_current_line_task_text_strips_prefixes(editor, qtbot): """Test get_current_line_task_text removes list/checkbox prefixes.""" test_cases = [ diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py index 8ff73b1..12f96c5 100644 --- a/tests/test_statistics_dialog.py +++ b/tests/test_statistics_dialog.py @@ -632,5 +632,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db): # Force a repaint to execute paintEvent heatmap.repaint() - # The month continuation logic (line 175) should prevent duplicate labels + # The month continuation logic should prevent duplicate labels # We can't easily test the visual output, but we ensure no crash From 61b3e5b45ab9b951a62877a67fd999d8cabe82f6 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 9 Dec 2025 12:48:59 +1100 Subject: [PATCH 2/6] Code comments --- bouquin/db.py | 2 +- bouquin/documents.py | 2 +- bouquin/invoices.py | 18 +++++++++--------- bouquin/main_window.py | 4 ++-- bouquin/markdown_editor.py | 10 +++++----- bouquin/pomodoro_timer.py | 2 +- bouquin/reminders.py | 2 +- bouquin/settings_dialog.py | 2 +- bouquin/statistics_dialog.py | 2 +- bouquin/time_log.py | 12 ++++++------ 10 files changed, 28 insertions(+), 28 deletions(-) diff --git a/bouquin/db.py b/bouquin/db.py index 46f72b1..f92c68e 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1301,7 +1301,7 @@ class DBManager: cur = self.conn.cursor() if granularity == "none": - # No grouping – one row per time_log record + # No grouping - one row per time_log record rows = cur.execute( """ SELECT diff --git a/bouquin/documents.py b/bouquin/documents.py index c30f31c..a554d0d 100644 --- a/bouquin/documents.py +++ b/bouquin/documents.py @@ -112,7 +112,7 @@ class TodaysDocumentsWidget(QFrame): if project_name: extra_parts.append(project_name) if extra_parts: - label = f"{file_name} – " + " · ".join(extra_parts) + label = f"{file_name} - " + " · ".join(extra_parts) item = QListWidgetItem(label) item.setData( diff --git a/bouquin/invoices.py b/bouquin/invoices.py index ee8d3a4..88a8475 100644 --- a/bouquin/invoices.py +++ b/bouquin/invoices.py @@ -418,7 +418,7 @@ class InvoiceDialog(QDialog): hours = minutes / 60.0 - # Hours – editable via spin box (override allowed) + # Hours - editable via spin box (override allowed) hours_spin = QDoubleSpinBox() hours_spin.setRange(0, 24) hours_spin.setDecimals(2) @@ -457,7 +457,7 @@ class InvoiceDialog(QDialog): descr_parts = [date_str, activity] if note: descr_parts.append(note) - descr = " – ".join(descr_parts) + descr = " - ".join(descr_parts) hours_widget = self.table.cellWidget(r, self.COL_HOURS) hours = ( @@ -567,10 +567,10 @@ class InvoiceDialog(QDialog): details = self._db.get_client_by_company(text) if not details: - # New client – leave other fields as-is + # New client - leave other fields as-is return - # We don't touch the company combo text – user already chose/typed it. + # We don't touch the company combo text - user already chose/typed it. client_name, client_company, client_address, client_email = details if client_name: self.client_name_edit.setText(client_name) @@ -609,7 +609,7 @@ class InvoiceDialog(QDialog): else InvoiceDetailMode.SUMMARY ) - # Build line items + collect time_log_ids + # Build line items & collect time_log_ids if detail_mode == InvoiceDetailMode.DETAILED: items = self._detail_line_items() time_log_ids: list[int] = [] @@ -631,7 +631,7 @@ class InvoiceDialog(QDialog): ) return - # Rate + tax info + # Rate & tax info rate_cents = int(round(self.rate_spin.value() * 100)) currency = self.currency_edit.text().strip() tax_label = self.tax_label_edit.text().strip() or None @@ -715,7 +715,7 @@ class InvoiceDialog(QDialog): doc = QTextDocument() - # 🔹 Load company profile *before* building HTML + # Load company profile before building HTML profile = self._db.get_company_profile() self._company_profile = None if profile: @@ -1178,7 +1178,7 @@ class InvoicesDialog(QDialog): row_idx, self.COL_TAX_RATE, QTableWidgetItem(tax_rate_text) ) - # Column 7–9: amounts (cents → dollars) + # Column 7-9: amounts (cents → dollars) self.table.setItem( row_idx, self.COL_SUBTOTAL, @@ -1441,7 +1441,7 @@ class InvoicesDialog(QDialog): self._db.set_invoice_field_by_id(inv_id, field, cents) - # Normalize formatting in the table + # Normalise formatting in the table self._reloading_invoices = True try: item.setText(f"{cents / 100.0:.2f}") diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 737b11a..0a3cc9c 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -496,7 +496,7 @@ class MainWindow(QMainWindow): idx = self._tab_index_for_date(date) if idx != -1: self.tab_widget.setCurrentIndex(idx) - # keep calendar selection in sync (don’t trigger load) + # keep calendar selection in sync (don't trigger load) from PySide6.QtCore import QSignalBlocker with QSignalBlocker(self.calendar): @@ -519,7 +519,7 @@ class MainWindow(QMainWindow): editor = MarkdownEditor(self.themes) - # Apply user’s preferred font size + # Apply user's preferred font size self._apply_font_size(editor) # Set up the editor's event connections diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 4e85f84..838a037 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -382,7 +382,7 @@ class MarkdownEditor(QTextEdit): cursor.removeSelectedText() cursor.insertText("\n" + new_text + "\n") else: - # Empty block – keep one blank line inside the fences + # Empty block - keep one blank line inside the fences cursor.removeSelectedText() cursor.insertText("\n\n") cursor.endEditBlock() @@ -789,7 +789,7 @@ class MarkdownEditor(QTextEdit): """ # When the user is actively dragging with the mouse, we *do* want the # checkbox/bullet to be part of the selection (for deleting whole rows). - # So don’t rewrite the selection in that case. + # So don't rewrite the selection in that case. if getattr(self, "_mouse_drag_selecting", False): return @@ -863,7 +863,7 @@ class MarkdownEditor(QTextEdit): ): return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ") - # Bullet list – Unicode bullet + # Bullet list - Unicode bullet if line.startswith(f"{self._BULLET_DISPLAY} "): return ("bullet", f"{self._BULLET_DISPLAY} ") @@ -1055,7 +1055,7 @@ class MarkdownEditor(QTextEdit): # of list prefixes (checkboxes / bullets / numbers). if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left): # Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of - # document / word-left) – we don't interfere with those. + # document / word-left) - we don't interfere with those. if event.modifiers() & Qt.ControlModifier: pass else: @@ -1367,7 +1367,7 @@ class MarkdownEditor(QTextEdit): cursor = self.cursorForPosition(event.pos()) block = cursor.block() - # If we’re on or inside a code block, open the editor instead + # If we're on or inside a code block, open the editor instead if self._is_inside_code_block(block) or block.text().strip().startswith("```"): # Only swallow the double-click if we actually opened a dialog. if not self._edit_code_block(block): diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py index 50d5a69..1c6588c 100644 --- a/bouquin/pomodoro_timer.py +++ b/bouquin/pomodoro_timer.py @@ -133,7 +133,7 @@ class PomodoroManager: if hasattr(time_log_widget, "show_pomodoro_widget"): time_log_widget.show_pomodoro_widget(self._active_timer) else: - # Fallback – just attach it as a child widget + # Fallback - just attach it as a child widget self._active_timer.setParent(time_log_widget) self._active_timer.show() diff --git a/bouquin/reminders.py b/bouquin/reminders.py index c127a99..eabbe17 100644 --- a/bouquin/reminders.py +++ b/bouquin/reminders.py @@ -484,7 +484,7 @@ class UpcomingRemindersWidget(QFrame): offset = (target_dow - first.dayOfWeek() + 7) % 7 candidate = first.addDays(offset + anchor_n * 7) - # If that nth weekday doesn’t exist this month (e.g. 5th Monday), skip + # If that nth weekday doesn't exist this month (e.g. 5th Monday), skip if candidate.month() != date.month(): return False diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index e209e9e..2d0b1a4 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -246,7 +246,7 @@ class SettingsDialog(QDialog): self.company_payment_details_edit, ) - # Logo picker – store bytes on self._logo_bytes + # Logo picker - store bytes on self._logo_bytes self._logo_bytes = logo_bytes logo_row = QHBoxLayout() self.logo_label = QLabel(strings._("invoice_company_logo_not_set")) diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py index f71c447..0a94126 100644 --- a/bouquin/statistics_dialog.py +++ b/bouquin/statistics_dialog.py @@ -216,7 +216,7 @@ class DateHeatmap(QWidget): col = int((x - self._margin_left) // cell_span) # week index row = int((y - self._margin_top) // cell_span) # dow (0..6) - # Only 7 rows (Mon–Sun) + # Only 7 rows (Mon-Sun) if not (0 <= row < 7): return diff --git a/bouquin/time_log.py b/bouquin/time_log.py index c8aaa14..e143d57 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -270,7 +270,7 @@ class TimeLogDialog(QDialog): self._date_iso = date_iso self._current_entry_id: Optional[int] = None self.cfg = load_db_config() - # Guard flag used when repopulating the table so we don’t treat + # Guard flag used when repopulating the table so we don't treat # programmatic item changes as user edits. self._reloading_entries: bool = False @@ -620,7 +620,7 @@ class TimeLogDialog(QDialog): hours_item = self.table.item(row, 3) if proj_item is None or act_item is None or hours_item is None: - # Incomplete row – nothing to do. + # Incomplete row - nothing to do. return # Recover the entry id from the hidden UserRole on the project cell @@ -829,7 +829,7 @@ class TimeCodeManagerDialog(QDialog): try: self._db.add_project(name) except ValueError: - # Empty / invalid name – nothing to do, but be defensive + # Empty / invalid name - nothing to do, but be defensive QMessageBox.warning( self, strings._("invalid_project_title"), @@ -1193,7 +1193,7 @@ class TimeReportDialog(QDialog): end = today elif preset == "last_week": - # Compute Monday–Sunday of the previous week (Monday-based weeks) + # Compute Monday-Sunday of the previous week (Monday-based weeks) # 1. Monday of this week: start_of_this_week = today.addDays(1 - today.dayOfWeek()) # 2. Last week is 7 days before that: @@ -1208,7 +1208,7 @@ class TimeReportDialog(QDialog): start = QDate(today.year(), 1, 1) end = today - else: # "custom" – leave fields as user-set + else: # "custom" - leave fields as user-set return # Update date edits without triggering anything else @@ -1284,7 +1284,7 @@ class TimeReportDialog(QDialog): # no note column self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) - # Summary label – include per-project totals when in "all projects" mode + # Summary label - include per-project totals when in "all projects" mode total_hours = self._last_total_minutes / 60.0 if self._last_all_projects: per_project_bits = [ From 0862ce7fd6d1397da00c5e2d02101399a9136a9a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 10 Dec 2025 18:27:15 +1100 Subject: [PATCH 3/6] Say just 'once' (not 'once (today)') in reminders, now that we can set the specific date --- bouquin/locales/en.json | 2 +- bouquin/locales/fr.json | 2 +- bouquin/reminders.py | 2 +- release.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 2a7baea..dbd8330 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -306,7 +306,7 @@ "reminder": "Reminder", "reminders": "Reminders", "time": "Time", - "once_today": "Once (today)", + "once": "Once", "every_day": "Every day", "every_weekday": "Every weekday (Mon-Fri)", "every_week": "Every week", diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 3ba5ba6..f77ebb1 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -274,7 +274,7 @@ "weekly": "hebdomadaire", "edit_reminder": "Modifier le rappel", "time": "Heure", - "once_today": "Une fois (aujourd'hui)", + "once": "Une fois (aujourd'hui)", "every_day": "Tous les jours", "every_weekday": "Tous les jours de semaine (lun-ven)", "every_week": "Toutes les semaines", diff --git a/bouquin/reminders.py b/bouquin/reminders.py index eabbe17..2c3a9c7 100644 --- a/bouquin/reminders.py +++ b/bouquin/reminders.py @@ -107,7 +107,7 @@ class ReminderDialog(QDialog): # Recurrence type self.type_combo = QComboBox() - self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE) + self.type_combo.addItem(strings._("once"), ReminderType.ONCE) self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY) self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS) self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY) diff --git a/release.sh b/release.sh index 5970bb3..9f8b3c8 100755 --- a/release.sh +++ b/release.sh @@ -3,7 +3,7 @@ set -eo pipefail # Clean caches etc -/home/user/venv-guardutils/bin/filedust -y . +filedust -y . # Publish to Pypi poetry build From fb873edcb5e2cce2095684b88f03adc4b3b95083 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 11 Dec 2025 14:03:08 +1100 Subject: [PATCH 4/6] isort followed by black --- bouquin/bug_report_dialog.py | 8 ++--- bouquin/code_block_editor_dialog.py | 13 ++++--- bouquin/code_highlighter.py | 4 +-- bouquin/db.py | 9 +++-- bouquin/document_utils.py | 2 +- bouquin/documents.py | 28 +++++++-------- bouquin/find_bar.py | 17 ++++------ bouquin/history_dialog.py | 13 ++++--- bouquin/invoices.py | 43 +++++++++++------------- bouquin/key_prompt.py | 6 ++-- bouquin/lock_overlay.py | 4 +-- bouquin/main.py | 11 +++--- bouquin/main_window.py | 28 +++++++-------- bouquin/markdown_editor.py | 12 +++---- bouquin/markdown_highlighter.py | 2 +- bouquin/pomodoro_timer.py | 2 +- bouquin/reminders.py | 34 +++++++++---------- bouquin/save_dialog.py | 8 +---- bouquin/search.py | 2 +- bouquin/settings.py | 1 + bouquin/settings_dialog.py | 28 +++++++-------- bouquin/statistics_dialog.py | 13 ++++--- bouquin/strings.py | 2 +- bouquin/tag_browser.py | 16 ++++----- bouquin/tags_widget.py | 8 ++--- bouquin/theme.py | 8 +++-- bouquin/time_log.py | 43 ++++++++++++------------ bouquin/toolbar.py | 4 +-- bouquin/version_check.py | 14 +++----- tests/conftest.py | 2 +- tests/test_bug_report_dialog.py | 4 +-- tests/test_code_block_editor_dialog.py | 8 ++--- tests/test_code_highlighter.py | 4 +-- tests/test_db.py | 10 +++--- tests/test_document_utils.py | 6 ++-- tests/test_documents.py | 9 +++-- tests/test_find_bar.py | 7 ++-- tests/test_history_dialog.py | 5 ++- tests/test_invoices.py | 18 +++++----- tests/test_key_prompt.py | 1 - tests/test_lock_overlay.py | 4 +-- tests/test_main.py | 1 + tests/test_main_window.py | 23 ++++++------- tests/test_markdown_editor.py | 27 +++++++-------- tests/test_markdown_editor_additional.py | 17 +++++----- tests/test_pomodoro_timer.py | 7 ++-- tests/test_reminders.py | 33 +++++++++--------- tests/test_settings.py | 6 +--- tests/test_settings_dialog.py | 10 +++--- tests/test_statistics_dialog.py | 10 +++--- tests/test_tabs.py | 11 +++--- tests/test_tags.py | 27 +++++++-------- tests/test_theme.py | 3 +- tests/test_time_log.py | 24 +++++-------- tests/test_toolbar.py | 4 +-- tests/test_version_check.py | 7 ++-- 56 files changed, 311 insertions(+), 360 deletions(-) diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py index 9cc727c..0743985 100644 --- a/bouquin/bug_report_dialog.py +++ b/bouquin/bug_report_dialog.py @@ -3,19 +3,17 @@ from __future__ import annotations import importlib.metadata import requests - from PySide6.QtWidgets import ( QDialog, - QVBoxLayout, - QLabel, - QTextEdit, QDialogButtonBox, + QLabel, QMessageBox, + QTextEdit, + QVBoxLayout, ) from . import strings - BUG_REPORT_HOST = "https://nr.mig5.net" ROUTE = "forms/bouquin/bugs" diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py index 59162c0..8df348d 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -1,15 +1,14 @@ from __future__ import annotations -from PySide6.QtCore import QSize, QRect, Qt -from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics - +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QPlainTextEdit, - QDialogButtonBox, QComboBox, + QDialog, + QDialogButtonBox, QLabel, + QPlainTextEdit, + QVBoxLayout, QWidget, ) diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py index 3e8d8da..74ef6d4 100644 --- a/bouquin/code_highlighter.py +++ b/bouquin/code_highlighter.py @@ -1,9 +1,9 @@ from __future__ import annotations import re -from typing import Optional, Dict +from typing import Dict, Optional -from PySide6.QtGui import QColor, QTextCharFormat, QFont +from PySide6.QtGui import QColor, QFont, QTextCharFormat class CodeHighlighter: diff --git a/bouquin/db.py b/bouquin/db.py index f92c68e..2b5cb44 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -5,16 +5,15 @@ import datetime as _dt import hashlib import html import json -import markdown import mimetypes import re - from dataclasses import dataclass from pathlib import Path -from sqlcipher3 import dbapi2 as sqlite -from sqlcipher3 import Binary -from typing import List, Sequence, Tuple, Dict +from typing import Dict, List, Sequence, Tuple +import markdown +from sqlcipher3 import Binary +from sqlcipher3 import dbapi2 as sqlite from . import strings diff --git a/bouquin/document_utils.py b/bouquin/document_utils.py index 550cfd4..fd7313e 100644 --- a/bouquin/document_utils.py +++ b/bouquin/document_utils.py @@ -8,8 +8,8 @@ and TagBrowserDialog). from __future__ import annotations -from pathlib import Path import tempfile +from pathlib import Path from typing import TYPE_CHECKING, Optional from PySide6.QtCore import QUrl diff --git a/bouquin/documents.py b/bouquin/documents.py index a554d0d..9f5a40f 100644 --- a/bouquin/documents.py +++ b/bouquin/documents.py @@ -5,32 +5,32 @@ from typing import Optional from PySide6.QtCore import Qt from PySide6.QtGui import QColor from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QFormLayout, - QComboBox, - QLineEdit, - QTableWidget, - QTableWidgetItem, QAbstractItemView, - QHeaderView, - QPushButton, + QComboBox, + QDialog, QFileDialog, - QMessageBox, - QWidget, + QFormLayout, QFrame, - QToolButton, + QHBoxLayout, + QHeaderView, + QLineEdit, QListWidget, QListWidgetItem, + QMessageBox, + QPushButton, QSizePolicy, QStyle, + QTableWidget, + QTableWidgetItem, + QToolButton, + QVBoxLayout, + QWidget, ) +from . import strings from .db import DBManager, DocumentRow from .settings import load_db_config from .time_log import TimeCodeManagerDialog -from . import strings class TodaysDocumentsWidget(QFrame): diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py index ae0206b..99a1fcd 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -1,20 +1,15 @@ from __future__ import annotations from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import ( - QShortcut, - QTextCursor, - QTextCharFormat, - QTextDocument, -) +from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument from PySide6.QtWidgets import ( - QWidget, - QHBoxLayout, - QLineEdit, - QLabel, - QPushButton, QCheckBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, QTextEdit, + QWidget, ) from . import strings diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index f2cdc1c..5966470 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -1,19 +1,22 @@ from __future__ import annotations -import difflib, re, html as _html +import difflib +import html as _html +import re from datetime import datetime + from PySide6.QtCore import Qt, Slot from PySide6.QtWidgets import ( + QAbstractItemView, QDialog, - QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, - QPushButton, QMessageBox, - QTextBrowser, + QPushButton, QTabWidget, - QAbstractItemView, + QTextBrowser, + QVBoxLayout, ) from . import strings diff --git a/bouquin/invoices.py b/bouquin/invoices.py index 88a8475..18071d6 100644 --- a/bouquin/invoices.py +++ b/bouquin/invoices.py @@ -2,44 +2,39 @@ from __future__ import annotations from dataclasses import dataclass from enum import Enum -from sqlcipher3 import dbapi2 as sqlite3 -from PySide6.QtCore import Qt, QDate, QUrl, Signal -from PySide6.QtGui import ( - QImage, - QTextDocument, - QPageLayout, - QDesktopServices, -) +from PySide6.QtCore import QDate, Qt, QUrl, Signal +from PySide6.QtGui import QDesktopServices, QImage, QPageLayout, QTextDocument from PySide6.QtPrintSupport import QPrinter from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QFormLayout, - QLabel, - QLineEdit, + QAbstractItemView, + QButtonGroup, + QCheckBox, QComboBox, QDateEdit, - QCheckBox, - QTextEdit, - QTableWidget, - QTableWidgetItem, - QAbstractItemView, - QHeaderView, - QPushButton, - QRadioButton, - QButtonGroup, + QDialog, QDoubleSpinBox, QFileDialog, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, QMessageBox, + QPushButton, + QRadioButton, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QVBoxLayout, QWidget, ) +from sqlcipher3 import dbapi2 as sqlite3 +from . import strings from .db import DBManager, TimeLogRow from .reminders import Reminder, ReminderType from .settings import load_db_config -from . import strings class InvoiceDetailMode(str, Enum): diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index 195599f..866f682 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -4,13 +4,13 @@ from pathlib import Path from PySide6.QtWidgets import ( QDialog, - QVBoxLayout, + QDialogButtonBox, + QFileDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, - QDialogButtonBox, - QFileDialog, + QVBoxLayout, ) from . import strings diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index 4a1a98e..90c12a8 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import Qt, QEvent -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +from PySide6.QtCore import QEvent, Qt +from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget from . import strings from .theme import ThemeManager diff --git a/bouquin/main.py b/bouquin/main.py index 958185d..6883755 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -2,13 +2,14 @@ from __future__ import annotations import sys from pathlib import Path -from PySide6.QtWidgets import QApplication -from PySide6.QtGui import QIcon -from .settings import APP_NAME, APP_ORG, get_settings -from .main_window import MainWindow -from .theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication + from . import strings +from .main_window import MainWindow +from .settings import APP_NAME, APP_ORG, get_settings +from .theme import Theme, ThemeConfig, ThemeManager def main(): diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 0a3cc9c..2def58e 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -2,21 +2,21 @@ from __future__ import annotations import datetime import os -import sys import re - +import sys from pathlib import Path + from PySide6.QtCore import ( QDate, - QTimer, - Qt, - QSettings, - Slot, - QUrl, - QEvent, - QSignalBlocker, QDateTime, + QEvent, + QSettings, + QSignalBlocker, + Qt, QTime, + QTimer, + QUrl, + Slot, ) from PySide6.QtGui import ( QAction, @@ -31,23 +31,24 @@ from PySide6.QtGui import ( QTextListFormat, ) from PySide6.QtWidgets import ( + QApplication, QCalendarWidget, QDialog, QFileDialog, + QLabel, QMainWindow, QMenu, QMessageBox, + QPushButton, QSizePolicy, QSplitter, QTableView, QTabWidget, QVBoxLayout, QWidget, - QLabel, - QPushButton, - QApplication, ) +from . import strings from .bug_report_dialog import BugReportDialog from .db import DBManager from .documents import DocumentsDialog, TodaysDocumentsWidget @@ -60,10 +61,9 @@ from .pomodoro_timer import PomodoroManager from .reminders import UpcomingRemindersWidget from .save_dialog import SaveDialog from .search import Search -from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config +from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .statistics_dialog import StatisticsDialog -from . import strings from .tags_widget import PageTagsWidget from .theme import ThemeManager from .time_log import TimeLogWidget diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 838a037..831ce9b 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -5,28 +5,28 @@ import re from pathlib import Path from typing import Optional, Tuple +from PySide6.QtCore import QRect, Qt, QTimer, QUrl from PySide6.QtGui import ( + QDesktopServices, QFont, QFontDatabase, QFontMetrics, QImage, QMouseEvent, QTextBlock, + QTextBlockFormat, QTextCharFormat, QTextCursor, QTextDocument, QTextFormat, - QTextBlockFormat, QTextImageFormat, - QDesktopServices, ) -from PySide6.QtCore import Qt, QRect, QTimer, QUrl from PySide6.QtWidgets import QDialog, QTextEdit -from .theme import ThemeManager -from .markdown_highlighter import MarkdownHighlighter -from .code_block_editor_dialog import CodeBlockEditorDialog from . import strings +from .code_block_editor_dialog import CodeBlockEditorDialog +from .markdown_highlighter import MarkdownHighlighter +from .theme import ThemeManager class MarkdownEditor(QTextEdit): diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 81b08b4..bb308d5 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -14,7 +14,7 @@ from PySide6.QtGui import ( QTextDocument, ) -from .theme import ThemeManager, Theme +from .theme import Theme, ThemeManager class MarkdownHighlighter(QSyntaxHighlighter): diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py index 1c6588c..e66c1f4 100644 --- a/bouquin/pomodoro_timer.py +++ b/bouquin/pomodoro_timer.py @@ -6,10 +6,10 @@ from typing import Optional from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtWidgets import ( QFrame, - QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QVBoxLayout, QWidget, ) diff --git a/bouquin/reminders.py b/bouquin/reminders.py index 2c3a9c7..9fc096a 100644 --- a/bouquin/reminders.py +++ b/bouquin/reminders.py @@ -4,30 +4,30 @@ from dataclasses import dataclass from enum import Enum from typing import Optional -from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal +from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QFormLayout, - QLineEdit, + QAbstractItemView, QComboBox, - QTimeEdit, - QPushButton, + QDateEdit, + QDialog, + QFormLayout, QFrame, - QWidget, - QToolButton, + QHBoxLayout, + QHeaderView, + QLineEdit, QListWidget, QListWidgetItem, - QStyle, - QSizePolicy, QMessageBox, + QPushButton, + QSizePolicy, + QSpinBox, + QStyle, QTableWidget, QTableWidgetItem, - QAbstractItemView, - QHeaderView, - QSpinBox, - QDateEdit, + QTimeEdit, + QToolButton, + QVBoxLayout, + QWidget, ) from . import strings @@ -566,8 +566,8 @@ class UpcomingRemindersWidget(QFrame): if not selected_items: return - from PySide6.QtWidgets import QMenu from PySide6.QtGui import QAction + from PySide6.QtWidgets import QMenu menu = QMenu(self) diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py index 6b4e05d..528896b 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -3,13 +3,7 @@ from __future__ import annotations import datetime from PySide6.QtGui import QFontMetrics -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QLabel, - QLineEdit, - QDialogButtonBox, -) +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout from . import strings diff --git a/bouquin/search.py b/bouquin/search.py index b2a885b..7dd7f7f 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -6,12 +6,12 @@ from typing import Iterable, Tuple from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, + QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QSizePolicy, - QHBoxLayout, QVBoxLayout, QWidget, ) diff --git a/bouquin/settings.py b/bouquin/settings.py index 91a6074..5a14c07 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path + from PySide6.QtCore import QSettings, QStandardPaths from .db import DBConfig diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 2d0b1a4..6ce6255 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -2,38 +2,36 @@ from __future__ import annotations from pathlib import Path +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QPalette from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, - QFrame, + QDialogButtonBox, QFileDialog, + QFormLayout, + QFrame, QGroupBox, + QHBoxLayout, QLabel, QLineEdit, - QFormLayout, - QHBoxLayout, - QVBoxLayout, + QMessageBox, QPushButton, - QDialogButtonBox, QRadioButton, QSizePolicy, QSpinBox, - QMessageBox, - QWidget, QTabWidget, QTextEdit, + QVBoxLayout, + QWidget, ) -from PySide6.QtCore import Qt, Slot -from PySide6.QtGui import QPalette - - -from .db import DBConfig, DBManager -from .settings import load_db_config, save_db_config -from .theme import Theme -from .key_prompt import KeyPrompt from . import strings +from .db import DBConfig, DBManager +from .key_prompt import KeyPrompt +from .settings import load_db_config, save_db_config +from .theme import Theme class SettingsDialog(QDialog): diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py index 0a94126..77b83f6 100644 --- a/bouquin/statistics_dialog.py +++ b/bouquin/statistics_dialog.py @@ -3,26 +3,25 @@ from __future__ import annotations import datetime as _dt from typing import Dict -from PySide6.QtCore import Qt, QSize, Signal -from PySide6.QtGui import QColor, QPainter, QPen, QBrush +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen from PySide6.QtWidgets import ( + QComboBox, QDialog, - QVBoxLayout, QFormLayout, - QLabel, QGroupBox, QHBoxLayout, - QComboBox, + QLabel, QScrollArea, - QWidget, QSizePolicy, + QVBoxLayout, + QWidget, ) from . import strings from .db import DBManager from .settings import load_db_config - # ---------- Activity heatmap ---------- diff --git a/bouquin/strings.py b/bouquin/strings.py index eff0e18..71e838b 100644 --- a/bouquin/strings.py +++ b/bouquin/strings.py @@ -1,5 +1,5 @@ -from importlib.resources import files import json +from importlib.resources import files # Get list of locales root = files("bouquin") / "locales" diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index 1e7cb01..210f7d3 100644 --- a/bouquin/tag_browser.py +++ b/bouquin/tag_browser.py @@ -1,22 +1,22 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( + QColorDialog, QDialog, - QVBoxLayout, QHBoxLayout, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, QTreeWidget, QTreeWidgetItem, - QPushButton, - QLabel, - QColorDialog, - QMessageBox, - QInputDialog, + QVBoxLayout, ) +from sqlcipher3.dbapi2 import IntegrityError +from . import strings from .db import DBManager from .settings import load_db_config -from . import strings -from sqlcipher3.dbapi2 import IntegrityError class TagBrowserDialog(QDialog): diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py index 423bd06..7ac4ad4 100644 --- a/bouquin/tags_widget.py +++ b/bouquin/tags_widget.py @@ -4,16 +4,16 @@ from typing import Optional from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( + QCompleter, QFrame, QHBoxLayout, - QVBoxLayout, - QWidget, - QToolButton, QLabel, QLineEdit, QSizePolicy, QStyle, - QCompleter, + QToolButton, + QVBoxLayout, + QWidget, ) from . import strings diff --git a/bouquin/theme.py b/bouquin/theme.py index 0f36d93..87b77f9 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -1,11 +1,13 @@ from __future__ import annotations + from dataclasses import dataclass from enum import Enum -from PySide6.QtGui import QPalette, QColor, QGuiApplication, QTextCharFormat -from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget -from PySide6.QtCore import QObject, Signal, Qt from weakref import WeakSet +from PySide6.QtCore import QObject, Qt, Signal +from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat +from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget + class Theme(Enum): SYSTEM = "system" diff --git a/bouquin/time_log.py b/bouquin/time_log.py index e143d57..7ca4e09 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -2,50 +2,49 @@ from __future__ import annotations import csv import html - from collections import defaultdict from datetime import datetime -from sqlcipher3.dbapi2 import IntegrityError from typing import Optional -from PySide6.QtCore import Qt, QDate, QUrl, Signal -from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout +from PySide6.QtCore import QDate, Qt, QUrl, Signal +from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument from PySide6.QtPrintSupport import QPrinter from PySide6.QtWidgets import ( + QAbstractItemView, QCalendarWidget, + QComboBox, + QCompleter, + QDateEdit, QDialog, QDialogButtonBox, - QFrame, - QVBoxLayout, - QHBoxLayout, - QWidget, + QDoubleSpinBox, QFileDialog, QFormLayout, - QLabel, - QComboBox, - QLineEdit, - QDoubleSpinBox, - QPushButton, - QTableWidget, - QTableWidgetItem, - QAbstractItemView, + QFrame, + QHBoxLayout, QHeaderView, - QTabWidget, + QInputDialog, + QLabel, + QLineEdit, QListWidget, QListWidgetItem, - QDateEdit, QMessageBox, - QCompleter, - QToolButton, + QPushButton, QSizePolicy, QStyle, - QInputDialog, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QToolButton, + QVBoxLayout, + QWidget, ) +from sqlcipher3.dbapi2 import IntegrityError +from . import strings from .db import DBManager from .settings import load_db_config from .theme import ThemeManager -from . import strings class TimeLogWidget(QFrame): diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 8090fe7..92383e6 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence from PySide6.QtWidgets import QToolBar from . import strings diff --git a/bouquin/version_check.py b/bouquin/version_check.py index b2010d5..5b62d02 100644 --- a/bouquin/version_check.py +++ b/bouquin/version_check.py @@ -5,23 +5,17 @@ import os import re import subprocess # nosec import tempfile +from importlib.resources import files from pathlib import Path import requests -from importlib.resources import files from PySide6.QtCore import QStandardPaths, Qt -from PySide6.QtWidgets import ( - QApplication, - QMessageBox, - QWidget, - QProgressDialog, -) -from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication +from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtSvg import QSvgRenderer +from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget -from .settings import APP_NAME from . import strings - +from .settings import APP_NAME # Where to fetch the latest version string from VERSION_URL = "https://mig5.net/bouquin/version.txt" diff --git a/tests/conftest.py b/tests/conftest.py index 878ccc7..4058d77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,7 +106,7 @@ def freeze_qt_time(monkeypatch): QTime.currentTime().addSecs(3600) is still the same calendar day. """ import bouquin.main_window as _mwmod - from PySide6.QtCore import QDate, QTime, QDateTime + from PySide6.QtCore import QDate, QDateTime, QTime today = QDate.currentDate() fixed_time = QTime(12, 0) diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py index 8d773e9..df839fd 100644 --- a/tests/test_bug_report_dialog.py +++ b/tests/test_bug_report_dialog.py @@ -1,8 +1,8 @@ import bouquin.bug_report_dialog as bugmod -from bouquin.bug_report_dialog import BugReportDialog from bouquin import strings -from PySide6.QtWidgets import QMessageBox +from bouquin.bug_report_dialog import BugReportDialog from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QMessageBox def test_bug_report_truncates_text_to_max_chars(qtbot): diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py index 6779bca..e64199b 100644 --- a/tests/test_code_block_editor_dialog.py +++ b/tests/test_code_block_editor_dialog.py @@ -1,13 +1,11 @@ -from PySide6.QtWidgets import QPushButton from bouquin import strings - -from PySide6.QtCore import QRect, QSize -from PySide6.QtGui import QPaintEvent, QFont - from bouquin.code_block_editor_dialog import ( CodeBlockEditorDialog, CodeEditorWithLineNumbers, ) +from PySide6.QtCore import QRect, QSize +from PySide6.QtGui import QFont, QPaintEvent +from PySide6.QtWidgets import QPushButton def _find_button_by_text(widget, text): diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py index 145e156..57ab8e7 100644 --- a/tests/test_code_highlighter.py +++ b/tests/test_code_highlighter.py @@ -1,5 +1,5 @@ -from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata -from PySide6.QtGui import QTextCharFormat, QFont +from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter +from PySide6.QtGui import QFont, QTextCharFormat def test_get_language_patterns_python(app): diff --git a/tests/test_db.py b/tests/test_db.py index 19a4d6e..12585f7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,10 +1,12 @@ -import pytest -import json, csv +import csv import datetime as dt -from sqlcipher3 import dbapi2 as sqlite -from bouquin.db import DBManager +import json from datetime import date, timedelta +import pytest +from bouquin.db import DBManager +from sqlcipher3 import dbapi2 as sqlite + def _today(): return dt.date.today().isoformat() diff --git a/tests/test_document_utils.py b/tests/test_document_utils.py index 6e91ba2..e1301df 100644 --- a/tests/test_document_utils.py +++ b/tests/test_document_utils.py @@ -1,10 +1,10 @@ -from unittest.mock import patch -from pathlib import Path import tempfile +from pathlib import Path +from unittest.mock import patch from PySide6.QtCore import QUrl -from PySide6.QtWidgets import QMessageBox, QWidget from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QMessageBox, QWidget def test_open_document_from_db_success(qtbot, app, fresh_db): diff --git a/tests/test_documents.py b/tests/test_documents.py index 8be5b83..0740b40 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -1,13 +1,12 @@ -from unittest.mock import patch, MagicMock -from pathlib import Path import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch from bouquin.db import DBConfig -from bouquin.documents import TodaysDocumentsWidget, DocumentsDialog +from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget from PySide6.QtCore import Qt, QUrl -from PySide6.QtWidgets import QMessageBox, QDialog, QFileDialog from PySide6.QtGui import QDesktopServices - +from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox # ============================================================================= # TodaysDocumentsWidget Tests diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py index c0ab938..de67c7e 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -1,10 +1,9 @@ import pytest - +from bouquin.find_bar import FindBar +from bouquin.markdown_editor import MarkdownEditor +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import QTextEdit, QWidget -from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.find_bar import FindBar @pytest.fixture diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py index da97a5a..98ab9c8 100644 --- a/tests/test_history_dialog.py +++ b/tests/test_history_dialog.py @@ -1,7 +1,6 @@ -from PySide6.QtWidgets import QWidget, QMessageBox, QApplication -from PySide6.QtCore import Qt, QTimer - from bouquin.history_dialog import HistoryDialog +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget def test_history_dialog_lists_and_revert(qtbot, fresh_db): diff --git a/tests/test_invoices.py b/tests/test_invoices.py index 80f1a90..89ef202 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -1,19 +1,17 @@ -import pytest from datetime import date, timedelta -from PySide6.QtCore import Qt, QDate -from PySide6.QtWidgets import QMessageBox - +import pytest from bouquin.invoices import ( - InvoiceDetailMode, - InvoiceLineItem, - _invoice_due_reminder_text, - InvoiceDialog, - InvoicesDialog, _INVOICE_REMINDER_TIME, + InvoiceDetailMode, + InvoiceDialog, + InvoiceLineItem, + InvoicesDialog, + _invoice_due_reminder_text, ) from bouquin.reminders import Reminder, ReminderType - +from PySide6.QtCore import QDate, Qt +from PySide6.QtWidgets import QMessageBox # ============================================================================ # Tests for InvoiceDetailMode enum diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py index 70ad1da..9aedffb 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -1,5 +1,4 @@ from bouquin.key_prompt import KeyPrompt - from PySide6.QtCore import QTimer from PySide6.QtWidgets import QFileDialog, QLineEdit diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py index 05de5f9..46b3cfd 100644 --- a/tests/test_lock_overlay.py +++ b/tests/test_lock_overlay.py @@ -1,7 +1,7 @@ +from bouquin.lock_overlay import LockOverlay +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtCore import QEvent from PySide6.QtWidgets import QWidget -from bouquin.lock_overlay import LockOverlay -from bouquin.theme import ThemeManager, ThemeConfig, Theme def test_lock_overlay_reacts_to_theme(app, qtbot): diff --git a/tests/test_main.py b/tests/test_main.py index 2a357fb..5bfb774 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import importlib import runpy + import pytest diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 2cf787d..6c09e71 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,22 +1,19 @@ -import pytest import importlib.metadata - from datetime import date, timedelta from pathlib import Path - -import bouquin.main_window as mwmod -from bouquin.main_window import MainWindow -from bouquin.theme import Theme, ThemeConfig, ThemeManager -from bouquin.settings import get_settings -from bouquin.key_prompt import KeyPrompt -from bouquin.db import DBConfig, DBManager -from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect -from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog -from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent - from unittest.mock import Mock, patch +import bouquin.main_window as mwmod import bouquin.version_check as version_check +import pytest +from bouquin.db import DBConfig, DBManager +from bouquin.key_prompt import KeyPrompt +from bouquin.main_window import MainWindow +from bouquin.settings import get_settings +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer +from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor +from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 8d869a9..73f58f4 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1,21 +1,20 @@ import base64 + import pytest - -from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl -from PySide6.QtGui import ( - QImage, - QColor, - QKeyEvent, - QTextCursor, - QTextDocument, - QFont, - QTextCharFormat, -) -from PySide6.QtWidgets import QApplication, QTextEdit - from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_highlighter import MarkdownHighlighter -from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl +from PySide6.QtGui import ( + QColor, + QFont, + QImage, + QKeyEvent, + QTextCharFormat, + QTextCursor, + QTextDocument, +) +from PySide6.QtWidgets import QApplication, QTextEdit def _today(): diff --git a/tests/test_markdown_editor_additional.py b/tests/test_markdown_editor_additional.py index 2584baa..4037ed1 100644 --- a/tests/test_markdown_editor_additional.py +++ b/tests/test_markdown_editor_additional.py @@ -4,19 +4,18 @@ These tests should be added to test_markdown_editor.py. """ import pytest -from PySide6.QtCore import Qt, QPoint +from bouquin.markdown_editor import MarkdownEditor +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtCore import QPoint, Qt from PySide6.QtGui import ( - QImage, QColor, + QImage, QKeyEvent, + QMouseEvent, QTextCursor, QTextDocument, - QMouseEvent, ) -from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import ThemeManager, ThemeConfig, Theme - def text(editor) -> str: return editor.toPlainText() @@ -145,8 +144,8 @@ def test_edit_code_block_checks_document(app, qtbot): def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch): """Test _edit_code_block when dialog is cancelled.""" - from PySide6.QtWidgets import QDialog import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog class CancelledDialog: def __init__(self, code, language, parent=None, allow_delete=False): @@ -175,8 +174,8 @@ def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch): def test_edit_code_block_with_delete(editor, qtbot, monkeypatch): """Test _edit_code_block when user deletes the block.""" - from PySide6.QtWidgets import QDialog import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog class DeleteDialog: def __init__(self, code, language, parent=None, allow_delete=False): @@ -214,8 +213,8 @@ def test_edit_code_block_with_delete(editor, qtbot, monkeypatch): def test_edit_code_block_language_change(editor, qtbot, monkeypatch): """Test _edit_code_block with language change.""" - from PySide6.QtWidgets import QDialog import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog class LanguageChangeDialog: def __init__(self, code, language, parent=None, allow_delete=False): diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 5ffeafd..1c2e450 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -1,8 +1,9 @@ from unittest.mock import Mock, patch -from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from PySide6.QtWidgets import QWidget, QVBoxLayout, QToolBar, QLabel + +from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtGui import QAction +from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget class DummyTimeLogWidget(QWidget): diff --git a/tests/test_reminders.py b/tests/test_reminders.py index 16e8dc9..b9e3bfc 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -1,17 +1,16 @@ -import pytest - -from unittest.mock import patch, MagicMock -from bouquin.reminders import ( - Reminder, - ReminderType, - ReminderDialog, - UpcomingRemindersWidget, - ManageRemindersDialog, -) -from PySide6.QtCore import QDateTime, QDate, QTime -from PySide6.QtWidgets import QDialog, QMessageBox, QWidget - from datetime import date, timedelta +from unittest.mock import MagicMock, patch + +import pytest +from bouquin.reminders import ( + ManageRemindersDialog, + Reminder, + ReminderDialog, + ReminderType, + UpcomingRemindersWidget, +) +from PySide6.QtCore import QDate, QDateTime, QTime +from PySide6.QtWidgets import QDialog, QMessageBox, QWidget @pytest.fixture @@ -851,9 +850,9 @@ def test_edit_reminder_dialog(qtbot, fresh_db): def test_upcoming_reminders_context_menu_shows( qtbot, app, fresh_db, freeze_reminders_time, monkeypatch ): - from PySide6 import QtWidgets, QtGui - from PySide6.QtCore import QPoint from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget + from PySide6 import QtGui, QtWidgets + from PySide6.QtCore import QPoint # Add a future reminder for today r = Reminder( @@ -909,9 +908,9 @@ def test_upcoming_reminders_context_menu_shows( def test_upcoming_reminders_delete_selected_dedupes( qtbot, app, fresh_db, freeze_reminders_time, monkeypatch ): - from PySide6.QtWidgets import QMessageBox - from PySide6.QtCore import QItemSelectionModel from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget + from PySide6.QtCore import QItemSelectionModel + from PySide6.QtWidgets import QMessageBox r = Reminder( id=None, diff --git a/tests/test_settings.py b/tests/test_settings.py index f272ab2..086d590 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,9 +1,5 @@ -from bouquin.settings import ( - get_settings, - load_db_config, - save_db_config, -) from bouquin.db import DBConfig +from bouquin.settings import get_settings, load_db_config, save_db_config def _clear_db_settings(): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index ad53951..0b1dafd 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,11 +1,11 @@ -from bouquin.db import DBManager, DBConfig -from bouquin.key_prompt import KeyPrompt import bouquin.settings_dialog as sd -from bouquin.settings_dialog import SettingsDialog -from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.db import DBConfig, DBManager +from bouquin.key_prompt import KeyPrompt from bouquin.settings import get_settings +from bouquin.settings_dialog import SettingsDialog +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtCore import QTimer -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog +from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py index 12f96c5..46a6eb0 100644 --- a/tests/test_statistics_dialog.py +++ b/tests/test_statistics_dialog.py @@ -1,13 +1,11 @@ import datetime as _dt -from datetime import datetime, timedelta, date +from datetime import date, datetime, timedelta from bouquin import strings - -from PySide6.QtCore import Qt, QPoint, QDate -from PySide6.QtWidgets import QLabel, QWidget -from PySide6.QtTest import QTest - from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog +from PySide6.QtCore import QDate, QPoint, Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QLabel, QWidget class FakeStatsDB: diff --git a/tests/test_tabs.py b/tests/test_tabs.py index fe73828..b495356 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -1,12 +1,11 @@ import types -from PySide6.QtWidgets import QFileDialog -from PySide6.QtGui import QTextCursor - -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.settings import get_settings -from bouquin.main_window import MainWindow from bouquin.history_dialog import HistoryDialog +from bouquin.main_window import MainWindow +from bouquin.settings import get_settings +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QFileDialog def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): diff --git a/tests/test_tags.py b/tests/test_tags.py index 8564c6b..89e5fbd 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,24 +1,21 @@ +import bouquin.strings as strings import pytest - -from PySide6.QtCore import Qt, QPoint, QEvent, QDate -from PySide6.QtGui import QMouseEvent, QColor +from bouquin.db import DBManager +from bouquin.flow_layout import FlowLayout +from bouquin.strings import load_strings +from bouquin.tag_browser import TagBrowserDialog +from bouquin.tags_widget import PageTagsWidget, TagChip +from PySide6.QtCore import QDate, QEvent, QPoint, Qt +from PySide6.QtGui import QColor, QMouseEvent from PySide6.QtWidgets import ( QApplication, - QMessageBox, - QInputDialog, QColorDialog, QDialog, + QInputDialog, + QMessageBox, ) -from bouquin.db import DBManager -from bouquin.strings import load_strings -from bouquin.tags_widget import PageTagsWidget, TagChip -from bouquin.tag_browser import TagBrowserDialog -from bouquin.flow_layout import FlowLayout from sqlcipher3.dbapi2 import IntegrityError -import bouquin.strings as strings - - # ============================================================================ # DB Layer Tag Tests # ============================================================================ @@ -1649,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db): def test_flow_layout_take_at_invalid_index(app): """Test FlowLayout.takeAt with out-of-bounds index""" - from PySide6.QtWidgets import QWidget, QLabel + from PySide6.QtWidgets import QLabel, QWidget widget = QWidget() layout = FlowLayout(widget) @@ -1673,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app): def test_flow_layout_take_at_boundary(app): """Test FlowLayout.takeAt at exact boundary""" - from PySide6.QtWidgets import QWidget, QLabel + from PySide6.QtWidgets import QLabel, QWidget widget = QWidget() layout = FlowLayout(widget) diff --git a/tests/test_theme.py b/tests/test_theme.py index 6f19a62..a1dc283 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,8 +1,7 @@ +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtGui import QPalette from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget -from bouquin.theme import Theme, ThemeConfig, ThemeManager - def test_theme_manager_apply_light_and_dark(app): cfg = ThemeConfig(theme=Theme.LIGHT) diff --git a/tests/test_time_log.py b/tests/test_time_log.py index 6a997ed..0a6797c 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1,24 +1,18 @@ -import pytest from datetime import date, timedelta -from PySide6.QtCore import Qt, QDate -from PySide6.QtWidgets import ( - QMessageBox, - QInputDialog, - QFileDialog, - QDialog, -) -from sqlcipher3.dbapi2 import IntegrityError +from unittest.mock import MagicMock, patch -from bouquin.theme import ThemeManager, ThemeConfig, Theme +import bouquin.strings as strings +import pytest +from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.time_log import ( - TimeLogWidget, - TimeLogDialog, TimeCodeManagerDialog, + TimeLogDialog, + TimeLogWidget, TimeReportDialog, ) -import bouquin.strings as strings - -from unittest.mock import patch, MagicMock +from PySide6.QtCore import QDate, Qt +from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox +from sqlcipher3.dbapi2 import IntegrityError @pytest.fixture diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index 3794760..fdc8829 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -1,8 +1,8 @@ import pytest -from PySide6.QtWidgets import QWidget from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.toolbar import ToolBar +from PySide6.QtWidgets import QWidget @pytest.fixture diff --git a/tests/test_version_check.py b/tests/test_version_check.py index b5afe12..01fac35 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,9 +1,10 @@ -import pytest -from unittest.mock import Mock, patch import subprocess +from unittest.mock import Mock, patch + +import pytest from bouquin.version_check import VersionChecker -from PySide6.QtWidgets import QMessageBox, QWidget from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QMessageBox, QWidget def test_version_checker_init(app): From 57614cefa1a53d17f8d66e87d7a9142295699e77 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 11 Dec 2025 15:45:03 +1100 Subject: [PATCH 5/6] Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs) --- CHANGELOG.md | 1 + bouquin/history_dialog.py | 68 +++++++++++++++++++++++++++++++++++++-- bouquin/locales/en.json | 8 ++--- bouquin/main_window.py | 2 +- bouquin/time_log.py | 12 +++---- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e9853..76b8115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. * Add 'Last week' to Time Report dialog range option + * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs) # 0.6.4 diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 5966470..c145cce 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -5,11 +5,14 @@ import html as _html import re from datetime import datetime -from PySide6.QtCore import Qt, Slot +from PySide6.QtCore import QDate, Qt, Slot from PySide6.QtWidgets import ( QAbstractItemView, + QCalendarWidget, QDialog, + QDialogButtonBox, QHBoxLayout, + QLabel, QListWidget, QListWidgetItem, QMessageBox, @@ -20,6 +23,7 @@ from PySide6.QtWidgets import ( ) from . import strings +from .theme import ThemeManager def _markdown_to_text(s: str) -> str: @@ -73,16 +77,29 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str: class HistoryDialog(QDialog): """Show versions for a date, preview, diff, and allow revert.""" - def __init__(self, db, date_iso: str, parent=None): + def __init__( + self, db, date_iso: str, parent=None, themes: ThemeManager | None = None + ): super().__init__(parent) self.setWindowTitle(f"{strings._('history')} — {date_iso}") self._db = db self._date = date_iso + self._themes = themes self._versions = [] # list[dict] from DB self._current_id = None # id of current root = QVBoxLayout(self) + # --- Top: date label + change-date button + date_row = QHBoxLayout() + self.date_label = QLabel(strings._("date_label").format(date=date_iso)) + date_row.addWidget(self.date_label) + date_row.addStretch(1) + self.change_date_btn = QPushButton(strings._("change_date")) + self.change_date_btn.clicked.connect(self._on_change_date_clicked) + date_row.addWidget(self.change_date_btn) + root.addLayout(date_row) + # Top: list of versions top = QHBoxLayout() self.list = QListWidget() @@ -120,6 +137,53 @@ class HistoryDialog(QDialog): self._load_versions() + @Slot() + def _on_change_date_clicked(self) -> None: + """Let the user choose a different date and reload entries.""" + + # Start from current dialog date; fall back to today if invalid + current_qdate = QDate.fromString(self._date, Qt.ISODate) + if not current_qdate.isValid(): + current_qdate = QDate.currentDate() + + dlg = QDialog(self) + dlg.setWindowTitle(strings._("select_date_title")) + + layout = QVBoxLayout(dlg) + + calendar = QCalendarWidget(dlg) + calendar.setSelectedDate(current_qdate) + layout.addWidget(calendar) + # Apply the same theming as the main sidebar calendar + if self._themes is not None: + self._themes.register_calendar(calendar) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg + ) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + layout.addWidget(buttons) + + if dlg.exec() != QDialog.Accepted: + return + + new_qdate = calendar.selectedDate() + new_iso = new_qdate.toString(Qt.ISODate) + if new_iso == self._date: + # No change + return + + # Update state + self._date = new_iso + + # Update window title and header label + self.setWindowTitle(strings._("for").format(date=new_iso)) + self.date_label.setText(strings._("date_label").format(date=new_iso)) + + # Reload entries for the newly selected date + self._load_versions() + # --- Data/UX helpers --- def _load_versions(self): # [{id,version_no,created_at,note,is_current}] diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index dbd8330..332f13d 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -251,10 +251,10 @@ "select_project_title": "Select project", "time_log": "Time log", "time_log_collapsed_hint": "Time log", - "time_log_date_label": "Time log date: {date}", - "time_log_change_date": "Change date", - "time_log_select_date_title": "Select time log date", - "time_log_for": "Time log for {date}", + "date_label": "Date: {date}", + "change_date": "Change date", + "select_date_title": "Select date", + "for": "For {date}", "time_log_no_date": "Time log", "time_log_no_entries": "No time entries yet", "time_log_report": "Time log report", diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 2def58e..44b9f50 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1354,7 +1354,7 @@ class MainWindow(QMainWindow): else: date_iso = self._current_date_iso() - dlg = HistoryDialog(self.db, date_iso, self) + dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes) if dlg.exec() == QDialog.Accepted: # refresh editor + calendar (head pointer may have changed) self._load_selected_date(date_iso) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 7ca4e09..1adf3c3 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -277,7 +277,7 @@ class TimeLogDialog(QDialog): self.close_after_add = close_after_add - self.setWindowTitle(strings._("time_log_for").format(date=date_iso)) + self.setWindowTitle(strings._("for").format(date=date_iso)) self.resize(900, 600) root = QVBoxLayout(self) @@ -285,12 +285,12 @@ class TimeLogDialog(QDialog): # --- Top: date label + change-date button date_row = QHBoxLayout() - self.date_label = QLabel(strings._("time_log_date_label").format(date=date_iso)) + self.date_label = QLabel(strings._("date_label").format(date=date_iso)) date_row.addWidget(self.date_label) date_row.addStretch(1) - self.change_date_btn = QPushButton(strings._("time_log_change_date")) + self.change_date_btn = QPushButton(strings._("change_date")) self.change_date_btn.clicked.connect(self._on_change_date_clicked) date_row.addWidget(self.change_date_btn) @@ -477,7 +477,7 @@ class TimeLogDialog(QDialog): current_qdate = QDate.currentDate() dlg = QDialog(self) - dlg.setWindowTitle(strings._("time_log_select_date_title")) + dlg.setWindowTitle(strings._("select_date_title")) layout = QVBoxLayout(dlg) @@ -508,8 +508,8 @@ class TimeLogDialog(QDialog): self._date_iso = new_iso # Update window title and header label - self.setWindowTitle(strings._("time_log_for").format(date=new_iso)) - self.date_label.setText(strings._("time_log_date_label").format(date=new_iso)) + self.setWindowTitle(strings._("for").format(date=new_iso)) + self.date_label.setText(strings._("date_label").format(date=new_iso)) # Reload entries for the newly selected date self._reload_entries() From 7a75d33bb0317e6a32d152480521bfb842375194 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 11 Dec 2025 16:17:22 +1100 Subject: [PATCH 6/6] 0.7.0 --- poetry.lock | 196 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/poetry.lock b/poetry.lock index 49d843f..addf793 100644 --- a/poetry.lock +++ b/poetry.lock @@ -146,103 +146,103 @@ files = [ [[package]] name = "coverage" -version = "7.12.0" +version = "7.13.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, - {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, - {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, - {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, - {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, - {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, - {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, - {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, - {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, - {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, - {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, - {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, - {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, - {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, - {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, - {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, - {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, - {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, - {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, - {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, - {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, - {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, - {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, - {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, + {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, + {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, + {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, + {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, + {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, + {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, + {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, + {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, + {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, + {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, + {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, + {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, + {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, + {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, + {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, + {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, + {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, + {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, + {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, + {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, + {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, + {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, ] [package.dependencies] @@ -747,20 +747,20 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.1" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"}, + {file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" diff --git a/pyproject.toml b/pyproject.toml index 8f8cfd1..b26e6bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.6.4" +version = "0.7.0" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md"