Coverage for app/aircraft/co_owner_form_parsing.py: 100%
86 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"""Phase 39a: validation for the shared-ownership manage-owners form.
3Extracted from the aircraft.manage_owners route (following the same
4pattern as maintenance/form_parsing.py) so the dynamic-rows / sum-to-100
5validation can be unit-tested and fuzzed directly. Never raises on
6arbitrary form data.
7"""
9from __future__ import annotations
11from collections.abc import Sequence
12from datetime import date as _date
13from decimal import Decimal, InvalidOperation
14from typing import Any
16from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
18TWO_PLACES_EXPONENT = -2
21def _parse_decimal(raw: str) -> Decimal | None:
22 """Decimal("inf")/Decimal("nan") parse without raising, so an explicit
23 finiteness check is required alongside the sign/range checks below."""
24 try:
25 v = Decimal(raw)
26 except (InvalidOperation, ValueError):
27 return None
28 return v if v.is_finite() else None
31def _exceeds_two_decimal_places(d: Decimal) -> bool:
32 exponent = d.as_tuple().exponent
33 # Only ever a non-int ('n'/'N'/'F') for nan/snan/infinity, already
34 # excluded by _parse_decimal's is_finite() check — isinstance narrows
35 # the type for mypy rather than asserting something new.
36 return isinstance(exponent, int) and exponent < TWO_PLACES_EXPONENT
39def parse_owners_form(
40 form: Any,
41) -> tuple[list[dict[str, Any]], _date | None, Decimal | None, list[str]]:
42 """Parse the manage-owners form.
44 `form` must support `.get(name, default)` and `.getlist(name)` (an
45 ``ImmutableMultiDict`` in production, a plain dict-like stub in tests).
47 Returns (rows, billing_start, hourly_rate, errors). `rows` is a list of
48 {"user_id": int, "share_pct": Decimal, "buy_in_amount": Decimal} dicts;
49 a row is skipped — "removed" — when it has no user selected (the blank
50 template rows) or its "remove" checkbox is checked (existing rows).
51 The caller replaces the aircraft's entire owner set with exactly these
52 rows; zero rows is valid (clears co-ownership).
53 """
54 errors: list[str] = []
55 user_ids_raw: Sequence[str] = form.getlist("owner_user_id[]")
56 share_raw: Sequence[str] = form.getlist("owner_share_pct[]")
57 buyin_raw: Sequence[str] = form.getlist("owner_buy_in_amount[]")
58 # Checkbox values are the row index — mirrors the WB-config station
59 # pattern (station_is_fuel[]): unchecked boxes never submit at all, so
60 # this list only ever contains the indices of *checked* rows.
61 remove_indices = set(form.getlist("owner_remove[]"))
63 rows: list[dict[str, Any]] = []
64 seen_users: set[int] = set()
66 for i, uid_s in enumerate(user_ids_raw):
67 uid_s = uid_s.strip()
68 if not uid_s or str(i) in remove_indices:
69 continue
70 try:
71 uid = int(uid_s)
72 except ValueError:
73 errors.append(_("Invalid owner selection."))
74 continue
75 if uid in seen_users:
76 errors.append(_("Each owner can only appear once."))
77 continue
79 share_s = (share_raw[i] if i < len(share_raw) else "").strip()
80 share = _parse_decimal(share_s)
81 if (
82 share is None
83 or share <= 0
84 or share > 100
85 or _exceeds_two_decimal_places(share)
86 ):
87 errors.append(
88 _(
89 "Share for each owner must be greater than 0, at most 100, "
90 "and have at most 2 decimal places."
91 )
92 )
93 continue
95 buyin_s = (buyin_raw[i] if i < len(buyin_raw) else "").strip()
96 buy_in = _parse_decimal(buyin_s) if buyin_s else Decimal(0)
97 if buy_in is None or buy_in < 0:
98 errors.append(_("Buy-in amount must be a non-negative number."))
99 continue
101 seen_users.add(uid)
102 rows.append({"user_id": uid, "share_pct": share, "buy_in_amount": buy_in})
104 if rows:
105 total = sum((r["share_pct"] for r in rows), Decimal(0))
106 if total != Decimal(100):
107 errors.append(_("Share percentages must sum to exactly 100%%."))
109 billing_start_raw = (form.get("co_owner_billing_start", "") or "").strip()
110 billing_start: _date | None = None
111 if billing_start_raw:
112 try:
113 billing_start = _date.fromisoformat(billing_start_raw)
114 except ValueError:
115 errors.append(_("Billing start date must be a valid date (YYYY-MM-DD)."))
117 rate_raw = (form.get("co_owner_hourly_rate", "") or "").strip()
118 rate: Decimal | None = None
119 if rate_raw:
120 rate = _parse_decimal(rate_raw)
121 if rate is None or rate < 0:
122 errors.append(_("Hourly rate must be a non-negative number."))
123 rate = None
125 return rows, billing_start, rate, errors
128def parse_reserve_fields(
129 form: Any,
130) -> tuple[Decimal | None, Decimal | None, list[str]]:
131 """Parse the two reserve/overhaul fund contribution fields (39g,
132 stretch goal). At most one of hourly/monthly may be set — that mode
133 exclusivity is validated here, since it spans both fields at once.
134 Returns (hourly, monthly, errors)."""
135 errors: list[str] = []
137 hourly_raw = (form.get("reserve_contribution_hourly", "") or "").strip()
138 hourly: Decimal | None = None
139 if hourly_raw:
140 hourly = _parse_decimal(hourly_raw)
141 if hourly is None or hourly < 0:
142 errors.append(
143 _("Reserve contribution (hourly) must be a non-negative number.")
144 )
145 hourly = None
147 monthly_raw = (form.get("reserve_contribution_monthly", "") or "").strip()
148 monthly: Decimal | None = None
149 if monthly_raw:
150 monthly = _parse_decimal(monthly_raw)
151 if monthly is None or monthly < 0:
152 errors.append(
153 _("Reserve contribution (monthly) must be a non-negative number.")
154 )
155 monthly = None
157 if hourly is not None and monthly is not None:
158 errors.append(
159 _("Set either an hourly or a monthly reserve contribution, not both.")
160 )
162 return hourly, monthly, errors