Coverage for app/expenses/form_parsing.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""Shared validation for the Expense editable field set (excluding receipts).
3``parse_expense_fields`` extracts the validation previously inlined
4directly in ``_validate_and_save`` (expenses/routes.py), following the
5same pattern as flights/form_parsing.py, pilots/form_parsing.py, and
6maintenance/form_parsing.py — a standalone, importable function that never
7raises on arbitrary form data. Unlike those, this preserves
8``_validate_and_save``'s original "return on first error" contract (a
9single error message, not an accumulated list) — that's its pre-existing
10UX behaviour (only the first problem found is ever shown), not something
11this extraction should change. The receipt-file upload isn't included:
12it operates on a ``FileStorage`` object and a real ``Expense``/``Aircraft``
13row, not string form fields, so it stays in the route.
14"""
16from __future__ import annotations
18import math
19from collections.abc import Mapping
20from datetime import date as _date
21from typing import Any
23from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
24from models import ( # pyright: ignore[reportMissingImports]
25 ExpenseCategory,
26 ExpenseRecurrence,
27 ExpenseType,
28)
31def parse_expense_fields(f: Mapping[str, str]) -> tuple[dict[str, Any], str | None]:
32 """Validate the editable Expense fields (excluding the receipt upload).
34 Returns ``(values, None)`` on success, or ``({}, error_message)`` on the
35 first validation failure — mirrors ``_validate_and_save``'s pre-existing
36 single-error-at-a-time behaviour exactly.
37 """
38 date_str = (f.get("date") or "").strip()
39 expense_type = (f.get("expense_type") or "").strip()
40 expense_category = (f.get("expense_category") or "").strip()
41 description = (f.get("description") or "").strip() or None
42 amount_str = (f.get("amount") or "").strip()
43 currency = (f.get("currency") or "EUR").strip()
44 quantity_str = (f.get("quantity") or "").strip()
45 unit = (f.get("unit") or "").strip() or None
46 coverage_start_str = (f.get("coverage_start") or "").strip()
47 coverage_end_str = (f.get("coverage_end") or "").strip()
48 recurrence = (f.get("recurrence") or "").strip() or None
49 recurrence_end_str = (f.get("recurrence_end") or "").strip()
51 if not date_str:
52 return {}, str(_("Date is required."))
53 try:
54 date_val = _date.fromisoformat(date_str)
55 except ValueError:
56 return {}, str(_("Invalid date format."))
58 if expense_type not in ExpenseType.ALL:
59 return {}, str(_("Invalid expense type."))
61 if not expense_category:
62 expense_category = ExpenseCategory.DEFAULTS.get(
63 expense_type, ExpenseCategory.OPERATING
64 )
65 if expense_category not in ExpenseCategory.ALL:
66 return {}, str(_("Invalid expense category."))
68 if not amount_str:
69 return {}, str(_("Amount is required."))
70 try:
71 amount = float(amount_str)
72 if not math.isfinite(amount) or amount < 0:
73 raise ValueError
74 except ValueError:
75 return {}, str(_("Amount must be a non-negative number."))
77 quantity = None
78 if quantity_str:
79 try:
80 quantity = float(quantity_str)
81 if not math.isfinite(quantity) or quantity < 0:
82 raise ValueError
83 except ValueError:
84 return {}, str(_("Quantity must be a non-negative number."))
86 coverage_start = None
87 coverage_end = None
88 if coverage_start_str or coverage_end_str:
89 if not (coverage_start_str and coverage_end_str):
90 return {}, str(
91 _("Coverage start and end dates must both be set, or both left blank.")
92 )
93 try:
94 coverage_start = _date.fromisoformat(coverage_start_str)
95 coverage_end = _date.fromisoformat(coverage_end_str)
96 except ValueError:
97 return {}, str(_("Invalid coverage date format."))
98 if coverage_end < coverage_start:
99 return {}, str(_("Coverage end date must not be before the start date."))
101 if recurrence is not None and recurrence not in ExpenseRecurrence.ALL:
102 return {}, str(_("Invalid recurrence."))
103 recurrence_end = None
104 if recurrence_end_str:
105 if recurrence is None:
106 return {}, str(_("A recurrence end date requires a recurrence."))
107 try:
108 recurrence_end = _date.fromisoformat(recurrence_end_str)
109 except ValueError:
110 return {}, str(_("Invalid recurrence end date format."))
111 if recurrence_end < date_val:
112 return {}, str(
113 _("The recurrence end date must not be before the expense date.")
114 )
116 values: dict[str, Any] = {
117 "date": date_val,
118 "expense_type": expense_type,
119 "expense_category": expense_category,
120 "description": description,
121 "amount": amount,
122 "currency": currency,
123 "quantity": quantity,
124 "unit": unit,
125 "coverage_start": coverage_start,
126 "coverage_end": coverage_end,
127 "recurrence": recurrence,
128 "recurrence_end": recurrence_end,
129 }
130 return values, None