Coverage for app/offline/serialize.py: 100%

18 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1"""Canonical string serialization for offline-sync conflict detection. 

2 

3Conflict detection (see ``offline/routes.py``) compares strings, so every 

4editable field must have exactly one canonical string form. These functions 

5are the single authority used by the snapshot API, the sync API's conflict 

6scan, and the sync API's response — never re-derive these formats elsewhere. 

7 

8Unified-model note: a Flight row now carries both the airframe-log fields 

9and the EASA pilot-log figures together — there's no more separate "pilot" 

10sub-object nested inside the aircraft snapshot (the old 

11PILOT_LINKED_EDITABLE_FIELDS / canonical_linked_pilot_fields / 

12canonical_linked_pilot_derived split), since they're just more columns on 

13the same row now. FLIGHT_EDITABLE_FIELDS below covers a managed-aircraft 

14row end to end; PILOT_EDITABLE_FIELDS covers a standalone row (aircraft_id 

15NULL) end to end — both driven by the same Flight model, just a different 

16field subset per "which side" a workbench edits. 

17""" 

18 

19from decimal import Decimal 

20from typing import TYPE_CHECKING 

21 

22if TYPE_CHECKING: 

23 from datetime import time as _time 

24 

25 from models import Flight 

26 

27 

28# The editable field set for a managed-aircraft Flight row's offline sync 

29# (app/offline routes, aircraft workbench, outbox). crew_name_0/crew_name_1/ 

30# crew_role_1 are wire names for pic_name/second_crew_name/second_crew_role 

31# (there's no crew_role_0 any more — the PIC slot's role is implicit). 

32FLIGHT_EDITABLE_FIELDS: tuple[str, ...] = ( 

33 "date", 

34 "departure_icao", 

35 "arrival_icao", 

36 "departure_time", 

37 "arrival_time", 

38 "takeoff_time", 

39 "landing_time", 

40 "flight_time", 

41 "flight_time_counter_start", 

42 "flight_time_counter_end", 

43 "engine_time", 

44 "engine_time_counter_start", 

45 "engine_time_counter_end", 

46 "fuel_added_before_qty", 

47 "fuel_added_before_unit", 

48 "fuel_added_after_qty", 

49 "fuel_added_after_unit", 

50 "fuel_remaining_qty", 

51 "oil_added_before_l", 

52 "oil_added_after_l", 

53 "passenger_count", 

54 "landing_count", 

55 "nature_of_flight", 

56 "notes", 

57 "crew_name_0", 

58 "crew_name_1", 

59 "crew_role_1", 

60 "night_time", 

61 "instrument_time", 

62 "landings_day", 

63 "landings_night", 

64 "single_pilot_se", 

65 "single_pilot_me", 

66 "multi_pilot", 

67 "function_pic", 

68 "function_copilot", 

69 "function_dual", 

70 "function_instructor", 

71) 

72 

73 

74def _fmt_decimal(value: Decimal | float | None, decimals: int) -> str: 

75 if value is None: 

76 return "" 

77 return f"{float(value):.{decimals}f}" 

78 

79 

80def _fmt_int(value: int | None) -> str: 

81 return "" if value is None else str(int(value)) 

82 

83 

84def _fmt_time(value: "_time | None") -> str: 

85 return "" if value is None else value.strftime("%H:%M") 

86 

87 

88def _fmt_str(value: str | None) -> str: 

89 return "" if value is None else value.strip() 

90 

91 

92# The editable field set for a standalone Flight row's offline sync 

93# (aircraft_id IS NULL — rental/FSTD/other-club flights) — everything 

94# parse_pilot_fields/apply_pilot_fields handles. Wire names match the 

95# pre-refactor PilotLogbookEntry columns (aircraft_type, departure_place, 

96# remarks, …) even though the underlying Flight columns are named 

97# differently (other_aircraft_type, departure_icao, notes, …) — see 

98# pilots/form_parsing.py, which already does this same wire<->storage 

99# mapping for the online standalone entry form. `cross_country` is a model 

100# column with no form field anywhere and is intentionally excluded. 

101PILOT_EDITABLE_FIELDS: tuple[str, ...] = ( 

102 "date", 

103 "aircraft_type", 

104 "aircraft_type_icao", 

105 "aircraft_registration", 

106 "departure_place", 

107 "departure_time", 

108 "arrival_place", 

109 "arrival_time", 

110 "takeoff_time", 

111 "landing_time", 

112 "pic_name", 

113 "night_time", 

114 "instrument_time", 

115 "landings_day", 

116 "landings_night", 

117 "single_pilot_se", 

118 "single_pilot_me", 

119 "multi_pilot", 

120 "function_pic", 

121 "function_copilot", 

122 "function_dual", 

123 "function_instructor", 

124 "remarks", 

125 "entry_type", 

126 "fstd_type", 

127 "fstd_duration", 

128) 

129 

130 

131def canonical_pilot_entry(pe: "Flight") -> dict[str, str]: 

132 """Canonical (string, per-field) serialization of a standalone Flight 

133 row's editable fields (aircraft_id IS NULL).""" 

134 return { 

135 "date": pe.date.isoformat() if pe.date else "", 

136 "aircraft_type": _fmt_str(pe.other_aircraft_type), 

137 "aircraft_type_icao": _fmt_str(pe.other_aircraft_type_icao), 

138 "aircraft_registration": _fmt_str(pe.other_aircraft_registration), 

139 "departure_place": _fmt_str(pe.departure_icao), 

140 "departure_time": _fmt_time(pe.departure_time), 

141 "arrival_place": _fmt_str(pe.arrival_icao), 

142 "arrival_time": _fmt_time(pe.arrival_time), 

143 "takeoff_time": _fmt_time(pe.takeoff_time), 

144 "landing_time": _fmt_time(pe.landing_time), 

145 "pic_name": _fmt_str(pe.pic_name), 

146 "night_time": _fmt_decimal(pe.night_time, 1), 

147 "instrument_time": _fmt_decimal(pe.instrument_time, 1), 

148 "landings_day": _fmt_int(pe.landings_day), 

149 "landings_night": _fmt_int(pe.landings_night), 

150 "single_pilot_se": _fmt_decimal(pe.single_pilot_se, 1), 

151 "single_pilot_me": _fmt_decimal(pe.single_pilot_me, 1), 

152 "multi_pilot": _fmt_decimal(pe.multi_pilot, 1), 

153 "function_pic": _fmt_decimal(pe.function_pic, 1), 

154 "function_copilot": _fmt_decimal(pe.function_copilot, 1), 

155 "function_dual": _fmt_decimal(pe.function_dual, 1), 

156 "function_instructor": _fmt_decimal(pe.function_instructor, 1), 

157 "remarks": _fmt_str(pe.notes), 

158 "entry_type": _fmt_str(pe.entry_type), 

159 "fstd_type": _fmt_str(pe.fstd_type), 

160 "fstd_duration": _fmt_decimal(pe.fstd_duration, 1), 

161 } 

162 

163 

164def canonical_entry(fe: "Flight") -> dict[str, str]: 

165 """Canonical (string, per-field) serialization of a managed-aircraft 

166 Flight row's editable fields.""" 

167 return { 

168 "date": fe.date.isoformat() if fe.date else "", 

169 "departure_icao": (fe.departure_icao or "").strip().upper(), 

170 "arrival_icao": (fe.arrival_icao or "").strip().upper(), 

171 "departure_time": _fmt_time(fe.departure_time), 

172 "arrival_time": _fmt_time(fe.arrival_time), 

173 "takeoff_time": _fmt_time(fe.takeoff_time), 

174 "landing_time": _fmt_time(fe.landing_time), 

175 "flight_time": _fmt_decimal(fe.flight_time, 1), 

176 "flight_time_counter_start": _fmt_decimal(fe.flight_time_counter_start, 1), 

177 "flight_time_counter_end": _fmt_decimal(fe.flight_time_counter_end, 1), 

178 "engine_time": _fmt_decimal(fe.engine_time, 1), 

179 "engine_time_counter_start": _fmt_decimal(fe.engine_time_counter_start, 1), 

180 "engine_time_counter_end": _fmt_decimal(fe.engine_time_counter_end, 1), 

181 "fuel_added_before_qty": _fmt_decimal(fe.fuel_added_before_qty, 2), 

182 "fuel_added_before_unit": _fmt_str(fe.fuel_added_before_unit), 

183 "fuel_added_after_qty": _fmt_decimal(fe.fuel_added_after_qty, 2), 

184 "fuel_added_after_unit": _fmt_str(fe.fuel_added_after_unit), 

185 "fuel_remaining_qty": _fmt_decimal(fe.fuel_remaining_qty, 2), 

186 "oil_added_before_l": _fmt_decimal(fe.oil_added_before_l, 2), 

187 "oil_added_after_l": _fmt_decimal(fe.oil_added_after_l, 2), 

188 "passenger_count": _fmt_int(fe.passenger_count), 

189 "landing_count": _fmt_int(fe.landing_count), 

190 "nature_of_flight": _fmt_str(fe.nature_of_flight), 

191 "notes": _fmt_str(fe.notes), 

192 "crew_name_0": _fmt_str(fe.pic_name), 

193 "crew_name_1": _fmt_str(fe.second_crew_name), 

194 "crew_role_1": _fmt_str(fe.second_crew_role), 

195 "night_time": _fmt_decimal(fe.night_time, 1), 

196 "instrument_time": _fmt_decimal(fe.instrument_time, 1), 

197 "landings_day": _fmt_int(fe.landings_day), 

198 "landings_night": _fmt_int(fe.landings_night), 

199 "single_pilot_se": _fmt_decimal(fe.single_pilot_se, 1), 

200 "single_pilot_me": _fmt_decimal(fe.single_pilot_me, 1), 

201 "multi_pilot": _fmt_decimal(fe.multi_pilot, 1), 

202 "function_pic": _fmt_decimal(fe.function_pic, 1), 

203 "function_copilot": _fmt_decimal(fe.function_copilot, 1), 

204 "function_dual": _fmt_decimal(fe.function_dual, 1), 

205 "function_instructor": _fmt_decimal(fe.function_instructor, 1), 

206 }