Invoicing
All checks were successful
CI / test (push) Successful in 7m5s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 25s

This commit is contained in:
Miguel Jacq 2025-12-08 20:34:11 +11:00
parent e5c7ccb1da
commit 81878c63d9
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
16 changed files with 3656 additions and 54 deletions

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ __pycache__
dist dist
.coverage .coverage
*.db *.db
*.pdf
*.csv
*.html

View file

@ -1,3 +1,8 @@
# 0.7.0
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
* Add 'Last week' to Time Report dialog range option
# 0.6.4 # 0.6.4
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.

View file

@ -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). 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). 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 ### From PyPi/pip

View file

@ -41,6 +41,26 @@ DocumentRow = Tuple[
int, # size_bytes int, # size_bytes
str, # uploaded_at (ISO) 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 = [ _TAG_COLORS = [
"#FFB3BA", # soft red "#FFB3BA", # soft red
@ -77,11 +97,31 @@ class DBConfig:
time_log: bool = True time_log: bool = True
reminders: bool = True reminders: bool = True
documents: bool = True documents: bool = True
invoicing: bool = False
locale: str = "en" locale: str = "en"
font_size: int = 11 font_size: int = 11
class DBManager: 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): def __init__(self, cfg: DBConfig):
self.cfg = cfg self.cfg = cfg
self.conn: sqlite.Connection | None = None self.conn: sqlite.Connection | None = None
@ -252,6 +292,76 @@ class DBManager:
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
ON 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() self.conn.commit()
@ -942,6 +1052,14 @@ class DBManager:
).fetchall() ).fetchall()
return [(r["id"], r["name"]) for r in rows] 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: def add_project(self, name: str) -> int:
name = name.strip() name = name.strip()
if not name: if not name:
@ -1718,3 +1836,431 @@ class DBManager:
(tag_name,), (tag_name,),
).fetchall() ).fetchall()
return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows] 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

1450
bouquin/invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -52,7 +52,6 @@
"backup_failed": "Backup failed", "backup_failed": "Backup failed",
"quit": "Quit", "quit": "Quit",
"cancel": "Cancel", "cancel": "Cancel",
"close": "Close",
"save": "Save", "save": "Save",
"help": "Help", "help": "Help",
"saved": "Saved", "saved": "Saved",
@ -202,6 +201,7 @@
"by_week": "by week", "by_week": "by week",
"date_range": "Date range", "date_range": "Date range",
"custom_range": "Custom", "custom_range": "Custom",
"last_week": "Last week",
"this_week": "This week", "this_week": "This week",
"this_month": "This month", "this_month": "This month",
"this_year": "This year", "this_year": "This year",
@ -234,6 +234,8 @@
"projects": "Projects", "projects": "Projects",
"rename_activity": "Rename activity", "rename_activity": "Rename activity",
"rename_project": "Rename project", "rename_project": "Rename project",
"reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report", "run_report": "Run report",
"add_activity_title": "Add activity", "add_activity_title": "Add activity",
"add_activity_label": "Add an activity", "add_activity_label": "Add an activity",
@ -359,5 +361,54 @@
"documents_search_label": "Search", "documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)", "documents_search_placeholder": "Type to search documents (all projects)",
"todays_documents": "Documents from this day", "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."
} }

View file

@ -117,6 +117,9 @@ class MainWindow(QMainWindow):
self.upcoming_reminders = UpcomingRemindersWidget(self.db) self.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) 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) self.pomodoro_manager = PomodoroManager(self.db, self)
# Lock the calendar to the left panel at the top to stop it stretching # Lock the calendar to the left panel at the top to stop it stretching
@ -1448,6 +1451,7 @@ class MainWindow(QMainWindow):
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) 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.locale = getattr(new_cfg, "locale", self.cfg.locale)
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)

View file

@ -45,6 +45,7 @@ def load_db_config() -> DBConfig:
time_log = s.value("ui/time_log", True, type=bool) time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool) reminders = s.value("ui/reminders", True, type=bool)
documents = s.value("ui/documents", 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) locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int) font_size = s.value("ui/font_size", 11, type=int)
return DBConfig( return DBConfig(
@ -57,6 +58,7 @@ def load_db_config() -> DBConfig:
time_log=time_log, time_log=time_log,
reminders=reminders, reminders=reminders,
documents=documents, documents=documents,
invoicing=invoicing,
locale=locale, locale=locale,
font_size=font_size, font_size=font_size,
) )
@ -73,5 +75,6 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders)) s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/documents", str(cfg.documents)) s.setValue("ui/documents", str(cfg.documents))
s.setValue("ui/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale)) s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size)) s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -7,8 +7,11 @@ from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
QFrame, QFrame,
QFileDialog,
QGroupBox, QGroupBox,
QLabel, QLabel,
QLineEdit,
QFormLayout,
QHBoxLayout, QHBoxLayout,
QVBoxLayout, QVBoxLayout,
QPushButton, QPushButton,
@ -19,6 +22,7 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
QWidget, QWidget,
QTabWidget, QTabWidget,
QTextEdit,
) )
from PySide6.QtCore import Qt, Slot from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette from PySide6.QtGui import QPalette
@ -176,6 +180,17 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor) self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log) 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 = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders) self.reminders.setChecked(self.current_settings.reminders)
self.reminders.setCursor(Qt.PointingHandCursor) self.reminders.setCursor(Qt.PointingHandCursor)
@ -187,6 +202,68 @@ class SettingsDialog(QDialog):
features_layout.addWidget(self.documents) features_layout.addWidget(self.documents)
layout.addWidget(features_group) 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() layout.addStretch()
return page return page
@ -314,14 +391,60 @@ class SettingsDialog(QDialog):
time_log=self.time_log.isChecked(), time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(), reminders=self.reminders.isChecked(),
documents=self.documents.isChecked(), documents=self.documents.isChecked(),
invoicing=(
self.invoicing.isChecked() if self.time_log.isChecked() else False
),
locale=self.locale_combobox.currentText(), locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(), font_size=self.font_size.value(),
) )
save_db_config(self._cfg) 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.parent().themes.set(selected_theme)
self.accept() 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): def _change_key(self):
p1 = KeyPrompt( p1 = KeyPrompt(
self, self,

View file

@ -8,7 +8,7 @@ from datetime import datetime
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
from typing import Optional from typing import Optional
from PySide6.QtCore import Qt, QDate, QUrl from PySide6.QtCore import Qt, QDate, QUrl, Signal
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
from PySide6.QtPrintSupport import QPrinter from PySide6.QtPrintSupport import QPrinter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
) )
from .db import DBManager from .db import DBManager
from .settings import load_db_config
from .theme import ThemeManager from .theme import ThemeManager
from . import strings from . import strings
@ -53,6 +54,8 @@ class TimeLogWidget(QFrame):
Shown in the left sidebar above the Tags widget. Shown in the left sidebar above the Tags widget.
""" """
remindersChanged = Signal()
def __init__( def __init__(
self, self,
db: DBManager, db: DBManager,
@ -61,6 +64,7 @@ class TimeLogWidget(QFrame):
): ):
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self.cfg = load_db_config()
self._themes = themes self._themes = themes
self._current_date: Optional[str] = None self._current_date: Optional[str] = None
@ -82,6 +86,15 @@ class TimeLogWidget(QFrame):
self.log_btn.setAutoRaise(True) self.log_btn.setAutoRaise(True)
self.log_btn.clicked.connect(self._open_dialog_log_only) 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 = QToolButton()
self.open_btn.setIcon( self.open_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView) self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
@ -95,6 +108,7 @@ class TimeLogWidget(QFrame):
header.addWidget(self.toggle_btn) header.addWidget(self.toggle_btn)
header.addStretch(1) header.addStretch(1)
header.addWidget(self.log_btn) header.addWidget(self.log_btn)
header.addWidget(self.report_btn)
header.addWidget(self.open_btn) header.addWidget(self.open_btn)
# Body: simple summary label for the day # Body: simple summary label for the day
@ -149,6 +163,14 @@ class TimeLogWidget(QFrame):
# ----- internals --------------------------------------------------- # ----- 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: def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked) self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
@ -247,6 +269,7 @@ class TimeLogDialog(QDialog):
self._themes = themes self._themes = themes
self._date_iso = date_iso self._date_iso = date_iso
self._current_entry_id: Optional[int] = None self._current_entry_id: Optional[int] = None
self.cfg = load_db_config()
# Guard flag used when repopulating the table so we dont treat # Guard flag used when repopulating the table so we dont treat
# programmatic item changes as user edits. # programmatic item changes as user edits.
self._reloading_entries: bool = False self._reloading_entries: bool = False
@ -320,13 +343,9 @@ class TimeLogDialog(QDialog):
self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.clicked.connect(self._on_delete_entry)
self.delete_btn.setEnabled(False) 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.addStretch(1)
btn_row.addWidget(self.add_update_btn) btn_row.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn) btn_row.addWidget(self.delete_btn)
btn_row.addWidget(self.report_btn)
root.addLayout(btn_row) root.addLayout(btn_row)
# --- Table of entries for this date # --- Table of entries for this date
@ -355,12 +374,19 @@ class TimeLogDialog(QDialog):
self.table.itemChanged.connect(self._on_table_item_changed) self.table.itemChanged.connect(self._on_table_item_changed)
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
# --- Total time and Close button # --- Total time, Reporting and Close button
close_row = QHBoxLayout() close_row = QHBoxLayout()
self.total_label = QLabel( self.total_label = QLabel(
strings._("time_log_total_hours").format(hours=self.total_hours) 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.total_label)
close_row.addWidget(self.report_btn)
close_row.addStretch(1) close_row.addStretch(1)
close_btn = QPushButton(strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
@ -981,9 +1007,12 @@ class TimeReportDialog(QDialog):
Shows decimal hours per time period. Shows decimal hours per time period.
""" """
remindersChanged = Signal()
def __init__(self, db: DBManager, parent=None): def __init__(self, db: DBManager, parent=None):
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self.cfg = load_db_config()
# state for last run # state for last run
self._last_rows: list[tuple[str, str, str, str, int]] = [] self._last_rows: list[tuple[str, str, str, str, int]] = []
@ -992,6 +1021,7 @@ class TimeReportDialog(QDialog):
self._last_start: str = "" self._last_start: str = ""
self._last_end: str = "" self._last_end: str = ""
self._last_gran_label: str = "" self._last_gran_label: str = ""
self._last_time_logs: list = []
self.setWindowTitle(strings._("time_log_report")) self.setWindowTitle(strings._("time_log_report"))
self.resize(600, 400) self.resize(600, 400)
@ -999,9 +1029,20 @@ class TimeReportDialog(QDialog):
root = QVBoxLayout(self) root = QVBoxLayout(self)
form = QFormLayout() 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 # Project
self.project_combo = QComboBox() self.project_combo = QComboBox()
self.project_combo.addItem(strings._("all_projects"), None) 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(): for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id) self.project_combo.addItem(name, proj_id)
form.addRow(strings._("project"), self.project_combo) form.addRow(strings._("project"), self.project_combo)
@ -1013,6 +1054,7 @@ class TimeReportDialog(QDialog):
self.range_preset = QComboBox() self.range_preset = QComboBox()
self.range_preset.addItem(strings._("custom_range"), "custom") self.range_preset.addItem(strings._("custom_range"), "custom")
self.range_preset.addItem(strings._("today"), "today") 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_week"), "this_week")
self.range_preset.addItem(strings._("this_month"), "this_month") self.range_preset.addItem(strings._("this_month"), "this_month")
self.range_preset.addItem(strings._("this_year"), "this_year") self.range_preset.addItem(strings._("this_year"), "this_year")
@ -1061,6 +1103,10 @@ class TimeReportDialog(QDialog):
run_row.addWidget(run_btn) run_row.addWidget(run_btn)
run_row.addWidget(export_btn) run_row.addWidget(export_btn)
run_row.addWidget(pdf_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) root.addLayout(run_row)
# Table # Table
@ -1146,6 +1192,14 @@ class TimeReportDialog(QDialog):
start = today.addDays(1 - today.dayOfWeek()) start = today.addDays(1 - today.dayOfWeek())
end = today end = today
elif preset == "last_week":
# Compute MondaySunday 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": elif preset == "this_month":
start = QDate(today.year(), today.month(), 1) start = QDate(today.year(), today.month(), 1)
end = today end = today
@ -1187,11 +1241,13 @@ class TimeReportDialog(QDialog):
if proj_data is None: if proj_data is None:
# All projects # All projects
self._last_all_projects = True self._last_all_projects = True
self._last_time_logs = []
self._last_project_name = strings._("all_projects") self._last_project_name = strings._("all_projects")
rows_for_table = self._db.time_report_all(start, end, gran) rows_for_table = self._db.time_report_all(start, end, gran)
else: else:
self._last_all_projects = False self._last_all_projects = False
proj_id = int(proj_data) 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() project_name = self.project_combo.currentText()
self._last_project_name = project_name self._last_project_name = project_name
@ -1525,3 +1581,55 @@ class TimeReportDialog(QDialog):
strings._("export_pdf_error_title"), strings._("export_pdf_error_title"),
strings._("export_pdf_error_message").format(error=str(exc)), 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()

View file

@ -159,7 +159,7 @@ def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
rect = QRect(0, 0, line_area.width(), line_area.height()) rect = QRect(0, 0, line_area.width(), line_area.height())
paint_event = QPaintEvent(rect) 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) editor.line_number_area_paint_event(paint_event)
# Should not crash # Should not crash

1348
tests/test_invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -97,7 +97,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app): 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) prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
qtbot.addWidget(prompt) qtbot.addWidget(prompt)
@ -168,7 +168,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path):
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): 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 = tmp_path / "initial.db"
initial_db.touch() initial_db.touch()
@ -180,7 +180,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
# Mock the file dialog to return a different file # Mock the file dialog to return a different file
def mock_get_open_filename(*args, **kwargs): 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)" return str(new_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)

View file

@ -1928,7 +1928,7 @@ def test_editor_delete_operations(qtbot, app):
def test_markdown_highlighter_dark_theme(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 # Create theme manager with dark theme
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
@ -2293,7 +2293,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
# Force rehighlight # Force rehighlight
editor.highlighter.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 # We can't easily verify the exact formatting, but we ensure no crash
@ -2305,13 +2305,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
# Force rehighlight # Force rehighlight
editor.highlighter.rehighlight() editor.highlighter.rehighlight()
# The overlap detection (lines 252, 264) should prevent issues
def test_highlighter_italic_edge_cases(editor, qtbot): def test_highlighter_italic_edge_cases(editor, qtbot):
"""Test italic formatting edge cases.""" """Test italic formatting edge cases."""
# Test edge case: avoiding stealing markers that are part of double # Test edge case: avoiding stealing markers that are part of double
# This tests lines 267-270
editor.setPlainText("**not italic* text**") editor.setPlainText("**not italic* text**")
# Force rehighlight # Force rehighlight

View file

@ -44,7 +44,6 @@ def editor(app, qtbot):
return ed return ed
# Test for line 215: document is None guard
def test_update_code_block_backgrounds_with_no_document(app, qtbot): def test_update_code_block_backgrounds_with_no_document(app, qtbot):
"""Test _update_code_block_row_backgrounds when document is None.""" """Test _update_code_block_row_backgrounds when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -60,7 +59,6 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot):
editor._update_code_block_row_backgrounds() 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): def test_find_code_block_bounds_invalid_block(editor):
"""Test _find_code_block_bounds with invalid block.""" """Test _find_code_block_bounds with invalid block."""
editor.setPlainText("some text") editor.setPlainText("some text")
@ -124,7 +122,6 @@ def test_find_code_block_bounds_no_opening_fence(editor):
assert result is None 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): def test_edit_code_block_checks_document(app, qtbot):
"""Test _edit_code_block when editor has no document.""" """Test _edit_code_block when editor has no document."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -249,7 +246,6 @@ def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
assert lang == "javascript" assert lang == "javascript"
# Test for lines 443-490: _delete_code_block
def test_delete_code_block_no_bounds(editor): def test_delete_code_block_no_bounds(editor):
"""Test _delete_code_block when bounds can't be found.""" """Test _delete_code_block when bounds can't be found."""
editor.setPlainText("not a code block") editor.setPlainText("not a code block")
@ -307,7 +303,6 @@ def test_delete_code_block_with_text_after(editor):
assert "text after" in new_text assert "text after" in new_text
# Test for line 496: _apply_line_spacing with no document
def test_apply_line_spacing_no_document(app): def test_apply_line_spacing_no_document(app):
"""Test _apply_line_spacing when document is None.""" """Test _apply_line_spacing when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -319,7 +314,6 @@ def test_apply_line_spacing_no_document(app):
editor._apply_line_spacing(125.0) editor._apply_line_spacing(125.0)
# Test for line 517: _apply_code_block_spacing
def test_apply_code_block_spacing(editor): def test_apply_code_block_spacing(editor):
"""Test _apply_code_block_spacing applies correct spacing.""" """Test _apply_code_block_spacing applies correct spacing."""
editor.setPlainText("```\nline1\nline2\n```") editor.setPlainText("```\nline1\nline2\n```")
@ -334,7 +328,6 @@ def test_apply_code_block_spacing(editor):
assert block.isValid() assert block.isValid()
# Test for line 604: to_markdown with metadata
def test_to_markdown_with_code_metadata(editor): def test_to_markdown_with_code_metadata(editor):
"""Test to_markdown includes code block metadata.""" """Test to_markdown includes code block metadata."""
editor.setPlainText("```python\ncode\n```") editor.setPlainText("```python\ncode\n```")
@ -348,7 +341,6 @@ def test_to_markdown_with_code_metadata(editor):
assert "code-langs" in md or "code" in md 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): def test_from_markdown_creates_code_metadata(app):
"""Test from_markdown creates _code_metadata if missing.""" """Test from_markdown creates _code_metadata if missing."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -364,7 +356,6 @@ def test_from_markdown_creates_code_metadata(app):
assert hasattr(editor, "_code_metadata") 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): def test_embed_images_preserves_original_size(editor, tmp_path):
"""Test that embedded images preserve their original dimensions.""" """Test that embedded images preserve their original dimensions."""
# Create a test image # Create a test image
@ -387,7 +378,6 @@ def test_embed_images_preserves_original_size(editor, tmp_path):
assert doc is not None 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): def test_trim_list_prefix_no_selection(editor):
"""Test _maybe_trim_list_prefix_from_line_selection with no selection.""" """Test _maybe_trim_list_prefix_from_line_selection with no selection."""
editor.setPlainText("- item") editor.setPlainText("- item")
@ -447,7 +437,6 @@ def test_trim_list_prefix_during_adjustment(editor):
editor._adjusting_selection = False editor._adjusting_selection = False
# Test for lines 848, 860-866: _detect_list_type
def test_detect_list_type_checkbox_checked(editor): def test_detect_list_type_checkbox_checked(editor):
"""Test _detect_list_type with checked checkbox.""" """Test _detect_list_type with checked checkbox."""
list_type, prefix = editor._detect_list_type( list_type, prefix = editor._detect_list_type(
@ -478,7 +467,6 @@ def test_detect_list_type_not_a_list(editor):
assert prefix == "" assert prefix == ""
# Test for lines 876, 884-886: list prefix length calculation
def test_list_prefix_length_numbered(editor): def test_list_prefix_length_numbered(editor):
"""Test _list_prefix_length_for_block with numbered list.""" """Test _list_prefix_length_for_block with numbered list."""
editor.setPlainText("123. item") editor.setPlainText("123. item")
@ -489,7 +477,6 @@ def test_list_prefix_length_numbered(editor):
assert length > 0 assert length > 0
# Test for lines 948-949: keyPressEvent with Ctrl+Home
def test_key_press_ctrl_home(editor, qtbot): def test_key_press_ctrl_home(editor, qtbot):
"""Test Ctrl+Home key combination.""" """Test Ctrl+Home key combination."""
editor.setPlainText("line1\nline2\nline3") editor.setPlainText("line1\nline2\nline3")
@ -504,7 +491,6 @@ def test_key_press_ctrl_home(editor, qtbot):
assert editor.textCursor().position() == 0 assert editor.textCursor().position() == 0
# Test for lines 957-960: keyPressEvent with Ctrl+Left
def test_key_press_ctrl_left(editor, qtbot): def test_key_press_ctrl_left(editor, qtbot):
"""Test Ctrl+Left key combination.""" """Test Ctrl+Left key combination."""
editor.setPlainText("word1 word2 word3") editor.setPlainText("word1 word2 word3")
@ -518,7 +504,6 @@ def test_key_press_ctrl_left(editor, qtbot):
# Should move left by word # Should move left by word
# Test for lines 984-988, 1044: Home key in list
def test_key_press_home_in_list(editor, qtbot): def test_key_press_home_in_list(editor, qtbot):
"""Test Home key in list item.""" """Test Home key in list item."""
editor.setPlainText("- item text") editor.setPlainText("- item text")
@ -534,7 +519,6 @@ def test_key_press_home_in_list(editor, qtbot):
assert pos > 0 assert pos > 0
# Test for lines 1067-1073: Left key in list prefix
def test_key_press_left_in_list_prefix(editor, qtbot): def test_key_press_left_in_list_prefix(editor, qtbot):
"""Test Left key when in list prefix region.""" """Test Left key when in list prefix region."""
editor.setPlainText("- item") editor.setPlainText("- item")
@ -549,7 +533,6 @@ def test_key_press_left_in_list_prefix(editor, qtbot):
# Should snap to after prefix # 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): def test_key_press_up_in_code_block(editor, qtbot):
"""Test Up key inside code block.""" """Test Up key inside code block."""
editor.setPlainText("```\ncode line 1\ncode line 2\n```") editor.setPlainText("```\ncode line 1\ncode line 2\n```")
@ -579,7 +562,6 @@ def test_key_press_down_in_list_item(editor, qtbot):
# Should snap to after prefix on next line # 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): def test_key_press_enter_after_markers(editor, qtbot):
"""Test Enter key after style markers.""" """Test Enter key after style markers."""
editor.setPlainText("text **") editor.setPlainText("text **")
@ -593,7 +575,6 @@ def test_key_press_enter_after_markers(editor, qtbot):
# Should handle markers # Should handle markers
# Test for lines 1146-1164: Enter on fence line
def test_key_press_enter_on_closing_fence(editor, qtbot): def test_key_press_enter_on_closing_fence(editor, qtbot):
"""Test Enter key on closing fence line.""" """Test Enter key on closing fence line."""
editor.setPlainText("```\ncode\n```") editor.setPlainText("```\ncode\n```")
@ -608,7 +589,6 @@ def test_key_press_enter_on_closing_fence(editor, qtbot):
# Should create new line after fence # Should create new line after fence
# Test for lines 1185-1189: Backspace in empty checkbox
def test_key_press_backspace_empty_checkbox(editor, qtbot): def test_key_press_backspace_empty_checkbox(editor, qtbot):
"""Test Backspace in empty checkbox item.""" """Test Backspace in empty checkbox item."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ") editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
@ -622,7 +602,6 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot):
# Should remove checkbox # Should remove checkbox
# Test for lines 1205, 1215-1221: Backspace in numbered list
def test_key_press_backspace_numbered_list(editor, qtbot): def test_key_press_backspace_numbered_list(editor, qtbot):
"""Test Backspace at start of numbered list item.""" """Test Backspace at start of numbered list item."""
editor.setPlainText("1. ") editor.setPlainText("1. ")
@ -634,7 +613,6 @@ def test_key_press_backspace_numbered_list(editor, qtbot):
editor.keyPressEvent(event) 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): def test_key_press_tab_in_bullet_list(editor, qtbot):
"""Test Tab key in bullet list.""" """Test Tab key in bullet list."""
editor.setPlainText("- item") editor.setPlainText("- item")
@ -672,7 +650,6 @@ def test_key_press_tab_in_checkbox(editor, qtbot):
editor.keyPressEvent(event) editor.keyPressEvent(event)
# Test for lines 1282-1283: Auto-pairing skip
def test_apply_weight_to_selection(editor, qtbot): def test_apply_weight_to_selection(editor, qtbot):
"""Test apply_weight makes text bold.""" """Test apply_weight makes text bold."""
editor.setPlainText("text to bold") editor.setPlainText("text to bold")
@ -712,7 +689,6 @@ def test_apply_strikethrough_to_selection(editor, qtbot):
assert "~~" in md 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): def test_apply_code_on_selection(editor, qtbot):
"""Test apply_code with selected text.""" """Test apply_code with selected text."""
editor.setPlainText("some code") editor.setPlainText("some code")
@ -728,7 +704,6 @@ def test_apply_code_on_selection(editor, qtbot):
# May contain code block elements depending on dialog behavior # May contain code block elements depending on dialog behavior
# Test for line 1386: toggle_numbers
def test_toggle_numbers_on_plain_text(editor, qtbot): def test_toggle_numbers_on_plain_text(editor, qtbot):
"""Test toggle_numbers converts text to numbered list.""" """Test toggle_numbers converts text to numbered list."""
editor.setPlainText("item 1") editor.setPlainText("item 1")
@ -742,7 +717,6 @@ def test_toggle_numbers_on_plain_text(editor, qtbot):
assert "1." in text assert "1." in text
# Test for lines 1402-1407: toggle_bullets
def test_toggle_bullets_on_plain_text(editor, qtbot): def test_toggle_bullets_on_plain_text(editor, qtbot):
"""Test toggle_bullets converts text to bullet list.""" """Test toggle_bullets converts text to bullet list."""
editor.setPlainText("item 1") editor.setPlainText("item 1")
@ -771,7 +745,6 @@ def test_toggle_bullets_removes_bullets(editor, qtbot):
assert text.strip() == "item 1" assert text.strip() == "item 1"
# Test for line 1429: toggle_checkboxes
def test_toggle_checkboxes_on_bullets(editor, qtbot): def test_toggle_checkboxes_on_bullets(editor, qtbot):
"""Test toggle_checkboxes converts bullets to checkboxes.""" """Test toggle_checkboxes converts bullets to checkboxes."""
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1") editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
@ -786,7 +759,6 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot):
assert editor._CHECK_UNCHECKED_DISPLAY in text assert editor._CHECK_UNCHECKED_DISPLAY in text
# Test for line 1452: apply_heading
def test_apply_heading_various_levels(editor, qtbot): def test_apply_heading_various_levels(editor, qtbot):
"""Test apply_heading with different levels.""" """Test apply_heading with different levels."""
test_cases = [ test_cases = [
@ -809,7 +781,6 @@ def test_apply_heading_various_levels(editor, qtbot):
assert text.startswith(expected_marker) 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): def test_insert_image_from_path_invalid_extension(editor, tmp_path):
"""Test insert_image_from_path with invalid extension.""" """Test insert_image_from_path with invalid extension."""
invalid_file = tmp_path / "file.txt" invalid_file = tmp_path / "file.txt"
@ -827,7 +798,6 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path):
editor.insert_image_from_path(nonexistent) editor.insert_image_from_path(nonexistent)
# Test for lines 1578-1579: mousePressEvent checkbox toggle
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot): def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
"""Test clicking checkbox toggles it from unchecked to checked.""" """Test clicking checkbox toggles it from unchecked to checked."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
@ -872,7 +842,6 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
assert editor._CHECK_UNCHECKED_DISPLAY in text assert editor._CHECK_UNCHECKED_DISPLAY in text
# Test for line 1602: mouseDoubleClickEvent
def test_mouse_double_click_suppression(editor, qtbot): def test_mouse_double_click_suppression(editor, qtbot):
"""Test double-click suppression for checkboxes.""" """Test double-click suppression for checkboxes."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
@ -895,7 +864,6 @@ def test_mouse_double_click_suppression(editor, qtbot):
assert not editor._suppress_next_checkbox_double_click 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): def test_context_menu_in_code_block(editor, qtbot):
"""Test context menu when in code block.""" """Test context menu when in code block."""
editor.setPlainText("```python\ncode\n```") editor.setPlainText("```python\ncode\n```")
@ -915,7 +883,6 @@ def test_context_menu_in_code_block(editor, qtbot):
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash # 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): def test_set_code_block_language(editor, qtbot):
"""Test _set_code_block_language sets metadata.""" """Test _set_code_block_language sets metadata."""
editor.setPlainText("```\ncode\n```") editor.setPlainText("```\ncode\n```")
@ -929,7 +896,6 @@ def test_set_code_block_language(editor, qtbot):
assert lang == "python" assert lang == "python"
# Test for lines 1770-1783: get_current_line_task_text
def test_get_current_line_task_text_strips_prefixes(editor, qtbot): def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
"""Test get_current_line_task_text removes list/checkbox prefixes.""" """Test get_current_line_task_text removes list/checkbox prefixes."""
test_cases = [ test_cases = [

View file

@ -632,5 +632,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
# Force a repaint to execute paintEvent # Force a repaint to execute paintEvent
heatmap.repaint() 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 # We can't easily test the visual output, but we ensure no crash