Invoicing
This commit is contained in:
parent
e5c7ccb1da
commit
81878c63d9
16 changed files with 3656 additions and 54 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ __pycache__
|
||||||
dist
|
dist
|
||||||
.coverage
|
.coverage
|
||||||
*.db
|
*.db
|
||||||
|
*.pdf
|
||||||
|
*.csv
|
||||||
|
*.html
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
# 0.7.0
|
||||||
|
|
||||||
|
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
|
||||||
|
* Add 'Last week' to Time Report dialog range option
|
||||||
|
|
||||||
# 0.6.4
|
# 0.6.4
|
||||||
|
|
||||||
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
|
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,6 @@ report from within the app, or optionally to check for new versions to upgrade t
|
||||||
|
|
||||||
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||||
|
|
||||||
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
|
|
||||||
|
|
||||||
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||||
|
|
||||||
### From PyPi/pip
|
### From PyPi/pip
|
||||||
|
|
|
||||||
546
bouquin/db.py
546
bouquin/db.py
|
|
@ -41,6 +41,26 @@ DocumentRow = Tuple[
|
||||||
int, # size_bytes
|
int, # size_bytes
|
||||||
str, # uploaded_at (ISO)
|
str, # uploaded_at (ISO)
|
||||||
]
|
]
|
||||||
|
ProjectBillingRow = Tuple[
|
||||||
|
int, # project_id
|
||||||
|
int, # hourly_rate_cents
|
||||||
|
str, # currency
|
||||||
|
str | None, # tax_label
|
||||||
|
float | None, # tax_rate_percent
|
||||||
|
str | None, # client_name
|
||||||
|
str | None, # client_company
|
||||||
|
str | None, # client_address
|
||||||
|
str | None, # client_email
|
||||||
|
]
|
||||||
|
CompanyProfileRow = Tuple[
|
||||||
|
str | None, # name
|
||||||
|
str | None, # address
|
||||||
|
str | None, # phone
|
||||||
|
str | None, # email
|
||||||
|
str | None, # tax_id
|
||||||
|
str | None, # payment_details
|
||||||
|
bytes | None, # logo
|
||||||
|
]
|
||||||
|
|
||||||
_TAG_COLORS = [
|
_TAG_COLORS = [
|
||||||
"#FFB3BA", # soft red
|
"#FFB3BA", # soft red
|
||||||
|
|
@ -77,11 +97,31 @@ class DBConfig:
|
||||||
time_log: bool = True
|
time_log: bool = True
|
||||||
reminders: bool = True
|
reminders: bool = True
|
||||||
documents: bool = True
|
documents: bool = True
|
||||||
|
invoicing: bool = False
|
||||||
locale: str = "en"
|
locale: str = "en"
|
||||||
font_size: int = 11
|
font_size: int = 11
|
||||||
|
|
||||||
|
|
||||||
class DBManager:
|
class DBManager:
|
||||||
|
# Allow list of invoice columns allowed for dynamic field helpers
|
||||||
|
_INVOICE_COLUMN_ALLOWLIST = frozenset(
|
||||||
|
{
|
||||||
|
"invoice_number",
|
||||||
|
"issue_date",
|
||||||
|
"due_date",
|
||||||
|
"currency",
|
||||||
|
"tax_label",
|
||||||
|
"tax_rate_percent",
|
||||||
|
"subtotal_cents",
|
||||||
|
"tax_cents",
|
||||||
|
"total_cents",
|
||||||
|
"detail_mode",
|
||||||
|
"paid_at",
|
||||||
|
"payment_note",
|
||||||
|
"document_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, cfg: DBConfig):
|
def __init__(self, cfg: DBConfig):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.conn: sqlite.Connection | None = None
|
self.conn: sqlite.Connection | None = None
|
||||||
|
|
@ -252,6 +292,76 @@ class DBManager:
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
|
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
|
||||||
ON document_tags(tag_id);
|
ON document_tags(tag_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_billing (
|
||||||
|
project_id INTEGER PRIMARY KEY
|
||||||
|
REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
hourly_rate_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'AUD',
|
||||||
|
tax_label TEXT,
|
||||||
|
tax_rate_percent REAL,
|
||||||
|
client_name TEXT, -- contact person
|
||||||
|
client_company TEXT, -- business name
|
||||||
|
client_address TEXT,
|
||||||
|
client_email TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS company_profile (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
name TEXT,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
email TEXT,
|
||||||
|
tax_id TEXT,
|
||||||
|
payment_details TEXT,
|
||||||
|
logo BLOB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL
|
||||||
|
REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
|
invoice_number TEXT NOT NULL,
|
||||||
|
issue_date TEXT NOT NULL, -- yyyy-MM-dd
|
||||||
|
due_date TEXT,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
tax_label TEXT,
|
||||||
|
tax_rate_percent REAL,
|
||||||
|
subtotal_cents INTEGER NOT NULL,
|
||||||
|
tax_cents INTEGER NOT NULL,
|
||||||
|
total_cents INTEGER NOT NULL,
|
||||||
|
detail_mode TEXT NOT NULL, -- 'detailed' | 'summary'
|
||||||
|
paid_at TEXT,
|
||||||
|
payment_note TEXT,
|
||||||
|
document_id INTEGER,
|
||||||
|
FOREIGN KEY(document_id) REFERENCES project_documents(id)
|
||||||
|
ON DELETE SET NULL,
|
||||||
|
UNIQUE(project_id, invoice_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_invoices_project
|
||||||
|
ON invoices(project_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_line_items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
invoice_id INTEGER NOT NULL
|
||||||
|
REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
hours REAL NOT NULL,
|
||||||
|
rate_cents INTEGER NOT NULL,
|
||||||
|
amount_cents INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_invoice_line_items_invoice
|
||||||
|
ON invoice_line_items(invoice_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_time_log (
|
||||||
|
invoice_id INTEGER NOT NULL
|
||||||
|
REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
time_log_id INTEGER NOT NULL
|
||||||
|
REFERENCES time_log(id) ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (invoice_id, time_log_id)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
@ -942,6 +1052,14 @@ class DBManager:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [(r["id"], r["name"]) for r in rows]
|
return [(r["id"], r["name"]) for r in rows]
|
||||||
|
|
||||||
|
def list_projects_by_id(self, project_id: int) -> str:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT name FROM projects WHERE id = ?;",
|
||||||
|
(project_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row["name"] if row else ""
|
||||||
|
|
||||||
def add_project(self, name: str) -> int:
|
def add_project(self, name: str) -> int:
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
|
|
@ -1718,3 +1836,431 @@ class DBManager:
|
||||||
(tag_name,),
|
(tag_name,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows]
|
return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows]
|
||||||
|
|
||||||
|
# ------------------------- Billing settings ------------------------#
|
||||||
|
|
||||||
|
def get_project_billing(self, project_id: int) -> ProjectBillingRow | None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
row = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
hourly_rate_cents,
|
||||||
|
currency,
|
||||||
|
tax_label,
|
||||||
|
tax_rate_percent,
|
||||||
|
client_name,
|
||||||
|
client_company,
|
||||||
|
client_address,
|
||||||
|
client_email
|
||||||
|
FROM project_billing
|
||||||
|
WHERE project_id = ?
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
row["project_id"],
|
||||||
|
row["hourly_rate_cents"],
|
||||||
|
row["currency"],
|
||||||
|
row["tax_label"],
|
||||||
|
row["tax_rate_percent"],
|
||||||
|
row["client_name"],
|
||||||
|
row["client_company"],
|
||||||
|
row["client_address"],
|
||||||
|
row["client_email"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_project_billing(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
hourly_rate_cents: int,
|
||||||
|
currency: str,
|
||||||
|
tax_label: str | None,
|
||||||
|
tax_rate_percent: float | None,
|
||||||
|
client_name: str | None,
|
||||||
|
client_company: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
client_email: str | None,
|
||||||
|
) -> None:
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO project_billing (
|
||||||
|
project_id,
|
||||||
|
hourly_rate_cents,
|
||||||
|
currency,
|
||||||
|
tax_label,
|
||||||
|
tax_rate_percent,
|
||||||
|
client_name,
|
||||||
|
client_company,
|
||||||
|
client_address,
|
||||||
|
client_email
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(project_id) DO UPDATE SET
|
||||||
|
hourly_rate_cents = excluded.hourly_rate_cents,
|
||||||
|
currency = excluded.currency,
|
||||||
|
tax_label = excluded.tax_label,
|
||||||
|
tax_rate_percent = excluded.tax_rate_percent,
|
||||||
|
client_name = excluded.client_name,
|
||||||
|
client_company = excluded.client_company,
|
||||||
|
client_address = excluded.client_address,
|
||||||
|
client_email = excluded.client_email;
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
hourly_rate_cents,
|
||||||
|
currency,
|
||||||
|
tax_label,
|
||||||
|
tax_rate_percent,
|
||||||
|
client_name,
|
||||||
|
client_company,
|
||||||
|
client_address,
|
||||||
|
client_email,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_client_companies(self) -> list[str]:
|
||||||
|
"""Return distinct client display names from project_billing."""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT client_company
|
||||||
|
FROM project_billing
|
||||||
|
WHERE client_company IS NOT NULL
|
||||||
|
AND TRIM(client_company) <> ''
|
||||||
|
ORDER BY LOWER(client_company);
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [r["client_company"] for r in rows]
|
||||||
|
|
||||||
|
def get_client_by_company(
|
||||||
|
self, client_company: str
|
||||||
|
) -> tuple[str | None, str | None, str | None, str | None] | None:
|
||||||
|
"""
|
||||||
|
Return (contact_name, client_display_name, address, email)
|
||||||
|
for a given client display name, based on the most recent project using it.
|
||||||
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
row = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT client_name, client_company, client_address, client_email
|
||||||
|
FROM project_billing
|
||||||
|
WHERE client_company = ?
|
||||||
|
AND client_company IS NOT NULL
|
||||||
|
AND TRIM(client_company) <> ''
|
||||||
|
ORDER BY project_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(client_company,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
row["client_name"],
|
||||||
|
row["client_company"],
|
||||||
|
row["client_address"],
|
||||||
|
row["client_email"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------- Company profile ------------------------#
|
||||||
|
|
||||||
|
def get_company_profile(self) -> CompanyProfileRow | None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
row = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT name, address, phone, email, tax_id, payment_details, logo
|
||||||
|
FROM company_profile
|
||||||
|
WHERE id = 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
row["name"],
|
||||||
|
row["address"],
|
||||||
|
row["phone"],
|
||||||
|
row["email"],
|
||||||
|
row["tax_id"],
|
||||||
|
row["payment_details"],
|
||||||
|
row["logo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_company_profile(
|
||||||
|
self,
|
||||||
|
name: str | None,
|
||||||
|
address: str | None,
|
||||||
|
phone: str | None,
|
||||||
|
email: str | None,
|
||||||
|
tax_id: str | None,
|
||||||
|
payment_details: str | None,
|
||||||
|
logo: bytes | None,
|
||||||
|
) -> None:
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO company_profile (id, name, address, phone, email, tax_id, payment_details, logo)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
address = excluded.address,
|
||||||
|
phone = excluded.phone,
|
||||||
|
email = excluded.email,
|
||||||
|
tax_id = excluded.tax_id,
|
||||||
|
payment_details = excluded.payment_details,
|
||||||
|
logo = excluded.logo;
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
tax_id,
|
||||||
|
payment_details,
|
||||||
|
Binary(logo) if logo else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------- Invoices -------------------------------#
|
||||||
|
|
||||||
|
def create_invoice(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
invoice_number: str,
|
||||||
|
issue_date: str,
|
||||||
|
due_date: str | None,
|
||||||
|
currency: str,
|
||||||
|
tax_label: str | None,
|
||||||
|
tax_rate_percent: float | None,
|
||||||
|
detail_mode: str, # 'detailed' or 'summary'
|
||||||
|
line_items: list[tuple[str, float, int]], # (description, hours, rate_cents)
|
||||||
|
time_log_ids: list[int],
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Create invoice + line items + link time logs.
|
||||||
|
Returns invoice ID.
|
||||||
|
"""
|
||||||
|
if line_items:
|
||||||
|
first_rate_cents = line_items[0][2]
|
||||||
|
else:
|
||||||
|
first_rate_cents = 0
|
||||||
|
|
||||||
|
total_hours = sum(hours for _desc, hours, _rate in line_items)
|
||||||
|
subtotal_cents = int(round(total_hours * first_rate_cents))
|
||||||
|
tax_cents = int(round(subtotal_cents * (tax_rate_percent or 0) / 100.0))
|
||||||
|
total_cents = subtotal_cents + tax_cents
|
||||||
|
|
||||||
|
with self.conn:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invoices (
|
||||||
|
project_id,
|
||||||
|
invoice_number,
|
||||||
|
issue_date,
|
||||||
|
due_date,
|
||||||
|
currency,
|
||||||
|
tax_label,
|
||||||
|
tax_rate_percent,
|
||||||
|
subtotal_cents,
|
||||||
|
tax_cents,
|
||||||
|
total_cents,
|
||||||
|
detail_mode
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
invoice_number,
|
||||||
|
issue_date,
|
||||||
|
due_date,
|
||||||
|
currency,
|
||||||
|
tax_label,
|
||||||
|
tax_rate_percent,
|
||||||
|
subtotal_cents,
|
||||||
|
tax_cents,
|
||||||
|
total_cents,
|
||||||
|
detail_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invoice_id = cur.lastrowid
|
||||||
|
|
||||||
|
# Line items
|
||||||
|
for desc, hours, rate_cents in line_items:
|
||||||
|
amount_cents = int(round(hours * rate_cents))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invoice_line_items (
|
||||||
|
invoice_id, description, hours, rate_cents, amount_cents
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(invoice_id, desc, hours, rate_cents, amount_cents),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link time logs
|
||||||
|
for tl_id in time_log_ids:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO invoice_time_log (invoice_id, time_log_id) VALUES (?, ?)",
|
||||||
|
(invoice_id, tl_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return invoice_id
|
||||||
|
|
||||||
|
def get_invoice_count_by_project_id_and_year(
|
||||||
|
self, project_id: int, year: str
|
||||||
|
) -> None:
|
||||||
|
with self.conn:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT COUNT(*) AS c FROM invoices WHERE project_id = ? AND issue_date LIKE ?",
|
||||||
|
(project_id, year),
|
||||||
|
).fetchone()
|
||||||
|
return row["c"]
|
||||||
|
|
||||||
|
def get_all_invoices(self, project_id: int | None = None) -> None:
|
||||||
|
with self.conn:
|
||||||
|
if project_id is None:
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.project_id,
|
||||||
|
p.name AS project_name,
|
||||||
|
i.invoice_number,
|
||||||
|
i.issue_date,
|
||||||
|
i.due_date,
|
||||||
|
i.currency,
|
||||||
|
i.tax_label,
|
||||||
|
i.tax_rate_percent,
|
||||||
|
i.subtotal_cents,
|
||||||
|
i.tax_cents,
|
||||||
|
i.total_cents,
|
||||||
|
i.paid_at,
|
||||||
|
i.payment_note
|
||||||
|
FROM invoices AS i
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.project_id,
|
||||||
|
p.name AS project_name,
|
||||||
|
i.invoice_number,
|
||||||
|
i.issue_date,
|
||||||
|
i.due_date,
|
||||||
|
i.currency,
|
||||||
|
i.tax_label,
|
||||||
|
i.tax_rate_percent,
|
||||||
|
i.subtotal_cents,
|
||||||
|
i.tax_cents,
|
||||||
|
i.total_cents,
|
||||||
|
i.paid_at,
|
||||||
|
i.payment_note
|
||||||
|
FROM invoices AS i
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
WHERE i.project_id = ?
|
||||||
|
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
).fetchall()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _validate_invoice_field(self, field: str) -> str:
|
||||||
|
if field not in self._INVOICE_COLUMN_ALLOWLIST:
|
||||||
|
raise ValueError(f"Invalid invoice field name: {field!r}")
|
||||||
|
return field
|
||||||
|
|
||||||
|
def get_invoice_field_by_id(self, invoice_id: int, field: str) -> None:
|
||||||
|
field = self._validate_invoice_field(field)
|
||||||
|
|
||||||
|
with self.conn:
|
||||||
|
row = self.conn.execute(
|
||||||
|
f"SELECT {field} FROM invoices WHERE id = ?", # nosec B608
|
||||||
|
(invoice_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def set_invoice_field_by_id(
|
||||||
|
self, invoice_id: int, field: str, value: str | None = None
|
||||||
|
) -> None:
|
||||||
|
field = self._validate_invoice_field(field)
|
||||||
|
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE invoices SET {field} = ? WHERE id = ?", # nosec B608
|
||||||
|
(
|
||||||
|
value,
|
||||||
|
invoice_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None:
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE invoices SET invoice_number = ? WHERE id = ?",
|
||||||
|
(invoice_number, invoice_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_invoice_document(self, invoice_id: int, document_id: int) -> None:
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE invoices SET document_id = ? WHERE id = ?",
|
||||||
|
(document_id, invoice_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def time_logs_for_range(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
start_date_iso: str,
|
||||||
|
end_date_iso: str,
|
||||||
|
) -> list[TimeLogRow]:
|
||||||
|
"""
|
||||||
|
Return raw time log rows for a project/date range.
|
||||||
|
|
||||||
|
Shape matches time_log_for_date: TimeLogRow.
|
||||||
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.page_date,
|
||||||
|
t.project_id,
|
||||||
|
p.name AS project_name,
|
||||||
|
t.activity_id,
|
||||||
|
a.name AS activity_name,
|
||||||
|
t.minutes,
|
||||||
|
t.note,
|
||||||
|
t.created_at AS created_at
|
||||||
|
FROM time_log t
|
||||||
|
JOIN projects p ON p.id = t.project_id
|
||||||
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
WHERE t.project_id = ?
|
||||||
|
AND t.page_date BETWEEN ? AND ?
|
||||||
|
ORDER BY t.page_date, LOWER(a.name), t.id;
|
||||||
|
""",
|
||||||
|
(project_id, start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result: list[TimeLogRow] = []
|
||||||
|
for r in rows:
|
||||||
|
result.append(
|
||||||
|
(
|
||||||
|
r["id"],
|
||||||
|
r["page_date"],
|
||||||
|
r["project_id"],
|
||||||
|
r["project_name"],
|
||||||
|
r["activity_id"],
|
||||||
|
r["activity_name"],
|
||||||
|
r["minutes"],
|
||||||
|
r["note"],
|
||||||
|
r["created_at"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
|
||||||
1450
bouquin/invoices.py
Normal file
1450
bouquin/invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -52,7 +52,6 @@
|
||||||
"backup_failed": "Backup failed",
|
"backup_failed": "Backup failed",
|
||||||
"quit": "Quit",
|
"quit": "Quit",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
|
|
@ -202,6 +201,7 @@
|
||||||
"by_week": "by week",
|
"by_week": "by week",
|
||||||
"date_range": "Date range",
|
"date_range": "Date range",
|
||||||
"custom_range": "Custom",
|
"custom_range": "Custom",
|
||||||
|
"last_week": "Last week",
|
||||||
"this_week": "This week",
|
"this_week": "This week",
|
||||||
"this_month": "This month",
|
"this_month": "This month",
|
||||||
"this_year": "This year",
|
"this_year": "This year",
|
||||||
|
|
@ -234,6 +234,8 @@
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"rename_activity": "Rename activity",
|
"rename_activity": "Rename activity",
|
||||||
"rename_project": "Rename project",
|
"rename_project": "Rename project",
|
||||||
|
"reporting": "Reporting",
|
||||||
|
"reporting_and_invoicing": "Reporting and Invoicing",
|
||||||
"run_report": "Run report",
|
"run_report": "Run report",
|
||||||
"add_activity_title": "Add activity",
|
"add_activity_title": "Add activity",
|
||||||
"add_activity_label": "Add an activity",
|
"add_activity_label": "Add an activity",
|
||||||
|
|
@ -359,5 +361,54 @@
|
||||||
"documents_search_label": "Search",
|
"documents_search_label": "Search",
|
||||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||||
"todays_documents": "Documents from this day",
|
"todays_documents": "Documents from this day",
|
||||||
"todays_documents_none": "No documents yet."
|
"todays_documents_none": "No documents yet.",
|
||||||
|
"manage_invoices": "Manage Invoices",
|
||||||
|
"create_invoice": "Create Invoice",
|
||||||
|
"invoice_amount": "Amount",
|
||||||
|
"invoice_apply_tax": "Apply Tax",
|
||||||
|
"invoice_client_address": "Client Address",
|
||||||
|
"invoice_client_company": "Client Company",
|
||||||
|
"invoice_client_email": "Client E-mail",
|
||||||
|
"invoice_client_name": "Client Contact",
|
||||||
|
"invoice_currency": "Currency",
|
||||||
|
"invoice_dialog_title": "Create Invoice",
|
||||||
|
"invoice_due_date": "Due Date",
|
||||||
|
"invoice_hourly_rate": "Hourly Rate",
|
||||||
|
"invoice_hours": "Hours",
|
||||||
|
"invoice_issue_date": "Issue Date",
|
||||||
|
"invoice_mode_detailed": "Detailed mode",
|
||||||
|
"invoice_mode_summary": "Summary mode",
|
||||||
|
"invoice_number": "Invoice Number",
|
||||||
|
"invoice_save_and_export": "Save and export",
|
||||||
|
"invoice_save_pdf_title": "Save PDF",
|
||||||
|
"invoice_subtotal": "Subtotal",
|
||||||
|
"invoice_summary_default_desc": "Consultant services for the month of",
|
||||||
|
"invoice_summary_desc": "Summary description",
|
||||||
|
"invoice_summary_hours": "Summary hours",
|
||||||
|
"invoice_tax": "Tax details",
|
||||||
|
"invoice_tax_label": "Tax type",
|
||||||
|
"invoice_tax_rate": "Tax rate",
|
||||||
|
"invoice_tax_total": "Tax total",
|
||||||
|
"invoice_total": "Total",
|
||||||
|
"invoice_paid_at": "Paid on",
|
||||||
|
"invoice_payment_note": "Payment notes",
|
||||||
|
"invoice_project_required_title": "Project required",
|
||||||
|
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
|
||||||
|
"invoice_need_report_title": "Report required",
|
||||||
|
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
|
||||||
|
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
|
||||||
|
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
|
||||||
|
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
|
||||||
|
"invoice_company_profile": "Business Profile",
|
||||||
|
"invoice_company_name": "Business Name",
|
||||||
|
"invoice_company_address": "Address",
|
||||||
|
"invoice_company_phone": "Phone",
|
||||||
|
"invoice_company_email": "E-mail",
|
||||||
|
"invoice_company_tax_id": "Tax number",
|
||||||
|
"invoice_company_payment_details": "Payment details",
|
||||||
|
"invoice_company_logo": "Logo",
|
||||||
|
"invoice_company_logo_choose": "Choose logo",
|
||||||
|
"invoice_company_logo_set": "Logo has been set",
|
||||||
|
"invoice_company_logo_not_set": "Logo not set",
|
||||||
|
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ class MainWindow(QMainWindow):
|
||||||
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
||||||
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
||||||
|
|
||||||
|
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
|
||||||
|
self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
|
||||||
|
|
||||||
self.pomodoro_manager = PomodoroManager(self.db, self)
|
self.pomodoro_manager = PomodoroManager(self.db, self)
|
||||||
|
|
||||||
# Lock the calendar to the left panel at the top to stop it stretching
|
# Lock the calendar to the left panel at the top to stop it stretching
|
||||||
|
|
@ -1448,6 +1451,7 @@ class MainWindow(QMainWindow):
|
||||||
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
||||||
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
||||||
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
|
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
|
||||||
|
self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
|
||||||
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
||||||
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ def load_db_config() -> DBConfig:
|
||||||
time_log = s.value("ui/time_log", True, type=bool)
|
time_log = s.value("ui/time_log", True, type=bool)
|
||||||
reminders = s.value("ui/reminders", True, type=bool)
|
reminders = s.value("ui/reminders", True, type=bool)
|
||||||
documents = s.value("ui/documents", True, type=bool)
|
documents = s.value("ui/documents", True, type=bool)
|
||||||
|
invoicing = s.value("ui/invoicing", True, type=bool)
|
||||||
locale = s.value("ui/locale", "en", type=str)
|
locale = s.value("ui/locale", "en", type=str)
|
||||||
font_size = s.value("ui/font_size", 11, type=int)
|
font_size = s.value("ui/font_size", 11, type=int)
|
||||||
return DBConfig(
|
return DBConfig(
|
||||||
|
|
@ -57,6 +58,7 @@ def load_db_config() -> DBConfig:
|
||||||
time_log=time_log,
|
time_log=time_log,
|
||||||
reminders=reminders,
|
reminders=reminders,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
|
invoicing=invoicing,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
font_size=font_size,
|
font_size=font_size,
|
||||||
)
|
)
|
||||||
|
|
@ -73,5 +75,6 @@ def save_db_config(cfg: DBConfig) -> None:
|
||||||
s.setValue("ui/time_log", str(cfg.time_log))
|
s.setValue("ui/time_log", str(cfg.time_log))
|
||||||
s.setValue("ui/reminders", str(cfg.reminders))
|
s.setValue("ui/reminders", str(cfg.reminders))
|
||||||
s.setValue("ui/documents", str(cfg.documents))
|
s.setValue("ui/documents", str(cfg.documents))
|
||||||
|
s.setValue("ui/invoicing", str(cfg.invoicing))
|
||||||
s.setValue("ui/locale", str(cfg.locale))
|
s.setValue("ui/locale", str(cfg.locale))
|
||||||
s.setValue("ui/font_size", str(cfg.font_size))
|
s.setValue("ui/font_size", str(cfg.font_size))
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
|
QFileDialog,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
|
@ -19,6 +22,7 @@ from PySide6.QtWidgets import (
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QWidget,
|
QWidget,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
|
QTextEdit,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Slot
|
from PySide6.QtCore import Qt, Slot
|
||||||
from PySide6.QtGui import QPalette
|
from PySide6.QtGui import QPalette
|
||||||
|
|
@ -176,6 +180,17 @@ class SettingsDialog(QDialog):
|
||||||
self.time_log.setCursor(Qt.PointingHandCursor)
|
self.time_log.setCursor(Qt.PointingHandCursor)
|
||||||
features_layout.addWidget(self.time_log)
|
features_layout.addWidget(self.time_log)
|
||||||
|
|
||||||
|
self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
|
||||||
|
invoicing_enabled = getattr(self.current_settings, "invoicing", False)
|
||||||
|
self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
|
||||||
|
self.invoicing.setCursor(Qt.PointingHandCursor)
|
||||||
|
features_layout.addWidget(self.invoicing)
|
||||||
|
# Invoicing only if time_log is enabled
|
||||||
|
if not self.current_settings.time_log:
|
||||||
|
self.invoicing.setChecked(False)
|
||||||
|
self.invoicing.setEnabled(False)
|
||||||
|
self.time_log.toggled.connect(self._on_time_log_toggled)
|
||||||
|
|
||||||
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
||||||
self.reminders.setChecked(self.current_settings.reminders)
|
self.reminders.setChecked(self.current_settings.reminders)
|
||||||
self.reminders.setCursor(Qt.PointingHandCursor)
|
self.reminders.setCursor(Qt.PointingHandCursor)
|
||||||
|
|
@ -187,6 +202,68 @@ class SettingsDialog(QDialog):
|
||||||
features_layout.addWidget(self.documents)
|
features_layout.addWidget(self.documents)
|
||||||
|
|
||||||
layout.addWidget(features_group)
|
layout.addWidget(features_group)
|
||||||
|
|
||||||
|
# --- Invoicing / company profile section -------------------------
|
||||||
|
self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
|
||||||
|
invoicing_layout = QFormLayout(self.invoicing_group)
|
||||||
|
|
||||||
|
profile = self._db.get_company_profile() or (
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
name, address, phone, email, tax_id, payment_details, logo_bytes = profile
|
||||||
|
|
||||||
|
self.company_name_edit = QLineEdit(name or "")
|
||||||
|
self.company_address_edit = QTextEdit(address or "")
|
||||||
|
self.company_phone_edit = QLineEdit(phone or "")
|
||||||
|
self.company_email_edit = QLineEdit(email or "")
|
||||||
|
self.company_tax_id_edit = QLineEdit(tax_id or "")
|
||||||
|
self.company_payment_details_edit = QTextEdit()
|
||||||
|
self.company_payment_details_edit.setPlainText(payment_details or "")
|
||||||
|
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_name") + ":", self.company_name_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_address") + ":", self.company_address_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_phone") + ":", self.company_phone_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_email") + ":", self.company_email_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_payment_details") + ":",
|
||||||
|
self.company_payment_details_edit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logo picker – store bytes on self._logo_bytes
|
||||||
|
self._logo_bytes = logo_bytes
|
||||||
|
logo_row = QHBoxLayout()
|
||||||
|
self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
|
||||||
|
if logo_bytes:
|
||||||
|
self.logo_label.setText(strings._("invoice_company_logo_set"))
|
||||||
|
logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
|
||||||
|
logo_btn.clicked.connect(self._on_choose_logo)
|
||||||
|
logo_row.addWidget(self.logo_label)
|
||||||
|
logo_row.addWidget(logo_btn)
|
||||||
|
invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
|
||||||
|
|
||||||
|
# Show/hide this whole block based on invoicing checkbox
|
||||||
|
self.invoicing_group.setVisible(self.invoicing.isChecked())
|
||||||
|
self.invoicing.toggled.connect(self.invoicing_group.setVisible)
|
||||||
|
|
||||||
|
layout.addWidget(self.invoicing_group)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
@ -314,14 +391,60 @@ class SettingsDialog(QDialog):
|
||||||
time_log=self.time_log.isChecked(),
|
time_log=self.time_log.isChecked(),
|
||||||
reminders=self.reminders.isChecked(),
|
reminders=self.reminders.isChecked(),
|
||||||
documents=self.documents.isChecked(),
|
documents=self.documents.isChecked(),
|
||||||
|
invoicing=(
|
||||||
|
self.invoicing.isChecked() if self.time_log.isChecked() else False
|
||||||
|
),
|
||||||
locale=self.locale_combobox.currentText(),
|
locale=self.locale_combobox.currentText(),
|
||||||
font_size=self.font_size.value(),
|
font_size=self.font_size.value(),
|
||||||
)
|
)
|
||||||
|
|
||||||
save_db_config(self._cfg)
|
save_db_config(self._cfg)
|
||||||
|
|
||||||
|
# Save company profile only if invoicing is enabled
|
||||||
|
if self.invoicing.isChecked() and self.time_log.isChecked():
|
||||||
|
self._db.save_company_profile(
|
||||||
|
name=self.company_name_edit.text().strip() or None,
|
||||||
|
address=self.company_address_edit.toPlainText().strip() or None,
|
||||||
|
phone=self.company_phone_edit.text().strip() or None,
|
||||||
|
email=self.company_email_edit.text().strip() or None,
|
||||||
|
tax_id=self.company_tax_id_edit.text().strip() or None,
|
||||||
|
payment_details=self.company_payment_details_edit.toPlainText().strip()
|
||||||
|
or None,
|
||||||
|
logo=getattr(self, "_logo_bytes", None),
|
||||||
|
)
|
||||||
|
|
||||||
self.parent().themes.set(selected_theme)
|
self.parent().themes.set(selected_theme)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
def _on_time_log_toggled(self, checked: bool) -> None:
|
||||||
|
"""
|
||||||
|
Enforce 'invoicing depends on time logging'.
|
||||||
|
"""
|
||||||
|
if not checked:
|
||||||
|
# Turn off + disable invoicing if time logging is disabled
|
||||||
|
self.invoicing.setChecked(False)
|
||||||
|
self.invoicing.setEnabled(False)
|
||||||
|
else:
|
||||||
|
# Let the user enable invoicing when time logging is enabled
|
||||||
|
self.invoicing.setEnabled(True)
|
||||||
|
|
||||||
|
def _on_choose_logo(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
strings._("company_logo_choose"),
|
||||||
|
"",
|
||||||
|
"Images (*.png *.jpg *.jpeg *.bmp)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
self._logo_bytes = f.read()
|
||||||
|
self.logo_label.setText(Path(path).name)
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, strings._("error"), str(exc))
|
||||||
|
|
||||||
def _change_key(self):
|
def _change_key(self):
|
||||||
p1 = KeyPrompt(
|
p1 = KeyPrompt(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from datetime import datetime
|
||||||
from sqlcipher3.dbapi2 import IntegrityError
|
from sqlcipher3.dbapi2 import IntegrityError
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QDate, QUrl
|
from PySide6.QtCore import Qt, QDate, QUrl, Signal
|
||||||
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
|
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
|
||||||
from PySide6.QtPrintSupport import QPrinter
|
from PySide6.QtPrintSupport import QPrinter
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
|
from .settings import load_db_config
|
||||||
from .theme import ThemeManager
|
from .theme import ThemeManager
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
||||||
|
|
@ -53,6 +54,8 @@ class TimeLogWidget(QFrame):
|
||||||
Shown in the left sidebar above the Tags widget.
|
Shown in the left sidebar above the Tags widget.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
remindersChanged = Signal()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db: DBManager,
|
db: DBManager,
|
||||||
|
|
@ -61,6 +64,7 @@ class TimeLogWidget(QFrame):
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._db = db
|
self._db = db
|
||||||
|
self.cfg = load_db_config()
|
||||||
self._themes = themes
|
self._themes = themes
|
||||||
self._current_date: Optional[str] = None
|
self._current_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -82,6 +86,15 @@ class TimeLogWidget(QFrame):
|
||||||
self.log_btn.setAutoRaise(True)
|
self.log_btn.setAutoRaise(True)
|
||||||
self.log_btn.clicked.connect(self._open_dialog_log_only)
|
self.log_btn.clicked.connect(self._open_dialog_log_only)
|
||||||
|
|
||||||
|
self.report_btn = QToolButton()
|
||||||
|
self.report_btn.setText("📈")
|
||||||
|
self.report_btn.setAutoRaise(True)
|
||||||
|
self.report_btn.clicked.connect(self._on_run_report)
|
||||||
|
if self.cfg.invoicing:
|
||||||
|
self.report_btn.setToolTip(strings._("reporting_and_invoicing"))
|
||||||
|
else:
|
||||||
|
self.report_btn.setToolTip(strings._("reporting"))
|
||||||
|
|
||||||
self.open_btn = QToolButton()
|
self.open_btn = QToolButton()
|
||||||
self.open_btn.setIcon(
|
self.open_btn.setIcon(
|
||||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||||
|
|
@ -95,6 +108,7 @@ class TimeLogWidget(QFrame):
|
||||||
header.addWidget(self.toggle_btn)
|
header.addWidget(self.toggle_btn)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
header.addWidget(self.log_btn)
|
header.addWidget(self.log_btn)
|
||||||
|
header.addWidget(self.report_btn)
|
||||||
header.addWidget(self.open_btn)
|
header.addWidget(self.open_btn)
|
||||||
|
|
||||||
# Body: simple summary label for the day
|
# Body: simple summary label for the day
|
||||||
|
|
@ -149,6 +163,14 @@ class TimeLogWidget(QFrame):
|
||||||
|
|
||||||
# ----- internals ---------------------------------------------------
|
# ----- internals ---------------------------------------------------
|
||||||
|
|
||||||
|
def _on_run_report(self) -> None:
|
||||||
|
dlg = TimeReportDialog(self._db, self)
|
||||||
|
|
||||||
|
# Bubble the remindersChanged signal further up
|
||||||
|
dlg.remindersChanged.connect(self.remindersChanged.emit)
|
||||||
|
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
def _on_toggle(self, checked: bool) -> None:
|
def _on_toggle(self, checked: bool) -> None:
|
||||||
self.body.setVisible(checked)
|
self.body.setVisible(checked)
|
||||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||||
|
|
@ -247,6 +269,7 @@ class TimeLogDialog(QDialog):
|
||||||
self._themes = themes
|
self._themes = themes
|
||||||
self._date_iso = date_iso
|
self._date_iso = date_iso
|
||||||
self._current_entry_id: Optional[int] = None
|
self._current_entry_id: Optional[int] = None
|
||||||
|
self.cfg = load_db_config()
|
||||||
# Guard flag used when repopulating the table so we don’t treat
|
# Guard flag used when repopulating the table so we don’t treat
|
||||||
# programmatic item changes as user edits.
|
# programmatic item changes as user edits.
|
||||||
self._reloading_entries: bool = False
|
self._reloading_entries: bool = False
|
||||||
|
|
@ -320,13 +343,9 @@ class TimeLogDialog(QDialog):
|
||||||
self.delete_btn.clicked.connect(self._on_delete_entry)
|
self.delete_btn.clicked.connect(self._on_delete_entry)
|
||||||
self.delete_btn.setEnabled(False)
|
self.delete_btn.setEnabled(False)
|
||||||
|
|
||||||
self.report_btn = QPushButton("&" + strings._("run_report"))
|
|
||||||
self.report_btn.clicked.connect(self._on_run_report)
|
|
||||||
|
|
||||||
btn_row.addStretch(1)
|
btn_row.addStretch(1)
|
||||||
btn_row.addWidget(self.add_update_btn)
|
btn_row.addWidget(self.add_update_btn)
|
||||||
btn_row.addWidget(self.delete_btn)
|
btn_row.addWidget(self.delete_btn)
|
||||||
btn_row.addWidget(self.report_btn)
|
|
||||||
root.addLayout(btn_row)
|
root.addLayout(btn_row)
|
||||||
|
|
||||||
# --- Table of entries for this date
|
# --- Table of entries for this date
|
||||||
|
|
@ -355,12 +374,19 @@ class TimeLogDialog(QDialog):
|
||||||
self.table.itemChanged.connect(self._on_table_item_changed)
|
self.table.itemChanged.connect(self._on_table_item_changed)
|
||||||
root.addWidget(self.table, 1)
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
# --- Total time and Close button
|
# --- Total time, Reporting and Close button
|
||||||
close_row = QHBoxLayout()
|
close_row = QHBoxLayout()
|
||||||
self.total_label = QLabel(
|
self.total_label = QLabel(
|
||||||
strings._("time_log_total_hours").format(hours=self.total_hours)
|
strings._("time_log_total_hours").format(hours=self.total_hours)
|
||||||
)
|
)
|
||||||
|
if self.cfg.invoicing:
|
||||||
|
self.report_btn = QPushButton("&" + strings._("reporting_and_invoicing"))
|
||||||
|
else:
|
||||||
|
self.report_btn = QPushButton("&" + strings._("reporting"))
|
||||||
|
self.report_btn.clicked.connect(self._on_run_report)
|
||||||
|
|
||||||
close_row.addWidget(self.total_label)
|
close_row.addWidget(self.total_label)
|
||||||
|
close_row.addWidget(self.report_btn)
|
||||||
close_row.addStretch(1)
|
close_row.addStretch(1)
|
||||||
close_btn = QPushButton(strings._("close"))
|
close_btn = QPushButton(strings._("close"))
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
|
|
@ -981,9 +1007,12 @@ class TimeReportDialog(QDialog):
|
||||||
Shows decimal hours per time period.
|
Shows decimal hours per time period.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
remindersChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, db: DBManager, parent=None):
|
def __init__(self, db: DBManager, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._db = db
|
self._db = db
|
||||||
|
self.cfg = load_db_config()
|
||||||
|
|
||||||
# state for last run
|
# state for last run
|
||||||
self._last_rows: list[tuple[str, str, str, str, int]] = []
|
self._last_rows: list[tuple[str, str, str, str, int]] = []
|
||||||
|
|
@ -992,6 +1021,7 @@ class TimeReportDialog(QDialog):
|
||||||
self._last_start: str = ""
|
self._last_start: str = ""
|
||||||
self._last_end: str = ""
|
self._last_end: str = ""
|
||||||
self._last_gran_label: str = ""
|
self._last_gran_label: str = ""
|
||||||
|
self._last_time_logs: list = []
|
||||||
|
|
||||||
self.setWindowTitle(strings._("time_log_report"))
|
self.setWindowTitle(strings._("time_log_report"))
|
||||||
self.resize(600, 400)
|
self.resize(600, 400)
|
||||||
|
|
@ -999,9 +1029,20 @@ class TimeReportDialog(QDialog):
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.invoice_btn = QPushButton(strings._("create_invoice"))
|
||||||
|
self.invoice_btn.clicked.connect(self._on_create_invoice)
|
||||||
|
|
||||||
|
self.manage_invoices_btn = QPushButton(strings._("manage_invoices"))
|
||||||
|
self.manage_invoices_btn.clicked.connect(self._on_manage_invoices)
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
self.project_combo = QComboBox()
|
self.project_combo = QComboBox()
|
||||||
self.project_combo.addItem(strings._("all_projects"), None)
|
self.project_combo.addItem(strings._("all_projects"), None)
|
||||||
|
self.project_combo.currentIndexChanged.connect(
|
||||||
|
self._update_invoice_button_state
|
||||||
|
)
|
||||||
|
self._update_invoice_button_state()
|
||||||
for proj_id, name in self._db.list_projects():
|
for proj_id, name in self._db.list_projects():
|
||||||
self.project_combo.addItem(name, proj_id)
|
self.project_combo.addItem(name, proj_id)
|
||||||
form.addRow(strings._("project"), self.project_combo)
|
form.addRow(strings._("project"), self.project_combo)
|
||||||
|
|
@ -1013,6 +1054,7 @@ class TimeReportDialog(QDialog):
|
||||||
self.range_preset = QComboBox()
|
self.range_preset = QComboBox()
|
||||||
self.range_preset.addItem(strings._("custom_range"), "custom")
|
self.range_preset.addItem(strings._("custom_range"), "custom")
|
||||||
self.range_preset.addItem(strings._("today"), "today")
|
self.range_preset.addItem(strings._("today"), "today")
|
||||||
|
self.range_preset.addItem(strings._("last_week"), "last_week")
|
||||||
self.range_preset.addItem(strings._("this_week"), "this_week")
|
self.range_preset.addItem(strings._("this_week"), "this_week")
|
||||||
self.range_preset.addItem(strings._("this_month"), "this_month")
|
self.range_preset.addItem(strings._("this_month"), "this_month")
|
||||||
self.range_preset.addItem(strings._("this_year"), "this_year")
|
self.range_preset.addItem(strings._("this_year"), "this_year")
|
||||||
|
|
@ -1061,6 +1103,10 @@ class TimeReportDialog(QDialog):
|
||||||
run_row.addWidget(run_btn)
|
run_row.addWidget(run_btn)
|
||||||
run_row.addWidget(export_btn)
|
run_row.addWidget(export_btn)
|
||||||
run_row.addWidget(pdf_btn)
|
run_row.addWidget(pdf_btn)
|
||||||
|
# Only show invoicing if the feature is enabled
|
||||||
|
if getattr(self._db.cfg, "invoicing", False):
|
||||||
|
run_row.addWidget(self.invoice_btn)
|
||||||
|
run_row.addWidget(self.manage_invoices_btn)
|
||||||
root.addLayout(run_row)
|
root.addLayout(run_row)
|
||||||
|
|
||||||
# Table
|
# Table
|
||||||
|
|
@ -1146,6 +1192,14 @@ class TimeReportDialog(QDialog):
|
||||||
start = today.addDays(1 - today.dayOfWeek())
|
start = today.addDays(1 - today.dayOfWeek())
|
||||||
end = today
|
end = today
|
||||||
|
|
||||||
|
elif preset == "last_week":
|
||||||
|
# Compute Monday–Sunday of the previous week (Monday-based weeks)
|
||||||
|
# 1. Monday of this week:
|
||||||
|
start_of_this_week = today.addDays(1 - today.dayOfWeek())
|
||||||
|
# 2. Last week is 7 days before that:
|
||||||
|
start = start_of_this_week.addDays(-7) # last week's Monday
|
||||||
|
end = start_of_this_week.addDays(-1) # last week's Sunday
|
||||||
|
|
||||||
elif preset == "this_month":
|
elif preset == "this_month":
|
||||||
start = QDate(today.year(), today.month(), 1)
|
start = QDate(today.year(), today.month(), 1)
|
||||||
end = today
|
end = today
|
||||||
|
|
@ -1187,11 +1241,13 @@ class TimeReportDialog(QDialog):
|
||||||
if proj_data is None:
|
if proj_data is None:
|
||||||
# All projects
|
# All projects
|
||||||
self._last_all_projects = True
|
self._last_all_projects = True
|
||||||
|
self._last_time_logs = []
|
||||||
self._last_project_name = strings._("all_projects")
|
self._last_project_name = strings._("all_projects")
|
||||||
rows_for_table = self._db.time_report_all(start, end, gran)
|
rows_for_table = self._db.time_report_all(start, end, gran)
|
||||||
else:
|
else:
|
||||||
self._last_all_projects = False
|
self._last_all_projects = False
|
||||||
proj_id = int(proj_data)
|
proj_id = int(proj_data)
|
||||||
|
self._last_time_logs = self._db.time_logs_for_range(proj_id, start, end)
|
||||||
project_name = self.project_combo.currentText()
|
project_name = self.project_combo.currentText()
|
||||||
self._last_project_name = project_name
|
self._last_project_name = project_name
|
||||||
|
|
||||||
|
|
@ -1525,3 +1581,55 @@ class TimeReportDialog(QDialog):
|
||||||
strings._("export_pdf_error_title"),
|
strings._("export_pdf_error_title"),
|
||||||
strings._("export_pdf_error_message").format(error=str(exc)),
|
strings._("export_pdf_error_message").format(error=str(exc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _update_invoice_button_state(self) -> None:
|
||||||
|
data = self.project_combo.currentData()
|
||||||
|
if data is not None:
|
||||||
|
self.invoice_btn.show()
|
||||||
|
else:
|
||||||
|
self.invoice_btn.hide()
|
||||||
|
|
||||||
|
def _on_manage_invoices(self) -> None:
|
||||||
|
from .invoices import InvoicesDialog
|
||||||
|
|
||||||
|
dlg = InvoicesDialog(self._db, parent=self)
|
||||||
|
|
||||||
|
# When the dialog says "reminders changed", forward that outward
|
||||||
|
dlg.remindersChanged.connect(self.remindersChanged.emit)
|
||||||
|
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
def _on_create_invoice(self) -> None:
|
||||||
|
idx = self.project_combo.currentIndex()
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
project_id_data = self.project_combo.itemData(idx)
|
||||||
|
if project_id_data is None:
|
||||||
|
# Currently invoices are per-project, not cross-project
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_project_required_title"),
|
||||||
|
strings._("invoice_project_required_message"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
proj_id = int(project_id_data)
|
||||||
|
|
||||||
|
# Ensure we have a recent run to base this on
|
||||||
|
if not self._last_time_logs:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_need_report_title"),
|
||||||
|
strings._("invoice_need_report_message"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
start = self.from_date.date().toString("yyyy-MM-dd")
|
||||||
|
end = self.to_date.date().toString("yyyy-MM-dd")
|
||||||
|
|
||||||
|
from .invoices import InvoiceDialog
|
||||||
|
|
||||||
|
dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self)
|
||||||
|
dlg.remindersChanged.connect(self.remindersChanged.emit)
|
||||||
|
dlg.exec()
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
|
||||||
rect = QRect(0, 0, line_area.width(), line_area.height())
|
rect = QRect(0, 0, line_area.width(), line_area.height())
|
||||||
paint_event = QPaintEvent(rect)
|
paint_event = QPaintEvent(rect)
|
||||||
|
|
||||||
# This should exercise the painting loop (lines 87-130)
|
# This should exercise the painting loop
|
||||||
editor.line_number_area_paint_event(paint_event)
|
editor.line_number_area_paint_event(paint_event)
|
||||||
|
|
||||||
# Should not crash
|
# Should not crash
|
||||||
|
|
|
||||||
1348
tests/test_invoices.py
Normal file
1348
tests/test_invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -97,7 +97,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
|
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
|
||||||
"""Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
|
"""Test KeyPrompt with show_db_change but no initial_db_path"""
|
||||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
|
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
|
||||||
qtbot.addWidget(prompt)
|
qtbot.addWidget(prompt)
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
||||||
"""Test browsing when initial_db_path is set - covers line 57 with non-None path"""
|
"""Test browsing when initial_db_path is set"""
|
||||||
initial_db = tmp_path / "initial.db"
|
initial_db = tmp_path / "initial.db"
|
||||||
initial_db.touch()
|
initial_db.touch()
|
||||||
|
|
||||||
|
|
@ -180,7 +180,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
||||||
|
|
||||||
# Mock the file dialog to return a different file
|
# Mock the file dialog to return a different file
|
||||||
def mock_get_open_filename(*args, **kwargs):
|
def mock_get_open_filename(*args, **kwargs):
|
||||||
# Verify that start_dir was passed correctly (line 57)
|
# Verify that start_dir was passed correctly
|
||||||
return str(new_db), "SQLCipher DB (*.db)"
|
return str(new_db), "SQLCipher DB (*.db)"
|
||||||
|
|
||||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||||
|
|
|
||||||
|
|
@ -1928,7 +1928,7 @@ def test_editor_delete_operations(qtbot, app):
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_highlighter_dark_theme(qtbot, app):
|
def test_markdown_highlighter_dark_theme(qtbot, app):
|
||||||
"""Test markdown highlighter with dark theme - covers lines 74-75"""
|
"""Test markdown highlighter with dark theme"""
|
||||||
# Create theme manager with dark theme
|
# Create theme manager with dark theme
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
||||||
|
|
||||||
|
|
@ -2293,7 +2293,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
|
||||||
# Force rehighlight
|
# Force rehighlight
|
||||||
editor.highlighter.rehighlight()
|
editor.highlighter.rehighlight()
|
||||||
|
|
||||||
# Verify syntax highlighting was applied (lines 186-193)
|
# Verify syntax highlighting was applied
|
||||||
# We can't easily verify the exact formatting, but we ensure no crash
|
# We can't easily verify the exact formatting, but we ensure no crash
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2305,13 +2305,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
|
||||||
# Force rehighlight
|
# Force rehighlight
|
||||||
editor.highlighter.rehighlight()
|
editor.highlighter.rehighlight()
|
||||||
|
|
||||||
# The overlap detection (lines 252, 264) should prevent issues
|
|
||||||
|
|
||||||
|
|
||||||
def test_highlighter_italic_edge_cases(editor, qtbot):
|
def test_highlighter_italic_edge_cases(editor, qtbot):
|
||||||
"""Test italic formatting edge cases."""
|
"""Test italic formatting edge cases."""
|
||||||
# Test edge case: avoiding stealing markers that are part of double
|
# Test edge case: avoiding stealing markers that are part of double
|
||||||
# This tests lines 267-270
|
|
||||||
editor.setPlainText("**not italic* text**")
|
editor.setPlainText("**not italic* text**")
|
||||||
|
|
||||||
# Force rehighlight
|
# Force rehighlight
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ def editor(app, qtbot):
|
||||||
return ed
|
return ed
|
||||||
|
|
||||||
|
|
||||||
# Test for line 215: document is None guard
|
|
||||||
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
||||||
"""Test _update_code_block_row_backgrounds when document is None."""
|
"""Test _update_code_block_row_backgrounds when document is None."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -60,7 +59,6 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
||||||
editor._update_code_block_row_backgrounds()
|
editor._update_code_block_row_backgrounds()
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 295, 309, 313-319, 324, 326, 334: _find_code_block_bounds edge cases
|
|
||||||
def test_find_code_block_bounds_invalid_block(editor):
|
def test_find_code_block_bounds_invalid_block(editor):
|
||||||
"""Test _find_code_block_bounds with invalid block."""
|
"""Test _find_code_block_bounds with invalid block."""
|
||||||
editor.setPlainText("some text")
|
editor.setPlainText("some text")
|
||||||
|
|
@ -124,7 +122,6 @@ def test_find_code_block_bounds_no_opening_fence(editor):
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 356, 413, 417-418, 428-434: code block editing edge cases
|
|
||||||
def test_edit_code_block_checks_document(app, qtbot):
|
def test_edit_code_block_checks_document(app, qtbot):
|
||||||
"""Test _edit_code_block when editor has no document."""
|
"""Test _edit_code_block when editor has no document."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -249,7 +246,6 @@ def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
|
||||||
assert lang == "javascript"
|
assert lang == "javascript"
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 443-490: _delete_code_block
|
|
||||||
def test_delete_code_block_no_bounds(editor):
|
def test_delete_code_block_no_bounds(editor):
|
||||||
"""Test _delete_code_block when bounds can't be found."""
|
"""Test _delete_code_block when bounds can't be found."""
|
||||||
editor.setPlainText("not a code block")
|
editor.setPlainText("not a code block")
|
||||||
|
|
@ -307,7 +303,6 @@ def test_delete_code_block_with_text_after(editor):
|
||||||
assert "text after" in new_text
|
assert "text after" in new_text
|
||||||
|
|
||||||
|
|
||||||
# Test for line 496: _apply_line_spacing with no document
|
|
||||||
def test_apply_line_spacing_no_document(app):
|
def test_apply_line_spacing_no_document(app):
|
||||||
"""Test _apply_line_spacing when document is None."""
|
"""Test _apply_line_spacing when document is None."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -319,7 +314,6 @@ def test_apply_line_spacing_no_document(app):
|
||||||
editor._apply_line_spacing(125.0)
|
editor._apply_line_spacing(125.0)
|
||||||
|
|
||||||
|
|
||||||
# Test for line 517: _apply_code_block_spacing
|
|
||||||
def test_apply_code_block_spacing(editor):
|
def test_apply_code_block_spacing(editor):
|
||||||
"""Test _apply_code_block_spacing applies correct spacing."""
|
"""Test _apply_code_block_spacing applies correct spacing."""
|
||||||
editor.setPlainText("```\nline1\nline2\n```")
|
editor.setPlainText("```\nline1\nline2\n```")
|
||||||
|
|
@ -334,7 +328,6 @@ def test_apply_code_block_spacing(editor):
|
||||||
assert block.isValid()
|
assert block.isValid()
|
||||||
|
|
||||||
|
|
||||||
# Test for line 604: to_markdown with metadata
|
|
||||||
def test_to_markdown_with_code_metadata(editor):
|
def test_to_markdown_with_code_metadata(editor):
|
||||||
"""Test to_markdown includes code block metadata."""
|
"""Test to_markdown includes code block metadata."""
|
||||||
editor.setPlainText("```python\ncode\n```")
|
editor.setPlainText("```python\ncode\n```")
|
||||||
|
|
@ -348,7 +341,6 @@ def test_to_markdown_with_code_metadata(editor):
|
||||||
assert "code-langs" in md or "code" in md
|
assert "code-langs" in md or "code" in md
|
||||||
|
|
||||||
|
|
||||||
# Test for line 648: from_markdown without _code_metadata attribute
|
|
||||||
def test_from_markdown_creates_code_metadata(app):
|
def test_from_markdown_creates_code_metadata(app):
|
||||||
"""Test from_markdown creates _code_metadata if missing."""
|
"""Test from_markdown creates _code_metadata if missing."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -364,7 +356,6 @@ def test_from_markdown_creates_code_metadata(app):
|
||||||
assert hasattr(editor, "_code_metadata")
|
assert hasattr(editor, "_code_metadata")
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 718-736: image embedding with original size
|
|
||||||
def test_embed_images_preserves_original_size(editor, tmp_path):
|
def test_embed_images_preserves_original_size(editor, tmp_path):
|
||||||
"""Test that embedded images preserve their original dimensions."""
|
"""Test that embedded images preserve their original dimensions."""
|
||||||
# Create a test image
|
# Create a test image
|
||||||
|
|
@ -387,7 +378,6 @@ def test_embed_images_preserves_original_size(editor, tmp_path):
|
||||||
assert doc is not None
|
assert doc is not None
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 782, 791, 813-834: _maybe_trim_list_prefix_from_line_selection
|
|
||||||
def test_trim_list_prefix_no_selection(editor):
|
def test_trim_list_prefix_no_selection(editor):
|
||||||
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
|
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
|
||||||
editor.setPlainText("- item")
|
editor.setPlainText("- item")
|
||||||
|
|
@ -447,7 +437,6 @@ def test_trim_list_prefix_during_adjustment(editor):
|
||||||
editor._adjusting_selection = False
|
editor._adjusting_selection = False
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 848, 860-866: _detect_list_type
|
|
||||||
def test_detect_list_type_checkbox_checked(editor):
|
def test_detect_list_type_checkbox_checked(editor):
|
||||||
"""Test _detect_list_type with checked checkbox."""
|
"""Test _detect_list_type with checked checkbox."""
|
||||||
list_type, prefix = editor._detect_list_type(
|
list_type, prefix = editor._detect_list_type(
|
||||||
|
|
@ -478,7 +467,6 @@ def test_detect_list_type_not_a_list(editor):
|
||||||
assert prefix == ""
|
assert prefix == ""
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 876, 884-886: list prefix length calculation
|
|
||||||
def test_list_prefix_length_numbered(editor):
|
def test_list_prefix_length_numbered(editor):
|
||||||
"""Test _list_prefix_length_for_block with numbered list."""
|
"""Test _list_prefix_length_for_block with numbered list."""
|
||||||
editor.setPlainText("123. item")
|
editor.setPlainText("123. item")
|
||||||
|
|
@ -489,7 +477,6 @@ def test_list_prefix_length_numbered(editor):
|
||||||
assert length > 0
|
assert length > 0
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 948-949: keyPressEvent with Ctrl+Home
|
|
||||||
def test_key_press_ctrl_home(editor, qtbot):
|
def test_key_press_ctrl_home(editor, qtbot):
|
||||||
"""Test Ctrl+Home key combination."""
|
"""Test Ctrl+Home key combination."""
|
||||||
editor.setPlainText("line1\nline2\nline3")
|
editor.setPlainText("line1\nline2\nline3")
|
||||||
|
|
@ -504,7 +491,6 @@ def test_key_press_ctrl_home(editor, qtbot):
|
||||||
assert editor.textCursor().position() == 0
|
assert editor.textCursor().position() == 0
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 957-960: keyPressEvent with Ctrl+Left
|
|
||||||
def test_key_press_ctrl_left(editor, qtbot):
|
def test_key_press_ctrl_left(editor, qtbot):
|
||||||
"""Test Ctrl+Left key combination."""
|
"""Test Ctrl+Left key combination."""
|
||||||
editor.setPlainText("word1 word2 word3")
|
editor.setPlainText("word1 word2 word3")
|
||||||
|
|
@ -518,7 +504,6 @@ def test_key_press_ctrl_left(editor, qtbot):
|
||||||
# Should move left by word
|
# Should move left by word
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 984-988, 1044: Home key in list
|
|
||||||
def test_key_press_home_in_list(editor, qtbot):
|
def test_key_press_home_in_list(editor, qtbot):
|
||||||
"""Test Home key in list item."""
|
"""Test Home key in list item."""
|
||||||
editor.setPlainText("- item text")
|
editor.setPlainText("- item text")
|
||||||
|
|
@ -534,7 +519,6 @@ def test_key_press_home_in_list(editor, qtbot):
|
||||||
assert pos > 0
|
assert pos > 0
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1067-1073: Left key in list prefix
|
|
||||||
def test_key_press_left_in_list_prefix(editor, qtbot):
|
def test_key_press_left_in_list_prefix(editor, qtbot):
|
||||||
"""Test Left key when in list prefix region."""
|
"""Test Left key when in list prefix region."""
|
||||||
editor.setPlainText("- item")
|
editor.setPlainText("- item")
|
||||||
|
|
@ -549,7 +533,6 @@ def test_key_press_left_in_list_prefix(editor, qtbot):
|
||||||
# Should snap to after prefix
|
# Should snap to after prefix
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1088, 1095-1104: Up/Down in code blocks
|
|
||||||
def test_key_press_up_in_code_block(editor, qtbot):
|
def test_key_press_up_in_code_block(editor, qtbot):
|
||||||
"""Test Up key inside code block."""
|
"""Test Up key inside code block."""
|
||||||
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
|
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
|
||||||
|
|
@ -579,7 +562,6 @@ def test_key_press_down_in_list_item(editor, qtbot):
|
||||||
# Should snap to after prefix on next line
|
# Should snap to after prefix on next line
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1127-1130, 1134-1137: Enter key with markers
|
|
||||||
def test_key_press_enter_after_markers(editor, qtbot):
|
def test_key_press_enter_after_markers(editor, qtbot):
|
||||||
"""Test Enter key after style markers."""
|
"""Test Enter key after style markers."""
|
||||||
editor.setPlainText("text **")
|
editor.setPlainText("text **")
|
||||||
|
|
@ -593,7 +575,6 @@ def test_key_press_enter_after_markers(editor, qtbot):
|
||||||
# Should handle markers
|
# Should handle markers
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1146-1164: Enter on fence line
|
|
||||||
def test_key_press_enter_on_closing_fence(editor, qtbot):
|
def test_key_press_enter_on_closing_fence(editor, qtbot):
|
||||||
"""Test Enter key on closing fence line."""
|
"""Test Enter key on closing fence line."""
|
||||||
editor.setPlainText("```\ncode\n```")
|
editor.setPlainText("```\ncode\n```")
|
||||||
|
|
@ -608,7 +589,6 @@ def test_key_press_enter_on_closing_fence(editor, qtbot):
|
||||||
# Should create new line after fence
|
# Should create new line after fence
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1185-1189: Backspace in empty checkbox
|
|
||||||
def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
||||||
"""Test Backspace in empty checkbox item."""
|
"""Test Backspace in empty checkbox item."""
|
||||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
|
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
|
||||||
|
|
@ -622,7 +602,6 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
||||||
# Should remove checkbox
|
# Should remove checkbox
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1205, 1215-1221: Backspace in numbered list
|
|
||||||
def test_key_press_backspace_numbered_list(editor, qtbot):
|
def test_key_press_backspace_numbered_list(editor, qtbot):
|
||||||
"""Test Backspace at start of numbered list item."""
|
"""Test Backspace at start of numbered list item."""
|
||||||
editor.setPlainText("1. ")
|
editor.setPlainText("1. ")
|
||||||
|
|
@ -634,7 +613,6 @@ def test_key_press_backspace_numbered_list(editor, qtbot):
|
||||||
editor.keyPressEvent(event)
|
editor.keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1228, 1232, 1238-1242: Tab/Shift+Tab in lists
|
|
||||||
def test_key_press_tab_in_bullet_list(editor, qtbot):
|
def test_key_press_tab_in_bullet_list(editor, qtbot):
|
||||||
"""Test Tab key in bullet list."""
|
"""Test Tab key in bullet list."""
|
||||||
editor.setPlainText("- item")
|
editor.setPlainText("- item")
|
||||||
|
|
@ -672,7 +650,6 @@ def test_key_press_tab_in_checkbox(editor, qtbot):
|
||||||
editor.keyPressEvent(event)
|
editor.keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1282-1283: Auto-pairing skip
|
|
||||||
def test_apply_weight_to_selection(editor, qtbot):
|
def test_apply_weight_to_selection(editor, qtbot):
|
||||||
"""Test apply_weight makes text bold."""
|
"""Test apply_weight makes text bold."""
|
||||||
editor.setPlainText("text to bold")
|
editor.setPlainText("text to bold")
|
||||||
|
|
@ -712,7 +689,6 @@ def test_apply_strikethrough_to_selection(editor, qtbot):
|
||||||
assert "~~" in md
|
assert "~~" in md
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1358: apply_code - it opens a dialog, not just wraps in backticks
|
|
||||||
def test_apply_code_on_selection(editor, qtbot):
|
def test_apply_code_on_selection(editor, qtbot):
|
||||||
"""Test apply_code with selected text."""
|
"""Test apply_code with selected text."""
|
||||||
editor.setPlainText("some code")
|
editor.setPlainText("some code")
|
||||||
|
|
@ -728,7 +704,6 @@ def test_apply_code_on_selection(editor, qtbot):
|
||||||
# May contain code block elements depending on dialog behavior
|
# May contain code block elements depending on dialog behavior
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1386: toggle_numbers
|
|
||||||
def test_toggle_numbers_on_plain_text(editor, qtbot):
|
def test_toggle_numbers_on_plain_text(editor, qtbot):
|
||||||
"""Test toggle_numbers converts text to numbered list."""
|
"""Test toggle_numbers converts text to numbered list."""
|
||||||
editor.setPlainText("item 1")
|
editor.setPlainText("item 1")
|
||||||
|
|
@ -742,7 +717,6 @@ def test_toggle_numbers_on_plain_text(editor, qtbot):
|
||||||
assert "1." in text
|
assert "1." in text
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1402-1407: toggle_bullets
|
|
||||||
def test_toggle_bullets_on_plain_text(editor, qtbot):
|
def test_toggle_bullets_on_plain_text(editor, qtbot):
|
||||||
"""Test toggle_bullets converts text to bullet list."""
|
"""Test toggle_bullets converts text to bullet list."""
|
||||||
editor.setPlainText("item 1")
|
editor.setPlainText("item 1")
|
||||||
|
|
@ -771,7 +745,6 @@ def test_toggle_bullets_removes_bullets(editor, qtbot):
|
||||||
assert text.strip() == "item 1"
|
assert text.strip() == "item 1"
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1429: toggle_checkboxes
|
|
||||||
def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
||||||
"""Test toggle_checkboxes converts bullets to checkboxes."""
|
"""Test toggle_checkboxes converts bullets to checkboxes."""
|
||||||
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
||||||
|
|
@ -786,7 +759,6 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
||||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1452: apply_heading
|
|
||||||
def test_apply_heading_various_levels(editor, qtbot):
|
def test_apply_heading_various_levels(editor, qtbot):
|
||||||
"""Test apply_heading with different levels."""
|
"""Test apply_heading with different levels."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
|
|
@ -809,7 +781,6 @@ def test_apply_heading_various_levels(editor, qtbot):
|
||||||
assert text.startswith(expected_marker)
|
assert text.startswith(expected_marker)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1501-1505: insert_image_from_path
|
|
||||||
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
|
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
|
||||||
"""Test insert_image_from_path with invalid extension."""
|
"""Test insert_image_from_path with invalid extension."""
|
||||||
invalid_file = tmp_path / "file.txt"
|
invalid_file = tmp_path / "file.txt"
|
||||||
|
|
@ -827,7 +798,6 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path):
|
||||||
editor.insert_image_from_path(nonexistent)
|
editor.insert_image_from_path(nonexistent)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1578-1579: mousePressEvent checkbox toggle
|
|
||||||
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
|
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
|
||||||
"""Test clicking checkbox toggles it from unchecked to checked."""
|
"""Test clicking checkbox toggles it from unchecked to checked."""
|
||||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||||
|
|
@ -872,7 +842,6 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
|
||||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1602: mouseDoubleClickEvent
|
|
||||||
def test_mouse_double_click_suppression(editor, qtbot):
|
def test_mouse_double_click_suppression(editor, qtbot):
|
||||||
"""Test double-click suppression for checkboxes."""
|
"""Test double-click suppression for checkboxes."""
|
||||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||||
|
|
@ -895,7 +864,6 @@ def test_mouse_double_click_suppression(editor, qtbot):
|
||||||
assert not editor._suppress_next_checkbox_double_click
|
assert not editor._suppress_next_checkbox_double_click
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1692-1738: Context menu (lines 1670 was the image loading, not link handling)
|
|
||||||
def test_context_menu_in_code_block(editor, qtbot):
|
def test_context_menu_in_code_block(editor, qtbot):
|
||||||
"""Test context menu when in code block."""
|
"""Test context menu when in code block."""
|
||||||
editor.setPlainText("```python\ncode\n```")
|
editor.setPlainText("```python\ncode\n```")
|
||||||
|
|
@ -915,7 +883,6 @@ def test_context_menu_in_code_block(editor, qtbot):
|
||||||
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
|
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1742-1757: _set_code_block_language
|
|
||||||
def test_set_code_block_language(editor, qtbot):
|
def test_set_code_block_language(editor, qtbot):
|
||||||
"""Test _set_code_block_language sets metadata."""
|
"""Test _set_code_block_language sets metadata."""
|
||||||
editor.setPlainText("```\ncode\n```")
|
editor.setPlainText("```\ncode\n```")
|
||||||
|
|
@ -929,7 +896,6 @@ def test_set_code_block_language(editor, qtbot):
|
||||||
assert lang == "python"
|
assert lang == "python"
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1770-1783: get_current_line_task_text
|
|
||||||
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
|
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
|
||||||
"""Test get_current_line_task_text removes list/checkbox prefixes."""
|
"""Test get_current_line_task_text removes list/checkbox prefixes."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
|
|
|
||||||
|
|
@ -632,5 +632,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
|
||||||
# Force a repaint to execute paintEvent
|
# Force a repaint to execute paintEvent
|
||||||
heatmap.repaint()
|
heatmap.repaint()
|
||||||
|
|
||||||
# The month continuation logic (line 175) should prevent duplicate labels
|
# The month continuation logic should prevent duplicate labels
|
||||||
# We can't easily test the visual output, but we ensure no crash
|
# We can't easily test the visual output, but we ensure no crash
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue