diff --git a/.gitignore b/.gitignore index 07c956d..851b242 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,3 @@ __pycache__ dist .coverage *.db -*.pdf -*.csv -*.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b8115..9ee1413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,3 @@ -# 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 - * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs) - # 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 da87442..e2c5297 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ 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/bug_report_dialog.py b/bouquin/bug_report_dialog.py index 0743985..9cc727c 100644 --- a/bouquin/bug_report_dialog.py +++ b/bouquin/bug_report_dialog.py @@ -3,17 +3,19 @@ from __future__ import annotations import importlib.metadata import requests + from PySide6.QtWidgets import ( QDialog, - QDialogButtonBox, - QLabel, - QMessageBox, - QTextEdit, QVBoxLayout, + QLabel, + QTextEdit, + QDialogButtonBox, + QMessageBox, ) 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 8df348d..59162c0 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -1,14 +1,15 @@ from __future__ import annotations -from PySide6.QtCore import QRect, QSize, Qt -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette +from PySide6.QtCore import QSize, QRect, Qt +from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics + from PySide6.QtWidgets import ( - QComboBox, QDialog, - QDialogButtonBox, - QLabel, - QPlainTextEdit, QVBoxLayout, + QPlainTextEdit, + QDialogButtonBox, + QComboBox, + QLabel, QWidget, ) diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py index 74ef6d4..3e8d8da 100644 --- a/bouquin/code_highlighter.py +++ b/bouquin/code_highlighter.py @@ -1,9 +1,9 @@ from __future__ import annotations import re -from typing import Dict, Optional +from typing import Optional, Dict -from PySide6.QtGui import QColor, QFont, QTextCharFormat +from PySide6.QtGui import QColor, QTextCharFormat, QFont class CodeHighlighter: diff --git a/bouquin/db.py b/bouquin/db.py index 2b5cb44..2ebfa4c 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -5,15 +5,16 @@ 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 typing import Dict, List, Sequence, Tuple - -import markdown -from sqlcipher3 import Binary from sqlcipher3 import dbapi2 as sqlite +from sqlcipher3 import Binary +from typing import List, Sequence, Tuple, Dict + from . import strings @@ -40,26 +41,6 @@ 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 @@ -96,31 +77,11 @@ 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 @@ -291,76 +252,6 @@ 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() @@ -1051,14 +942,6 @@ 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: @@ -1300,7 +1183,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 @@ -1835,431 +1718,3 @@ 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/document_utils.py b/bouquin/document_utils.py index fd7313e..550cfd4 100644 --- a/bouquin/document_utils.py +++ b/bouquin/document_utils.py @@ -8,8 +8,8 @@ and TagBrowserDialog). from __future__ import annotations -import tempfile from pathlib import Path +import tempfile from typing import TYPE_CHECKING, Optional from PySide6.QtCore import QUrl diff --git a/bouquin/documents.py b/bouquin/documents.py index 9f5a40f..c30f31c 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 ( - QAbstractItemView, - QComboBox, QDialog, - QFileDialog, - QFormLayout, - QFrame, + QVBoxLayout, QHBoxLayout, - QHeaderView, + QFormLayout, + QComboBox, QLineEdit, - QListWidget, - QListWidgetItem, - QMessageBox, - QPushButton, - QSizePolicy, - QStyle, QTableWidget, QTableWidgetItem, - QToolButton, - QVBoxLayout, + QAbstractItemView, + QHeaderView, + QPushButton, + QFileDialog, + QMessageBox, QWidget, + QFrame, + QToolButton, + QListWidget, + QListWidgetItem, + QSizePolicy, + QStyle, ) -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): @@ -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/find_bar.py b/bouquin/find_bar.py index 99a1fcd..ae0206b 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -1,15 +1,20 @@ from __future__ import annotations from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument +from PySide6.QtGui import ( + QShortcut, + QTextCursor, + QTextCharFormat, + QTextDocument, +) from PySide6.QtWidgets import ( - QCheckBox, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QTextEdit, QWidget, + QHBoxLayout, + QLineEdit, + QLabel, + QPushButton, + QCheckBox, + QTextEdit, ) from . import strings diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index c145cce..f2cdc1c 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -1,29 +1,22 @@ from __future__ import annotations -import difflib -import html as _html -import re +import difflib, re, html as _html from datetime import datetime - -from PySide6.QtCore import QDate, Qt, Slot +from PySide6.QtCore import Qt, Slot from PySide6.QtWidgets import ( - QAbstractItemView, - QCalendarWidget, QDialog, - QDialogButtonBox, + QVBoxLayout, QHBoxLayout, - QLabel, QListWidget, QListWidgetItem, - QMessageBox, QPushButton, - QTabWidget, + QMessageBox, QTextBrowser, - QVBoxLayout, + QTabWidget, + QAbstractItemView, ) from . import strings -from .theme import ThemeManager def _markdown_to_text(s: str) -> str: @@ -77,29 +70,16 @@ 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, themes: ThemeManager | None = None - ): + def __init__(self, db, date_iso: str, parent=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() @@ -137,53 +117,6 @@ 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/invoices.py b/bouquin/invoices.py deleted file mode 100644 index 18071d6..0000000 --- a/bouquin/invoices.py +++ /dev/null @@ -1,1445 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum - -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 ( - QAbstractItemView, - QButtonGroup, - QCheckBox, - QComboBox, - QDateEdit, - 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 - - -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) - - # Normalise 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/key_prompt.py b/bouquin/key_prompt.py index 866f682..195599f 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -4,13 +4,13 @@ from pathlib import Path from PySide6.QtWidgets import ( QDialog, - QDialogButtonBox, - QFileDialog, + QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, - QVBoxLayout, + QDialogButtonBox, + QFileDialog, ) from . import strings diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 332f13d..b8c56f5 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -52,6 +52,7 @@ "backup_failed": "Backup failed", "quit": "Quit", "cancel": "Cancel", + "close": "Close", "save": "Save", "help": "Help", "saved": "Saved", @@ -201,7 +202,6 @@ "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,8 +234,6 @@ "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", @@ -251,10 +249,10 @@ "select_project_title": "Select project", "time_log": "Time log", "time_log_collapsed_hint": "Time log", - "date_label": "Date: {date}", - "change_date": "Change date", - "select_date_title": "Select date", - "for": "For {date}", + "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}", "time_log_no_date": "Time log", "time_log_no_entries": "No time entries yet", "time_log_report": "Time log report", @@ -306,7 +304,7 @@ "reminder": "Reminder", "reminders": "Reminders", "time": "Time", - "once": "Once", + "once_today": "Once (today)", "every_day": "Every day", "every_weekday": "Every weekday (Mon-Fri)", "every_week": "Every week", @@ -361,54 +359,5 @@ "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.", - "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." + "todays_documents_none": "No documents yet." } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index f77ebb1..3ba5ba6 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": "Une fois (aujourd'hui)", + "once_today": "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/lock_overlay.py b/bouquin/lock_overlay.py index 90c12a8..4a1a98e 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import QEvent, Qt -from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget +from PySide6.QtCore import Qt, QEvent +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton from . import strings from .theme import ThemeManager diff --git a/bouquin/main.py b/bouquin/main.py index 6883755..958185d 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -2,14 +2,13 @@ from __future__ import annotations import sys from pathlib import Path - -from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QIcon -from . import strings -from .main_window import MainWindow from .settings import APP_NAME, APP_ORG, get_settings +from .main_window import MainWindow from .theme import Theme, ThemeConfig, ThemeManager +from . import strings def main(): diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 44b9f50..aab7bbb 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -2,21 +2,21 @@ from __future__ import annotations import datetime import os -import re import sys -from pathlib import Path +import re +from pathlib import Path from PySide6.QtCore import ( QDate, - QDateTime, - QEvent, - QSettings, - QSignalBlocker, - Qt, - QTime, QTimer, - QUrl, + Qt, + QSettings, Slot, + QUrl, + QEvent, + QSignalBlocker, + QDateTime, + QTime, ) from PySide6.QtGui import ( QAction, @@ -31,24 +31,23 @@ 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 @@ -61,9 +60,10 @@ from .pomodoro_timer import PomodoroManager from .reminders import UpcomingRemindersWidget from .save_dialog import SaveDialog from .search import Search -from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config +from .settings import APP_ORG, APP_NAME, 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 @@ -117,9 +117,6 @@ 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 @@ -496,7 +493,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 +516,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 @@ -1354,7 +1351,7 @@ class MainWindow(QMainWindow): else: date_iso = self._current_date_iso() - dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes) + dlg = HistoryDialog(self.db, date_iso, self) if dlg.exec() == QDialog.Accepted: # refresh editor + calendar (head pointer may have changed) self._load_selected_date(date_iso) @@ -1451,7 +1448,6 @@ 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/markdown_editor.py b/bouquin/markdown_editor.py index 831ce9b..4e85f84 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 . import strings -from .code_block_editor_dialog import CodeBlockEditorDialog -from .markdown_highlighter import MarkdownHighlighter from .theme import ThemeManager +from .markdown_highlighter import MarkdownHighlighter +from .code_block_editor_dialog import CodeBlockEditorDialog +from . import strings class MarkdownEditor(QTextEdit): @@ -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/markdown_highlighter.py b/bouquin/markdown_highlighter.py index bb308d5..81b08b4 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -14,7 +14,7 @@ from PySide6.QtGui import ( QTextDocument, ) -from .theme import Theme, ThemeManager +from .theme import ThemeManager, Theme class MarkdownHighlighter(QSyntaxHighlighter): diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py index e66c1f4..50d5a69 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, ) @@ -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 9fc096a..c127a99 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 QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot +from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal from PySide6.QtWidgets import ( - QAbstractItemView, - QComboBox, - QDateEdit, QDialog, - QFormLayout, - QFrame, + QVBoxLayout, QHBoxLayout, - QHeaderView, + QFormLayout, QLineEdit, + QComboBox, + QTimeEdit, + QPushButton, + QFrame, + QWidget, + QToolButton, QListWidget, QListWidgetItem, - QMessageBox, - QPushButton, - QSizePolicy, - QSpinBox, QStyle, + QSizePolicy, + QMessageBox, QTableWidget, QTableWidgetItem, - QTimeEdit, - QToolButton, - QVBoxLayout, - QWidget, + QAbstractItemView, + QHeaderView, + QSpinBox, + QDateEdit, ) from . import strings @@ -107,7 +107,7 @@ class ReminderDialog(QDialog): # Recurrence type self.type_combo = QComboBox() - self.type_combo.addItem(strings._("once"), ReminderType.ONCE) + self.type_combo.addItem(strings._("once_today"), 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) @@ -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 @@ -566,8 +566,8 @@ class UpcomingRemindersWidget(QFrame): if not selected_items: return - from PySide6.QtGui import QAction from PySide6.QtWidgets import QMenu + from PySide6.QtGui import QAction menu = QMenu(self) diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py index 528896b..6b4e05d 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -3,7 +3,13 @@ from __future__ import annotations import datetime from PySide6.QtGui import QFontMetrics -from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QLabel, + QLineEdit, + QDialogButtonBox, +) from . import strings diff --git a/bouquin/search.py b/bouquin/search.py index 7dd7f7f..b2a885b 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 5a14c07..cfd8939 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path - from PySide6.QtCore import QSettings, QStandardPaths from .db import DBConfig @@ -46,7 +45,6 @@ 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( @@ -59,7 +57,6 @@ def load_db_config() -> DBConfig: time_log=time_log, reminders=reminders, documents=documents, - invoicing=invoicing, locale=locale, font_size=font_size, ) @@ -76,6 +73,5 @@ 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 6ce6255..68599ca 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -2,36 +2,34 @@ 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, - QDialogButtonBox, - QFileDialog, - QFormLayout, QFrame, QGroupBox, - QHBoxLayout, QLabel, - QLineEdit, - QMessageBox, + QHBoxLayout, + QVBoxLayout, QPushButton, + QDialogButtonBox, QRadioButton, QSizePolicy, QSpinBox, - QTabWidget, - QTextEdit, - QVBoxLayout, + QMessageBox, QWidget, + QTabWidget, ) +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QPalette + -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 +from .key_prompt import KeyPrompt + +from . import strings class SettingsDialog(QDialog): @@ -178,17 +176,6 @@ 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) @@ -200,68 +187,6 @@ 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 @@ -389,60 +314,14 @@ 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/statistics_dialog.py b/bouquin/statistics_dialog.py index 77b83f6..f71c447 100644 --- a/bouquin/statistics_dialog.py +++ b/bouquin/statistics_dialog.py @@ -3,25 +3,26 @@ from __future__ import annotations import datetime as _dt from typing import Dict -from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QBrush, QColor, QPainter, QPen +from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtGui import QColor, QPainter, QPen, QBrush from PySide6.QtWidgets import ( - QComboBox, QDialog, + QVBoxLayout, QFormLayout, + QLabel, QGroupBox, QHBoxLayout, - QLabel, + QComboBox, QScrollArea, - QSizePolicy, - QVBoxLayout, QWidget, + QSizePolicy, ) from . import strings from .db import DBManager from .settings import load_db_config + # ---------- Activity heatmap ---------- @@ -215,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/strings.py b/bouquin/strings.py index 71e838b..eff0e18 100644 --- a/bouquin/strings.py +++ b/bouquin/strings.py @@ -1,5 +1,5 @@ -import json from importlib.resources import files +import json # Get list of locales root = files("bouquin") / "locales" diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index 210f7d3..1e7cb01 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, - QVBoxLayout, + QPushButton, + QLabel, + QColorDialog, + QMessageBox, + QInputDialog, ) -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 7ac4ad4..423bd06 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, - QToolButton, - QVBoxLayout, - QWidget, + QCompleter, ) from . import strings diff --git a/bouquin/theme.py b/bouquin/theme.py index 87b77f9..0f36d93 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -1,12 +1,10 @@ from __future__ import annotations - from dataclasses import dataclass from enum import Enum -from weakref import WeakSet - -from PySide6.QtCore import QObject, Qt, Signal -from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat +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 class Theme(Enum): diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 1adf3c3..e5e9b64 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -2,49 +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 QDate, Qt, QUrl, Signal -from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument +from PySide6.QtCore import Qt, QDate, QUrl +from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout from PySide6.QtPrintSupport import QPrinter from PySide6.QtWidgets import ( - QAbstractItemView, QCalendarWidget, - QComboBox, - QCompleter, - QDateEdit, QDialog, QDialogButtonBox, - QDoubleSpinBox, + QFrame, + QVBoxLayout, + QHBoxLayout, + QWidget, QFileDialog, QFormLayout, - QFrame, - QHBoxLayout, - QHeaderView, - QInputDialog, QLabel, + QComboBox, QLineEdit, - QListWidget, - QListWidgetItem, - QMessageBox, + QDoubleSpinBox, QPushButton, - QSizePolicy, - QStyle, QTableWidget, QTableWidgetItem, + QAbstractItemView, + QHeaderView, QTabWidget, + QListWidget, + QListWidgetItem, + QDateEdit, + QMessageBox, + QCompleter, QToolButton, - QVBoxLayout, - QWidget, + QSizePolicy, + QStyle, + QInputDialog, ) -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): @@ -53,8 +53,6 @@ class TimeLogWidget(QFrame): Shown in the left sidebar above the Tags widget. """ - remindersChanged = Signal() - def __init__( self, db: DBManager, @@ -63,7 +61,6 @@ class TimeLogWidget(QFrame): ): super().__init__(parent) self._db = db - self.cfg = load_db_config() self._themes = themes self._current_date: Optional[str] = None @@ -85,15 +82,6 @@ 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) @@ -107,7 +95,6 @@ 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 @@ -162,14 +149,6 @@ 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) @@ -268,8 +247,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 + # Guard flag used when repopulating the table so we don’t treat # programmatic item changes as user edits. self._reloading_entries: bool = False @@ -277,7 +255,7 @@ class TimeLogDialog(QDialog): self.close_after_add = close_after_add - self.setWindowTitle(strings._("for").format(date=date_iso)) + self.setWindowTitle(strings._("time_log_for").format(date=date_iso)) self.resize(900, 600) root = QVBoxLayout(self) @@ -285,12 +263,12 @@ class TimeLogDialog(QDialog): # --- Top: date label + change-date button date_row = QHBoxLayout() - self.date_label = QLabel(strings._("date_label").format(date=date_iso)) + self.date_label = QLabel(strings._("time_log_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 = QPushButton(strings._("time_log_change_date")) self.change_date_btn.clicked.connect(self._on_change_date_clicked) date_row.addWidget(self.change_date_btn) @@ -342,9 +320,13 @@ 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 @@ -373,19 +355,12 @@ class TimeLogDialog(QDialog): self.table.itemChanged.connect(self._on_table_item_changed) root.addWidget(self.table, 1) - # --- Total time, Reporting and Close button + # --- Total time 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) @@ -477,7 +452,7 @@ class TimeLogDialog(QDialog): current_qdate = QDate.currentDate() dlg = QDialog(self) - dlg.setWindowTitle(strings._("select_date_title")) + dlg.setWindowTitle(strings._("time_log_select_date_title")) layout = QVBoxLayout(dlg) @@ -508,8 +483,8 @@ class TimeLogDialog(QDialog): self._date_iso = 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)) + self.setWindowTitle(strings._("time_log_for").format(date=new_iso)) + self.date_label.setText(strings._("time_log_date_label").format(date=new_iso)) # Reload entries for the newly selected date self._reload_entries() @@ -619,7 +594,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 @@ -828,7 +803,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"), @@ -1006,12 +981,9 @@ 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]] = [] @@ -1020,7 +992,6 @@ 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) @@ -1028,20 +999,9 @@ 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) @@ -1053,7 +1013,6 @@ 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") @@ -1102,10 +1061,6 @@ 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 @@ -1191,14 +1146,6 @@ 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 @@ -1207,7 +1154,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 @@ -1240,13 +1187,11 @@ 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 @@ -1283,7 +1228,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 = [ @@ -1580,55 +1525,3 @@ 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/bouquin/toolbar.py b/bouquin/toolbar.py index 92383e6..8090fe7 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup from PySide6.QtWidgets import QToolBar from . import strings diff --git a/bouquin/version_check.py b/bouquin/version_check.py index 5b62d02..b2010d5 100644 --- a/bouquin/version_check.py +++ b/bouquin/version_check.py @@ -5,17 +5,23 @@ 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.QtGui import QGuiApplication, QImage, QPainter, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QMessageBox, + QWidget, + QProgressDialog, +) +from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication from PySide6.QtSvg import QSvgRenderer -from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget -from . import strings from .settings import APP_NAME +from . import strings + # Where to fetch the latest version string from VERSION_URL = "https://mig5.net/bouquin/version.txt" diff --git a/poetry.lock b/poetry.lock index addf793..49d843f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -146,103 +146,103 @@ files = [ [[package]] name = "coverage" -version = "7.13.0" +version = "7.12.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {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"}, + {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"}, ] [package.dependencies] @@ -747,20 +747,20 @@ files = [ [[package]] name = "urllib3" -version = "2.6.1" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"}, - {file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" diff --git a/pyproject.toml b/pyproject.toml index b26e6bb..8f8cfd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.7.0" +version = "0.6.4" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/release.sh b/release.sh index 9f8b3c8..5970bb3 100755 --- a/release.sh +++ b/release.sh @@ -3,7 +3,7 @@ set -eo pipefail # Clean caches etc -filedust -y . +/home/user/venv-guardutils/bin/filedust -y . # Publish to Pypi poetry build diff --git a/tests/conftest.py b/tests/conftest.py index 4058d77..878ccc7 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, QDateTime, QTime + from PySide6.QtCore import QDate, QTime, QDateTime 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 df839fd..8d773e9 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 import strings from bouquin.bug_report_dialog import BugReportDialog -from PySide6.QtGui import QTextCursor +from bouquin import strings from PySide6.QtWidgets import QMessageBox +from PySide6.QtGui import QTextCursor 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 e64199b..9a59aa8 100644 --- a/tests/test_code_block_editor_dialog.py +++ b/tests/test_code_block_editor_dialog.py @@ -1,11 +1,13 @@ +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): @@ -157,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 + # This should exercise the painting loop (lines 87-130) editor.line_number_area_paint_event(paint_event) # Should not crash diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py index 57ab8e7..145e156 100644 --- a/tests/test_code_highlighter.py +++ b/tests/test_code_highlighter.py @@ -1,5 +1,5 @@ -from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter -from PySide6.QtGui import QFont, QTextCharFormat +from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata +from PySide6.QtGui import QTextCharFormat, QFont def test_get_language_patterns_python(app): diff --git a/tests/test_db.py b/tests/test_db.py index 12585f7..19a4d6e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,11 +1,9 @@ -import csv -import datetime as dt -import json -from datetime import date, timedelta - import pytest -from bouquin.db import DBManager +import json, csv +import datetime as dt from sqlcipher3 import dbapi2 as sqlite +from bouquin.db import DBManager +from datetime import date, timedelta def _today(): diff --git a/tests/test_document_utils.py b/tests/test_document_utils.py index e1301df..6e91ba2 100644 --- a/tests/test_document_utils.py +++ b/tests/test_document_utils.py @@ -1,10 +1,10 @@ -import tempfile -from pathlib import Path from unittest.mock import patch +from pathlib import Path +import tempfile from PySide6.QtCore import QUrl -from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import QMessageBox, QWidget +from PySide6.QtGui import QDesktopServices def test_open_document_from_db_success(qtbot, app, fresh_db): diff --git a/tests/test_documents.py b/tests/test_documents.py index 0740b40..8be5b83 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -1,12 +1,13 @@ -import tempfile +from unittest.mock import patch, MagicMock from pathlib import Path -from unittest.mock import MagicMock, patch +import tempfile from bouquin.db import DBConfig -from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget +from bouquin.documents import TodaysDocumentsWidget, DocumentsDialog 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 de67c7e..c0ab938 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -1,9 +1,10 @@ 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 98ab9c8..da97a5a 100644 --- a/tests/test_history_dialog.py +++ b/tests/test_history_dialog.py @@ -1,6 +1,7 @@ -from bouquin.history_dialog import HistoryDialog +from PySide6.QtWidgets import QWidget, QMessageBox, QApplication from PySide6.QtCore import Qt, QTimer -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget + +from bouquin.history_dialog import HistoryDialog def test_history_dialog_lists_and_revert(qtbot, fresh_db): diff --git a/tests/test_invoices.py b/tests/test_invoices.py deleted file mode 100644 index 89ef202..0000000 --- a/tests/test_invoices.py +++ /dev/null @@ -1,1346 +0,0 @@ -from datetime import date, timedelta - -import pytest -from bouquin.invoices import ( - _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 -# ============================================================================ - - -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 9aedffb..f044fac 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -1,4 +1,5 @@ from bouquin.key_prompt import KeyPrompt + from PySide6.QtCore import QTimer from PySide6.QtWidgets import QFileDialog, QLineEdit @@ -96,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""" + """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57""" prompt = KeyPrompt(show_db_change=True, initial_db_path=None) qtbot.addWidget(prompt) @@ -167,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""" + """Test browsing when initial_db_path is set - covers line 57 with non-None path""" initial_db = tmp_path / "initial.db" initial_db.touch() @@ -179,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 + # Verify that start_dir was passed correctly (line 57) return str(new_db), "SQLCipher DB (*.db)" monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py index 46b3cfd..05de5f9 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 5bfb774..2a357fb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,5 @@ import importlib import runpy - import pytest diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 6c09e71..2cf787d 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,19 +1,22 @@ +import pytest import importlib.metadata + from datetime import date, timedelta from pathlib import Path -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 +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.version_check as version_check 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 73f58f4..a4025ea 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1,21 +1,22 @@ import base64 - import pytest -from bouquin.markdown_editor import MarkdownEditor -from bouquin.markdown_highlighter import MarkdownHighlighter -from bouquin.theme import Theme, ThemeConfig, ThemeManager -from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl + +from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl from PySide6.QtGui import ( - QColor, - QFont, QImage, + QColor, QKeyEvent, - QTextCharFormat, 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 + def _today(): from datetime import date @@ -1927,7 +1928,7 @@ def test_editor_delete_operations(qtbot, app): def test_markdown_highlighter_dark_theme(qtbot, app): - """Test markdown highlighter with dark theme""" + """Test markdown highlighter with dark theme - covers lines 74-75""" # Create theme manager with dark theme themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) @@ -2292,7 +2293,7 @@ def test_highlighter_code_block_with_language(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # Verify syntax highlighting was applied + # Verify syntax highlighting was applied (lines 186-193) # We can't easily verify the exact formatting, but we ensure no crash @@ -2304,10 +2305,13 @@ 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 4037ed1..070d954 100644 --- a/tests/test_markdown_editor_additional.py +++ b/tests/test_markdown_editor_additional.py @@ -4,18 +4,19 @@ These tests should be added to test_markdown_editor.py. """ import pytest -from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import Theme, ThemeConfig, ThemeManager -from PySide6.QtCore import QPoint, Qt +from PySide6.QtCore import Qt, QPoint from PySide6.QtGui import ( - QColor, QImage, + QColor, 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() @@ -43,6 +44,7 @@ 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)) @@ -58,6 +60,7 @@ 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") @@ -121,6 +124,7 @@ 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)) @@ -144,8 +148,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.""" - import bouquin.markdown_editor as markdown_editor from PySide6.QtWidgets import QDialog + import bouquin.markdown_editor as markdown_editor class CancelledDialog: def __init__(self, code, language, parent=None, allow_delete=False): @@ -174,8 +178,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.""" - import bouquin.markdown_editor as markdown_editor from PySide6.QtWidgets import QDialog + import bouquin.markdown_editor as markdown_editor class DeleteDialog: def __init__(self, code, language, parent=None, allow_delete=False): @@ -213,8 +217,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.""" - import bouquin.markdown_editor as markdown_editor from PySide6.QtWidgets import QDialog + import bouquin.markdown_editor as markdown_editor class LanguageChangeDialog: def __init__(self, code, language, parent=None, allow_delete=False): @@ -245,6 +249,7 @@ 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") @@ -302,6 +307,7 @@ 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)) @@ -313,6 +319,7 @@ 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```") @@ -327,6 +334,7 @@ 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```") @@ -340,6 +348,7 @@ 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)) @@ -355,6 +364,7 @@ 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 @@ -377,6 +387,7 @@ 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") @@ -436,6 +447,7 @@ 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( @@ -466,6 +478,7 @@ 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") @@ -476,6 +489,7 @@ 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") @@ -490,6 +504,7 @@ 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") @@ -503,6 +518,7 @@ 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") @@ -518,6 +534,7 @@ 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") @@ -532,6 +549,7 @@ 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```") @@ -561,6 +579,7 @@ 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 **") @@ -574,6 +593,7 @@ 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```") @@ -588,6 +608,7 @@ 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} ") @@ -601,6 +622,7 @@ 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. ") @@ -612,6 +634,7 @@ 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") @@ -649,6 +672,7 @@ 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") @@ -688,6 +712,7 @@ 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") @@ -703,6 +728,7 @@ 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") @@ -716,6 +742,7 @@ 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") @@ -744,6 +771,7 @@ 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") @@ -758,6 +786,7 @@ 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 = [ @@ -780,6 +809,7 @@ 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" @@ -797,6 +827,7 @@ 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") @@ -841,6 +872,7 @@ 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") @@ -863,6 +895,7 @@ 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```") @@ -882,6 +915,7 @@ 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```") @@ -895,6 +929,7 @@ 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_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 1c2e450..5ffeafd 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -1,9 +1,8 @@ from unittest.mock import Mock, patch - -from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer -from bouquin.theme import Theme, ThemeConfig, ThemeManager +from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from PySide6.QtWidgets import QWidget, QVBoxLayout, QToolBar, QLabel 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 b9e3bfc..16e8dc9 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -1,17 +1,18 @@ -from datetime import date, timedelta -from unittest.mock import MagicMock, patch - import pytest + +from unittest.mock import patch, MagicMock from bouquin.reminders import ( - ManageRemindersDialog, Reminder, - ReminderDialog, ReminderType, + ReminderDialog, UpcomingRemindersWidget, + ManageRemindersDialog, ) -from PySide6.QtCore import QDate, QDateTime, QTime +from PySide6.QtCore import QDateTime, QDate, QTime from PySide6.QtWidgets import QDialog, QMessageBox, QWidget +from datetime import date, timedelta + @pytest.fixture def freeze_reminders_time(monkeypatch): @@ -850,9 +851,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 bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget - from PySide6 import QtGui, QtWidgets + from PySide6 import QtWidgets, QtGui from PySide6.QtCore import QPoint + from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget # Add a future reminder for today r = Reminder( @@ -908,9 +909,9 @@ def test_upcoming_reminders_context_menu_shows( def test_upcoming_reminders_delete_selected_dedupes( qtbot, app, fresh_db, freeze_reminders_time, monkeypatch ): - from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget - from PySide6.QtCore import QItemSelectionModel from PySide6.QtWidgets import QMessageBox + from PySide6.QtCore import QItemSelectionModel + from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget r = Reminder( id=None, diff --git a/tests/test_settings.py b/tests/test_settings.py index 086d590..f272ab2 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,5 +1,9 @@ +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 0b1dafd..ad53951 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,11 +1,11 @@ -import bouquin.settings_dialog as sd -from bouquin.db import DBConfig, DBManager +from bouquin.db import DBManager, DBConfig from bouquin.key_prompt import KeyPrompt -from bouquin.settings import get_settings +import bouquin.settings_dialog as sd from bouquin.settings_dialog import SettingsDialog -from bouquin.theme import Theme, ThemeConfig, ThemeManager +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.settings import get_settings from PySide6.QtCore import QTimer -from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog 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 46a6eb0..8ff73b1 100644 --- a/tests/test_statistics_dialog.py +++ b/tests/test_statistics_dialog.py @@ -1,11 +1,13 @@ import datetime as _dt -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta, date from bouquin import strings -from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog -from PySide6.QtCore import QDate, QPoint, Qt -from PySide6.QtTest import QTest + +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 class FakeStatsDB: @@ -630,5 +632,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db): # Force a repaint to execute paintEvent heatmap.repaint() - # The month continuation logic should prevent duplicate labels + # The month continuation logic (line 175) should prevent duplicate labels # We can't easily test the visual output, but we ensure no crash diff --git a/tests/test_tabs.py b/tests/test_tabs.py index b495356..fe73828 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -1,11 +1,12 @@ import types - -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 +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 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 89e5fbd..8564c6b 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,21 +1,24 @@ -import bouquin.strings as strings import pytest -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.QtCore import Qt, QPoint, QEvent, QDate +from PySide6.QtGui import QMouseEvent, QColor 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 # ============================================================================ @@ -1646,7 +1649,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 QLabel, QWidget + from PySide6.QtWidgets import QWidget, QLabel widget = QWidget() layout = FlowLayout(widget) @@ -1670,7 +1673,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 QLabel, QWidget + from PySide6.QtWidgets import QWidget, QLabel widget = QWidget() layout = FlowLayout(widget) diff --git a/tests/test_theme.py b/tests/test_theme.py index a1dc283..6f19a62 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,7 +1,8 @@ -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 0a6797c..6a997ed 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1,18 +1,24 @@ -from datetime import date, timedelta -from unittest.mock import MagicMock, patch - -import bouquin.strings as strings import pytest -from bouquin.theme import Theme, ThemeConfig, ThemeManager +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 bouquin.theme import ThemeManager, ThemeConfig, Theme from bouquin.time_log import ( - TimeCodeManagerDialog, - TimeLogDialog, TimeLogWidget, + TimeLogDialog, + TimeCodeManagerDialog, TimeReportDialog, ) -from PySide6.QtCore import QDate, Qt -from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox -from sqlcipher3.dbapi2 import IntegrityError +import bouquin.strings as strings + +from unittest.mock import patch, MagicMock @pytest.fixture diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index fdc8829..3794760 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -1,8 +1,8 @@ import pytest -from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import Theme, ThemeConfig, ThemeManager -from bouquin.toolbar import ToolBar from PySide6.QtWidgets import QWidget +from bouquin.markdown_editor import MarkdownEditor +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.toolbar import ToolBar @pytest.fixture diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 01fac35..b5afe12 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,10 +1,9 @@ -import subprocess -from unittest.mock import Mock, patch - import pytest +from unittest.mock import Mock, patch +import subprocess from bouquin.version_check import VersionChecker -from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QMessageBox, QWidget +from PySide6.QtGui import QPixmap def test_version_checker_init(app):