diff --git a/.gitignore b/.gitignore
index 851b242..07c956d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@ __pycache__
dist
.coverage
*.db
+*.pdf
+*.csv
+*.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ee1413..76b8115 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# 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 e2c5297..da87442 100644
--- a/README.md
+++ b/README.md
@@ -79,8 +79,6 @@ report from within the app, or optionally to check for new versions to upgrade t
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
-It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
-
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
### From PyPi/pip
diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py
index 9cc727c..0743985 100644
--- a/bouquin/bug_report_dialog.py
+++ b/bouquin/bug_report_dialog.py
@@ -3,19 +3,17 @@ from __future__ import annotations
import importlib.metadata
import requests
-
from PySide6.QtWidgets import (
QDialog,
- QVBoxLayout,
- QLabel,
- QTextEdit,
QDialogButtonBox,
+ QLabel,
QMessageBox,
+ QTextEdit,
+ QVBoxLayout,
)
from . import strings
-
BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs"
diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py
index 59162c0..8df348d 100644
--- a/bouquin/code_block_editor_dialog.py
+++ b/bouquin/code_block_editor_dialog.py
@@ -1,15 +1,14 @@
from __future__ import annotations
-from PySide6.QtCore import QSize, QRect, Qt
-from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics
-
+from PySide6.QtCore import QRect, QSize, Qt
+from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
from PySide6.QtWidgets import (
- QDialog,
- QVBoxLayout,
- QPlainTextEdit,
- QDialogButtonBox,
QComboBox,
+ QDialog,
+ QDialogButtonBox,
QLabel,
+ QPlainTextEdit,
+ QVBoxLayout,
QWidget,
)
diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py
index 3e8d8da..74ef6d4 100644
--- a/bouquin/code_highlighter.py
+++ b/bouquin/code_highlighter.py
@@ -1,9 +1,9 @@
from __future__ import annotations
import re
-from typing import Optional, Dict
+from typing import Dict, Optional
-from PySide6.QtGui import QColor, QTextCharFormat, QFont
+from PySide6.QtGui import QColor, QFont, QTextCharFormat
class CodeHighlighter:
diff --git a/bouquin/db.py b/bouquin/db.py
index 2ebfa4c..2b5cb44 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -5,16 +5,15 @@ import datetime as _dt
import hashlib
import html
import json
-import markdown
import mimetypes
import re
-
from dataclasses import dataclass
from pathlib import Path
-from sqlcipher3 import dbapi2 as sqlite
-from sqlcipher3 import Binary
-from typing import List, Sequence, Tuple, Dict
+from typing import Dict, List, Sequence, Tuple
+import markdown
+from sqlcipher3 import Binary
+from sqlcipher3 import dbapi2 as sqlite
from . import strings
@@ -41,6 +40,26 @@ DocumentRow = Tuple[
int, # size_bytes
str, # uploaded_at (ISO)
]
+ProjectBillingRow = Tuple[
+ int, # project_id
+ int, # hourly_rate_cents
+ str, # currency
+ str | None, # tax_label
+ float | None, # tax_rate_percent
+ str | None, # client_name
+ str | None, # client_company
+ str | None, # client_address
+ str | None, # client_email
+]
+CompanyProfileRow = Tuple[
+ str | None, # name
+ str | None, # address
+ str | None, # phone
+ str | None, # email
+ str | None, # tax_id
+ str | None, # payment_details
+ bytes | None, # logo
+]
_TAG_COLORS = [
"#FFB3BA", # soft red
@@ -77,11 +96,31 @@ class DBConfig:
time_log: bool = True
reminders: bool = True
documents: bool = True
+ invoicing: bool = False
locale: str = "en"
font_size: int = 11
class DBManager:
+ # Allow list of invoice columns allowed for dynamic field helpers
+ _INVOICE_COLUMN_ALLOWLIST = frozenset(
+ {
+ "invoice_number",
+ "issue_date",
+ "due_date",
+ "currency",
+ "tax_label",
+ "tax_rate_percent",
+ "subtotal_cents",
+ "tax_cents",
+ "total_cents",
+ "detail_mode",
+ "paid_at",
+ "payment_note",
+ "document_id",
+ }
+ )
+
def __init__(self, cfg: DBConfig):
self.cfg = cfg
self.conn: sqlite.Connection | None = None
@@ -252,6 +291,76 @@ class DBManager:
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
ON document_tags(tag_id);
+
+ CREATE TABLE IF NOT EXISTS project_billing (
+ project_id INTEGER PRIMARY KEY
+ REFERENCES projects(id) ON DELETE CASCADE,
+ hourly_rate_cents INTEGER NOT NULL DEFAULT 0,
+ currency TEXT NOT NULL DEFAULT 'AUD',
+ tax_label TEXT,
+ tax_rate_percent REAL,
+ client_name TEXT, -- contact person
+ client_company TEXT, -- business name
+ client_address TEXT,
+ client_email TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS company_profile (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ name TEXT,
+ address TEXT,
+ phone TEXT,
+ email TEXT,
+ tax_id TEXT,
+ payment_details TEXT,
+ logo BLOB
+ );
+
+ CREATE TABLE IF NOT EXISTS invoices (
+ id INTEGER PRIMARY KEY,
+ project_id INTEGER NOT NULL
+ REFERENCES projects(id) ON DELETE RESTRICT,
+ invoice_number TEXT NOT NULL,
+ issue_date TEXT NOT NULL, -- yyyy-MM-dd
+ due_date TEXT,
+ currency TEXT NOT NULL,
+ tax_label TEXT,
+ tax_rate_percent REAL,
+ subtotal_cents INTEGER NOT NULL,
+ tax_cents INTEGER NOT NULL,
+ total_cents INTEGER NOT NULL,
+ detail_mode TEXT NOT NULL, -- 'detailed' | 'summary'
+ paid_at TEXT,
+ payment_note TEXT,
+ document_id INTEGER,
+ FOREIGN KEY(document_id) REFERENCES project_documents(id)
+ ON DELETE SET NULL,
+ UNIQUE(project_id, invoice_number)
+ );
+
+ CREATE INDEX IF NOT EXISTS ix_invoices_project
+ ON invoices(project_id);
+
+ CREATE TABLE IF NOT EXISTS invoice_line_items (
+ id INTEGER PRIMARY KEY,
+ invoice_id INTEGER NOT NULL
+ REFERENCES invoices(id) ON DELETE CASCADE,
+ description TEXT NOT NULL,
+ hours REAL NOT NULL,
+ rate_cents INTEGER NOT NULL,
+ amount_cents INTEGER NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS ix_invoice_line_items_invoice
+ ON invoice_line_items(invoice_id);
+
+ CREATE TABLE IF NOT EXISTS invoice_time_log (
+ invoice_id INTEGER NOT NULL
+ REFERENCES invoices(id) ON DELETE CASCADE,
+ time_log_id INTEGER NOT NULL
+ REFERENCES time_log(id) ON DELETE RESTRICT,
+ PRIMARY KEY (invoice_id, time_log_id)
+ );
"""
)
self.conn.commit()
@@ -942,6 +1051,14 @@ class DBManager:
).fetchall()
return [(r["id"], r["name"]) for r in rows]
+ def list_projects_by_id(self, project_id: int) -> str:
+ cur = self.conn.cursor()
+ row = cur.execute(
+ "SELECT name FROM projects WHERE id = ?;",
+ (project_id,),
+ ).fetchone()
+ return row["name"] if row else ""
+
def add_project(self, name: str) -> int:
name = name.strip()
if not name:
@@ -1183,7 +1300,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
@@ -1718,3 +1835,431 @@ class DBManager:
(tag_name,),
).fetchall()
return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows]
+
+ # ------------------------- Billing settings ------------------------#
+
+ def get_project_billing(self, project_id: int) -> ProjectBillingRow | None:
+ cur = self.conn.cursor()
+ row = cur.execute(
+ """
+ SELECT
+ project_id,
+ hourly_rate_cents,
+ currency,
+ tax_label,
+ tax_rate_percent,
+ client_name,
+ client_company,
+ client_address,
+ client_email
+ FROM project_billing
+ WHERE project_id = ?
+ """,
+ (project_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return (
+ row["project_id"],
+ row["hourly_rate_cents"],
+ row["currency"],
+ row["tax_label"],
+ row["tax_rate_percent"],
+ row["client_name"],
+ row["client_company"],
+ row["client_address"],
+ row["client_email"],
+ )
+
+ def upsert_project_billing(
+ self,
+ project_id: int,
+ hourly_rate_cents: int,
+ currency: str,
+ tax_label: str | None,
+ tax_rate_percent: float | None,
+ client_name: str | None,
+ client_company: str | None,
+ client_address: str | None,
+ client_email: str | None,
+ ) -> None:
+ with self.conn:
+ self.conn.execute(
+ """
+ INSERT INTO project_billing (
+ project_id,
+ hourly_rate_cents,
+ currency,
+ tax_label,
+ tax_rate_percent,
+ client_name,
+ client_company,
+ client_address,
+ client_email
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(project_id) DO UPDATE SET
+ hourly_rate_cents = excluded.hourly_rate_cents,
+ currency = excluded.currency,
+ tax_label = excluded.tax_label,
+ tax_rate_percent = excluded.tax_rate_percent,
+ client_name = excluded.client_name,
+ client_company = excluded.client_company,
+ client_address = excluded.client_address,
+ client_email = excluded.client_email;
+ """,
+ (
+ project_id,
+ hourly_rate_cents,
+ currency,
+ tax_label,
+ tax_rate_percent,
+ client_name,
+ client_company,
+ client_address,
+ client_email,
+ ),
+ )
+
+ def list_client_companies(self) -> list[str]:
+ """Return distinct client display names from project_billing."""
+ cur = self.conn.cursor()
+ rows = cur.execute(
+ """
+ SELECT DISTINCT client_company
+ FROM project_billing
+ WHERE client_company IS NOT NULL
+ AND TRIM(client_company) <> ''
+ ORDER BY LOWER(client_company);
+ """
+ ).fetchall()
+ return [r["client_company"] for r in rows]
+
+ def get_client_by_company(
+ self, client_company: str
+ ) -> tuple[str | None, str | None, str | None, str | None] | None:
+ """
+ Return (contact_name, client_display_name, address, email)
+ for a given client display name, based on the most recent project using it.
+ """
+ cur = self.conn.cursor()
+ row = cur.execute(
+ """
+ SELECT client_name, client_company, client_address, client_email
+ FROM project_billing
+ WHERE client_company = ?
+ AND client_company IS NOT NULL
+ AND TRIM(client_company) <> ''
+ ORDER BY project_id DESC
+ LIMIT 1
+ """,
+ (client_company,),
+ ).fetchone()
+ if not row:
+ return None
+ return (
+ row["client_name"],
+ row["client_company"],
+ row["client_address"],
+ row["client_email"],
+ )
+
+ # ------------------------- Company profile ------------------------#
+
+ def get_company_profile(self) -> CompanyProfileRow | None:
+ cur = self.conn.cursor()
+ row = cur.execute(
+ """
+ SELECT name, address, phone, email, tax_id, payment_details, logo
+ FROM company_profile
+ WHERE id = 1
+ """
+ ).fetchone()
+ if not row:
+ return None
+ return (
+ row["name"],
+ row["address"],
+ row["phone"],
+ row["email"],
+ row["tax_id"],
+ row["payment_details"],
+ row["logo"],
+ )
+
+ def save_company_profile(
+ self,
+ name: str | None,
+ address: str | None,
+ phone: str | None,
+ email: str | None,
+ tax_id: str | None,
+ payment_details: str | None,
+ logo: bytes | None,
+ ) -> None:
+ with self.conn:
+ self.conn.execute(
+ """
+ INSERT INTO company_profile (id, name, address, phone, email, tax_id, payment_details, logo)
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ address = excluded.address,
+ phone = excluded.phone,
+ email = excluded.email,
+ tax_id = excluded.tax_id,
+ payment_details = excluded.payment_details,
+ logo = excluded.logo;
+ """,
+ (
+ name,
+ address,
+ phone,
+ email,
+ tax_id,
+ payment_details,
+ Binary(logo) if logo else None,
+ ),
+ )
+
+ # ------------------------- Invoices -------------------------------#
+
+ def create_invoice(
+ self,
+ project_id: int,
+ invoice_number: str,
+ issue_date: str,
+ due_date: str | None,
+ currency: str,
+ tax_label: str | None,
+ tax_rate_percent: float | None,
+ detail_mode: str, # 'detailed' or 'summary'
+ line_items: list[tuple[str, float, int]], # (description, hours, rate_cents)
+ time_log_ids: list[int],
+ ) -> int:
+ """
+ Create invoice + line items + link time logs.
+ Returns invoice ID.
+ """
+ if line_items:
+ first_rate_cents = line_items[0][2]
+ else:
+ first_rate_cents = 0
+
+ total_hours = sum(hours for _desc, hours, _rate in line_items)
+ subtotal_cents = int(round(total_hours * first_rate_cents))
+ tax_cents = int(round(subtotal_cents * (tax_rate_percent or 0) / 100.0))
+ total_cents = subtotal_cents + tax_cents
+
+ with self.conn:
+ cur = self.conn.cursor()
+ cur.execute(
+ """
+ INSERT INTO invoices (
+ project_id,
+ invoice_number,
+ issue_date,
+ due_date,
+ currency,
+ tax_label,
+ tax_rate_percent,
+ subtotal_cents,
+ tax_cents,
+ total_cents,
+ detail_mode
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ project_id,
+ invoice_number,
+ issue_date,
+ due_date,
+ currency,
+ tax_label,
+ tax_rate_percent,
+ subtotal_cents,
+ tax_cents,
+ total_cents,
+ detail_mode,
+ ),
+ )
+ invoice_id = cur.lastrowid
+
+ # Line items
+ for desc, hours, rate_cents in line_items:
+ amount_cents = int(round(hours * rate_cents))
+ cur.execute(
+ """
+ INSERT INTO invoice_line_items (
+ invoice_id, description, hours, rate_cents, amount_cents
+ )
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (invoice_id, desc, hours, rate_cents, amount_cents),
+ )
+
+ # Link time logs
+ for tl_id in time_log_ids:
+ cur.execute(
+ "INSERT INTO invoice_time_log (invoice_id, time_log_id) VALUES (?, ?)",
+ (invoice_id, tl_id),
+ )
+
+ return invoice_id
+
+ def get_invoice_count_by_project_id_and_year(
+ self, project_id: int, year: str
+ ) -> None:
+ with self.conn:
+ row = self.conn.execute(
+ "SELECT COUNT(*) AS c FROM invoices WHERE project_id = ? AND issue_date LIKE ?",
+ (project_id, year),
+ ).fetchone()
+ return row["c"]
+
+ def get_all_invoices(self, project_id: int | None = None) -> None:
+ with self.conn:
+ if project_id is None:
+ rows = self.conn.execute(
+ """
+ SELECT
+ i.id,
+ i.project_id,
+ p.name AS project_name,
+ i.invoice_number,
+ i.issue_date,
+ i.due_date,
+ i.currency,
+ i.tax_label,
+ i.tax_rate_percent,
+ i.subtotal_cents,
+ i.tax_cents,
+ i.total_cents,
+ i.paid_at,
+ i.payment_note
+ FROM invoices AS i
+ LEFT JOIN projects AS p ON p.id = i.project_id
+ ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
+ """
+ ).fetchall()
+ else:
+ rows = self.conn.execute(
+ """
+ SELECT
+ i.id,
+ i.project_id,
+ p.name AS project_name,
+ i.invoice_number,
+ i.issue_date,
+ i.due_date,
+ i.currency,
+ i.tax_label,
+ i.tax_rate_percent,
+ i.subtotal_cents,
+ i.tax_cents,
+ i.total_cents,
+ i.paid_at,
+ i.payment_note
+ FROM invoices AS i
+ LEFT JOIN projects AS p ON p.id = i.project_id
+ WHERE i.project_id = ?
+ ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
+ """,
+ (project_id,),
+ ).fetchall()
+ return rows
+
+ def _validate_invoice_field(self, field: str) -> str:
+ if field not in self._INVOICE_COLUMN_ALLOWLIST:
+ raise ValueError(f"Invalid invoice field name: {field!r}")
+ return field
+
+ def get_invoice_field_by_id(self, invoice_id: int, field: str) -> None:
+ field = self._validate_invoice_field(field)
+
+ with self.conn:
+ row = self.conn.execute(
+ f"SELECT {field} FROM invoices WHERE id = ?", # nosec B608
+ (invoice_id,),
+ ).fetchone()
+ return row
+
+ def set_invoice_field_by_id(
+ self, invoice_id: int, field: str, value: str | None = None
+ ) -> None:
+ field = self._validate_invoice_field(field)
+
+ with self.conn:
+ self.conn.execute(
+ f"UPDATE invoices SET {field} = ? WHERE id = ?", # nosec B608
+ (
+ value,
+ invoice_id,
+ ),
+ )
+
+ def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None:
+ with self.conn:
+ self.conn.execute(
+ "UPDATE invoices SET invoice_number = ? WHERE id = ?",
+ (invoice_number, invoice_id),
+ )
+
+ def set_invoice_document(self, invoice_id: int, document_id: int) -> None:
+ with self.conn:
+ self.conn.execute(
+ "UPDATE invoices SET document_id = ? WHERE id = ?",
+ (document_id, invoice_id),
+ )
+
+ def time_logs_for_range(
+ self,
+ project_id: int,
+ start_date_iso: str,
+ end_date_iso: str,
+ ) -> list[TimeLogRow]:
+ """
+ Return raw time log rows for a project/date range.
+
+ Shape matches time_log_for_date: TimeLogRow.
+ """
+ cur = self.conn.cursor()
+ rows = cur.execute(
+ """
+ SELECT
+ t.id,
+ t.page_date,
+ t.project_id,
+ p.name AS project_name,
+ t.activity_id,
+ a.name AS activity_name,
+ t.minutes,
+ t.note,
+ t.created_at AS created_at
+ FROM time_log t
+ JOIN projects p ON p.id = t.project_id
+ JOIN activities a ON a.id = t.activity_id
+ WHERE t.project_id = ?
+ AND t.page_date BETWEEN ? AND ?
+ ORDER BY t.page_date, LOWER(a.name), t.id;
+ """,
+ (project_id, start_date_iso, end_date_iso),
+ ).fetchall()
+
+ result: list[TimeLogRow] = []
+ for r in rows:
+ result.append(
+ (
+ r["id"],
+ r["page_date"],
+ r["project_id"],
+ r["project_name"],
+ r["activity_id"],
+ r["activity_name"],
+ r["minutes"],
+ r["note"],
+ r["created_at"],
+ )
+ )
+ return result
diff --git a/bouquin/document_utils.py b/bouquin/document_utils.py
index 550cfd4..fd7313e 100644
--- a/bouquin/document_utils.py
+++ b/bouquin/document_utils.py
@@ -8,8 +8,8 @@ and TagBrowserDialog).
from __future__ import annotations
-from pathlib import Path
import tempfile
+from pathlib import Path
from typing import TYPE_CHECKING, Optional
from PySide6.QtCore import QUrl
diff --git a/bouquin/documents.py b/bouquin/documents.py
index c30f31c..9f5a40f 100644
--- a/bouquin/documents.py
+++ b/bouquin/documents.py
@@ -5,32 +5,32 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
- QDialog,
- QVBoxLayout,
- QHBoxLayout,
- QFormLayout,
- QComboBox,
- QLineEdit,
- QTableWidget,
- QTableWidgetItem,
QAbstractItemView,
- QHeaderView,
- QPushButton,
+ QComboBox,
+ QDialog,
QFileDialog,
- QMessageBox,
- QWidget,
+ QFormLayout,
QFrame,
- QToolButton,
+ QHBoxLayout,
+ QHeaderView,
+ QLineEdit,
QListWidget,
QListWidgetItem,
+ QMessageBox,
+ QPushButton,
QSizePolicy,
QStyle,
+ QTableWidget,
+ QTableWidgetItem,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
)
+from . import strings
from .db import DBManager, DocumentRow
from .settings import load_db_config
from .time_log import TimeCodeManagerDialog
-from . import strings
class TodaysDocumentsWidget(QFrame):
@@ -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 ae0206b..99a1fcd 100644
--- a/bouquin/find_bar.py
+++ b/bouquin/find_bar.py
@@ -1,20 +1,15 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
-from PySide6.QtGui import (
- QShortcut,
- QTextCursor,
- QTextCharFormat,
- QTextDocument,
-)
+from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import (
- QWidget,
- QHBoxLayout,
- QLineEdit,
- QLabel,
- QPushButton,
QCheckBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
QTextEdit,
+ QWidget,
)
from . import strings
diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py
index f2cdc1c..c145cce 100644
--- a/bouquin/history_dialog.py
+++ b/bouquin/history_dialog.py
@@ -1,22 +1,29 @@
from __future__ import annotations
-import difflib, re, html as _html
+import difflib
+import html as _html
+import re
from datetime import datetime
-from PySide6.QtCore import Qt, Slot
+
+from PySide6.QtCore import QDate, Qt, Slot
from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QCalendarWidget,
QDialog,
- QVBoxLayout,
+ QDialogButtonBox,
QHBoxLayout,
+ QLabel,
QListWidget,
QListWidgetItem,
- QPushButton,
QMessageBox,
- QTextBrowser,
+ QPushButton,
QTabWidget,
- QAbstractItemView,
+ QTextBrowser,
+ QVBoxLayout,
)
from . import strings
+from .theme import ThemeManager
def _markdown_to_text(s: str) -> str:
@@ -70,16 +77,29 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
class HistoryDialog(QDialog):
"""Show versions for a date, preview, diff, and allow revert."""
- def __init__(self, db, date_iso: str, parent=None):
+ def __init__(
+ self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
+ ):
super().__init__(parent)
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
self._db = db
self._date = date_iso
+ self._themes = themes
self._versions = [] # list[dict] from DB
self._current_id = None # id of current
root = QVBoxLayout(self)
+ # --- Top: date label + change-date button
+ date_row = QHBoxLayout()
+ self.date_label = QLabel(strings._("date_label").format(date=date_iso))
+ date_row.addWidget(self.date_label)
+ date_row.addStretch(1)
+ self.change_date_btn = QPushButton(strings._("change_date"))
+ self.change_date_btn.clicked.connect(self._on_change_date_clicked)
+ date_row.addWidget(self.change_date_btn)
+ root.addLayout(date_row)
+
# Top: list of versions
top = QHBoxLayout()
self.list = QListWidget()
@@ -117,6 +137,53 @@ class HistoryDialog(QDialog):
self._load_versions()
+ @Slot()
+ def _on_change_date_clicked(self) -> None:
+ """Let the user choose a different date and reload entries."""
+
+ # Start from current dialog date; fall back to today if invalid
+ current_qdate = QDate.fromString(self._date, Qt.ISODate)
+ if not current_qdate.isValid():
+ current_qdate = QDate.currentDate()
+
+ dlg = QDialog(self)
+ dlg.setWindowTitle(strings._("select_date_title"))
+
+ layout = QVBoxLayout(dlg)
+
+ calendar = QCalendarWidget(dlg)
+ calendar.setSelectedDate(current_qdate)
+ layout.addWidget(calendar)
+ # Apply the same theming as the main sidebar calendar
+ if self._themes is not None:
+ self._themes.register_calendar(calendar)
+
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
+ )
+ buttons.accepted.connect(dlg.accept)
+ buttons.rejected.connect(dlg.reject)
+ layout.addWidget(buttons)
+
+ if dlg.exec() != QDialog.Accepted:
+ return
+
+ new_qdate = calendar.selectedDate()
+ new_iso = new_qdate.toString(Qt.ISODate)
+ if new_iso == self._date:
+ # No change
+ return
+
+ # Update state
+ self._date = new_iso
+
+ # Update window title and header label
+ self.setWindowTitle(strings._("for").format(date=new_iso))
+ self.date_label.setText(strings._("date_label").format(date=new_iso))
+
+ # Reload entries for the newly selected date
+ self._load_versions()
+
# --- Data/UX helpers ---
def _load_versions(self):
# [{id,version_no,created_at,note,is_current}]
diff --git a/bouquin/invoices.py b/bouquin/invoices.py
new file mode 100644
index 0000000..18071d6
--- /dev/null
+++ b/bouquin/invoices.py
@@ -0,0 +1,1445 @@
+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}
+ |
+
+
+
+
+
+
+ | ITEMS AND DESCRIPTION |
+ QTY/HRS |
+ PRICE |
+ AMOUNT ({currency}) |
+
+ {item_rows_html}
+
+
+
+
+
+ |
+ 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 195599f..866f682 100644
--- a/bouquin/key_prompt.py
+++ b/bouquin/key_prompt.py
@@ -4,13 +4,13 @@ from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
- QVBoxLayout,
+ QDialogButtonBox,
+ QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
- QDialogButtonBox,
- QFileDialog,
+ QVBoxLayout,
)
from . import strings
diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json
index b8c56f5..332f13d 100644
--- a/bouquin/locales/en.json
+++ b/bouquin/locales/en.json
@@ -52,7 +52,6 @@
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
- "close": "Close",
"save": "Save",
"help": "Help",
"saved": "Saved",
@@ -202,6 +201,7 @@
"by_week": "by week",
"date_range": "Date range",
"custom_range": "Custom",
+ "last_week": "Last week",
"this_week": "This week",
"this_month": "This month",
"this_year": "This year",
@@ -234,6 +234,8 @@
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
+ "reporting": "Reporting",
+ "reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
@@ -249,10 +251,10 @@
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
- "time_log_date_label": "Time log date: {date}",
- "time_log_change_date": "Change date",
- "time_log_select_date_title": "Select time log date",
- "time_log_for": "Time log for {date}",
+ "date_label": "Date: {date}",
+ "change_date": "Change date",
+ "select_date_title": "Select date",
+ "for": "For {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
@@ -304,7 +306,7 @@
"reminder": "Reminder",
"reminders": "Reminders",
"time": "Time",
- "once_today": "Once (today)",
+ "once": "Once",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
@@ -359,5 +361,54 @@
"documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)",
"todays_documents": "Documents from this day",
- "todays_documents_none": "No documents yet."
+ "todays_documents_none": "No documents yet.",
+ "manage_invoices": "Manage Invoices",
+ "create_invoice": "Create Invoice",
+ "invoice_amount": "Amount",
+ "invoice_apply_tax": "Apply Tax",
+ "invoice_client_address": "Client Address",
+ "invoice_client_company": "Client Company",
+ "invoice_client_email": "Client E-mail",
+ "invoice_client_name": "Client Contact",
+ "invoice_currency": "Currency",
+ "invoice_dialog_title": "Create Invoice",
+ "invoice_due_date": "Due Date",
+ "invoice_hourly_rate": "Hourly Rate",
+ "invoice_hours": "Hours",
+ "invoice_issue_date": "Issue Date",
+ "invoice_mode_detailed": "Detailed mode",
+ "invoice_mode_summary": "Summary mode",
+ "invoice_number": "Invoice Number",
+ "invoice_save_and_export": "Save and export",
+ "invoice_save_pdf_title": "Save PDF",
+ "invoice_subtotal": "Subtotal",
+ "invoice_summary_default_desc": "Consultant services for the month of",
+ "invoice_summary_desc": "Summary description",
+ "invoice_summary_hours": "Summary hours",
+ "invoice_tax": "Tax details",
+ "invoice_tax_label": "Tax type",
+ "invoice_tax_rate": "Tax rate",
+ "invoice_tax_total": "Tax total",
+ "invoice_total": "Total",
+ "invoice_paid_at": "Paid on",
+ "invoice_payment_note": "Payment notes",
+ "invoice_project_required_title": "Project required",
+ "invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
+ "invoice_need_report_title": "Report required",
+ "invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
+ "invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
+ "invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
+ "enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
+ "invoice_company_profile": "Business Profile",
+ "invoice_company_name": "Business Name",
+ "invoice_company_address": "Address",
+ "invoice_company_phone": "Phone",
+ "invoice_company_email": "E-mail",
+ "invoice_company_tax_id": "Tax number",
+ "invoice_company_payment_details": "Payment details",
+ "invoice_company_logo": "Logo",
+ "invoice_company_logo_choose": "Choose logo",
+ "invoice_company_logo_set": "Logo has been set",
+ "invoice_company_logo_not_set": "Logo not set",
+ "invoice_number_unique": "Invoice number must be unique. This invoice number already exists."
}
diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json
index 3ba5ba6..f77ebb1 100644
--- a/bouquin/locales/fr.json
+++ b/bouquin/locales/fr.json
@@ -274,7 +274,7 @@
"weekly": "hebdomadaire",
"edit_reminder": "Modifier le rappel",
"time": "Heure",
- "once_today": "Une fois (aujourd'hui)",
+ "once": "Une fois (aujourd'hui)",
"every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines",
diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py
index 4a1a98e..90c12a8 100644
--- a/bouquin/lock_overlay.py
+++ b/bouquin/lock_overlay.py
@@ -1,7 +1,7 @@
from __future__ import annotations
-from PySide6.QtCore import Qt, QEvent
-from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
+from PySide6.QtCore import QEvent, Qt
+from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from . import strings
from .theme import ThemeManager
diff --git a/bouquin/main.py b/bouquin/main.py
index 958185d..6883755 100644
--- a/bouquin/main.py
+++ b/bouquin/main.py
@@ -2,13 +2,14 @@ from __future__ import annotations
import sys
from pathlib import Path
-from PySide6.QtWidgets import QApplication
-from PySide6.QtGui import QIcon
-from .settings import APP_NAME, APP_ORG, get_settings
-from .main_window import MainWindow
-from .theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtGui import QIcon
+from PySide6.QtWidgets import QApplication
+
from . import strings
+from .main_window import MainWindow
+from .settings import APP_NAME, APP_ORG, get_settings
+from .theme import Theme, ThemeConfig, ThemeManager
def main():
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index aab7bbb..44b9f50 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -2,21 +2,21 @@ from __future__ import annotations
import datetime
import os
-import sys
import re
-
+import sys
from pathlib import Path
+
from PySide6.QtCore import (
QDate,
- QTimer,
- Qt,
- QSettings,
- Slot,
- QUrl,
- QEvent,
- QSignalBlocker,
QDateTime,
+ QEvent,
+ QSettings,
+ QSignalBlocker,
+ Qt,
QTime,
+ QTimer,
+ QUrl,
+ Slot,
)
from PySide6.QtGui import (
QAction,
@@ -31,23 +31,24 @@ from PySide6.QtGui import (
QTextListFormat,
)
from PySide6.QtWidgets import (
+ QApplication,
QCalendarWidget,
QDialog,
QFileDialog,
+ QLabel,
QMainWindow,
QMenu,
QMessageBox,
+ QPushButton,
QSizePolicy,
QSplitter,
QTableView,
QTabWidget,
QVBoxLayout,
QWidget,
- QLabel,
- QPushButton,
- QApplication,
)
+from . import strings
from .bug_report_dialog import BugReportDialog
from .db import DBManager
from .documents import DocumentsDialog, TodaysDocumentsWidget
@@ -60,10 +61,9 @@ from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget
from .save_dialog import SaveDialog
from .search import Search
-from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
+from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog
-from . import strings
from .tags_widget import PageTagsWidget
from .theme import ThemeManager
from .time_log import TimeLogWidget
@@ -117,6 +117,9 @@ class MainWindow(QMainWindow):
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
+ # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
+ self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
+
self.pomodoro_manager = PomodoroManager(self.db, self)
# Lock the calendar to the left panel at the top to stop it stretching
@@ -493,7 +496,7 @@ class MainWindow(QMainWindow):
idx = self._tab_index_for_date(date)
if idx != -1:
self.tab_widget.setCurrentIndex(idx)
- # keep calendar selection in sync (don’t trigger load)
+ # keep calendar selection in sync (don't trigger load)
from PySide6.QtCore import QSignalBlocker
with QSignalBlocker(self.calendar):
@@ -516,7 +519,7 @@ class MainWindow(QMainWindow):
editor = MarkdownEditor(self.themes)
- # Apply user’s preferred font size
+ # Apply user's preferred font size
self._apply_font_size(editor)
# Set up the editor's event connections
@@ -1351,7 +1354,7 @@ class MainWindow(QMainWindow):
else:
date_iso = self._current_date_iso()
- dlg = HistoryDialog(self.db, date_iso, self)
+ dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
if dlg.exec() == QDialog.Accepted:
# refresh editor + calendar (head pointer may have changed)
self._load_selected_date(date_iso)
@@ -1448,6 +1451,7 @@ class MainWindow(QMainWindow):
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
+ self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py
index 4e85f84..831ce9b 100644
--- a/bouquin/markdown_editor.py
+++ b/bouquin/markdown_editor.py
@@ -5,28 +5,28 @@ import re
from pathlib import Path
from typing import Optional, Tuple
+from PySide6.QtCore import QRect, Qt, QTimer, QUrl
from PySide6.QtGui import (
+ QDesktopServices,
QFont,
QFontDatabase,
QFontMetrics,
QImage,
QMouseEvent,
QTextBlock,
+ QTextBlockFormat,
QTextCharFormat,
QTextCursor,
QTextDocument,
QTextFormat,
- QTextBlockFormat,
QTextImageFormat,
- QDesktopServices,
)
-from PySide6.QtCore import Qt, QRect, QTimer, QUrl
from PySide6.QtWidgets import QDialog, QTextEdit
-from .theme import ThemeManager
-from .markdown_highlighter import MarkdownHighlighter
-from .code_block_editor_dialog import CodeBlockEditorDialog
from . import strings
+from .code_block_editor_dialog import CodeBlockEditorDialog
+from .markdown_highlighter import MarkdownHighlighter
+from .theme import ThemeManager
class MarkdownEditor(QTextEdit):
@@ -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 81b08b4..bb308d5 100644
--- a/bouquin/markdown_highlighter.py
+++ b/bouquin/markdown_highlighter.py
@@ -14,7 +14,7 @@ from PySide6.QtGui import (
QTextDocument,
)
-from .theme import ThemeManager, Theme
+from .theme import Theme, ThemeManager
class MarkdownHighlighter(QSyntaxHighlighter):
diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py
index 50d5a69..e66c1f4 100644
--- a/bouquin/pomodoro_timer.py
+++ b/bouquin/pomodoro_timer.py
@@ -6,10 +6,10 @@ from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QFrame,
- QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
+ QVBoxLayout,
QWidget,
)
@@ -133,7 +133,7 @@ class PomodoroManager:
if hasattr(time_log_widget, "show_pomodoro_widget"):
time_log_widget.show_pomodoro_widget(self._active_timer)
else:
- # Fallback – just attach it as a child widget
+ # Fallback - just attach it as a child widget
self._active_timer.setParent(time_log_widget)
self._active_timer.show()
diff --git a/bouquin/reminders.py b/bouquin/reminders.py
index c127a99..9fc096a 100644
--- a/bouquin/reminders.py
+++ b/bouquin/reminders.py
@@ -4,30 +4,30 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
-from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
+from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
from PySide6.QtWidgets import (
- QDialog,
- QVBoxLayout,
- QHBoxLayout,
- QFormLayout,
- QLineEdit,
+ QAbstractItemView,
QComboBox,
- QTimeEdit,
- QPushButton,
+ QDateEdit,
+ QDialog,
+ QFormLayout,
QFrame,
- QWidget,
- QToolButton,
+ QHBoxLayout,
+ QHeaderView,
+ QLineEdit,
QListWidget,
QListWidgetItem,
- QStyle,
- QSizePolicy,
QMessageBox,
+ QPushButton,
+ QSizePolicy,
+ QSpinBox,
+ QStyle,
QTableWidget,
QTableWidgetItem,
- QAbstractItemView,
- QHeaderView,
- QSpinBox,
- QDateEdit,
+ QTimeEdit,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
)
from . import strings
@@ -107,7 +107,7 @@ class ReminderDialog(QDialog):
# Recurrence type
self.type_combo = QComboBox()
- self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
+ self.type_combo.addItem(strings._("once"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
@@ -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.QtWidgets import QMenu
from PySide6.QtGui import QAction
+ from PySide6.QtWidgets import QMenu
menu = QMenu(self)
diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py
index 6b4e05d..528896b 100644
--- a/bouquin/save_dialog.py
+++ b/bouquin/save_dialog.py
@@ -3,13 +3,7 @@ from __future__ import annotations
import datetime
from PySide6.QtGui import QFontMetrics
-from PySide6.QtWidgets import (
- QDialog,
- QVBoxLayout,
- QLabel,
- QLineEdit,
- QDialogButtonBox,
-)
+from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
from . import strings
diff --git a/bouquin/search.py b/bouquin/search.py
index b2a885b..7dd7f7f 100644
--- a/bouquin/search.py
+++ b/bouquin/search.py
@@ -6,12 +6,12 @@ from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
+ QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QSizePolicy,
- QHBoxLayout,
QVBoxLayout,
QWidget,
)
diff --git a/bouquin/settings.py b/bouquin/settings.py
index cfd8939..5a14c07 100644
--- a/bouquin/settings.py
+++ b/bouquin/settings.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
+
from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig
@@ -45,6 +46,7 @@ def load_db_config() -> DBConfig:
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
documents = s.value("ui/documents", True, type=bool)
+ invoicing = s.value("ui/invoicing", True, type=bool)
locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int)
return DBConfig(
@@ -57,6 +59,7 @@ def load_db_config() -> DBConfig:
time_log=time_log,
reminders=reminders,
documents=documents,
+ invoicing=invoicing,
locale=locale,
font_size=font_size,
)
@@ -73,5 +76,6 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/documents", str(cfg.documents))
+ s.setValue("ui/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size))
diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py
index 68599ca..6ce6255 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -2,34 +2,36 @@ from __future__ import annotations
from pathlib import Path
+from PySide6.QtCore import Qt, Slot
+from PySide6.QtGui import QPalette
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
+ QDialogButtonBox,
+ QFileDialog,
+ QFormLayout,
QFrame,
QGroupBox,
- QLabel,
QHBoxLayout,
- QVBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
QPushButton,
- QDialogButtonBox,
QRadioButton,
QSizePolicy,
QSpinBox,
- QMessageBox,
- QWidget,
QTabWidget,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
)
-from PySide6.QtCore import Qt, Slot
-from PySide6.QtGui import QPalette
-
-
-from .db import DBConfig, DBManager
-from .settings import load_db_config, save_db_config
-from .theme import Theme
-from .key_prompt import KeyPrompt
from . import strings
+from .db import DBConfig, DBManager
+from .key_prompt import KeyPrompt
+from .settings import load_db_config, save_db_config
+from .theme import Theme
class SettingsDialog(QDialog):
@@ -176,6 +178,17 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log)
+ self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
+ invoicing_enabled = getattr(self.current_settings, "invoicing", False)
+ self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
+ self.invoicing.setCursor(Qt.PointingHandCursor)
+ features_layout.addWidget(self.invoicing)
+ # Invoicing only if time_log is enabled
+ if not self.current_settings.time_log:
+ self.invoicing.setChecked(False)
+ self.invoicing.setEnabled(False)
+ self.time_log.toggled.connect(self._on_time_log_toggled)
+
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders)
self.reminders.setCursor(Qt.PointingHandCursor)
@@ -187,6 +200,68 @@ class SettingsDialog(QDialog):
features_layout.addWidget(self.documents)
layout.addWidget(features_group)
+
+ # --- Invoicing / company profile section -------------------------
+ self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
+ invoicing_layout = QFormLayout(self.invoicing_group)
+
+ profile = self._db.get_company_profile() or (
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ name, address, phone, email, tax_id, payment_details, logo_bytes = profile
+
+ self.company_name_edit = QLineEdit(name or "")
+ self.company_address_edit = QTextEdit(address or "")
+ self.company_phone_edit = QLineEdit(phone or "")
+ self.company_email_edit = QLineEdit(email or "")
+ self.company_tax_id_edit = QLineEdit(tax_id or "")
+ self.company_payment_details_edit = QTextEdit()
+ self.company_payment_details_edit.setPlainText(payment_details or "")
+
+ invoicing_layout.addRow(
+ strings._("invoice_company_name") + ":", self.company_name_edit
+ )
+ invoicing_layout.addRow(
+ strings._("invoice_company_address") + ":", self.company_address_edit
+ )
+ invoicing_layout.addRow(
+ strings._("invoice_company_phone") + ":", self.company_phone_edit
+ )
+ invoicing_layout.addRow(
+ strings._("invoice_company_email") + ":", self.company_email_edit
+ )
+ invoicing_layout.addRow(
+ strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
+ )
+ invoicing_layout.addRow(
+ strings._("invoice_company_payment_details") + ":",
+ self.company_payment_details_edit,
+ )
+
+ # Logo picker - store bytes on self._logo_bytes
+ self._logo_bytes = logo_bytes
+ logo_row = QHBoxLayout()
+ self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
+ if logo_bytes:
+ self.logo_label.setText(strings._("invoice_company_logo_set"))
+ logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
+ logo_btn.clicked.connect(self._on_choose_logo)
+ logo_row.addWidget(self.logo_label)
+ logo_row.addWidget(logo_btn)
+ invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
+
+ # Show/hide this whole block based on invoicing checkbox
+ self.invoicing_group.setVisible(self.invoicing.isChecked())
+ self.invoicing.toggled.connect(self.invoicing_group.setVisible)
+
+ layout.addWidget(self.invoicing_group)
+
layout.addStretch()
return page
@@ -314,14 +389,60 @@ class SettingsDialog(QDialog):
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
documents=self.documents.isChecked(),
+ invoicing=(
+ self.invoicing.isChecked() if self.time_log.isChecked() else False
+ ),
locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(),
)
save_db_config(self._cfg)
+
+ # Save company profile only if invoicing is enabled
+ if self.invoicing.isChecked() and self.time_log.isChecked():
+ self._db.save_company_profile(
+ name=self.company_name_edit.text().strip() or None,
+ address=self.company_address_edit.toPlainText().strip() or None,
+ phone=self.company_phone_edit.text().strip() or None,
+ email=self.company_email_edit.text().strip() or None,
+ tax_id=self.company_tax_id_edit.text().strip() or None,
+ payment_details=self.company_payment_details_edit.toPlainText().strip()
+ or None,
+ logo=getattr(self, "_logo_bytes", None),
+ )
+
self.parent().themes.set(selected_theme)
self.accept()
+ def _on_time_log_toggled(self, checked: bool) -> None:
+ """
+ Enforce 'invoicing depends on time logging'.
+ """
+ if not checked:
+ # Turn off + disable invoicing if time logging is disabled
+ self.invoicing.setChecked(False)
+ self.invoicing.setEnabled(False)
+ else:
+ # Let the user enable invoicing when time logging is enabled
+ self.invoicing.setEnabled(True)
+
+ def _on_choose_logo(self) -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ strings._("company_logo_choose"),
+ "",
+ "Images (*.png *.jpg *.jpeg *.bmp)",
+ )
+ if not path:
+ return
+
+ try:
+ with open(path, "rb") as f:
+ self._logo_bytes = f.read()
+ self.logo_label.setText(Path(path).name)
+ except OSError as exc:
+ QMessageBox.warning(self, strings._("error"), str(exc))
+
def _change_key(self):
p1 = KeyPrompt(
self,
diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py
index f71c447..77b83f6 100644
--- a/bouquin/statistics_dialog.py
+++ b/bouquin/statistics_dialog.py
@@ -3,26 +3,25 @@ from __future__ import annotations
import datetime as _dt
from typing import Dict
-from PySide6.QtCore import Qt, QSize, Signal
-from PySide6.QtGui import QColor, QPainter, QPen, QBrush
+from PySide6.QtCore import QSize, Qt, Signal
+from PySide6.QtGui import QBrush, QColor, QPainter, QPen
from PySide6.QtWidgets import (
+ QComboBox,
QDialog,
- QVBoxLayout,
QFormLayout,
- QLabel,
QGroupBox,
QHBoxLayout,
- QComboBox,
+ QLabel,
QScrollArea,
- QWidget,
QSizePolicy,
+ QVBoxLayout,
+ QWidget,
)
from . import strings
from .db import DBManager
from .settings import load_db_config
-
# ---------- Activity heatmap ----------
@@ -216,7 +215,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 eff0e18..71e838b 100644
--- a/bouquin/strings.py
+++ b/bouquin/strings.py
@@ -1,5 +1,5 @@
-from importlib.resources import files
import json
+from importlib.resources import files
# Get list of locales
root = files("bouquin") / "locales"
diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py
index 1e7cb01..210f7d3 100644
--- a/bouquin/tag_browser.py
+++ b/bouquin/tag_browser.py
@@ -1,22 +1,22 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
+ QColorDialog,
QDialog,
- QVBoxLayout,
QHBoxLayout,
+ QInputDialog,
+ QLabel,
+ QMessageBox,
+ QPushButton,
QTreeWidget,
QTreeWidgetItem,
- QPushButton,
- QLabel,
- QColorDialog,
- QMessageBox,
- QInputDialog,
+ QVBoxLayout,
)
+from sqlcipher3.dbapi2 import IntegrityError
+from . import strings
from .db import DBManager
from .settings import load_db_config
-from . import strings
-from sqlcipher3.dbapi2 import IntegrityError
class TagBrowserDialog(QDialog):
diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py
index 423bd06..7ac4ad4 100644
--- a/bouquin/tags_widget.py
+++ b/bouquin/tags_widget.py
@@ -4,16 +4,16 @@ from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
+ QCompleter,
QFrame,
QHBoxLayout,
- QVBoxLayout,
- QWidget,
- QToolButton,
QLabel,
QLineEdit,
QSizePolicy,
QStyle,
- QCompleter,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
)
from . import strings
diff --git a/bouquin/theme.py b/bouquin/theme.py
index 0f36d93..87b77f9 100644
--- a/bouquin/theme.py
+++ b/bouquin/theme.py
@@ -1,11 +1,13 @@
from __future__ import annotations
+
from dataclasses import dataclass
from enum import Enum
-from PySide6.QtGui import QPalette, QColor, QGuiApplication, QTextCharFormat
-from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
-from PySide6.QtCore import QObject, Signal, Qt
from weakref import WeakSet
+from PySide6.QtCore import QObject, Qt, Signal
+from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat
+from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
+
class Theme(Enum):
SYSTEM = "system"
diff --git a/bouquin/time_log.py b/bouquin/time_log.py
index e5e9b64..1adf3c3 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 Qt, QDate, QUrl
-from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
+from PySide6.QtCore import QDate, Qt, QUrl, Signal
+from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument
from PySide6.QtPrintSupport import QPrinter
from PySide6.QtWidgets import (
+ QAbstractItemView,
QCalendarWidget,
+ QComboBox,
+ QCompleter,
+ QDateEdit,
QDialog,
QDialogButtonBox,
- QFrame,
- QVBoxLayout,
- QHBoxLayout,
- QWidget,
+ QDoubleSpinBox,
QFileDialog,
QFormLayout,
- QLabel,
- QComboBox,
- QLineEdit,
- QDoubleSpinBox,
- QPushButton,
- QTableWidget,
- QTableWidgetItem,
- QAbstractItemView,
+ QFrame,
+ QHBoxLayout,
QHeaderView,
- QTabWidget,
+ QInputDialog,
+ QLabel,
+ QLineEdit,
QListWidget,
QListWidgetItem,
- QDateEdit,
QMessageBox,
- QCompleter,
- QToolButton,
+ QPushButton,
QSizePolicy,
QStyle,
- QInputDialog,
+ QTableWidget,
+ QTableWidgetItem,
+ QTabWidget,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
)
+from sqlcipher3.dbapi2 import IntegrityError
-from .db import DBManager
-from .theme import ThemeManager
from . import strings
+from .db import DBManager
+from .settings import load_db_config
+from .theme import ThemeManager
class TimeLogWidget(QFrame):
@@ -53,6 +53,8 @@ class TimeLogWidget(QFrame):
Shown in the left sidebar above the Tags widget.
"""
+ remindersChanged = Signal()
+
def __init__(
self,
db: DBManager,
@@ -61,6 +63,7 @@ class TimeLogWidget(QFrame):
):
super().__init__(parent)
self._db = db
+ self.cfg = load_db_config()
self._themes = themes
self._current_date: Optional[str] = None
@@ -82,6 +85,15 @@ class TimeLogWidget(QFrame):
self.log_btn.setAutoRaise(True)
self.log_btn.clicked.connect(self._open_dialog_log_only)
+ self.report_btn = QToolButton()
+ self.report_btn.setText("📈")
+ self.report_btn.setAutoRaise(True)
+ self.report_btn.clicked.connect(self._on_run_report)
+ if self.cfg.invoicing:
+ self.report_btn.setToolTip(strings._("reporting_and_invoicing"))
+ else:
+ self.report_btn.setToolTip(strings._("reporting"))
+
self.open_btn = QToolButton()
self.open_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
@@ -95,6 +107,7 @@ class TimeLogWidget(QFrame):
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.log_btn)
+ header.addWidget(self.report_btn)
header.addWidget(self.open_btn)
# Body: simple summary label for the day
@@ -149,6 +162,14 @@ class TimeLogWidget(QFrame):
# ----- internals ---------------------------------------------------
+ def _on_run_report(self) -> None:
+ dlg = TimeReportDialog(self._db, self)
+
+ # Bubble the remindersChanged signal further up
+ dlg.remindersChanged.connect(self.remindersChanged.emit)
+
+ dlg.exec()
+
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
@@ -247,7 +268,8 @@ class TimeLogDialog(QDialog):
self._themes = themes
self._date_iso = date_iso
self._current_entry_id: Optional[int] = None
- # Guard flag used when repopulating the table so we don’t treat
+ self.cfg = load_db_config()
+ # Guard flag used when repopulating the table so we don't treat
# programmatic item changes as user edits.
self._reloading_entries: bool = False
@@ -255,7 +277,7 @@ class TimeLogDialog(QDialog):
self.close_after_add = close_after_add
- self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
+ self.setWindowTitle(strings._("for").format(date=date_iso))
self.resize(900, 600)
root = QVBoxLayout(self)
@@ -263,12 +285,12 @@ class TimeLogDialog(QDialog):
# --- Top: date label + change-date button
date_row = QHBoxLayout()
- self.date_label = QLabel(strings._("time_log_date_label").format(date=date_iso))
+ self.date_label = QLabel(strings._("date_label").format(date=date_iso))
date_row.addWidget(self.date_label)
date_row.addStretch(1)
- self.change_date_btn = QPushButton(strings._("time_log_change_date"))
+ self.change_date_btn = QPushButton(strings._("change_date"))
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
date_row.addWidget(self.change_date_btn)
@@ -320,13 +342,9 @@ class TimeLogDialog(QDialog):
self.delete_btn.clicked.connect(self._on_delete_entry)
self.delete_btn.setEnabled(False)
- self.report_btn = QPushButton("&" + strings._("run_report"))
- self.report_btn.clicked.connect(self._on_run_report)
-
btn_row.addStretch(1)
btn_row.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn)
- btn_row.addWidget(self.report_btn)
root.addLayout(btn_row)
# --- Table of entries for this date
@@ -355,12 +373,19 @@ class TimeLogDialog(QDialog):
self.table.itemChanged.connect(self._on_table_item_changed)
root.addWidget(self.table, 1)
- # --- Total time and Close button
+ # --- Total time, Reporting and Close button
close_row = QHBoxLayout()
self.total_label = QLabel(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
+ if self.cfg.invoicing:
+ self.report_btn = QPushButton("&" + strings._("reporting_and_invoicing"))
+ else:
+ self.report_btn = QPushButton("&" + strings._("reporting"))
+ self.report_btn.clicked.connect(self._on_run_report)
+
close_row.addWidget(self.total_label)
+ close_row.addWidget(self.report_btn)
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
@@ -452,7 +477,7 @@ class TimeLogDialog(QDialog):
current_qdate = QDate.currentDate()
dlg = QDialog(self)
- dlg.setWindowTitle(strings._("time_log_select_date_title"))
+ dlg.setWindowTitle(strings._("select_date_title"))
layout = QVBoxLayout(dlg)
@@ -483,8 +508,8 @@ class TimeLogDialog(QDialog):
self._date_iso = new_iso
# Update window title and header label
- self.setWindowTitle(strings._("time_log_for").format(date=new_iso))
- self.date_label.setText(strings._("time_log_date_label").format(date=new_iso))
+ self.setWindowTitle(strings._("for").format(date=new_iso))
+ self.date_label.setText(strings._("date_label").format(date=new_iso))
# Reload entries for the newly selected date
self._reload_entries()
@@ -594,7 +619,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
@@ -803,7 +828,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"),
@@ -981,9 +1006,12 @@ class TimeReportDialog(QDialog):
Shows decimal hours per time period.
"""
+ remindersChanged = Signal()
+
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
+ self.cfg = load_db_config()
# state for last run
self._last_rows: list[tuple[str, str, str, str, int]] = []
@@ -992,6 +1020,7 @@ class TimeReportDialog(QDialog):
self._last_start: str = ""
self._last_end: str = ""
self._last_gran_label: str = ""
+ self._last_time_logs: list = []
self.setWindowTitle(strings._("time_log_report"))
self.resize(600, 400)
@@ -999,9 +1028,20 @@ class TimeReportDialog(QDialog):
root = QVBoxLayout(self)
form = QFormLayout()
+
+ self.invoice_btn = QPushButton(strings._("create_invoice"))
+ self.invoice_btn.clicked.connect(self._on_create_invoice)
+
+ self.manage_invoices_btn = QPushButton(strings._("manage_invoices"))
+ self.manage_invoices_btn.clicked.connect(self._on_manage_invoices)
+
# Project
self.project_combo = QComboBox()
self.project_combo.addItem(strings._("all_projects"), None)
+ self.project_combo.currentIndexChanged.connect(
+ self._update_invoice_button_state
+ )
+ self._update_invoice_button_state()
for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id)
form.addRow(strings._("project"), self.project_combo)
@@ -1013,6 +1053,7 @@ class TimeReportDialog(QDialog):
self.range_preset = QComboBox()
self.range_preset.addItem(strings._("custom_range"), "custom")
self.range_preset.addItem(strings._("today"), "today")
+ self.range_preset.addItem(strings._("last_week"), "last_week")
self.range_preset.addItem(strings._("this_week"), "this_week")
self.range_preset.addItem(strings._("this_month"), "this_month")
self.range_preset.addItem(strings._("this_year"), "this_year")
@@ -1061,6 +1102,10 @@ class TimeReportDialog(QDialog):
run_row.addWidget(run_btn)
run_row.addWidget(export_btn)
run_row.addWidget(pdf_btn)
+ # Only show invoicing if the feature is enabled
+ if getattr(self._db.cfg, "invoicing", False):
+ run_row.addWidget(self.invoice_btn)
+ run_row.addWidget(self.manage_invoices_btn)
root.addLayout(run_row)
# Table
@@ -1146,6 +1191,14 @@ class TimeReportDialog(QDialog):
start = today.addDays(1 - today.dayOfWeek())
end = today
+ elif preset == "last_week":
+ # Compute Monday-Sunday of the previous week (Monday-based weeks)
+ # 1. Monday of this week:
+ start_of_this_week = today.addDays(1 - today.dayOfWeek())
+ # 2. Last week is 7 days before that:
+ start = start_of_this_week.addDays(-7) # last week's Monday
+ end = start_of_this_week.addDays(-1) # last week's Sunday
+
elif preset == "this_month":
start = QDate(today.year(), today.month(), 1)
end = today
@@ -1154,7 +1207,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
@@ -1187,11 +1240,13 @@ class TimeReportDialog(QDialog):
if proj_data is None:
# All projects
self._last_all_projects = True
+ self._last_time_logs = []
self._last_project_name = strings._("all_projects")
rows_for_table = self._db.time_report_all(start, end, gran)
else:
self._last_all_projects = False
proj_id = int(proj_data)
+ self._last_time_logs = self._db.time_logs_for_range(proj_id, start, end)
project_name = self.project_combo.currentText()
self._last_project_name = project_name
@@ -1228,7 +1283,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 = [
@@ -1525,3 +1580,55 @@ class TimeReportDialog(QDialog):
strings._("export_pdf_error_title"),
strings._("export_pdf_error_message").format(error=str(exc)),
)
+
+ def _update_invoice_button_state(self) -> None:
+ data = self.project_combo.currentData()
+ if data is not None:
+ self.invoice_btn.show()
+ else:
+ self.invoice_btn.hide()
+
+ def _on_manage_invoices(self) -> None:
+ from .invoices import InvoicesDialog
+
+ dlg = InvoicesDialog(self._db, parent=self)
+
+ # When the dialog says "reminders changed", forward that outward
+ dlg.remindersChanged.connect(self.remindersChanged.emit)
+
+ dlg.exec()
+
+ def _on_create_invoice(self) -> None:
+ idx = self.project_combo.currentIndex()
+ if idx < 0:
+ return
+
+ project_id_data = self.project_combo.itemData(idx)
+ if project_id_data is None:
+ # Currently invoices are per-project, not cross-project
+ QMessageBox.information(
+ self,
+ strings._("invoice_project_required_title"),
+ strings._("invoice_project_required_message"),
+ )
+ return
+
+ proj_id = int(project_id_data)
+
+ # Ensure we have a recent run to base this on
+ if not self._last_time_logs:
+ QMessageBox.information(
+ self,
+ strings._("invoice_need_report_title"),
+ strings._("invoice_need_report_message"),
+ )
+ return
+
+ start = self.from_date.date().toString("yyyy-MM-dd")
+ end = self.to_date.date().toString("yyyy-MM-dd")
+
+ from .invoices import InvoiceDialog
+
+ dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self)
+ dlg.remindersChanged.connect(self.remindersChanged.emit)
+ dlg.exec()
diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py
index 8090fe7..92383e6 100644
--- a/bouquin/toolbar.py
+++ b/bouquin/toolbar.py
@@ -1,7 +1,7 @@
from __future__ import annotations
-from PySide6.QtCore import Signal, Qt
-from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence
from PySide6.QtWidgets import QToolBar
from . import strings
diff --git a/bouquin/version_check.py b/bouquin/version_check.py
index b2010d5..5b62d02 100644
--- a/bouquin/version_check.py
+++ b/bouquin/version_check.py
@@ -5,23 +5,17 @@ import os
import re
import subprocess # nosec
import tempfile
+from importlib.resources import files
from pathlib import Path
import requests
-from importlib.resources import files
from PySide6.QtCore import QStandardPaths, Qt
-from PySide6.QtWidgets import (
- QApplication,
- QMessageBox,
- QWidget,
- QProgressDialog,
-)
-from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
+from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
from PySide6.QtSvg import QSvgRenderer
+from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
-from .settings import APP_NAME
from . import strings
-
+from .settings import APP_NAME
# Where to fetch the latest version string from
VERSION_URL = "https://mig5.net/bouquin/version.txt"
diff --git a/poetry.lock b/poetry.lock
index 49d843f..addf793 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -146,103 +146,103 @@ files = [
[[package]]
name = "coverage"
-version = "7.12.0"
+version = "7.13.0"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
files = [
- {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
- {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
- {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
- {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
- {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
- {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
- {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
- {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
- {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
- {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
- {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
- {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
- {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
- {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
- {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
- {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
- {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
- {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
- {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
- {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
- {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
- {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
- {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
- {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
- {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
- {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
- {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
- {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
- {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
- {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
- {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
- {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
- {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
- {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
- {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
- {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
- {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
- {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
- {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
- {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
- {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
- {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
- {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
- {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
- {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
- {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
- {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
- {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
- {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
- {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
- {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
- {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
- {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
- {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
- {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
- {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
- {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
- {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
- {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
- {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
- {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
- {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
- {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
- {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
- {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
- {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
- {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
- {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
- {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
- {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
- {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
- {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
- {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
- {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
- {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
- {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
- {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
- {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
- {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
- {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
- {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
- {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
- {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
- {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
- {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
- {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
- {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
- {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
- {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
- {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
- {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
- {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
+ {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
+ {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
+ {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
+ {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
+ {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
+ {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
+ {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
+ {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
+ {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
+ {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
+ {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
+ {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
+ {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
+ {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
+ {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
+ {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
+ {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
+ {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
+ {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
+ {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
+ {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
+ {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
+ {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
+ {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
+ {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
+ {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
+ {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
+ {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
+ {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
+ {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
+ {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
+ {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
+ {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
+ {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
+ {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
+ {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
+ {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
+ {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
+ {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
+ {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
+ {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
+ {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
+ {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
+ {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
+ {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
+ {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
+ {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
+ {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
+ {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
+ {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
+ {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
+ {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
+ {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
+ {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
+ {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
+ {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
+ {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
+ {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
+ {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
+ {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
+ {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
+ {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
+ {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
+ {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
+ {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
+ {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
+ {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
+ {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
+ {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
+ {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
+ {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
+ {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
+ {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
+ {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
+ {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
+ {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
+ {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
+ {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
+ {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
+ {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
+ {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
+ {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
+ {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
+ {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
+ {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
+ {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
+ {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
+ {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
+ {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
+ {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
+ {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
+ {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
]
[package.dependencies]
@@ -747,20 +747,20 @@ files = [
[[package]]
name = "urllib3"
-version = "2.5.0"
+version = "2.6.1"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
- {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
- {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
+ {file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
+ {file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
]
[package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
-zstd = ["zstandard (>=0.18.0)"]
+zstd = ["backports-zstd (>=1.0.0)"]
[metadata]
lock-version = "2.0"
diff --git a/pyproject.toml b/pyproject.toml
index 8f8cfd1..b26e6bb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
-version = "0.6.4"
+version = "0.7.0"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq "]
readme = "README.md"
diff --git a/release.sh b/release.sh
index 5970bb3..9f8b3c8 100755
--- a/release.sh
+++ b/release.sh
@@ -3,7 +3,7 @@
set -eo pipefail
# Clean caches etc
-/home/user/venv-guardutils/bin/filedust -y .
+filedust -y .
# Publish to Pypi
poetry build
diff --git a/tests/conftest.py b/tests/conftest.py
index 878ccc7..4058d77 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -106,7 +106,7 @@ def freeze_qt_time(monkeypatch):
QTime.currentTime().addSecs(3600) is still the same calendar day.
"""
import bouquin.main_window as _mwmod
- from PySide6.QtCore import QDate, QTime, QDateTime
+ from PySide6.QtCore import QDate, QDateTime, QTime
today = QDate.currentDate()
fixed_time = QTime(12, 0)
diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py
index 8d773e9..df839fd 100644
--- a/tests/test_bug_report_dialog.py
+++ b/tests/test_bug_report_dialog.py
@@ -1,8 +1,8 @@
import bouquin.bug_report_dialog as bugmod
-from bouquin.bug_report_dialog import BugReportDialog
from bouquin import strings
-from PySide6.QtWidgets import QMessageBox
+from bouquin.bug_report_dialog import BugReportDialog
from PySide6.QtGui import QTextCursor
+from PySide6.QtWidgets import QMessageBox
def test_bug_report_truncates_text_to_max_chars(qtbot):
diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py
index 9a59aa8..e64199b 100644
--- a/tests/test_code_block_editor_dialog.py
+++ b/tests/test_code_block_editor_dialog.py
@@ -1,13 +1,11 @@
-from PySide6.QtWidgets import QPushButton
from bouquin import strings
-
-from PySide6.QtCore import QRect, QSize
-from PySide6.QtGui import QPaintEvent, QFont
-
from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog,
CodeEditorWithLineNumbers,
)
+from PySide6.QtCore import QRect, QSize
+from PySide6.QtGui import QFont, QPaintEvent
+from PySide6.QtWidgets import QPushButton
def _find_button_by_text(widget, text):
@@ -159,7 +157,7 @@ def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
rect = QRect(0, 0, line_area.width(), line_area.height())
paint_event = QPaintEvent(rect)
- # This should exercise the painting loop (lines 87-130)
+ # This should exercise the painting loop
editor.line_number_area_paint_event(paint_event)
# Should not crash
diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py
index 145e156..57ab8e7 100644
--- a/tests/test_code_highlighter.py
+++ b/tests/test_code_highlighter.py
@@ -1,5 +1,5 @@
-from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
-from PySide6.QtGui import QTextCharFormat, QFont
+from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter
+from PySide6.QtGui import QFont, QTextCharFormat
def test_get_language_patterns_python(app):
diff --git a/tests/test_db.py b/tests/test_db.py
index 19a4d6e..12585f7 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -1,10 +1,12 @@
-import pytest
-import json, csv
+import csv
import datetime as dt
-from sqlcipher3 import dbapi2 as sqlite
-from bouquin.db import DBManager
+import json
from datetime import date, timedelta
+import pytest
+from bouquin.db import DBManager
+from sqlcipher3 import dbapi2 as sqlite
+
def _today():
return dt.date.today().isoformat()
diff --git a/tests/test_document_utils.py b/tests/test_document_utils.py
index 6e91ba2..e1301df 100644
--- a/tests/test_document_utils.py
+++ b/tests/test_document_utils.py
@@ -1,10 +1,10 @@
-from unittest.mock import patch
-from pathlib import Path
import tempfile
+from pathlib import Path
+from unittest.mock import patch
from PySide6.QtCore import QUrl
-from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QDesktopServices
+from PySide6.QtWidgets import QMessageBox, QWidget
def test_open_document_from_db_success(qtbot, app, fresh_db):
diff --git a/tests/test_documents.py b/tests/test_documents.py
index 8be5b83..0740b40 100644
--- a/tests/test_documents.py
+++ b/tests/test_documents.py
@@ -1,13 +1,12 @@
-from unittest.mock import patch, MagicMock
-from pathlib import Path
import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
from bouquin.db import DBConfig
-from bouquin.documents import TodaysDocumentsWidget, DocumentsDialog
+from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget
from PySide6.QtCore import Qt, QUrl
-from PySide6.QtWidgets import QMessageBox, QDialog, QFileDialog
from PySide6.QtGui import QDesktopServices
-
+from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
# =============================================================================
# TodaysDocumentsWidget Tests
diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py
index c0ab938..de67c7e 100644
--- a/tests/test_find_bar.py
+++ b/tests/test_find_bar.py
@@ -1,10 +1,9 @@
import pytest
-
+from bouquin.find_bar import FindBar
+from bouquin.markdown_editor import MarkdownEditor
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget
-from bouquin.markdown_editor import MarkdownEditor
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
-from bouquin.find_bar import FindBar
@pytest.fixture
diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py
index da97a5a..98ab9c8 100644
--- a/tests/test_history_dialog.py
+++ b/tests/test_history_dialog.py
@@ -1,7 +1,6 @@
-from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
-from PySide6.QtCore import Qt, QTimer
-
from bouquin.history_dialog import HistoryDialog
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
new file mode 100644
index 0000000..89ef202
--- /dev/null
+++ b/tests/test_invoices.py
@@ -0,0 +1,1346 @@
+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 f044fac..9aedffb 100644
--- a/tests/test_key_prompt.py
+++ b/tests/test_key_prompt.py
@@ -1,5 +1,4 @@
from bouquin.key_prompt import KeyPrompt
-
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFileDialog, QLineEdit
@@ -97,7 +96,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
- """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
+ """Test KeyPrompt with show_db_change but no initial_db_path"""
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
qtbot.addWidget(prompt)
@@ -168,7 +167,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path):
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
- """Test browsing when initial_db_path is set - covers line 57 with non-None path"""
+ """Test browsing when initial_db_path is set"""
initial_db = tmp_path / "initial.db"
initial_db.touch()
@@ -180,7 +179,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
# Mock the file dialog to return a different file
def mock_get_open_filename(*args, **kwargs):
- # Verify that start_dir was passed correctly (line 57)
+ # Verify that start_dir was passed correctly
return str(new_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py
index 05de5f9..46b3cfd 100644
--- a/tests/test_lock_overlay.py
+++ b/tests/test_lock_overlay.py
@@ -1,7 +1,7 @@
+from bouquin.lock_overlay import LockOverlay
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
-from bouquin.lock_overlay import LockOverlay
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_lock_overlay_reacts_to_theme(app, qtbot):
diff --git a/tests/test_main.py b/tests/test_main.py
index 2a357fb..5bfb774 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,5 +1,6 @@
import importlib
import runpy
+
import pytest
diff --git a/tests/test_main_window.py b/tests/test_main_window.py
index 2cf787d..6c09e71 100644
--- a/tests/test_main_window.py
+++ b/tests/test_main_window.py
@@ -1,22 +1,19 @@
-import pytest
import importlib.metadata
-
from datetime import date, timedelta
from pathlib import Path
-
-import bouquin.main_window as mwmod
-from bouquin.main_window import MainWindow
-from bouquin.theme import Theme, ThemeConfig, ThemeManager
-from bouquin.settings import get_settings
-from bouquin.key_prompt import KeyPrompt
-from bouquin.db import DBConfig, DBManager
-from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
-from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
-from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
-
from unittest.mock import Mock, patch
+import bouquin.main_window as mwmod
import bouquin.version_check as version_check
+import pytest
+from bouquin.db import DBConfig, DBManager
+from bouquin.key_prompt import KeyPrompt
+from bouquin.main_window import MainWindow
+from bouquin.settings import get_settings
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
+from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
+from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py
index a4025ea..73f58f4 100644
--- a/tests/test_markdown_editor.py
+++ b/tests/test_markdown_editor.py
@@ -1,21 +1,20 @@
import base64
+
import pytest
-
-from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl
-from PySide6.QtGui import (
- QImage,
- QColor,
- QKeyEvent,
- QTextCursor,
- QTextDocument,
- QFont,
- QTextCharFormat,
-)
-from PySide6.QtWidgets import QApplication, QTextEdit
-
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl
+from PySide6.QtGui import (
+ QColor,
+ QFont,
+ QImage,
+ QKeyEvent,
+ QTextCharFormat,
+ QTextCursor,
+ QTextDocument,
+)
+from PySide6.QtWidgets import QApplication, QTextEdit
def _today():
@@ -1928,7 +1927,7 @@ def test_editor_delete_operations(qtbot, app):
def test_markdown_highlighter_dark_theme(qtbot, app):
- """Test markdown highlighter with dark theme - covers lines 74-75"""
+ """Test markdown highlighter with dark theme"""
# Create theme manager with dark theme
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
@@ -2293,7 +2292,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
# Force rehighlight
editor.highlighter.rehighlight()
- # Verify syntax highlighting was applied (lines 186-193)
+ # Verify syntax highlighting was applied
# We can't easily verify the exact formatting, but we ensure no crash
@@ -2305,13 +2304,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
# Force rehighlight
editor.highlighter.rehighlight()
- # The overlap detection (lines 252, 264) should prevent issues
-
def test_highlighter_italic_edge_cases(editor, qtbot):
"""Test italic formatting edge cases."""
# Test edge case: avoiding stealing markers that are part of double
- # This tests lines 267-270
editor.setPlainText("**not italic* text**")
# Force rehighlight
diff --git a/tests/test_markdown_editor_additional.py b/tests/test_markdown_editor_additional.py
index 070d954..4037ed1 100644
--- a/tests/test_markdown_editor_additional.py
+++ b/tests/test_markdown_editor_additional.py
@@ -4,19 +4,18 @@ These tests should be added to test_markdown_editor.py.
"""
import pytest
-from PySide6.QtCore import Qt, QPoint
+from bouquin.markdown_editor import MarkdownEditor
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import (
- QImage,
QColor,
+ QImage,
QKeyEvent,
+ QMouseEvent,
QTextCursor,
QTextDocument,
- QMouseEvent,
)
-from bouquin.markdown_editor import MarkdownEditor
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
-
def text(editor) -> str:
return editor.toPlainText()
@@ -44,7 +43,6 @@ def editor(app, qtbot):
return ed
-# Test for line 215: document is None guard
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
"""Test _update_code_block_row_backgrounds when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@@ -60,7 +58,6 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot):
editor._update_code_block_row_backgrounds()
-# Test for lines 295, 309, 313-319, 324, 326, 334: _find_code_block_bounds edge cases
def test_find_code_block_bounds_invalid_block(editor):
"""Test _find_code_block_bounds with invalid block."""
editor.setPlainText("some text")
@@ -124,7 +121,6 @@ def test_find_code_block_bounds_no_opening_fence(editor):
assert result is None
-# Test for lines 356, 413, 417-418, 428-434: code block editing edge cases
def test_edit_code_block_checks_document(app, qtbot):
"""Test _edit_code_block when editor has no document."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@@ -148,8 +144,8 @@ def test_edit_code_block_checks_document(app, qtbot):
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
"""Test _edit_code_block when dialog is cancelled."""
- from PySide6.QtWidgets import QDialog
import bouquin.markdown_editor as markdown_editor
+ from PySide6.QtWidgets import QDialog
class CancelledDialog:
def __init__(self, code, language, parent=None, allow_delete=False):
@@ -178,8 +174,8 @@ def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
"""Test _edit_code_block when user deletes the block."""
- from PySide6.QtWidgets import QDialog
import bouquin.markdown_editor as markdown_editor
+ from PySide6.QtWidgets import QDialog
class DeleteDialog:
def __init__(self, code, language, parent=None, allow_delete=False):
@@ -217,8 +213,8 @@ def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
"""Test _edit_code_block with language change."""
- from PySide6.QtWidgets import QDialog
import bouquin.markdown_editor as markdown_editor
+ from PySide6.QtWidgets import QDialog
class LanguageChangeDialog:
def __init__(self, code, language, parent=None, allow_delete=False):
@@ -249,7 +245,6 @@ def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
assert lang == "javascript"
-# Test for lines 443-490: _delete_code_block
def test_delete_code_block_no_bounds(editor):
"""Test _delete_code_block when bounds can't be found."""
editor.setPlainText("not a code block")
@@ -307,7 +302,6 @@ def test_delete_code_block_with_text_after(editor):
assert "text after" in new_text
-# Test for line 496: _apply_line_spacing with no document
def test_apply_line_spacing_no_document(app):
"""Test _apply_line_spacing when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@@ -319,7 +313,6 @@ def test_apply_line_spacing_no_document(app):
editor._apply_line_spacing(125.0)
-# Test for line 517: _apply_code_block_spacing
def test_apply_code_block_spacing(editor):
"""Test _apply_code_block_spacing applies correct spacing."""
editor.setPlainText("```\nline1\nline2\n```")
@@ -334,7 +327,6 @@ def test_apply_code_block_spacing(editor):
assert block.isValid()
-# Test for line 604: to_markdown with metadata
def test_to_markdown_with_code_metadata(editor):
"""Test to_markdown includes code block metadata."""
editor.setPlainText("```python\ncode\n```")
@@ -348,7 +340,6 @@ def test_to_markdown_with_code_metadata(editor):
assert "code-langs" in md or "code" in md
-# Test for line 648: from_markdown without _code_metadata attribute
def test_from_markdown_creates_code_metadata(app):
"""Test from_markdown creates _code_metadata if missing."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@@ -364,7 +355,6 @@ def test_from_markdown_creates_code_metadata(app):
assert hasattr(editor, "_code_metadata")
-# Test for lines 718-736: image embedding with original size
def test_embed_images_preserves_original_size(editor, tmp_path):
"""Test that embedded images preserve their original dimensions."""
# Create a test image
@@ -387,7 +377,6 @@ def test_embed_images_preserves_original_size(editor, tmp_path):
assert doc is not None
-# Test for lines 782, 791, 813-834: _maybe_trim_list_prefix_from_line_selection
def test_trim_list_prefix_no_selection(editor):
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
editor.setPlainText("- item")
@@ -447,7 +436,6 @@ def test_trim_list_prefix_during_adjustment(editor):
editor._adjusting_selection = False
-# Test for lines 848, 860-866: _detect_list_type
def test_detect_list_type_checkbox_checked(editor):
"""Test _detect_list_type with checked checkbox."""
list_type, prefix = editor._detect_list_type(
@@ -478,7 +466,6 @@ def test_detect_list_type_not_a_list(editor):
assert prefix == ""
-# Test for lines 876, 884-886: list prefix length calculation
def test_list_prefix_length_numbered(editor):
"""Test _list_prefix_length_for_block with numbered list."""
editor.setPlainText("123. item")
@@ -489,7 +476,6 @@ def test_list_prefix_length_numbered(editor):
assert length > 0
-# Test for lines 948-949: keyPressEvent with Ctrl+Home
def test_key_press_ctrl_home(editor, qtbot):
"""Test Ctrl+Home key combination."""
editor.setPlainText("line1\nline2\nline3")
@@ -504,7 +490,6 @@ def test_key_press_ctrl_home(editor, qtbot):
assert editor.textCursor().position() == 0
-# Test for lines 957-960: keyPressEvent with Ctrl+Left
def test_key_press_ctrl_left(editor, qtbot):
"""Test Ctrl+Left key combination."""
editor.setPlainText("word1 word2 word3")
@@ -518,7 +503,6 @@ def test_key_press_ctrl_left(editor, qtbot):
# Should move left by word
-# Test for lines 984-988, 1044: Home key in list
def test_key_press_home_in_list(editor, qtbot):
"""Test Home key in list item."""
editor.setPlainText("- item text")
@@ -534,7 +518,6 @@ def test_key_press_home_in_list(editor, qtbot):
assert pos > 0
-# Test for lines 1067-1073: Left key in list prefix
def test_key_press_left_in_list_prefix(editor, qtbot):
"""Test Left key when in list prefix region."""
editor.setPlainText("- item")
@@ -549,7 +532,6 @@ def test_key_press_left_in_list_prefix(editor, qtbot):
# Should snap to after prefix
-# Test for lines 1088, 1095-1104: Up/Down in code blocks
def test_key_press_up_in_code_block(editor, qtbot):
"""Test Up key inside code block."""
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
@@ -579,7 +561,6 @@ def test_key_press_down_in_list_item(editor, qtbot):
# Should snap to after prefix on next line
-# Test for lines 1127-1130, 1134-1137: Enter key with markers
def test_key_press_enter_after_markers(editor, qtbot):
"""Test Enter key after style markers."""
editor.setPlainText("text **")
@@ -593,7 +574,6 @@ def test_key_press_enter_after_markers(editor, qtbot):
# Should handle markers
-# Test for lines 1146-1164: Enter on fence line
def test_key_press_enter_on_closing_fence(editor, qtbot):
"""Test Enter key on closing fence line."""
editor.setPlainText("```\ncode\n```")
@@ -608,7 +588,6 @@ def test_key_press_enter_on_closing_fence(editor, qtbot):
# Should create new line after fence
-# Test for lines 1185-1189: Backspace in empty checkbox
def test_key_press_backspace_empty_checkbox(editor, qtbot):
"""Test Backspace in empty checkbox item."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
@@ -622,7 +601,6 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot):
# Should remove checkbox
-# Test for lines 1205, 1215-1221: Backspace in numbered list
def test_key_press_backspace_numbered_list(editor, qtbot):
"""Test Backspace at start of numbered list item."""
editor.setPlainText("1. ")
@@ -634,7 +612,6 @@ def test_key_press_backspace_numbered_list(editor, qtbot):
editor.keyPressEvent(event)
-# Test for lines 1228, 1232, 1238-1242: Tab/Shift+Tab in lists
def test_key_press_tab_in_bullet_list(editor, qtbot):
"""Test Tab key in bullet list."""
editor.setPlainText("- item")
@@ -672,7 +649,6 @@ def test_key_press_tab_in_checkbox(editor, qtbot):
editor.keyPressEvent(event)
-# Test for lines 1282-1283: Auto-pairing skip
def test_apply_weight_to_selection(editor, qtbot):
"""Test apply_weight makes text bold."""
editor.setPlainText("text to bold")
@@ -712,7 +688,6 @@ def test_apply_strikethrough_to_selection(editor, qtbot):
assert "~~" in md
-# Test for line 1358: apply_code - it opens a dialog, not just wraps in backticks
def test_apply_code_on_selection(editor, qtbot):
"""Test apply_code with selected text."""
editor.setPlainText("some code")
@@ -728,7 +703,6 @@ def test_apply_code_on_selection(editor, qtbot):
# May contain code block elements depending on dialog behavior
-# Test for line 1386: toggle_numbers
def test_toggle_numbers_on_plain_text(editor, qtbot):
"""Test toggle_numbers converts text to numbered list."""
editor.setPlainText("item 1")
@@ -742,7 +716,6 @@ def test_toggle_numbers_on_plain_text(editor, qtbot):
assert "1." in text
-# Test for lines 1402-1407: toggle_bullets
def test_toggle_bullets_on_plain_text(editor, qtbot):
"""Test toggle_bullets converts text to bullet list."""
editor.setPlainText("item 1")
@@ -771,7 +744,6 @@ def test_toggle_bullets_removes_bullets(editor, qtbot):
assert text.strip() == "item 1"
-# Test for line 1429: toggle_checkboxes
def test_toggle_checkboxes_on_bullets(editor, qtbot):
"""Test toggle_checkboxes converts bullets to checkboxes."""
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
@@ -786,7 +758,6 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot):
assert editor._CHECK_UNCHECKED_DISPLAY in text
-# Test for line 1452: apply_heading
def test_apply_heading_various_levels(editor, qtbot):
"""Test apply_heading with different levels."""
test_cases = [
@@ -809,7 +780,6 @@ def test_apply_heading_various_levels(editor, qtbot):
assert text.startswith(expected_marker)
-# Test for lines 1501-1505: insert_image_from_path
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
"""Test insert_image_from_path with invalid extension."""
invalid_file = tmp_path / "file.txt"
@@ -827,7 +797,6 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path):
editor.insert_image_from_path(nonexistent)
-# Test for lines 1578-1579: mousePressEvent checkbox toggle
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
"""Test clicking checkbox toggles it from unchecked to checked."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
@@ -872,7 +841,6 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
assert editor._CHECK_UNCHECKED_DISPLAY in text
-# Test for line 1602: mouseDoubleClickEvent
def test_mouse_double_click_suppression(editor, qtbot):
"""Test double-click suppression for checkboxes."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
@@ -895,7 +863,6 @@ def test_mouse_double_click_suppression(editor, qtbot):
assert not editor._suppress_next_checkbox_double_click
-# Test for lines 1692-1738: Context menu (lines 1670 was the image loading, not link handling)
def test_context_menu_in_code_block(editor, qtbot):
"""Test context menu when in code block."""
editor.setPlainText("```python\ncode\n```")
@@ -915,7 +882,6 @@ def test_context_menu_in_code_block(editor, qtbot):
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
-# Test for lines 1742-1757: _set_code_block_language
def test_set_code_block_language(editor, qtbot):
"""Test _set_code_block_language sets metadata."""
editor.setPlainText("```\ncode\n```")
@@ -929,7 +895,6 @@ def test_set_code_block_language(editor, qtbot):
assert lang == "python"
-# Test for lines 1770-1783: get_current_line_task_text
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
"""Test get_current_line_task_text removes list/checkbox prefixes."""
test_cases = [
diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py
index 5ffeafd..1c2e450 100644
--- a/tests/test_pomodoro_timer.py
+++ b/tests/test_pomodoro_timer.py
@@ -1,8 +1,9 @@
from unittest.mock import Mock, patch
-from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
-from PySide6.QtWidgets import QWidget, QVBoxLayout, QToolBar, QLabel
+
+from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QAction
+from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
class DummyTimeLogWidget(QWidget):
diff --git a/tests/test_reminders.py b/tests/test_reminders.py
index 16e8dc9..b9e3bfc 100644
--- a/tests/test_reminders.py
+++ b/tests/test_reminders.py
@@ -1,17 +1,16 @@
-import pytest
-
-from unittest.mock import patch, MagicMock
-from bouquin.reminders import (
- Reminder,
- ReminderType,
- ReminderDialog,
- UpcomingRemindersWidget,
- ManageRemindersDialog,
-)
-from PySide6.QtCore import QDateTime, QDate, QTime
-from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
-
from datetime import date, timedelta
+from unittest.mock import MagicMock, patch
+
+import pytest
+from bouquin.reminders import (
+ ManageRemindersDialog,
+ Reminder,
+ ReminderDialog,
+ ReminderType,
+ UpcomingRemindersWidget,
+)
+from PySide6.QtCore import QDate, QDateTime, QTime
+from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
@pytest.fixture
@@ -851,9 +850,9 @@ def test_edit_reminder_dialog(qtbot, fresh_db):
def test_upcoming_reminders_context_menu_shows(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
):
- from PySide6 import QtWidgets, QtGui
- from PySide6.QtCore import QPoint
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
+ from PySide6 import QtGui, QtWidgets
+ from PySide6.QtCore import QPoint
# Add a future reminder for today
r = Reminder(
@@ -909,9 +908,9 @@ def test_upcoming_reminders_context_menu_shows(
def test_upcoming_reminders_delete_selected_dedupes(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
):
- from PySide6.QtWidgets import QMessageBox
- from PySide6.QtCore import QItemSelectionModel
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
+ from PySide6.QtCore import QItemSelectionModel
+ from PySide6.QtWidgets import QMessageBox
r = Reminder(
id=None,
diff --git a/tests/test_settings.py b/tests/test_settings.py
index f272ab2..086d590 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,9 +1,5 @@
-from bouquin.settings import (
- get_settings,
- load_db_config,
- save_db_config,
-)
from bouquin.db import DBConfig
+from bouquin.settings import get_settings, load_db_config, save_db_config
def _clear_db_settings():
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index ad53951..0b1dafd 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -1,11 +1,11 @@
-from bouquin.db import DBManager, DBConfig
-from bouquin.key_prompt import KeyPrompt
import bouquin.settings_dialog as sd
-from bouquin.settings_dialog import SettingsDialog
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
+from bouquin.db import DBConfig, DBManager
+from bouquin.key_prompt import KeyPrompt
from bouquin.settings import get_settings
+from bouquin.settings_dialog import SettingsDialog
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QTimer
-from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
+from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py
index 8ff73b1..46a6eb0 100644
--- a/tests/test_statistics_dialog.py
+++ b/tests/test_statistics_dialog.py
@@ -1,13 +1,11 @@
import datetime as _dt
-from datetime import datetime, timedelta, date
+from datetime import date, datetime, timedelta
from bouquin import strings
-
-from PySide6.QtCore import Qt, QPoint, QDate
-from PySide6.QtWidgets import QLabel, QWidget
-from PySide6.QtTest import QTest
-
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
+from PySide6.QtCore import QDate, QPoint, Qt
+from PySide6.QtTest import QTest
+from PySide6.QtWidgets import QLabel, QWidget
class FakeStatsDB:
@@ -632,5 +630,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
# Force a repaint to execute paintEvent
heatmap.repaint()
- # The month continuation logic (line 175) should prevent duplicate labels
+ # The month continuation logic should prevent duplicate labels
# We can't easily test the visual output, but we ensure no crash
diff --git a/tests/test_tabs.py b/tests/test_tabs.py
index fe73828..b495356 100644
--- a/tests/test_tabs.py
+++ b/tests/test_tabs.py
@@ -1,12 +1,11 @@
import types
-from PySide6.QtWidgets import QFileDialog
-from PySide6.QtGui import QTextCursor
-
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
-from bouquin.settings import get_settings
-from bouquin.main_window import MainWindow
from bouquin.history_dialog import HistoryDialog
+from bouquin.main_window import MainWindow
+from bouquin.settings import get_settings
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtGui import QTextCursor
+from PySide6.QtWidgets import QFileDialog
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
diff --git a/tests/test_tags.py b/tests/test_tags.py
index 8564c6b..89e5fbd 100644
--- a/tests/test_tags.py
+++ b/tests/test_tags.py
@@ -1,24 +1,21 @@
+import bouquin.strings as strings
import pytest
-
-from PySide6.QtCore import Qt, QPoint, QEvent, QDate
-from PySide6.QtGui import QMouseEvent, QColor
+from bouquin.db import DBManager
+from bouquin.flow_layout import FlowLayout
+from bouquin.strings import load_strings
+from bouquin.tag_browser import TagBrowserDialog
+from bouquin.tags_widget import PageTagsWidget, TagChip
+from PySide6.QtCore import QDate, QEvent, QPoint, Qt
+from PySide6.QtGui import QColor, QMouseEvent
from PySide6.QtWidgets import (
QApplication,
- QMessageBox,
- QInputDialog,
QColorDialog,
QDialog,
+ QInputDialog,
+ QMessageBox,
)
-from bouquin.db import DBManager
-from bouquin.strings import load_strings
-from bouquin.tags_widget import PageTagsWidget, TagChip
-from bouquin.tag_browser import TagBrowserDialog
-from bouquin.flow_layout import FlowLayout
from sqlcipher3.dbapi2 import IntegrityError
-import bouquin.strings as strings
-
-
# ============================================================================
# DB Layer Tag Tests
# ============================================================================
@@ -1649,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db):
def test_flow_layout_take_at_invalid_index(app):
"""Test FlowLayout.takeAt with out-of-bounds index"""
- from PySide6.QtWidgets import QWidget, QLabel
+ from PySide6.QtWidgets import QLabel, QWidget
widget = QWidget()
layout = FlowLayout(widget)
@@ -1673,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app):
def test_flow_layout_take_at_boundary(app):
"""Test FlowLayout.takeAt at exact boundary"""
- from PySide6.QtWidgets import QWidget, QLabel
+ from PySide6.QtWidgets import QLabel, QWidget
widget = QWidget()
layout = FlowLayout(widget)
diff --git a/tests/test_theme.py b/tests/test_theme.py
index 6f19a62..a1dc283 100644
--- a/tests/test_theme.py
+++ b/tests/test_theme.py
@@ -1,8 +1,7 @@
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
-from bouquin.theme import Theme, ThemeConfig, ThemeManager
-
def test_theme_manager_apply_light_and_dark(app):
cfg = ThemeConfig(theme=Theme.LIGHT)
diff --git a/tests/test_time_log.py b/tests/test_time_log.py
index 6a997ed..0a6797c 100644
--- a/tests/test_time_log.py
+++ b/tests/test_time_log.py
@@ -1,24 +1,18 @@
-import pytest
from datetime import date, timedelta
-from PySide6.QtCore import Qt, QDate
-from PySide6.QtWidgets import (
- QMessageBox,
- QInputDialog,
- QFileDialog,
- QDialog,
-)
-from sqlcipher3.dbapi2 import IntegrityError
+from unittest.mock import MagicMock, patch
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
+import bouquin.strings as strings
+import pytest
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.time_log import (
- TimeLogWidget,
- TimeLogDialog,
TimeCodeManagerDialog,
+ TimeLogDialog,
+ TimeLogWidget,
TimeReportDialog,
)
-import bouquin.strings as strings
-
-from unittest.mock import patch, MagicMock
+from PySide6.QtCore import QDate, Qt
+from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox
+from sqlcipher3.dbapi2 import IntegrityError
@pytest.fixture
diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py
index 3794760..fdc8829 100644
--- a/tests/test_toolbar.py
+++ b/tests/test_toolbar.py
@@ -1,8 +1,8 @@
import pytest
-from PySide6.QtWidgets import QWidget
from bouquin.markdown_editor import MarkdownEditor
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.toolbar import ToolBar
+from PySide6.QtWidgets import QWidget
@pytest.fixture
diff --git a/tests/test_version_check.py b/tests/test_version_check.py
index b5afe12..01fac35 100644
--- a/tests/test_version_check.py
+++ b/tests/test_version_check.py
@@ -1,9 +1,10 @@
-import pytest
-from unittest.mock import Mock, patch
import subprocess
+from unittest.mock import Mock, patch
+
+import pytest
from bouquin.version_check import VersionChecker
-from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QPixmap
+from PySide6.QtWidgets import QMessageBox, QWidget
def test_version_checker_init(app):