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

1"""Shared validation for the Expense editable field set (excluding receipts). 

2 

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""" 

15 

16from __future__ import annotations 

17 

18import math 

19from collections.abc import Mapping 

20from datetime import date as _date 

21from typing import Any 

22 

23from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

24from models import ( # pyright: ignore[reportMissingImports] 

25 ExpenseCategory, 

26 ExpenseRecurrence, 

27 ExpenseType, 

28) 

29 

30 

31def parse_expense_fields(f: Mapping[str, str]) -> tuple[dict[str, Any], str | None]: 

32 """Validate the editable Expense fields (excluding the receipt upload). 

33 

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() 

50 

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.")) 

57 

58 if expense_type not in ExpenseType.ALL: 

59 return {}, str(_("Invalid expense type.")) 

60 

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.")) 

67 

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.")) 

76 

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.")) 

85 

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.")) 

100 

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 ) 

115 

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