Coverage for app/offline/routes.py: 100%
198 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
1import decimal
2from collections.abc import Callable
3from datetime import UTC, datetime
4from functools import wraps
5from typing import Any, cast
7from flask import ( # pyright: ignore[reportMissingImports]
8 Blueprint,
9 abort,
10 jsonify,
11 render_template,
12 request,
13 session,
14)
15from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
16from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
17from flask_wtf.csrf import ( # pyright: ignore[reportMissingImports]
18 CSRFError,
19 generate_csrf,
20)
21from flights.form_parsing import ( # pyright: ignore[reportMissingImports]
22 apply_flight_fields,
23 flight_is_lenient,
24 parse_flight_fields,
25)
26from flights.routes import ( # pyright: ignore[reportMissingImports]
27 _check_flight_hour_milestone,
28 _find_duplicate_flight,
29)
30from flights.shared_flight import ( # pyright: ignore[reportMissingImports]
31 can_edit_shared,
32 notify_shared_changes,
33 protect_other_pilots,
34 restore_other_pilots,
35 shared_snapshot,
36)
37from models import ( # pyright: ignore[reportMissingImports]
38 Aircraft,
39 Flight,
40 TenantUser,
41 db,
42)
43from pilots.form_parsing import ( # pyright: ignore[reportMissingImports]
44 apply_pilot_fields,
45 parse_pilot_fields,
46)
47from utils import ( # pyright: ignore[reportMissingImports]
48 current_user_role,
49 login_required,
50 require_pilot_access,
51 user_can_access_aircraft,
52)
54from offline.serialize import ( # pyright: ignore[reportMissingImports]
55 FLIGHT_EDITABLE_FIELDS,
56 PILOT_EDITABLE_FIELDS,
57 canonical_entry,
58 canonical_pilot_entry,
59)
61offline_bp = Blueprint("offline", __name__)
64@offline_bp.errorhandler(CSRFError)
65def _csrf_error(e: CSRFError) -> ResponseReturnValue:
66 """Same-shape JSON for CSRF failures on any /api/offline/* POST.
68 Without this, Flask-WTF's CSRFError (a 400 BadRequest) renders the
69 default HTML error page, which breaks every caller here — they all
70 parse the response as JSON.
71 """
72 return jsonify({"status": "invalid", "errors": [str(e.description)]}), 400
75def api_login_required(f: Callable[..., Any]) -> Callable[..., Any]:
76 """Like @login_required, but returns JSON 401 instead of redirecting.
78 Fetch/IndexedDB-driven callers cannot follow a redirect to the login
79 page usefully — they need a status they can branch on.
80 """
82 @wraps(f)
83 def decorated(*args: Any, **kwargs: Any) -> Any:
84 if not session.get("user_id"):
85 return jsonify({"status": "auth"}), 401
86 return f(*args, **kwargs)
88 return decorated
91def _tenant_id() -> int:
92 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
93 if not tu:
94 abort(403)
95 return int(tu.tenant_id)
98def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
99 """Mirrors flights._get_aircraft_or_404 — same tenant/access guard."""
100 ac = db.session.get(Aircraft, aircraft_id)
101 if (
102 not ac
103 or ac.tenant_id != _tenant_id()
104 or not user_can_access_aircraft(aircraft_id)
105 ):
106 abort(404)
107 return ac
110def _get_flight_or_404(flight_id: int) -> Flight:
111 """Mirrors flights._get_flight_or_404 — same tenant/identity guard as
112 edit_flight (tenant check for a managed-aircraft row, crew-identity
113 check for a standalone one)."""
114 fe = db.session.get(Flight, flight_id)
115 if not fe:
116 abort(404)
117 if fe.aircraft_id is not None:
118 ac = db.session.get(Aircraft, fe.aircraft_id)
119 if not ac or ac.tenant_id != _tenant_id():
120 abort(404)
121 else:
122 uid = session.get("user_id")
123 if fe.pic_user_id != uid and fe.second_crew_user_id != uid:
124 abort(404)
125 return fe
128@offline_bp.route("/api/offline/aircraft/<aircraft_ref:aircraft_id>/logbook")
129@api_login_required
130def aircraft_logbook_snapshot(aircraft_id: int) -> ResponseReturnValue:
131 ac = _get_aircraft_or_404(aircraft_id)
132 entries = (
133 Flight.query.filter_by(aircraft_id=ac.id)
134 .order_by(Flight.date.asc(), Flight.id.asc())
135 .all()
136 )
137 return jsonify(
138 {
139 "aircraft": {
140 "id": ac.id,
141 "registration": ac.registration,
142 "has_flight_counter": ac.has_flight_counter,
143 "flight_counter_offset": str(ac.flight_counter_offset),
144 },
145 "snapshot_taken_at": datetime.now(UTC).isoformat(),
146 "entries": [
147 {
148 "id": fe.id,
149 "fields": canonical_entry(fe),
150 "meta": {
151 "has_flight_counter_photo": bool(fe.flight_counter_photo),
152 "has_engine_counter_photo": bool(fe.engine_counter_photo),
153 "has_fuel_photo": bool(fe.fuel_photo),
154 "has_flight_counter_photo_preflight": bool(
155 fe.flight_counter_photo_preflight
156 ),
157 "has_engine_counter_photo_preflight": bool(
158 fe.engine_counter_photo_preflight
159 ),
160 "has_gps_track": fe.gps_track_id is not None,
161 "source": fe.source,
162 "created_at": fe.created_at.isoformat()
163 if fe.created_at
164 else None,
165 },
166 }
167 for fe in entries
168 ],
169 }
170 )
173@offline_bp.route("/api/offline/csrf")
174@api_login_required
175def csrf_token() -> ResponseReturnValue:
176 return jsonify({"csrf_token": generate_csrf()})
179@offline_bp.route("/aircraft/<aircraft_ref:aircraft_id>/logbook/offline")
180@login_required
181@require_pilot_access
182def workbench(aircraft_id: int) -> ResponseReturnValue:
183 ac = _get_aircraft_or_404(aircraft_id)
184 return render_template("offline/workbench.html", aircraft=ac)
187@offline_bp.route("/offline/changes")
188@login_required
189def changes() -> ResponseReturnValue:
190 return render_template("offline/changes.html")
193@offline_bp.route("/pilot/logbook/offline")
194@login_required
195@require_pilot_access
196def pilot_workbench() -> ResponseReturnValue:
197 return render_template("offline/pilot_workbench.html")
200_EDITABLE_FIELD_SET = set(FLIGHT_EDITABLE_FIELDS)
202# EASA figures on FLIGHT_EDITABLE_FIELDS that parse_flight_fields (the
203# airframe-only parser shared with the online form) doesn't parse — the
204# online form parses these itself in _handle_log_flight_post rather than
205# through that shared helper, so the offline sync path parses them here too.
206_EASA_DECIMAL_FIELDS = (
207 "night_time",
208 "instrument_time",
209 "single_pilot_se",
210 "single_pilot_me",
211 "multi_pilot",
212 "function_pic",
213 "function_copilot",
214 "function_dual",
215 "function_instructor",
216)
217_EASA_INT_FIELDS = ("landings_day", "landings_night")
220def _parse_easa_decimal(raw: str) -> decimal.Decimal | None:
221 if not raw:
222 return None
223 try:
224 v = decimal.Decimal(raw)
225 return v if v >= 0 else None
226 except decimal.InvalidOperation:
227 return None
230def _parse_easa_int(raw: str) -> int | None:
231 if not raw:
232 return None
233 try:
234 v = int(raw)
235 return v if v >= 0 else None
236 except ValueError:
237 return None
240def _apply_easa_fields(fe: Flight, effective: dict[str, str]) -> None:
241 for key in _EASA_DECIMAL_FIELDS:
242 setattr(fe, key, _parse_easa_decimal(effective[key]))
243 for key in _EASA_INT_FIELDS:
244 setattr(fe, key, _parse_easa_int(effective[key]))
247def _shared_flight_locked_response() -> ResponseReturnValue:
248 """A pilot who confirmed someone else's flight can't change its shared
249 details offline — they suggest a correction online instead."""
250 return (
251 jsonify(
252 {
253 "status": "invalid",
254 "errors": [
255 _(
256 "Another pilot logged this flight, so only they can change "
257 "its details. Open the flight online to suggest a correction."
258 )
259 ],
260 }
261 ),
262 400,
263 )
266def _malformed_sync_body(fields: Any, base: Any) -> bool:
267 return (
268 not isinstance(fields, dict)
269 or not isinstance(base, dict)
270 or set(fields.keys()) != _EDITABLE_FIELD_SET
271 or set(base.keys()) != _EDITABLE_FIELD_SET
272 or not all(isinstance(v, str) for v in fields.values())
273 or not all(isinstance(v, str) for v in base.values())
274 )
277@offline_bp.route("/api/offline/flights/<int:flight_id>/sync", methods=["POST"])
278@api_login_required
279@require_pilot_access
280def sync_flight(flight_id: int) -> ResponseReturnValue:
281 fe = _get_flight_or_404(flight_id)
282 uid = int(session["user_id"])
283 if not can_edit_shared(fe, uid, current_user_role()):
284 return _shared_flight_locked_response()
286 body = request.get_json(silent=True)
287 if not isinstance(body, dict):
288 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
290 fields_raw = body.get("fields")
291 base_raw = body.get("base")
292 force_duplicate = bool(body.get("force_duplicate", False))
294 if _malformed_sync_body(fields_raw, base_raw):
295 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
296 fields = cast("dict[str, str]", fields_raw)
297 base = cast("dict[str, str]", base_raw)
299 ac = db.session.get(Aircraft, fe.aircraft_id) if fe.aircraft_id else None
300 current = canonical_entry(fe)
302 # Per-field conflict scan: a field is only in conflict when the user
303 # changed it (fields != base) AND the server also moved since the
304 # snapshot AND the server didn't happen to land on the same value.
305 conflicts: list[dict[str, str]] = []
306 effective = dict(current)
307 for key in FLIGHT_EDITABLE_FIELDS:
308 if fields[key] != base[key]:
309 if current[key] != base[key] and current[key] != fields[key]:
310 conflicts.append(
311 {
312 "field": key,
313 "base": base[key],
314 "local": fields[key],
315 "server": current[key],
316 }
317 )
318 else:
319 effective[key] = fields[key]
321 if conflicts:
322 return (
323 jsonify({"status": "conflict", "conflicts": conflicts, "entry": current}),
324 409,
325 )
327 values, errors = parse_flight_fields(
328 effective, ac, strict=not flight_is_lenient(fe)
329 )
330 if errors:
331 return jsonify({"status": "invalid", "errors": errors}), 400
333 if (
334 effective["date"] != current["date"]
335 or effective["departure_icao"] != current["departure_icao"]
336 or effective["arrival_icao"] != current["arrival_icao"]
337 ):
338 dup = _find_duplicate_flight(
339 aircraft_id=fe.aircraft_id,
340 pilot_user_id=uid,
341 date=values["date"],
342 dep_icao=values["departure_icao"],
343 arr_icao=values["arrival_icao"],
344 block_off=fe.block_off_utc,
345 block_on=fe.block_on_utc,
346 exclude_flight_id=fe.id,
347 )
348 if dup and not force_duplicate:
349 return jsonify({"status": "duplicate"}), 409
351 shared_before = shared_snapshot(fe)
352 other_pilots = protect_other_pilots(fe, uid)
353 apply_flight_fields(fe, values)
354 _apply_easa_fields(fe, effective)
355 restore_other_pilots(fe, other_pilots)
357 db.session.commit()
358 notify_shared_changes(fe, uid, shared_before)
359 if fe.aircraft_id:
360 _check_flight_hour_milestone(fe)
362 return jsonify({"status": "ok", "entry": canonical_entry(fe)})
365@offline_bp.route("/api/offline/pilot/logbook")
366@api_login_required
367@require_pilot_access
368def pilot_logbook_snapshot() -> ResponseReturnValue:
369 from sqlalchemy import or_ # pyright: ignore[reportMissingImports]
371 uid = int(session["user_id"])
372 entries = (
373 Flight.query.filter(
374 or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid),
375 Flight.aircraft_id.is_(None),
376 )
377 .order_by(Flight.date.asc(), Flight.id.asc())
378 .all()
379 )
380 return jsonify(
381 {
382 "snapshot_taken_at": datetime.now(UTC).isoformat(),
383 "entries": [
384 {"id": pe.id, "fields": canonical_pilot_entry(pe)} for pe in entries
385 ],
386 }
387 )
390_PILOT_EDITABLE_FIELD_SET = set(PILOT_EDITABLE_FIELDS)
393def _malformed_pilot_sync_body(fields: Any, base: Any) -> bool:
394 return (
395 not isinstance(fields, dict)
396 or not isinstance(base, dict)
397 or set(fields.keys()) != _PILOT_EDITABLE_FIELD_SET
398 or set(base.keys()) != _PILOT_EDITABLE_FIELD_SET
399 or not all(isinstance(v, str) for v in fields.values())
400 or not all(isinstance(v, str) for v in base.values())
401 )
404@offline_bp.route("/api/offline/pilot/logbook/<int:entry_id>/sync", methods=["POST"])
405@api_login_required
406@require_pilot_access
407def sync_pilot_entry(entry_id: int) -> ResponseReturnValue:
408 uid = int(session["user_id"])
409 pe = db.session.get(Flight, entry_id)
410 if (
411 not pe
412 or (pe.pic_user_id != uid and pe.second_crew_user_id != uid)
413 or pe.aircraft_id is not None
414 ):
415 abort(404)
416 if not can_edit_shared(pe, uid, current_user_role()):
417 return _shared_flight_locked_response()
419 body = request.get_json(silent=True)
420 if not isinstance(body, dict):
421 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
423 fields_raw = body.get("fields")
424 base_raw = body.get("base")
426 if _malformed_pilot_sync_body(fields_raw, base_raw):
427 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
428 fields = cast("dict[str, str]", fields_raw)
429 base = cast("dict[str, str]", base_raw)
431 current = canonical_pilot_entry(pe)
433 conflicts: list[dict[str, str]] = []
434 effective = dict(current)
435 for key in PILOT_EDITABLE_FIELDS:
436 if fields[key] != base[key]:
437 if current[key] != base[key] and current[key] != fields[key]:
438 conflicts.append(
439 {
440 "field": key,
441 "base": base[key],
442 "local": fields[key],
443 "server": current[key],
444 }
445 )
446 else:
447 effective[key] = fields[key]
449 if conflicts:
450 return (
451 jsonify({"status": "conflict", "conflicts": conflicts, "entry": current}),
452 409,
453 )
455 values, errors = parse_pilot_fields(effective)
456 if errors:
457 return jsonify({"status": "invalid", "errors": errors}), 400
459 shared_before = shared_snapshot(pe)
460 other_pilots = protect_other_pilots(pe, uid)
461 apply_pilot_fields(pe, values)
462 restore_other_pilots(pe, other_pilots)
463 db.session.commit()
464 notify_shared_changes(pe, uid, shared_before)
466 return jsonify({"status": "ok", "entry": canonical_pilot_entry(pe)})