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
« 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.
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.
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"""
19from decimal import Decimal
20from typing import TYPE_CHECKING
22if TYPE_CHECKING:
23 from datetime import time as _time
25 from models import Flight
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)
74def _fmt_decimal(value: Decimal | float | None, decimals: int) -> str:
75 if value is None:
76 return ""
77 return f"{float(value):.{decimals}f}"
80def _fmt_int(value: int | None) -> str:
81 return "" if value is None else str(int(value))
84def _fmt_time(value: "_time | None") -> str:
85 return "" if value is None else value.strftime("%H:%M")
88def _fmt_str(value: str | None) -> str:
89 return "" if value is None else value.strip()
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)
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 }
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 }