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

1"""Phase 39a: validation for the shared-ownership manage-owners form. 

2 

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

8 

9from __future__ import annotations 

10 

11from collections.abc import Sequence 

12from datetime import date as _date 

13from decimal import Decimal, InvalidOperation 

14from typing import Any 

15 

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

17 

18TWO_PLACES_EXPONENT = -2 

19 

20 

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 

29 

30 

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 

37 

38 

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. 

43 

44 `form` must support `.get(name, default)` and `.getlist(name)` (an 

45 ``ImmutableMultiDict`` in production, a plain dict-like stub in tests). 

46 

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[]")) 

62 

63 rows: list[dict[str, Any]] = [] 

64 seen_users: set[int] = set() 

65 

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 

78 

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 

94 

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 

100 

101 seen_users.add(uid) 

102 rows.append({"user_id": uid, "share_pct": share, "buy_in_amount": buy_in}) 

103 

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

108 

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

116 

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 

124 

125 return rows, billing_start, rate, errors 

126 

127 

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] = [] 

136 

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 

146 

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 

156 

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 ) 

161 

162 return hourly, monthly, errors