Compare commits

...

4 commits

14 changed files with 3004 additions and 590 deletions

View file

@ -1,3 +1,10 @@
# 0.9.0
* Add 'Projects' interface for unified time/invoice/docs view.
* Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
* Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
* Update dependencies
# 0.8.4
* Update dependencies

View file

@ -308,6 +308,40 @@ class DBManager:
client_email TEXT
);
CREATE INDEX IF NOT EXISTS ix_project_billing_client_company
ON project_billing(client_company);
CREATE TABLE IF NOT EXISTS project_buckets (
project_id INTEGER PRIMARY KEY
REFERENCES projects(id) ON DELETE CASCADE,
baseline_minutes INTEGER NOT NULL DEFAULT 0,
bucket_ceiling_minutes INTEGER NOT NULL DEFAULT 0,
warn_at_percent REAL NOT NULL DEFAULT 80.0,
updated_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
)
);
CREATE TABLE IF NOT EXISTS project_bucket_ledger (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL
REFERENCES projects(id) ON DELETE CASCADE,
occurred_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
entry_type TEXT NOT NULL,
baseline_delta_minutes INTEGER NOT NULL DEFAULT 0,
ceiling_delta_minutes INTEGER NOT NULL DEFAULT 0,
description TEXT,
invoice_id INTEGER,
FOREIGN KEY(invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS ix_project_bucket_ledger_project
ON project_bucket_ledger(project_id, occurred_at);
CREATE TABLE IF NOT EXISTS company_profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
@ -336,6 +370,9 @@ class DBManager:
paid_at TEXT,
payment_note TEXT,
document_id INTEGER,
created_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
FOREIGN KEY(document_id) REFERENCES project_documents(id)
ON DELETE SET NULL,
UNIQUE(project_id, invoice_number)
@ -366,8 +403,20 @@ class DBManager:
);
"""
)
self._ensure_column(
"invoices",
"created_at",
"created_at TEXT",
)
self.conn.commit()
def _ensure_column(self, table: str, column: str, definition: str) -> None:
"""Add a simple column during startup schema upgrades if needed."""
rows = self.conn.execute(f"PRAGMA table_info({table})").fetchall()
if any(str(r["name"]) == column for r in rows):
return
self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {definition}")
def rekey(self, new_key: str) -> None:
"""
Change the SQLCipher passphrase in-place, then reopen the connection
@ -1219,6 +1268,496 @@ class DBManager:
(project_id,),
)
# -------- Time logging: project buckets ---------------------------
def _normalise_project_id(self, project_id: int) -> int:
project_id = int(project_id)
if project_id <= 0:
raise ValueError("invalid project id")
return project_id
def _normalise_minutes_delta(self, minutes: int | float | None) -> int:
return int(round(float(minutes or 0)))
def _normalise_bucket_warning(self, warn_at_percent: float | None) -> float:
return min(100.0, max(0.0, float(warn_at_percent or 0.0)))
def _project_bucket_ledger_totals(self, project_id: int) -> tuple[int, int]:
row = self.conn.execute(
"""
SELECT
COALESCE(SUM(baseline_delta_minutes), 0) AS baseline_minutes,
COALESCE(SUM(ceiling_delta_minutes), 0) AS bucket_ceiling_minutes
FROM project_bucket_ledger
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
baseline = max(0, int(row["baseline_minutes"] or 0))
ceiling = max(0, int(row["bucket_ceiling_minutes"] or 0))
return baseline, ceiling
def _sync_project_bucket_cache(self, project_id: int) -> None:
"""Refresh the project_buckets cache from the ledger."""
baseline, ceiling = self._project_bucket_ledger_totals(project_id)
existing = self.conn.execute(
"SELECT warn_at_percent FROM project_buckets WHERE project_id = ?;",
(project_id,),
).fetchone()
warn_at = float(existing["warn_at_percent"] or 80.0) if existing else 80.0
self.conn.execute(
"""
INSERT INTO project_buckets (
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent
)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
baseline_minutes = excluded.baseline_minutes,
bucket_ceiling_minutes = excluded.bucket_ceiling_minutes,
warn_at_percent = project_buckets.warn_at_percent,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""",
(project_id, baseline, ceiling, warn_at),
)
def add_project_bucket_ledger_entry(
self,
project_id: int,
entry_type: str,
baseline_delta_minutes: int = 0,
ceiling_delta_minutes: int = 0,
description: str | None = None,
invoice_id: int | None = None,
) -> int:
"""Append an auditable project bucket ledger entry.
Positive ``baseline_delta_minutes`` increases the already-spent baseline.
Positive ``ceiling_delta_minutes`` increases the prepaid bucket ceiling.
Negative deltas are used for explicit corrections when the user lowers a
previously saved baseline or ceiling.
"""
project_id = self._normalise_project_id(project_id)
entry_type = str(entry_type or "adjustment").strip() or "adjustment"
baseline_delta_minutes = self._normalise_minutes_delta(baseline_delta_minutes)
ceiling_delta_minutes = self._normalise_minutes_delta(ceiling_delta_minutes)
description = (description or "").strip() or None
if baseline_delta_minutes == 0 and ceiling_delta_minutes == 0:
raise ValueError("bucket ledger entry has no minute delta")
with self.conn:
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO project_bucket_ledger (
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description,
invoice_id
)
VALUES (?, ?, ?, ?, ?, ?);
""",
(
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description,
invoice_id,
),
)
ledger_id = cur.lastrowid
self._sync_project_bucket_cache(project_id)
return ledger_id
def upsert_project_bucket(
self,
project_id: int,
baseline_minutes: int,
bucket_ceiling_minutes: int,
warn_at_percent: float = 80.0,
) -> None:
"""Save project bucket settings as an auditable ledger adjustment.
``baseline_minutes`` represents already-spent hours that pre-date
Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative
prepaid ceiling purchased for this project. The current values are
derived from ``project_bucket_ledger`` rather than silently overwritten.
"""
project_id = self._normalise_project_id(project_id)
baseline_minutes = max(0, int(baseline_minutes or 0))
bucket_ceiling_minutes = max(0, int(bucket_ceiling_minutes or 0))
warn_at_percent = self._normalise_bucket_warning(warn_at_percent)
current_baseline, current_ceiling = self._project_bucket_ledger_totals(
project_id
)
baseline_delta = baseline_minutes - current_baseline
ceiling_delta = bucket_ceiling_minutes - current_ceiling
with self.conn:
self.conn.execute(
"""
INSERT INTO project_buckets (
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent
)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
warn_at_percent = excluded.warn_at_percent,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""",
(
project_id,
current_baseline,
current_ceiling,
warn_at_percent,
),
)
if baseline_delta or ceiling_delta:
self.conn.execute(
"""
INSERT INTO project_bucket_ledger (
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description
)
VALUES (?, ?, ?, ?, ?);
""",
(
project_id,
"settings_adjustment",
baseline_delta,
ceiling_delta,
"Bucket settings updated",
),
)
self._sync_project_bucket_cache(project_id)
self.conn.execute(
"""
UPDATE project_buckets
SET warn_at_percent = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE project_id = ?;
""",
(warn_at_percent, project_id),
)
def add_to_project_bucket_ceiling(
self,
project_id: int,
add_minutes: int,
description: str | None = None,
invoice_id: int | None = None,
) -> None:
"""Increase a project's cumulative bucket ceiling by ``add_minutes``."""
project_id = self._normalise_project_id(project_id)
add_minutes = max(0, int(add_minutes or 0))
if add_minutes <= 0:
return
entry_type = "prepaid_invoice" if invoice_id is not None else "manual_topup"
self.add_project_bucket_ledger_entry(
project_id,
entry_type,
ceiling_delta_minutes=add_minutes,
description=description or "Bucket ceiling increased",
invoice_id=invoice_id,
)
def get_project_bucket(self, project_id: int):
"""Return the bucket row for a project, or None if none is configured."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return None
row = self.conn.execute(
"""
SELECT
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent,
updated_at
FROM project_buckets
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
if row is None:
baseline, ceiling = self._project_bucket_ledger_totals(project_id)
if baseline == 0 and ceiling == 0:
return None
self._sync_project_bucket_cache(project_id)
row = self.conn.execute(
"""
SELECT
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent,
updated_at
FROM project_buckets
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
return row
def logged_minutes_for_project(self, project_id: int) -> int:
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return 0
row = self.conn.execute(
"""
SELECT COALESCE(SUM(minutes), 0) AS minutes
FROM time_log
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
return int(row["minutes"] or 0)
def project_bucket_status(self, project_id: int) -> dict[str, object] | None:
"""Return computed bucket state for a project.
States:
- ``unconfigured``: no bucket ceiling has been set
- ``ok``: below warning threshold
- ``warning``: at/above warning threshold but below ceiling
- ``reached``: exactly at ceiling
- ``exceeded``: beyond ceiling
"""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return None
project_name = self.list_projects_by_id(project_id)
if not project_name:
return None
bucket = self.get_project_bucket(project_id)
logged = self.logged_minutes_for_project(project_id)
baseline = int(bucket["baseline_minutes"] or 0) if bucket else 0
ceiling = int(bucket["bucket_ceiling_minutes"] or 0) if bucket else 0
warn_at = float(bucket["warn_at_percent"] or 80.0) if bucket else 80.0
used = baseline + logged
if ceiling <= 0:
state = "unconfigured"
remaining = None
pct = None
else:
remaining = ceiling - used
pct = used / ceiling * 100.0
if used > ceiling:
state = "exceeded"
elif used == ceiling:
state = "reached"
elif pct >= warn_at:
state = "warning"
else:
state = "ok"
return {
"project_id": project_id,
"project_name": project_name,
"baseline_minutes": baseline,
"logged_minutes": logged,
"used_minutes": used,
"bucket_ceiling_minutes": ceiling,
"remaining_minutes": remaining,
"percent_used": pct,
"warn_at_percent": warn_at,
"state": state,
}
def list_project_summaries(self):
"""Return one row per project with aggregate time/docs/invoices."""
rows = self.conn.execute(
"""
SELECT
p.id AS project_id,
p.name AS project_name,
COALESCE(pb.baseline_minutes, 0) AS baseline_minutes,
COALESCE(pb.bucket_ceiling_minutes, 0) AS bucket_ceiling_minutes,
COALESCE(pb.warn_at_percent, 80.0) AS warn_at_percent,
COALESCE((
SELECT SUM(t.minutes)
FROM time_log AS t
WHERE t.project_id = p.id
), 0) AS logged_minutes,
COALESCE((
SELECT COUNT(*)
FROM time_log AS t2
WHERE t2.project_id = p.id
), 0) AS time_log_count,
COALESCE((
SELECT COUNT(*)
FROM project_documents AS d
WHERE d.project_id = p.id
), 0) AS document_count,
COALESCE((
SELECT COUNT(*)
FROM invoices AS i
WHERE i.project_id = p.id
), 0) AS invoice_count
FROM projects AS p
LEFT JOIN project_buckets AS pb
ON pb.project_id = p.id
ORDER BY LOWER(p.name);
"""
).fetchall()
return rows
def time_logs_for_project(self, project_id: int):
"""Return all time-log entries for a project, newest first."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
return self.conn.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
FROM time_log AS t
JOIN projects AS p ON p.id = t.project_id
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
ORDER BY t.page_date DESC, t.created_at DESC, t.id DESC;
""",
(project_id,),
).fetchall()
def project_bucket_ledger_for_project(self, project_id: int):
"""Return bucket ledger rows, including time-log consumption entries."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
l.occurred_at AS occurred_at,
l.entry_type AS entry_type,
l.description AS description,
l.baseline_delta_minutes AS baseline_delta_minutes,
l.ceiling_delta_minutes AS ceiling_delta_minutes,
0 AS used_delta_minutes,
l.invoice_id AS invoice_id,
i.invoice_number AS invoice_number,
NULL AS time_log_id,
NULL AS page_date,
NULL AS activity_name,
l.id AS source_id
FROM project_bucket_ledger AS l
LEFT JOIN invoices AS i ON i.id = l.invoice_id
WHERE l.project_id = ?
UNION ALL
SELECT
t.created_at AS occurred_at,
'time_log' AS entry_type,
COALESCE(NULLIF(t.note, ''), a.name) AS description,
0 AS baseline_delta_minutes,
0 AS ceiling_delta_minutes,
t.minutes AS used_delta_minutes,
NULL AS invoice_id,
NULL AS invoice_number,
t.id AS time_log_id,
t.page_date AS page_date,
a.name AS activity_name,
t.id AS source_id
FROM time_log AS t
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
ORDER BY occurred_at DESC, source_id DESC;
""",
(project_id, project_id),
).fetchall()
return rows
def project_activity_log_for_project(self, project_id: int):
"""Return a generated project changelog from existing dated records."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
t.created_at AS occurred_at,
'time_log' AS event_type,
'Time logged' AS title,
printf('%.2f hours for %s%s',
t.minutes / 60.0,
a.name,
CASE
WHEN COALESCE(t.note, '') = '' THEN ''
ELSE ': ' || t.note
END) AS details,
t.id AS source_id
FROM time_log AS t
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
UNION ALL
SELECT
d.uploaded_at AS occurred_at,
'document' AS event_type,
'Document added' AS title,
d.file_name || CASE
WHEN COALESCE(d.description, '') = '' THEN ''
ELSE ': ' || d.description
END AS details,
d.id AS source_id
FROM project_documents AS d
WHERE d.project_id = ?
UNION ALL
SELECT
COALESCE(i.created_at, i.issue_date) AS occurred_at,
'invoice' AS event_type,
'Invoice issued' AS title,
i.invoice_number || '' || printf('%.2f %s', i.total_cents / 100.0, i.currency) AS details,
i.id AS source_id
FROM invoices AS i
WHERE i.project_id = ?
UNION ALL
SELECT
l.occurred_at AS occurred_at,
'bucket' AS event_type,
'Bucket ledger updated' AS title,
COALESCE(l.description, l.entry_type) AS details,
l.id AS source_id
FROM project_bucket_ledger AS l
WHERE l.project_id = ?
ORDER BY occurred_at DESC, source_id DESC;
""",
(project_id, project_id, project_id, project_id),
).fetchall()
return rows
def list_activities(self) -> list[ActivityRow]:
cur = self.conn.cursor()
rows = cur.execute(
@ -2101,7 +2640,7 @@ class DBManager:
)
def list_client_companies(self) -> list[str]:
"""Return distinct client display names from project_billing."""
"""Return distinct client display names from project billing settings."""
cur = self.conn.cursor()
rows = cur.execute(
"""
@ -2203,6 +2742,42 @@ class DBManager:
# ------------------------- Invoices -------------------------------#
def invoices_for_project_with_documents(self, project_id: int):
"""Return all invoices for a project with linked invoice document metadata."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
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,
i.document_id,
i.created_at,
d.file_name AS document_file_name
FROM invoices AS i
LEFT JOIN projects AS p ON p.id = i.project_id
LEFT JOIN project_documents AS d ON d.id = i.document_id
WHERE i.project_id = ?
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
""",
(project_id,),
).fetchall()
return rows
def create_invoice(
self,
project_id: int,

View file

@ -91,6 +91,7 @@ class InvoiceDialog(QDialog):
super().__init__(parent)
self._db = db
self._project_id = project_id
self.last_invoice_id: int | None = None
self._start = start_date_iso
self._end = end_date_iso
@ -661,6 +662,7 @@ class InvoiceDialog(QDialog):
line_items=[(li.description, li.hours, li.rate_cents) for li in items],
time_log_ids=time_log_ids,
)
self.last_invoice_id = invoice_id
# Automatically create a reminder for the invoice due date
if self.cfg.reminders:

View file

@ -190,7 +190,7 @@
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
"reminder": "Reminder",
"set_reminder": "Set reminder prompt",
"set_reminder": "Set Reminder",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
@ -276,9 +276,8 @@
"time_log_report": "Time log report",
"time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"time_log_with_total": "Time log ({hours:.2f}h)",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",
@ -314,22 +313,19 @@
"manage_reminders": "Manage Reminders",
"upcoming_reminders": "Upcoming Reminders",
"no_upcoming_reminders": "No upcoming reminders",
"once": "once",
"once": "Once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"add_reminder": "Add Reminder",
"set_reminder": "Set Reminder",
"edit_reminder": "Edit Reminder",
"delete_reminder": "Delete Reminder",
"delete_reminders": "Delete Reminders",
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
"reminder": "Reminder",
"reminders": "Reminders",
"time": "Time",
"once": "Once",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
@ -437,5 +433,58 @@
"invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
"invoice_required": "Please select a specific invoice before trying to delete an invoice.",
"refresh": "Refresh",
"status": "Status",
"client": "Client",
"documents": "Documents",
"invoices": "Invoices",
"documents_select_document": "Please select a document first.",
"toolbar_projects": "Projects",
"projects_title": "Projects",
"projects_none": "No projects have been configured yet. Add a project from the time logging dialog first.",
"projects_summary_tab": "Summary",
"project_bucket": "Project bucket",
"project_bucket_settings": "Bucket settings",
"project_bucket_replenish": "Replenish",
"project_bucket_baseline": "Baseline",
"project_bucket_ceiling": "Bucket ceiling",
"project_bucket_warn_at": "Warn at",
"project_hours_logged": "Logged",
"project_bucket_used": "Used",
"project_bucket_remaining": "Remaining",
"project_bucket_add_to_ceiling": "Add to ceiling",
"project_bucket_no_project": "Select a project to view its bucket.",
"project_bucket_unconfigured": "{project}: {used:.2f}h used so far ({baseline:.2f}h baseline + {logged:.2f}h logged). No bucket ceiling has been set.",
"project_bucket_status": "{project}: {used:.2f}h / {ceiling:.2f}h used{percent}{remaining}. Baseline {baseline:.2f}h, logged in Bouquin {logged:.2f}h. Status: {state}.",
"project_bucket_state_unconfigured": "No bucket",
"project_bucket_state_ok": "OK",
"project_bucket_state_warning": "Approaching bucket ceiling",
"project_bucket_state_reached": "Bucket ceiling reached",
"project_bucket_state_exceeded": "Bucket ceiling exceeded",
"project_bucket_alert_title": "Project bucket alert",
"project_bucket_alert_message": "{status}",
"project_open_invoice_document": "Open invoice document",
"project_invoice_no_document": "This invoice does not have a linked document.",
"project_bucket_invoice_prepaid": "Invoice prepaid hours",
"project_prepaid_invoice_default_desc": "Prepaid support bucket ({hours:.2f} hours)",
"project_prepaid_invoice_hours_required": "Enter a prepaid-hours amount greater than zero before creating an invoice.",
"time_logs": "Time logs",
"project_bucket_ledger_tab": "Bucket ledger",
"project_changelog_tab": "Project log",
"project_bucket_baseline_delta": "Baseline Δ",
"project_bucket_ceiling_delta": "Ceiling Δ",
"project_bucket_used_delta": "Used Δ",
"project_bucket_manual_topup_desc": "Manual bucket top-up ({hours:.2f} hours)",
"project_bucket_prepaid_invoice_desc": "Prepaid bucket invoice ({hours:.2f} hours)",
"project_bucket_ledger_type_settings_adjustment": "Bucket settings",
"project_bucket_ledger_type_manual_topup": "Manual top-up",
"project_bucket_ledger_type_prepaid_invoice": "Prepaid invoice",
"project_bucket_ledger_type_time_log": "Time logged",
"project_changelog_type_time_log": "Time log",
"project_changelog_type_document": "Document",
"project_changelog_type_invoice": "Invoice",
"project_changelog_type_bucket": "Bucket",
"summary": "Summary",
"details": "Details"
}

View file

@ -49,6 +49,7 @@ from PySide6.QtWidgets import (
from . import strings
from .bug_report_dialog import BugReportDialog
from .projects import ProjectsDialog
from .db import DBManager
from .documents import DocumentsDialog, TodaysDocumentsWidget
from .find_bar import FindBar
@ -239,6 +240,12 @@ class MainWindow(QMainWindow):
act_stats.setShortcut("Ctrl+Shift+S")
act_stats.triggered.connect(self._open_statistics)
file_menu.addAction(act_stats)
self.actProjects = QAction(strings._("projects"), self)
self.actProjects.setShortcut("Ctrl+Shift+C")
self.actProjects.setShortcutContext(Qt.ApplicationShortcut)
self.actProjects.triggered.connect(self._open_projects)
file_menu.addAction(self.actProjects)
self.addAction(self.actProjects)
act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self)
act_lock.setShortcut("Ctrl+Shift+L")
act_lock.triggered.connect(self._enter_lock)
@ -338,6 +345,9 @@ class MainWindow(QMainWindow):
if not self.cfg.time_log:
self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
self.toolBar.actProjects.setVisible(False)
self.actProjects.setVisible(False)
self.actProjects.setEnabled(False)
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)
@ -1461,6 +1471,7 @@ class MainWindow(QMainWindow):
self._tb_alarm = self._on_alarm_requested
self._tb_timer = self._on_timer_requested
self._tb_documents = self._on_documents_requested
self._tb_projects = self._open_projects
self._tb_font_larger = self._on_font_larger_requested
self._tb_font_smaller = self._on_font_smaller_requested
@ -1475,6 +1486,7 @@ class MainWindow(QMainWindow):
tb.alarmRequested.connect(self._tb_alarm)
tb.timerRequested.connect(self._tb_timer)
tb.documentsRequested.connect(self._tb_documents)
tb.projectsRequested.connect(self._tb_projects)
tb.insertImageRequested.connect(self._on_insert_image)
tb.historyRequested.connect(self._open_history)
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
@ -1716,6 +1728,13 @@ class MainWindow(QMainWindow):
timer.start(msecs)
self._reminder_timers.append(timer)
# ----------- Projects handler ------------#
def _open_projects(self):
if not self.cfg.time_log:
return
dlg = ProjectsDialog(self.db, self)
dlg.exec()
# ----------- Documents handler ------------#
def _on_documents_requested(self):
documents_dlg = DocumentsDialog(self.db, self)
@ -1868,9 +1887,15 @@ class MainWindow(QMainWindow):
if not self.cfg.time_log:
self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
self.toolBar.actProjects.setVisible(False)
self.actProjects.setVisible(False)
self.actProjects.setEnabled(False)
else:
self.time_log.show()
self.toolBar.actTimer.setVisible(True)
self.toolBar.actProjects.setVisible(True)
self.actProjects.setVisible(True)
self.actProjects.setEnabled(True)
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)

845
bouquin/projects.py Normal file
View file

@ -0,0 +1,845 @@
from __future__ import annotations
from PySide6.QtCore import QDate, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .document_utils import open_document_from_db
from .invoices import InvoiceDialog
_WARNING_STYLES = {
"unconfigured": "",
"ok": "QLabel { padding: 6px; border: 1px solid #5b8f5b; border-radius: 4px; }",
"warning": "QLabel { padding: 6px; border: 1px solid #b58900; border-radius: 4px; }",
"reached": (
"QLabel { padding: 6px; border: 1px solid #b85c00; "
"border-radius: 4px; font-weight: bold; }"
),
"exceeded": (
"QLabel { padding: 6px; border: 1px solid #b00020; "
"border-radius: 4px; font-weight: bold; }"
),
}
def hours_from_minutes(minutes: int | float | None) -> float:
return float(minutes or 0) / 60.0
def minutes_from_hours(hours: float | int | None) -> int:
return int(round(float(hours or 0) * 60))
def format_bucket_status(status: dict[str, object] | None) -> str:
"""Human-readable project bucket status used by project/time-log UIs."""
if not status:
return strings._("project_bucket_no_project")
project = str(status.get("project_name") or "")
state = str(status.get("state") or "unconfigured")
baseline = hours_from_minutes(status.get("baseline_minutes"))
logged = hours_from_minutes(status.get("logged_minutes"))
used = hours_from_minutes(status.get("used_minutes"))
ceiling = hours_from_minutes(status.get("bucket_ceiling_minutes"))
remaining_minutes = status.get("remaining_minutes")
remaining = (
None if remaining_minutes is None else hours_from_minutes(remaining_minutes)
)
pct = status.get("percent_used")
pct_text = "" if pct is None else f" ({float(pct):.1f}%)"
if state == "unconfigured":
return strings._("project_bucket_unconfigured").format(
project=project,
baseline=baseline,
logged=logged,
used=used,
)
remaining_text = "" if remaining is None else f", {remaining:.2f}h remaining"
return strings._("project_bucket_status").format(
project=project,
used=used,
ceiling=ceiling,
percent=pct_text,
remaining=remaining_text,
baseline=baseline,
logged=logged,
state=strings._(f"project_bucket_state_{state}"),
)
class ProjectsDialog(QDialog):
"""Project overview and prepaid-hours bucket manager."""
SUM_PROJECT = 0
SUM_LOGGED = 1
SUM_BASELINE = 2
SUM_USED = 3
SUM_CEILING = 4
SUM_REMAINING = 5
SUM_STATE = 6
SUM_TIME_LOGS = 7
SUM_DOCS = 8
SUM_INVOICES = 9
LOG_DATE = 0
LOG_ACTIVITY = 1
LOG_NOTE = 2
LOG_HOURS = 3
LOG_CREATED = 4
LEDGER_DATE = 0
LEDGER_TYPE = 1
LEDGER_BASELINE = 2
LEDGER_CEILING = 3
LEDGER_USED = 4
LEDGER_NOTE = 5
CHANGE_DATE = 0
CHANGE_TYPE = 1
CHANGE_TITLE = 2
CHANGE_DETAILS = 3
DOC_FILE = 0
DOC_ADDED = 1
DOC_DESCRIPTION = 2
DOC_SIZE = 3
INV_NUMBER = 0
INV_ISSUE = 1
INV_DUE = 2
INV_TOTAL = 3
INV_PAID = 4
INV_DOCUMENT = 5
def __init__(self, db: DBManager, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._db = db
self._reloading = False
self.setWindowTitle(strings._("projects_title"))
self.resize(1100, 700)
root = QVBoxLayout(self)
top_form = QFormLayout()
top_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
root.addLayout(top_form)
project_row = QHBoxLayout()
self.project_combo = QComboBox()
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
project_row.addWidget(self.project_combo, 1)
self.refresh_btn = QPushButton(strings._("refresh"))
self.refresh_btn.clicked.connect(self.reload)
project_row.addWidget(self.refresh_btn)
top_form.addRow(strings._("project"), project_row)
self.status_label = QLabel("")
self.status_label.setWordWrap(True)
self.status_label.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.status_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding
)
self.status_label.setMinimumHeight(
self.status_label.fontMetrics().lineSpacing() * 3 + 18
)
top_form.addRow(strings._("project_bucket"), self.status_label)
bucket_row = QHBoxLayout()
self.baseline_spin = QDoubleSpinBox()
self.baseline_spin.setRange(0.0, 1_000_000.0)
self.baseline_spin.setDecimals(2)
self.baseline_spin.setSingleStep(1.0)
self.baseline_spin.setSuffix(" h")
bucket_row.addWidget(QLabel(strings._("project_bucket_baseline")))
bucket_row.addWidget(self.baseline_spin)
self.ceiling_spin = QDoubleSpinBox()
self.ceiling_spin.setRange(0.0, 1_000_000.0)
self.ceiling_spin.setDecimals(2)
self.ceiling_spin.setSingleStep(1.0)
self.ceiling_spin.setSuffix(" h")
bucket_row.addWidget(QLabel(strings._("project_bucket_ceiling")))
bucket_row.addWidget(self.ceiling_spin)
self.warn_spin = QDoubleSpinBox()
self.warn_spin.setRange(0.0, 100.0)
self.warn_spin.setDecimals(0)
self.warn_spin.setSingleStep(5.0)
self.warn_spin.setSuffix(" %")
bucket_row.addWidget(QLabel(strings._("project_bucket_warn_at")))
bucket_row.addWidget(self.warn_spin)
self.save_bucket_btn = QPushButton(strings._("save"))
self.save_bucket_btn.clicked.connect(self._save_bucket)
bucket_row.addWidget(self.save_bucket_btn)
top_form.addRow(strings._("project_bucket_settings"), bucket_row)
topup_row = QHBoxLayout()
self.topup_spin = QDoubleSpinBox()
self.topup_spin.setRange(0.0, 1_000_000.0)
self.topup_spin.setDecimals(2)
self.topup_spin.setSingleStep(1.0)
self.topup_spin.setValue(40.0)
self.topup_spin.setSuffix(" h")
topup_row.addWidget(self.topup_spin)
self.topup_btn = QPushButton(strings._("project_bucket_add_to_ceiling"))
self.topup_btn.clicked.connect(self._add_to_ceiling)
topup_row.addWidget(self.topup_btn)
self.invoice_prepaid_btn = QPushButton(
strings._("project_bucket_invoice_prepaid")
)
self.invoice_prepaid_btn.clicked.connect(self._invoice_prepaid_hours)
topup_row.addWidget(self.invoice_prepaid_btn)
topup_row.addStretch(1)
top_form.addRow(strings._("project_bucket_replenish"), topup_row)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
self.summary_table = QTableWidget()
self.summary_table.setColumnCount(10)
self.summary_table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("project_hours_logged"),
strings._("project_bucket_baseline"),
strings._("project_bucket_used"),
strings._("project_bucket_ceiling"),
strings._("project_bucket_remaining"),
strings._("status"),
strings._("time_logs"),
strings._("documents"),
strings._("invoices"),
]
)
self.summary_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.summary_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.summary_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.summary_table.itemSelectionChanged.connect(self._on_summary_selected)
header = self.summary_table.horizontalHeader()
header.setSectionResizeMode(self.SUM_PROJECT, QHeaderView.Stretch)
for col in range(1, 10):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
self.tabs.addTab(self.summary_table, strings._("projects_summary_tab"))
logs_tab = QWidget()
logs_layout = QVBoxLayout(logs_tab)
self.time_logs_table = QTableWidget()
self.time_logs_table.setColumnCount(5)
self.time_logs_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("activity"),
strings._("note"),
strings._("hours"),
strings._("created_at"),
]
)
self.time_logs_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.time_logs_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.time_logs_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
log_header = self.time_logs_table.horizontalHeader()
log_header.setSectionResizeMode(self.LOG_DATE, QHeaderView.ResizeToContents)
log_header.setSectionResizeMode(self.LOG_ACTIVITY, QHeaderView.ResizeToContents)
log_header.setSectionResizeMode(self.LOG_NOTE, QHeaderView.Stretch)
log_header.setSectionResizeMode(self.LOG_HOURS, QHeaderView.ResizeToContents)
log_header.setSectionResizeMode(self.LOG_CREATED, QHeaderView.ResizeToContents)
logs_layout.addWidget(self.time_logs_table, 1)
self.tabs.addTab(logs_tab, strings._("time_logs"))
ledger_tab = QWidget()
ledger_layout = QVBoxLayout(ledger_tab)
self.bucket_ledger_table = QTableWidget()
self.bucket_ledger_table.setColumnCount(6)
self.bucket_ledger_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("type"),
strings._("project_bucket_baseline_delta"),
strings._("project_bucket_ceiling_delta"),
strings._("project_bucket_used_delta"),
strings._("note"),
]
)
self.bucket_ledger_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.bucket_ledger_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.bucket_ledger_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
ledger_header = self.bucket_ledger_table.horizontalHeader()
ledger_header.setSectionResizeMode(
self.LEDGER_DATE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_TYPE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_BASELINE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_CEILING, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_USED, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(self.LEDGER_NOTE, QHeaderView.Stretch)
ledger_layout.addWidget(self.bucket_ledger_table, 1)
self.tabs.addTab(ledger_tab, strings._("project_bucket_ledger_tab"))
changelog_tab = QWidget()
changelog_layout = QVBoxLayout(changelog_tab)
self.changelog_table = QTableWidget()
self.changelog_table.setColumnCount(4)
self.changelog_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("type"),
strings._("summary"),
strings._("details"),
]
)
self.changelog_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.changelog_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.changelog_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
changelog_header = self.changelog_table.horizontalHeader()
changelog_header.setSectionResizeMode(
self.CHANGE_DATE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(
self.CHANGE_TYPE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(
self.CHANGE_TITLE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(self.CHANGE_DETAILS, QHeaderView.Stretch)
changelog_layout.addWidget(self.changelog_table, 1)
self.tabs.addTab(changelog_tab, strings._("project_changelog_tab"))
docs_tab = QWidget()
docs_layout = QVBoxLayout(docs_tab)
self.documents_table = QTableWidget()
self.documents_table.setColumnCount(4)
self.documents_table.setHorizontalHeaderLabels(
[
strings._("documents_col_file"),
strings._("documents_col_added"),
strings._("documents_col_description"),
strings._("documents_col_size"),
]
)
self.documents_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.documents_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.documents_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.documents_table.itemDoubleClicked.connect(self._open_selected_document)
doc_header = self.documents_table.horizontalHeader()
doc_header.setSectionResizeMode(self.DOC_FILE, QHeaderView.Stretch)
doc_header.setSectionResizeMode(self.DOC_ADDED, QHeaderView.ResizeToContents)
doc_header.setSectionResizeMode(self.DOC_DESCRIPTION, QHeaderView.Stretch)
doc_header.setSectionResizeMode(self.DOC_SIZE, QHeaderView.ResizeToContents)
docs_layout.addWidget(self.documents_table, 1)
docs_buttons = QHBoxLayout()
docs_buttons.addStretch(1)
self.open_doc_btn = QPushButton(strings._("documents_open"))
self.open_doc_btn.clicked.connect(self._open_selected_document)
docs_buttons.addWidget(self.open_doc_btn)
docs_layout.addLayout(docs_buttons)
self.tabs.addTab(docs_tab, strings._("documents"))
invoices_tab = QWidget()
invoices_layout = QVBoxLayout(invoices_tab)
self.invoices_table = QTableWidget()
self.invoices_table.setColumnCount(6)
self.invoices_table.setHorizontalHeaderLabels(
[
strings._("invoice_number"),
strings._("invoice_issue_date"),
strings._("invoice_due_date"),
strings._("invoice_total"),
strings._("invoice_paid_at"),
strings._("documents_col_file"),
]
)
self.invoices_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.invoices_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.invoices_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.invoices_table.itemDoubleClicked.connect(self._open_invoice_document)
inv_header = self.invoices_table.horizontalHeader()
inv_header.setSectionResizeMode(self.INV_NUMBER, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_ISSUE, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_DUE, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_TOTAL, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_PAID, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_DOCUMENT, QHeaderView.Stretch)
invoices_layout.addWidget(self.invoices_table, 1)
invoice_buttons = QHBoxLayout()
invoice_buttons.addStretch(1)
self.open_invoice_doc_btn = QPushButton(
strings._("project_open_invoice_document")
)
self.open_invoice_doc_btn.clicked.connect(self._open_invoice_document)
invoice_buttons.addWidget(self.open_invoice_doc_btn)
invoices_layout.addLayout(invoice_buttons)
self.tabs.addTab(invoices_tab, strings._("invoices"))
bottom = QHBoxLayout()
bottom.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
bottom.addWidget(close_btn)
root.addLayout(bottom)
self.reload()
def reload(self) -> None:
current = self._current_project_id()
self._load_projects(current)
self._reload_summary()
self._load_selected_project()
def _load_projects(self, preferred: int | None = None) -> None:
self._reloading = True
try:
self.project_combo.clear()
for r in self._db.list_project_summaries():
self.project_combo.addItem(str(r["project_name"]), int(r["project_id"]))
if preferred is not None:
idx = self.project_combo.findData(preferred)
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
elif self.project_combo.count() > 0:
self.project_combo.setCurrentIndex(0)
finally:
self._reloading = False
def _current_project_id(self) -> int | None:
data = self.project_combo.currentData()
return int(data) if data is not None else None
def _on_project_changed(self, _idx: int) -> None:
if self._reloading:
return
self._load_selected_project()
def _on_summary_selected(self) -> None:
if self._reloading:
return
row = self.summary_table.currentRow()
if row < 0:
return
item = self.summary_table.item(row, self.SUM_PROJECT)
if item is None:
return
project_id = item.data(Qt.ItemDataRole.UserRole)
if project_id is None:
return
idx = self.project_combo.findData(int(project_id))
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
def _state_for_row(self, r) -> str:
baseline = int(r["baseline_minutes"] or 0)
logged = int(r["logged_minutes"] or 0)
used = baseline + logged
ceiling = int(r["bucket_ceiling_minutes"] or 0)
warn_at = float(r["warn_at_percent"] or 80.0)
if ceiling <= 0:
return "unconfigured"
pct = used / ceiling * 100.0
if used > ceiling:
return "exceeded"
if used == ceiling:
return "reached"
if pct >= warn_at:
return "warning"
return "ok"
def _reload_summary(self) -> None:
self._reloading = True
try:
rows = self._db.list_project_summaries()
self.summary_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
project_id = int(r["project_id"])
baseline = int(r["baseline_minutes"] or 0)
logged = int(r["logged_minutes"] or 0)
used = baseline + logged
ceiling = int(r["bucket_ceiling_minutes"] or 0)
remaining = ceiling - used if ceiling > 0 else None
state = self._state_for_row(r)
project_item = QTableWidgetItem(str(r["project_name"]))
project_item.setData(Qt.ItemDataRole.UserRole, project_id)
self.summary_table.setItem(row_idx, self.SUM_PROJECT, project_item)
self.summary_table.setItem(
row_idx,
self.SUM_LOGGED,
QTableWidgetItem(f"{hours_from_minutes(logged):.2f}"),
)
self.summary_table.setItem(
row_idx,
self.SUM_BASELINE,
QTableWidgetItem(f"{hours_from_minutes(baseline):.2f}"),
)
self.summary_table.setItem(
row_idx,
self.SUM_USED,
QTableWidgetItem(f"{hours_from_minutes(used):.2f}"),
)
ceiling_text = (
"" if ceiling <= 0 else f"{hours_from_minutes(ceiling):.2f}"
)
remaining_text = (
"" if remaining is None else f"{hours_from_minutes(remaining):.2f}"
)
state_text = strings._(f"project_bucket_state_{state}")
self.summary_table.setItem(
row_idx, self.SUM_CEILING, QTableWidgetItem(ceiling_text)
)
self.summary_table.setItem(
row_idx, self.SUM_REMAINING, QTableWidgetItem(remaining_text)
)
self.summary_table.setItem(
row_idx, self.SUM_STATE, QTableWidgetItem(state_text)
)
self.summary_table.setItem(
row_idx,
self.SUM_TIME_LOGS,
QTableWidgetItem(str(r["time_log_count"] or 0)),
)
self.summary_table.setItem(
row_idx,
self.SUM_DOCS,
QTableWidgetItem(str(r["document_count"] or 0)),
)
self.summary_table.setItem(
row_idx,
self.SUM_INVOICES,
QTableWidgetItem(str(r["invoice_count"] or 0)),
)
finally:
self._reloading = False
def _load_selected_project(self) -> None:
project_id = self._current_project_id()
if project_id is None:
self.status_label.setText(strings._("projects_none"))
self.status_label.setStyleSheet("")
self.baseline_spin.setValue(0.0)
self.ceiling_spin.setValue(0.0)
self.warn_spin.setValue(80.0)
self.time_logs_table.setRowCount(0)
self.bucket_ledger_table.setRowCount(0)
self.changelog_table.setRowCount(0)
self.documents_table.setRowCount(0)
self.invoices_table.setRowCount(0)
return
status = self._db.project_bucket_status(project_id)
self.status_label.setText(format_bucket_status(status))
state = str(status.get("state") if status else "unconfigured")
self.status_label.setStyleSheet(_WARNING_STYLES.get(state, ""))
bucket = self._db.get_project_bucket(project_id)
self.baseline_spin.setValue(
hours_from_minutes(bucket["baseline_minutes"] if bucket else 0)
)
self.ceiling_spin.setValue(
hours_from_minutes(bucket["bucket_ceiling_minutes"] if bucket else 0)
)
self.warn_spin.setValue(float(bucket["warn_at_percent"] if bucket else 80.0))
self._reload_time_logs(project_id)
self._reload_bucket_ledger(project_id)
self._reload_changelog(project_id)
self._reload_documents(project_id)
self._reload_invoices(project_id)
def _reload_time_logs(self, project_id: int) -> None:
rows = self._db.time_logs_for_project(project_id)
self.time_logs_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
self.time_logs_table.setItem(
row_idx, self.LOG_DATE, QTableWidgetItem(r["page_date"] or "")
)
self.time_logs_table.setItem(
row_idx, self.LOG_ACTIVITY, QTableWidgetItem(r["activity_name"] or "")
)
self.time_logs_table.setItem(
row_idx, self.LOG_NOTE, QTableWidgetItem(r["note"] or "")
)
self.time_logs_table.setItem(
row_idx,
self.LOG_HOURS,
QTableWidgetItem(f"{hours_from_minutes(r['minutes']):.2f}"),
)
self.time_logs_table.setItem(
row_idx, self.LOG_CREATED, QTableWidgetItem(r["created_at"] or "")
)
def _format_delta_hours(self, minutes: int | None, invert: bool = False) -> str:
minutes = int(minutes or 0)
if minutes == 0:
return ""
if invert:
minutes = -minutes
sign = "+" if minutes > 0 else "-"
return f"{sign}{hours_from_minutes(abs(minutes)):.2f}"
def _reload_bucket_ledger(self, project_id: int) -> None:
rows = self._db.project_bucket_ledger_for_project(project_id)
self.bucket_ledger_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
entry_type = str(r["entry_type"] or "")
type_text = strings._(f"project_bucket_ledger_type_{entry_type}")
if type_text == f"project_bucket_ledger_type_{entry_type}":
type_text = entry_type.replace("_", " ").title()
note = r["description"] or ""
if r["invoice_number"]:
note = (
f"{note} ({r['invoice_number']})" if note else r["invoice_number"]
)
if entry_type == "time_log" and r["page_date"]:
activity = r["activity_name"] or ""
note = f"{r['page_date']}{activity}: {note}".strip()
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_DATE, QTableWidgetItem(r["occurred_at"] or "")
)
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_TYPE, QTableWidgetItem(type_text)
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_BASELINE,
QTableWidgetItem(self._format_delta_hours(r["baseline_delta_minutes"])),
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_CEILING,
QTableWidgetItem(self._format_delta_hours(r["ceiling_delta_minutes"])),
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_USED,
QTableWidgetItem(
self._format_delta_hours(r["used_delta_minutes"], invert=True)
),
)
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_NOTE, QTableWidgetItem(note)
)
def _reload_changelog(self, project_id: int) -> None:
rows = self._db.project_activity_log_for_project(project_id)
self.changelog_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
event_type = str(r["event_type"] or "")
type_text = strings._(f"project_changelog_type_{event_type}")
if type_text == f"project_changelog_type_{event_type}":
type_text = event_type.replace("_", " ").title()
self.changelog_table.setItem(
row_idx, self.CHANGE_DATE, QTableWidgetItem(r["occurred_at"] or "")
)
self.changelog_table.setItem(
row_idx, self.CHANGE_TYPE, QTableWidgetItem(type_text)
)
self.changelog_table.setItem(
row_idx, self.CHANGE_TITLE, QTableWidgetItem(r["title"] or "")
)
self.changelog_table.setItem(
row_idx, self.CHANGE_DETAILS, QTableWidgetItem(r["details"] or "")
)
def _reload_documents(self, project_id: int) -> None:
rows = self._db.documents_for_project(project_id)
self.documents_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
doc_id, _project_id, _project_name, file_name, description, size, added = r
file_item = QTableWidgetItem(file_name or "")
file_item.setData(Qt.ItemDataRole.UserRole, int(doc_id))
self.documents_table.setItem(row_idx, self.DOC_FILE, file_item)
self.documents_table.setItem(
row_idx, self.DOC_ADDED, QTableWidgetItem(added or "")
)
self.documents_table.setItem(
row_idx, self.DOC_DESCRIPTION, QTableWidgetItem(description or "")
)
self.documents_table.setItem(
row_idx, self.DOC_SIZE, QTableWidgetItem(str(size or 0))
)
def _reload_invoices(self, project_id: int) -> None:
rows = self._db.invoices_for_project_with_documents(project_id)
self.invoices_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
document_id = r["document_id"]
file_name = r["document_file_name"] or ""
num_item = QTableWidgetItem(r["invoice_number"] or "")
num_item.setData(Qt.ItemDataRole.UserRole, int(r["id"]))
self.invoices_table.setItem(row_idx, self.INV_NUMBER, num_item)
self.invoices_table.setItem(
row_idx, self.INV_ISSUE, QTableWidgetItem(r["issue_date"] or "")
)
self.invoices_table.setItem(
row_idx, self.INV_DUE, QTableWidgetItem(r["due_date"] or "")
)
total = int(r["total_cents"] or 0) / 100.0
currency = r["currency"] or ""
self.invoices_table.setItem(
row_idx,
self.INV_TOTAL,
QTableWidgetItem(f"{total:.2f} {currency}".strip()),
)
self.invoices_table.setItem(
row_idx, self.INV_PAID, QTableWidgetItem(r["paid_at"] or "")
)
doc_item = QTableWidgetItem(file_name)
doc_item.setData(
Qt.ItemDataRole.UserRole,
int(document_id) if document_id else None,
)
self.invoices_table.setItem(row_idx, self.INV_DOCUMENT, doc_item)
def _save_bucket(self) -> None:
project_id = self._current_project_id()
if project_id is None:
return
self._db.upsert_project_bucket(
project_id,
minutes_from_hours(self.baseline_spin.value()),
minutes_from_hours(self.ceiling_spin.value()),
float(self.warn_spin.value()),
)
self.reload()
def _add_to_ceiling(self) -> None:
project_id = self._current_project_id()
if project_id is None:
return
add_minutes = minutes_from_hours(self.topup_spin.value())
if add_minutes <= 0:
return
self._db.add_to_project_bucket_ceiling(
project_id,
add_minutes,
description=strings._("project_bucket_manual_topup_desc").format(
hours=hours_from_minutes(add_minutes)
),
)
self.reload()
def _invoice_prepaid_hours(self) -> None:
project_id = self._current_project_id()
if project_id is None:
return
hours = float(self.topup_spin.value())
if hours <= 0.0:
QMessageBox.warning(
self,
strings._("project_bucket_invoice_prepaid"),
strings._("project_prepaid_invoice_hours_required"),
)
return
today = QDate.currentDate().toString("yyyy-MM-dd")
dialog = InvoiceDialog(
self._db,
project_id,
today,
today,
time_rows=[],
parent=self,
)
dialog.rb_summary.setChecked(True)
dialog.summary_desc_edit.setText(
strings._("project_prepaid_invoice_default_desc").format(hours=hours)
)
dialog.summary_hours_spin.setValue(hours)
dialog._recalc_totals()
if dialog.exec() == QDialog.Accepted:
invoice_id = getattr(dialog, "last_invoice_id", None)
if invoice_id is not None:
self._db.add_to_project_bucket_ceiling(
project_id,
minutes_from_hours(hours),
description=strings._("project_bucket_prepaid_invoice_desc").format(
hours=hours
),
invoice_id=int(invoice_id),
)
self.reload()
def _selected_doc_id(self) -> tuple[int, str] | None:
row = self.documents_table.currentRow()
if row < 0:
return None
item = self.documents_table.item(row, self.DOC_FILE)
if item is None:
return None
doc_id = item.data(Qt.ItemDataRole.UserRole)
file_name = item.text()
if doc_id is None or not file_name:
return None
return int(doc_id), file_name
def _open_selected_document(self, *_args) -> None:
selected = self._selected_doc_id()
if not selected:
QMessageBox.information(
self,
strings._("documents_open"),
strings._("documents_select_document"),
)
return
doc_id, file_name = selected
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
def _open_invoice_document(self, *_args) -> None:
row = self.invoices_table.currentRow()
if row < 0:
return
doc_item = self.invoices_table.item(row, self.INV_DOCUMENT)
if doc_item is None:
return
doc_id = doc_item.data(Qt.ItemDataRole.UserRole)
file_name = doc_item.text()
if doc_id is None or not file_name:
QMessageBox.information(
self,
strings._("project_open_invoice_document"),
strings._("project_invoice_no_document"),
)
return
open_document_from_db(self._db, int(doc_id), file_name, parent_widget=self)

View file

@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
from sqlcipher4.dbapi2 import IntegrityError
from . import strings
from .projects import format_bucket_status
from .db import DBManager
from .settings import load_db_config
from .theme import ThemeManager
@ -302,6 +303,7 @@ class TimeLogDialog(QDialog):
# Project
proj_row = QHBoxLayout()
self.project_combo = QComboBox()
self.project_combo.currentIndexChanged.connect(self._refresh_bucket_indicator)
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
self.manage_projects_btn.clicked.connect(self._manage_projects)
proj_row.addWidget(self.project_combo, 1)
@ -331,6 +333,10 @@ class TimeLogDialog(QDialog):
self.hours_spin.setValue(0.25)
form.addRow(strings._("hours"), self.hours_spin)
self.bucket_label = QLabel("")
self.bucket_label.setWordWrap(True)
form.addRow(strings._("project_bucket"), self.bucket_label)
root.addLayout(form)
# --- Buttons for entry
@ -409,6 +415,7 @@ class TimeLogDialog(QDialog):
self.project_combo.clear()
for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id)
self._refresh_bucket_indicator()
def _reload_activities(self) -> None:
activities = [name for _, name in self._db.list_activities()]
@ -461,11 +468,48 @@ class TimeLogDialog(QDialog):
self.total_label.setText(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
self._refresh_bucket_indicator()
self._current_entry_id = None
self.delete_btn.setEnabled(False)
self.add_update_btn.setText("&" + strings._("add_time_entry"))
# ----- Project bucket indicator -----------------------------------
def _refresh_bucket_indicator(self, *_args) -> None:
if not hasattr(self, "bucket_label"):
return
proj_id = self._ensure_project_id()
status = self._db.project_bucket_status(proj_id) if proj_id else None
self.bucket_label.setText(format_bucket_status(status))
state = str(status.get("state") if status else "unconfigured")
if state == "warning":
style = "QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }"
elif state in ("reached", "exceeded"):
style = "QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }"
elif state == "ok":
style = "QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }"
else:
style = ""
self.bucket_label.setStyleSheet(style)
def _maybe_show_bucket_alert(self, project_id: int | None) -> None:
if project_id is None:
return
status = self._db.project_bucket_status(project_id)
if not status:
return
state = str(status.get("state") or "")
if state not in {"reached", "exceeded"}:
return
QMessageBox.warning(
self,
strings._("project_bucket_alert_title"),
strings._("project_bucket_alert_message").format(
status=format_bucket_status(status)
),
)
# ----- Actions -----------------------------------------------------
def _on_change_date_clicked(self) -> None:
@ -561,6 +605,7 @@ class TimeLogDialog(QDialog):
)
self._reload_entries()
self._maybe_show_bucket_alert(proj_id)
if self.close_after_add:
self.close()
@ -1135,6 +1180,10 @@ class TimeReportDialog(QDialog):
self.total_label = QLabel("")
root.addWidget(self.total_label)
self.bucket_label = QLabel("")
self.bucket_label.setWordWrap(True)
root.addWidget(self.bucket_label)
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
@ -1328,6 +1377,28 @@ class TimeReportDialog(QDialog):
strings._("time_report_total").format(hours=total_hours)
)
if proj_data is None:
self.bucket_label.setText("")
self.bucket_label.setStyleSheet("")
else:
status = self._db.project_bucket_status(int(proj_data))
self.bucket_label.setText(format_bucket_status(status))
state = str(status.get("state") if status else "unconfigured")
if state == "warning":
self.bucket_label.setStyleSheet(
"QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }"
)
elif state in ("reached", "exceeded"):
self.bucket_label.setStyleSheet(
"QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }"
)
elif state == "ok":
self.bucket_label.setStyleSheet(
"QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }"
)
else:
self.bucket_label.setStyleSheet("")
def _export_csv(self):
if not self._last_rows:
QMessageBox.information(

View file

@ -21,6 +21,7 @@ class ToolBar(QToolBar):
alarmRequested = Signal()
timerRequested = Signal()
documentsRequested = Signal()
projectsRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
@ -127,6 +128,11 @@ class ToolBar(QToolBar):
self.actDocuments = QAction("📁", self)
self.actDocuments.setToolTip(strings._("toolbar_documents"))
self.actDocuments.triggered.connect(self.documentsRequested)
# Projects
self.actProjects = QAction("📌", self)
self.actProjects.setToolTip(strings._("toolbar_projects"))
self.actProjects.triggered.connect(self.projectsRequested)
# Headings are mutually exclusive (like radio buttons)
self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True)
@ -159,6 +165,7 @@ class ToolBar(QToolBar):
self.actInsertImg,
self.actAlarm,
self.actTimer,
self.actProjects,
self.actDocuments,
self.actHistory,
]
@ -186,6 +193,7 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actTimer, "")
self._style_letter_button(self.actProjects, "📌")
self._style_letter_button(self.actDocuments, "📁")
# History

8
debian/changelog vendored
View file

@ -1,3 +1,11 @@
bouquin (0.9.0) unstable; urgency=medium
* Add 'Projects' interface for unified time/invoice/docs view.
* Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
* Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
-- Miguel Jacq <mig@mig5.net> Sun, 07 Jun 2026 17:18:00 +1000
bouquin (0.8.4) unstable; urgency=medium
* Dependency updates

295
poetry.lock generated
View file

@ -13,13 +13,13 @@ files = [
[[package]]
name = "certifi"
version = "2026.4.22"
version = "2026.5.20"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
{file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
{file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
{file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
]
[[package]]
@ -173,117 +173,117 @@ files = [
[[package]]
name = "coverage"
version = "7.14.0"
version = "7.14.1"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
files = [
{file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"},
{file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"},
{file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"},
{file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"},
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"},
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"},
{file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"},
{file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"},
{file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"},
{file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"},
{file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"},
{file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"},
{file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"},
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"},
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"},
{file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"},
{file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"},
{file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"},
{file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"},
{file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"},
{file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"},
{file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"},
{file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"},
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"},
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"},
{file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"},
{file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"},
{file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"},
{file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"},
{file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"},
{file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"},
{file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"},
{file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"},
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"},
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"},
{file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"},
{file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"},
{file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"},
{file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"},
{file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"},
{file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"},
{file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"},
{file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"},
{file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"},
{file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"},
{file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"},
{file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"},
{file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"},
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"},
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"},
{file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"},
{file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"},
{file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"},
{file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"},
{file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"},
{file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"},
{file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"},
{file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"},
{file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"},
{file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"},
{file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"},
{file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"},
{file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"},
{file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"},
{file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"},
{file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"},
{file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"},
{file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"},
{file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"},
{file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"},
{file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"},
{file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"},
{file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"},
{file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"},
{file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"},
{file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"},
{file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"},
{file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"},
{file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"},
{file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"},
{file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"},
{file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"},
{file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"},
{file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"},
{file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"},
{file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"},
{file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"},
{file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"},
{file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"},
{file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"},
{file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"},
{file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"},
{file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"},
{file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"},
{file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"},
{file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"},
{file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"},
{file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"},
{file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"},
{file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"},
{file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"},
{file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"},
{file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"},
{file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"},
{file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"},
{file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"},
{file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"},
{file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"},
{file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"},
{file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"},
{file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"},
{file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"},
{file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"},
{file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"},
{file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"},
{file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"},
{file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"},
{file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"},
{file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"},
{file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"},
{file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"},
{file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"},
]
[package.dependencies]
@ -325,13 +325,13 @@ test = ["pytest (>=6)"]
[[package]]
name = "idna"
version = "3.15"
version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
@ -421,57 +421,58 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6"
version = "6.11.0"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
{file = "pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"},
{file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"},
{file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"},
]
[package.dependencies]
PySide6_Addons = "6.11.0"
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
PySide6_Addons = "6.11.1"
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6-addons"
version = "6.11.0"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
{file = "pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"},
]
[package.dependencies]
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
[[package]]
name = "pyside6-essentials"
version = "6.11.0"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"},
]
[package.dependencies]
shiboken6 = "6.11.0"
shiboken6 = "6.11.1"
[[package]]
name = "pytest"
@ -554,13 +555,13 @@ doc = ["sphinx", "sphinx_rtd_theme"]
[[package]]
name = "requests"
version = "2.34.0"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
files = [
{file = "requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60"},
{file = "requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a"},
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
@ -575,16 +576,16 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "shiboken6"
version = "6.11.0"
version = "6.11.1"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
{file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"},
{file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"},
{file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"},
]
[[package]]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.8.4"
version = "0.9.0"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"

View file

@ -4,7 +4,7 @@
# provides the Python distribution/module as "sqlcipher4". To keep Fedora's
# auto-generated python3dist() Requires correct, we rewrite the dependency key in
# pyproject.toml at build time.
%global upstream_version 0.8.4
%global upstream_version 0.9.0
Name: bouquin
Version: %{upstream_version}
@ -82,6 +82,10 @@ install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolo
%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
%changelog
* Sun Jun 07 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Add 'Projects' interface for unified time/invoice/docs view.
- Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
- Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
* Wed May 13 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Dependency updates
- SQLCipher 4.16.0

View file

@ -1900,9 +1900,60 @@ def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
qtbot.addWidget(window)
window.show()
# Verify time_log widget is hidden
# Verify time_log widget is hidden, including dependent Projects entry points.
assert window.time_log.isHidden()
assert not window.toolBar.actTimer.isVisible()
assert not window.toolBar.actProjects.isVisible()
assert not window.actProjects.isVisible()
assert not window.actProjects.isEnabled()
def test_main_window_projects_action_visible_with_time_log(qtbot, app, tmp_db_cfg):
"""Projects is available from the menu/shortcut only when time logging is enabled."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", True)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
assert window.toolBar.actProjects.isVisible()
assert window.actProjects.isVisible()
assert window.actProjects.isEnabled()
def test_main_window_open_projects_noops_when_time_log_disabled(qtbot, app, tmp_db_cfg):
"""The handler is also guarded, so a stale shortcut cannot open Projects."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", False)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
with patch("bouquin.main_window.ProjectsDialog") as projects_dialog:
window._open_projects()
projects_dialog.assert_not_called()
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):

768
tests/test_projects.py Normal file
View file

@ -0,0 +1,768 @@
from unittest.mock import patch
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QDialog, QMessageBox
from bouquin.projects import (
ProjectsDialog,
format_bucket_status,
hours_from_minutes,
minutes_from_hours,
)
from bouquin.time_log import TimeLogDialog, TimeReportDialog
def _add_project(fresh_db, project_name: str) -> int:
project_id = fresh_db.add_project(project_name)
fresh_db.upsert_project_billing(
project_id,
hourly_rate_cents=15000,
currency="AUD",
tax_label="GST",
tax_rate_percent=10.0,
client_name=f"{project_name} Contact",
client_company=project_name,
client_address="1 Example Street",
client_email="client@example.test",
)
return project_id
def _add_minutes(fresh_db, project_id: int, minutes: int, note: str = "Work") -> int:
activity_id = fresh_db.add_activity("Support")
return fresh_db.add_time_log("2026-01-01", project_id, activity_id, minutes, note)
# ============================================================================
# Unit helpers
# ============================================================================
def test_project_hour_minute_helpers_round_trip():
assert hours_from_minutes(None) == 0.0
assert hours_from_minutes(90) == 1.5
assert minutes_from_hours(None) == 0
assert minutes_from_hours(1.25) == 75
assert minutes_from_hours(1.333) == 80
def test_format_bucket_status_handles_none_unconfigured_and_reached(fresh_db):
assert "Select a project" in format_bucket_status(None)
project_id = _add_project(fresh_db, "No Bucket Project")
_add_minutes(fresh_db, project_id, 30)
unconfigured = fresh_db.project_bucket_status(project_id)
text = format_bucket_status(unconfigured)
assert "No Bucket Project" in text
assert "No bucket ceiling" in text
assert "0.50h used" in text
fresh_db.upsert_project_bucket(project_id, 30, 60, 50.0)
reached = fresh_db.project_bucket_status(project_id)
text = format_bucket_status(reached)
assert "1.00h / 1.00h" in text
assert "Bucket ceiling reached" in text
# ============================================================================
# DB behaviour
# ============================================================================
def test_project_bucket_status_includes_baseline_and_logged_time(fresh_db):
project_id = _add_project(fresh_db, "Support Retainer")
_add_minutes(fresh_db, project_id, 90)
fresh_db.upsert_project_bucket(
project_id,
baseline_minutes=60,
bucket_ceiling_minutes=180,
warn_at_percent=80.0,
)
status = fresh_db.project_bucket_status(project_id)
assert status["project_id"] == project_id
assert status["project_name"] == "Support Retainer"
assert status["baseline_minutes"] == 60
assert status["logged_minutes"] == 90
assert status["used_minutes"] == 150
assert status["remaining_minutes"] == 30
assert status["percent_used"] == 150 / 180 * 100.0
assert status["state"] == "warning"
fresh_db.add_to_project_bucket_ceiling(project_id, 60)
status = fresh_db.project_bucket_status(project_id)
assert status["bucket_ceiling_minutes"] == 240
assert status["state"] == "ok"
def test_project_bucket_status_state_boundaries(fresh_db):
project_id = _add_project(fresh_db, "Boundary Project")
_add_minutes(fresh_db, project_id, 30)
status = fresh_db.project_bucket_status(project_id)
assert status["state"] == "unconfigured"
assert status["bucket_ceiling_minutes"] == 0
assert status["remaining_minutes"] is None
assert status["percent_used"] is None
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
assert fresh_db.project_bucket_status(project_id)["state"] == "ok"
fresh_db.upsert_project_bucket(project_id, 66, 120, 80.0)
status = fresh_db.project_bucket_status(project_id)
assert status["used_minutes"] == 96
assert status["percent_used"] == 80.0
assert status["state"] == "warning"
fresh_db.upsert_project_bucket(project_id, 90, 120, 80.0)
status = fresh_db.project_bucket_status(project_id)
assert status["used_minutes"] == 120
assert status["remaining_minutes"] == 0
assert status["state"] == "reached"
fresh_db.upsert_project_bucket(project_id, 91, 120, 80.0)
status = fresh_db.project_bucket_status(project_id)
assert status["used_minutes"] == 121
assert status["remaining_minutes"] == -1
assert status["state"] == "exceeded"
def test_project_bucket_input_sanitisation_and_invalid_projects(fresh_db):
project_id = _add_project(fresh_db, "Sanitised Project")
fresh_db.upsert_project_bucket(
project_id,
baseline_minutes=-60,
bucket_ceiling_minutes=-120,
warn_at_percent=150.0,
)
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["project_id"] == project_id
assert bucket["baseline_minutes"] == 0
assert bucket["bucket_ceiling_minutes"] == 0
assert bucket["warn_at_percent"] == 100.0
fresh_db.upsert_project_bucket(project_id, 10, 60, -5.0)
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["warn_at_percent"] == 0.0
fresh_db.add_to_project_bucket_ceiling(project_id, -60)
assert fresh_db.get_project_bucket(project_id)["bucket_ceiling_minutes"] == 60
for bad_project_id in (0, -1):
try:
fresh_db.upsert_project_bucket(bad_project_id, 0, 60, 80.0)
except ValueError as exc:
assert "invalid project id" in str(exc)
else:
raise AssertionError("invalid project id should raise")
assert fresh_db.get_project_bucket(0) is None
assert fresh_db.logged_minutes_for_project(0) == 0
assert fresh_db.project_bucket_status(0) is None
assert fresh_db.time_logs_for_project(0) == []
assert fresh_db.invoices_for_project_with_documents(0) == []
def test_project_summaries_include_all_projects_and_are_sorted(fresh_db):
alpha = _add_project(fresh_db, "alpha project")
zulu = _add_project(fresh_db, "Zulu Project")
fresh_db.upsert_project_bucket(zulu, 15, 120, 80.0)
rows = fresh_db.list_project_summaries()
names = [r["project_name"] for r in rows]
assert names == ["alpha project", "Zulu Project"]
alpha_row = next(r for r in rows if r["project_id"] == alpha)
assert alpha_row["document_count"] == 0
assert alpha_row["invoice_count"] == 0
assert alpha_row["time_log_count"] == 0
assert alpha_row["logged_minutes"] == 0
assert alpha_row["baseline_minutes"] == 0
zulu_row = next(r for r in rows if r["project_id"] == zulu)
assert zulu_row["baseline_minutes"] == 15
assert zulu_row["bucket_ceiling_minutes"] == 120
def test_project_documents_time_logs_and_invoices_are_isolated(fresh_db, tmp_path):
project_id = _add_project(fresh_db, "Project With Docs")
other_project = _add_project(fresh_db, "Other Project")
_add_minutes(fresh_db, project_id, 120, "Build work")
_add_minutes(fresh_db, other_project, 300, "Other work")
fresh_db.upsert_project_bucket(project_id, 0, 240, 80.0)
doc_path = tmp_path / "invoice.pdf"
doc_path.write_bytes(b"not really a pdf")
doc_id = fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="Invoice document",
uploaded_at="2026-02-02",
)
other_doc = tmp_path / "other.pdf"
other_doc.write_bytes(b"other")
fresh_db.add_document_from_path(other_project, str(other_doc))
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="INV-1",
issue_date="2026-02-03",
due_date="2026-02-17",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid bucket", 2.0, 10000)],
time_log_ids=[],
)
fresh_db.set_invoice_document(invoice_id, doc_id)
fresh_db.create_invoice(
project_id=other_project,
invoice_number="INV-OTHER",
issue_date="2026-02-03",
due_date="2026-02-17",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Other", 1.0, 10000)],
time_log_ids=[],
)
row = next(
r for r in fresh_db.list_project_summaries() if r["project_id"] == project_id
)
assert row["logged_minutes"] == 120
assert row["time_log_count"] == 1
assert row["document_count"] == 1
assert row["invoice_count"] == 1
logs = fresh_db.time_logs_for_project(project_id)
assert len(logs) == 1
assert logs[0]["note"] == "Build work"
docs = fresh_db.documents_for_project(project_id)
assert len(docs) == 1
assert docs[0][3] == "invoice.pdf"
invoices = fresh_db.invoices_for_project_with_documents(project_id)
assert len(invoices) == 1
assert invoices[0]["invoice_number"] == "INV-1"
assert invoices[0]["document_id"] == doc_id
assert invoices[0]["document_file_name"] == "invoice.pdf"
def test_prepaid_invoice_can_exist_without_logged_time_links(fresh_db):
project_id = _add_project(fresh_db, "Prepaid Project")
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="PREPAID-1",
issue_date="2026-02-10",
due_date="2026-02-24",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid support bucket", 40.0, 15000)],
time_log_ids=[],
)
invoice = fresh_db.invoices_for_project_with_documents(project_id)[0]
assert invoice["id"] == invoice_id
assert invoice["total_cents"] == 600000
linked = fresh_db.conn.execute(
"SELECT COUNT(*) AS c FROM invoice_time_log WHERE invoice_id = ?",
(invoice_id,),
).fetchone()
assert linked["c"] == 0
# ============================================================================
# UI behaviour
# ============================================================================
def test_projects_dialog_loads_summary_time_logs_documents_and_invoices(
qtbot, fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "UI Project")
_add_minutes(fresh_db, project_id, 60, "Initial support")
fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0)
doc_path = tmp_path / "ui-invoice.pdf"
doc_path.write_bytes(b"ui")
doc_id = fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="UI invoice",
uploaded_at="2026-03-01",
)
invoice_id = fresh_db.create_invoice(
project_id,
"UI-1",
"2026-03-02",
"2026-03-16",
"AUD",
None,
None,
"summary",
[("UI work", 1.0, 10000)],
[],
)
fresh_db.set_invoice_document(invoice_id, doc_id)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.currentData() == project_id
assert dialog.summary_table.rowCount() == 1
assert dialog.time_logs_table.rowCount() == 1
assert dialog.documents_table.rowCount() == 1
assert dialog.invoices_table.rowCount() == 1
assert dialog.summary_table.item(0, dialog.SUM_USED).text() == "1.50"
assert (
dialog.summary_table.item(0, dialog.SUM_STATE).text()
== "Approaching bucket ceiling"
)
assert dialog.time_logs_table.item(0, dialog.LOG_NOTE).text() == "Initial support"
assert dialog.documents_table.item(0, dialog.DOC_FILE).text() == "ui-invoice.pdf"
assert dialog.invoices_table.item(0, dialog.INV_NUMBER).text() == "UI-1"
assert "UI Project" in dialog.status_label.text()
assert "1.50h / 2.00h" in dialog.status_label.text()
assert (
dialog.status_label.minimumHeight()
>= dialog.status_label.fontMetrics().lineSpacing() * 3 + 18
)
def test_projects_dialog_saves_and_replenishes_bucket(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Editable Project")
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.currentData() == project_id
dialog.baseline_spin.setValue(1.5)
dialog.ceiling_spin.setValue(10.0)
dialog.warn_spin.setValue(75.0)
dialog._save_bucket()
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["baseline_minutes"] == 90
assert bucket["bucket_ceiling_minutes"] == 600
assert bucket["warn_at_percent"] == 75.0
dialog.topup_spin.setValue(40.0)
dialog._add_to_ceiling()
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["bucket_ceiling_minutes"] == 3000
assert "48.50h remaining" in dialog.status_label.text()
class _FakeRadioButton:
def __init__(self):
self.checked = False
def setChecked(self, checked):
self.checked = checked
class _FakeLineEdit:
def __init__(self):
self.value = ""
def setText(self, text):
self.value = text
class _FakeSpinBox:
def __init__(self):
self.value_set = None
def setValue(self, value):
self.value_set = value
class _FakeInvoiceDialog:
instances = []
def __init__(
self, db, project_id, start_date_iso, end_date_iso, time_rows=None, parent=None
):
self.db = db
self.project_id = project_id
self.start_date_iso = start_date_iso
self.end_date_iso = end_date_iso
self.time_rows = time_rows
self.parent = parent
self.rb_summary = _FakeRadioButton()
self.summary_desc_edit = _FakeLineEdit()
self.summary_hours_spin = _FakeSpinBox()
self.recalculated = False
self.executed = False
_FakeInvoiceDialog.instances.append(self)
def _recalc_totals(self):
self.recalculated = True
def exec(self):
self.executed = True
return QDialog.Accepted
def test_projects_dialog_can_open_prepaid_invoice_for_unspent_hours(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Prepaid UI Project")
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(40.0)
_FakeInvoiceDialog.instances.clear()
with patch("bouquin.projects.InvoiceDialog", _FakeInvoiceDialog):
dialog._invoice_prepaid_hours()
assert len(_FakeInvoiceDialog.instances) == 1
invoice_dialog = _FakeInvoiceDialog.instances[0]
assert invoice_dialog.db is fresh_db
assert invoice_dialog.project_id == project_id
assert invoice_dialog.time_rows == []
assert invoice_dialog.parent is dialog
assert invoice_dialog.rb_summary.checked is True
assert invoice_dialog.summary_hours_spin.value_set == 40.0
assert invoice_dialog.summary_desc_edit.value == (
"Prepaid support bucket (40.00 hours)"
)
assert invoice_dialog.recalculated is True
assert invoice_dialog.executed is True
def test_projects_dialog_warns_when_prepaid_invoice_hours_are_zero(qtbot, fresh_db):
_add_project(fresh_db, "Zero Project")
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(0.0)
with patch.object(QMessageBox, "warning") as warning:
dialog._invoice_prepaid_hours()
assert warning.called
assert "greater than zero" in warning.call_args.args[2]
def test_projects_dialog_opens_selected_document_and_invoice_document(
qtbot, fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "Open Project")
doc_path = tmp_path / "open.pdf"
doc_path.write_bytes(b"open")
doc_id = fresh_db.add_document_from_path(project_id, str(doc_path))
invoice_id = fresh_db.create_invoice(
project_id,
"OPEN-1",
"2026-05-01",
None,
"AUD",
None,
None,
"summary",
[("Open work", 1.0, 10000)],
[],
)
fresh_db.set_invoice_document(invoice_id, doc_id)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.documents_table.selectRow(0)
with patch("bouquin.projects.open_document_from_db") as open_doc:
dialog._open_selected_document()
open_doc.assert_called_once_with(
fresh_db, doc_id, "open.pdf", parent_widget=dialog
)
dialog.invoices_table.selectRow(0)
with patch("bouquin.projects.open_document_from_db") as open_doc:
dialog._open_invoice_document()
open_doc.assert_called_once_with(
fresh_db, doc_id, "open.pdf", parent_widget=dialog
)
def test_projects_dialog_reports_missing_invoice_document(qtbot, fresh_db):
project_id = _add_project(fresh_db, "No Doc Project")
fresh_db.create_invoice(
project_id,
"NO-DOC-1",
"2026-05-01",
None,
"AUD",
None,
None,
"summary",
[("Work", 1.0, 10000)],
[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.invoices_table.selectRow(0)
with patch.object(QMessageBox, "information") as info:
dialog._open_invoice_document()
assert info.called
def test_time_log_dialog_updates_bucket_indicator_and_alerts_on_reach(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Logging Project")
fresh_db.upsert_project_bucket(project_id, 0, 60, 80.0)
dialog = TimeLogDialog(fresh_db, "2026-06-01")
qtbot.addWidget(dialog)
idx = dialog.project_combo.findData(project_id)
dialog.project_combo.setCurrentIndex(idx)
assert "0.00h / 1.00h" in dialog.bucket_label.text()
assert "Status: OK" in dialog.bucket_label.text()
dialog.activity_edit.setText("Support")
dialog.hours_spin.setValue(1.0)
with patch.object(QMessageBox, "warning") as warning:
dialog._on_add_or_update()
assert warning.called
assert "Bucket ceiling reached" in warning.call_args.args[2]
status = fresh_db.project_bucket_status(project_id)
assert status["state"] == "reached"
assert "Bucket ceiling reached" in dialog.bucket_label.text()
def test_time_log_dialog_does_not_alert_before_reaching_bucket(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Safe Logging")
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
dialog = TimeLogDialog(fresh_db, "2026-06-02")
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id))
dialog.activity_edit.setText("Support")
dialog.hours_spin.setValue(0.5)
with patch.object(QMessageBox, "warning") as warning:
dialog._on_add_or_update()
warning.assert_not_called()
assert fresh_db.project_bucket_status(project_id)["state"] == "ok"
def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all(
qtbot, fresh_db
):
project_id = _add_project(fresh_db, "Report Project")
_add_minutes(fresh_db, project_id, 90)
fresh_db.upsert_project_bucket(project_id, 0, 120, 75.0)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.from_date.setDate(QDate.fromString("2026-01-01", "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString("2026-01-31", "yyyy-MM-dd"))
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id))
dialog._run_report()
assert "Report Project" in dialog.bucket_label.text()
assert "Approaching bucket ceiling" in dialog.bucket_label.text()
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None))
dialog._run_report()
assert dialog.bucket_label.text() == ""
def test_project_bucket_ledger_records_settings_topups_and_time_use(fresh_db):
project_id = _add_project(fresh_db, "Ledger Project")
fresh_db.upsert_project_bucket(project_id, 30, 120, 80.0)
fresh_db.add_to_project_bucket_ceiling(
project_id,
60,
description="Extra prepaid hours",
)
_add_minutes(fresh_db, project_id, 45, "Ledger work")
rows = fresh_db.project_bucket_ledger_for_project(project_id)
types = [r["entry_type"] for r in rows]
assert "settings_adjustment" in types
assert "manual_topup" in types
assert "time_log" in types
settings = next(r for r in rows if r["entry_type"] == "settings_adjustment")
assert settings["baseline_delta_minutes"] == 30
assert settings["ceiling_delta_minutes"] == 120
topup = next(r for r in rows if r["entry_type"] == "manual_topup")
assert topup["ceiling_delta_minutes"] == 60
assert topup["description"] == "Extra prepaid hours"
time_log = next(r for r in rows if r["entry_type"] == "time_log")
assert time_log["used_delta_minutes"] == 45
assert time_log["description"] == "Ledger work"
status = fresh_db.project_bucket_status(project_id)
assert status["baseline_minutes"] == 30
assert status["logged_minutes"] == 45
assert status["bucket_ceiling_minutes"] == 180
assert status["remaining_minutes"] == 105
def test_project_bucket_ledger_records_negative_corrections(fresh_db):
project_id = _add_project(fresh_db, "Correction Project")
fresh_db.upsert_project_bucket(project_id, 120, 240, 80.0)
fresh_db.upsert_project_bucket(project_id, 60, 180, 80.0)
rows = [
r
for r in fresh_db.project_bucket_ledger_for_project(project_id)
if r["entry_type"] == "settings_adjustment"
]
assert len(rows) == 2
assert any(r["baseline_delta_minutes"] == -60 for r in rows)
assert any(r["ceiling_delta_minutes"] == -60 for r in rows)
status = fresh_db.project_bucket_status(project_id)
assert status["baseline_minutes"] == 60
assert status["bucket_ceiling_minutes"] == 180
def test_project_activity_log_includes_time_documents_invoices_and_bucket_events(
fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "Activity Log Project")
_add_minutes(fresh_db, project_id, 30, "Log work")
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
doc_path = tmp_path / "activity-doc.pdf"
doc_path.write_bytes(b"activity")
fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="Activity document",
uploaded_at="2026-04-01",
)
fresh_db.create_invoice(
project_id=project_id,
invoice_number="ACT-001",
issue_date="2026-04-02",
due_date="2026-04-16",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Activity prepaid", 2.0, 10000)],
time_log_ids=[],
)
rows = fresh_db.project_activity_log_for_project(project_id)
event_types = [r["event_type"] for r in rows]
assert "time_log" in event_types
assert "document" in event_types
assert "invoice" in event_types
assert "bucket" in event_types
assert any("Log work" in r["details"] for r in rows)
assert any("activity-doc.pdf" in r["details"] for r in rows)
assert any("ACT-001" in r["details"] for r in rows)
assert any("Bucket settings updated" in r["details"] for r in rows)
def test_projects_dialog_shows_bucket_ledger_and_project_log(qtbot, fresh_db, tmp_path):
project_id = _add_project(fresh_db, "Ledger UI Project")
_add_minutes(fresh_db, project_id, 60, "UI ledger work")
fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0)
fresh_db.add_to_project_bucket_ceiling(
project_id,
60,
description="UI top-up",
)
doc_path = tmp_path / "ledger-ui.pdf"
doc_path.write_bytes(b"ledger")
fresh_db.add_document_from_path(project_id, str(doc_path), uploaded_at="2026-05-01")
fresh_db.create_invoice(
project_id=project_id,
invoice_number="LEDGER-UI-001",
issue_date="2026-05-02",
due_date=None,
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Ledger UI prepaid", 1.0, 10000)],
time_log_ids=[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.bucket_ledger_table.rowCount() == 3
ledger_types = {
dialog.bucket_ledger_table.item(row, dialog.LEDGER_TYPE).text()
for row in range(dialog.bucket_ledger_table.rowCount())
}
assert "Bucket settings" in ledger_types
assert "Manual top-up" in ledger_types
assert "Time logged" in ledger_types
assert any(
dialog.bucket_ledger_table.item(row, dialog.LEDGER_NOTE).text()
for row in range(dialog.bucket_ledger_table.rowCount())
)
changelog_types = {
dialog.changelog_table.item(row, dialog.CHANGE_TYPE).text()
for row in range(dialog.changelog_table.rowCount())
}
assert {"Time log", "Document", "Invoice", "Bucket"}.issubset(changelog_types)
assert any(
"LEDGER-UI-001"
in dialog.changelog_table.item(row, dialog.CHANGE_DETAILS).text()
for row in range(dialog.changelog_table.rowCount())
)
def test_prepaid_invoice_dialog_adds_bucket_ledger_entry_when_invoice_is_created(
qtbot, fresh_db
):
project_id = _add_project(fresh_db, "Prepaid Ledger Project")
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="PREPAID-LEDGER-001",
issue_date="2026-06-01",
due_date=None,
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid", 40.0, 15000)],
time_log_ids=[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(40.0)
class _InvoiceDialogWithId(_FakeInvoiceDialog):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.last_invoice_id = invoice_id
with patch("bouquin.projects.InvoiceDialog", _InvoiceDialogWithId):
dialog._invoice_prepaid_hours()
row = next(
r
for r in fresh_db.project_bucket_ledger_for_project(project_id)
if r["entry_type"] == "prepaid_invoice"
)
assert row["ceiling_delta_minutes"] == 2400
assert row["invoice_id"] == invoice_id
assert "40.00 hours" in row["description"]