Coverage for app/offline/routes.py: 100%
183 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
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 models import ( # pyright: ignore[reportMissingImports]
31 Aircraft,
32 Flight,
33 TenantUser,
34 db,
35)
36from pilots.form_parsing import ( # pyright: ignore[reportMissingImports]
37 apply_pilot_fields,
38 parse_pilot_fields,
39)
40from utils import ( # pyright: ignore[reportMissingImports]
41 login_required,
42 require_pilot_access,
43 user_can_access_aircraft,
44)
46from offline.serialize import ( # pyright: ignore[reportMissingImports]
47 FLIGHT_EDITABLE_FIELDS,
48 PILOT_EDITABLE_FIELDS,
49 canonical_entry,
50 canonical_pilot_entry,
51)
53offline_bp = Blueprint("offline", __name__)
56@offline_bp.errorhandler(CSRFError)
57def _csrf_error(e: CSRFError) -> ResponseReturnValue:
58 """Same-shape JSON for CSRF failures on any /api/offline/* POST.
60 Without this, Flask-WTF's CSRFError (a 400 BadRequest) renders the
61 default HTML error page, which breaks every caller here — they all
62 parse the response as JSON.
63 """
64 return jsonify({"status": "invalid", "errors": [str(e.description)]}), 400
67def api_login_required(f: Callable[..., Any]) -> Callable[..., Any]:
68 """Like @login_required, but returns JSON 401 instead of redirecting.
70 Fetch/IndexedDB-driven callers cannot follow a redirect to the login
71 page usefully — they need a status they can branch on.
72 """
74 @wraps(f)
75 def decorated(*args: Any, **kwargs: Any) -> Any:
76 if not session.get("user_id"):
77 return jsonify({"status": "auth"}), 401
78 return f(*args, **kwargs)
80 return decorated
83def _tenant_id() -> int:
84 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
85 if not tu:
86 abort(403)
87 return int(tu.tenant_id)
90def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
91 """Mirrors flights._get_aircraft_or_404 — same tenant/access guard."""
92 ac = db.session.get(Aircraft, aircraft_id)
93 if (
94 not ac
95 or ac.tenant_id != _tenant_id()
96 or not user_can_access_aircraft(aircraft_id)
97 ):
98 abort(404)
99 return ac
102def _get_flight_or_404(flight_id: int) -> Flight:
103 """Mirrors flights._get_flight_or_404 — same tenant/identity guard as
104 edit_flight (tenant check for a managed-aircraft row, crew-identity
105 check for a standalone one)."""
106 fe = db.session.get(Flight, flight_id)
107 if not fe:
108 abort(404)
109 if fe.aircraft_id is not None:
110 ac = db.session.get(Aircraft, fe.aircraft_id)
111 if not ac or ac.tenant_id != _tenant_id():
112 abort(404)
113 else:
114 uid = session.get("user_id")
115 if fe.pic_user_id != uid and fe.second_crew_user_id != uid:
116 abort(404)
117 return fe
120@offline_bp.route("/api/offline/aircraft/<aircraft_ref:aircraft_id>/logbook")
121@api_login_required
122def aircraft_logbook_snapshot(aircraft_id: int) -> ResponseReturnValue:
123 ac = _get_aircraft_or_404(aircraft_id)
124 entries = (
125 Flight.query.filter_by(aircraft_id=ac.id)
126 .order_by(Flight.date.asc(), Flight.id.asc())
127 .all()
128 )
129 return jsonify(
130 {
131 "aircraft": {
132 "id": ac.id,
133 "registration": ac.registration,
134 "has_flight_counter": ac.has_flight_counter,
135 "flight_counter_offset": str(ac.flight_counter_offset),
136 },
137 "snapshot_taken_at": datetime.now(UTC).isoformat(),
138 "entries": [
139 {
140 "id": fe.id,
141 "fields": canonical_entry(fe),
142 "meta": {
143 "has_flight_counter_photo": bool(fe.flight_counter_photo),
144 "has_engine_counter_photo": bool(fe.engine_counter_photo),
145 "has_fuel_photo": bool(fe.fuel_photo),
146 "has_gps_track": fe.gps_track_id is not None,
147 "source": fe.source,
148 "created_at": fe.created_at.isoformat()
149 if fe.created_at
150 else None,
151 },
152 }
153 for fe in entries
154 ],
155 }
156 )
159@offline_bp.route("/api/offline/csrf")
160@api_login_required
161def csrf_token() -> ResponseReturnValue:
162 return jsonify({"csrf_token": generate_csrf()})
165@offline_bp.route("/aircraft/<aircraft_ref:aircraft_id>/logbook/offline")
166@login_required
167@require_pilot_access
168def workbench(aircraft_id: int) -> ResponseReturnValue:
169 ac = _get_aircraft_or_404(aircraft_id)
170 return render_template("offline/workbench.html", aircraft=ac)
173@offline_bp.route("/offline/changes")
174@login_required
175def changes() -> ResponseReturnValue:
176 return render_template("offline/changes.html")
179@offline_bp.route("/pilot/logbook/offline")
180@login_required
181@require_pilot_access
182def pilot_workbench() -> ResponseReturnValue:
183 return render_template("offline/pilot_workbench.html")
186_EDITABLE_FIELD_SET = set(FLIGHT_EDITABLE_FIELDS)
188# EASA figures on FLIGHT_EDITABLE_FIELDS that parse_flight_fields (the
189# airframe-only parser shared with the online form) doesn't parse — the
190# online form parses these itself in _handle_log_flight_post rather than
191# through that shared helper, so the offline sync path parses them here too.
192_EASA_DECIMAL_FIELDS = (
193 "night_time",
194 "instrument_time",
195 "single_pilot_se",
196 "single_pilot_me",
197 "multi_pilot",
198 "function_pic",
199 "function_copilot",
200 "function_dual",
201 "function_instructor",
202)
203_EASA_INT_FIELDS = ("landings_day", "landings_night")
206def _parse_easa_decimal(raw: str) -> decimal.Decimal | None:
207 if not raw:
208 return None
209 try:
210 v = decimal.Decimal(raw)
211 return v if v >= 0 else None
212 except decimal.InvalidOperation:
213 return None
216def _parse_easa_int(raw: str) -> int | None:
217 if not raw:
218 return None
219 try:
220 v = int(raw)
221 return v if v >= 0 else None
222 except ValueError:
223 return None
226def _apply_easa_fields(fe: Flight, effective: dict[str, str]) -> None:
227 for key in _EASA_DECIMAL_FIELDS:
228 setattr(fe, key, _parse_easa_decimal(effective[key]))
229 for key in _EASA_INT_FIELDS:
230 setattr(fe, key, _parse_easa_int(effective[key]))
233def _malformed_sync_body(fields: Any, base: Any) -> bool:
234 return (
235 not isinstance(fields, dict)
236 or not isinstance(base, dict)
237 or set(fields.keys()) != _EDITABLE_FIELD_SET
238 or set(base.keys()) != _EDITABLE_FIELD_SET
239 or not all(isinstance(v, str) for v in fields.values())
240 or not all(isinstance(v, str) for v in base.values())
241 )
244@offline_bp.route("/api/offline/flights/<int:flight_id>/sync", methods=["POST"])
245@api_login_required
246@require_pilot_access
247def sync_flight(flight_id: int) -> ResponseReturnValue:
248 fe = _get_flight_or_404(flight_id)
249 uid = int(session["user_id"])
251 body = request.get_json(silent=True)
252 if not isinstance(body, dict):
253 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
255 fields_raw = body.get("fields")
256 base_raw = body.get("base")
257 force_duplicate = bool(body.get("force_duplicate", False))
259 if _malformed_sync_body(fields_raw, base_raw):
260 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
261 fields = cast("dict[str, str]", fields_raw)
262 base = cast("dict[str, str]", base_raw)
264 ac = db.session.get(Aircraft, fe.aircraft_id) if fe.aircraft_id else None
265 current = canonical_entry(fe)
267 # Per-field conflict scan: a field is only in conflict when the user
268 # changed it (fields != base) AND the server also moved since the
269 # snapshot AND the server didn't happen to land on the same value.
270 conflicts: list[dict[str, str]] = []
271 effective = dict(current)
272 for key in FLIGHT_EDITABLE_FIELDS:
273 if fields[key] != base[key]:
274 if current[key] != base[key] and current[key] != fields[key]:
275 conflicts.append(
276 {
277 "field": key,
278 "base": base[key],
279 "local": fields[key],
280 "server": current[key],
281 }
282 )
283 else:
284 effective[key] = fields[key]
286 if conflicts:
287 return (
288 jsonify({"status": "conflict", "conflicts": conflicts, "entry": current}),
289 409,
290 )
292 values, errors = parse_flight_fields(
293 effective, ac, strict=not flight_is_lenient(fe)
294 )
295 if errors:
296 return jsonify({"status": "invalid", "errors": errors}), 400
298 if (
299 effective["date"] != current["date"]
300 or effective["departure_icao"] != current["departure_icao"]
301 or effective["arrival_icao"] != current["arrival_icao"]
302 ):
303 dup = _find_duplicate_flight(
304 aircraft_id=fe.aircraft_id,
305 pilot_user_id=uid,
306 date=values["date"],
307 dep_icao=values["departure_icao"],
308 arr_icao=values["arrival_icao"],
309 block_off=fe.block_off_utc,
310 block_on=fe.block_on_utc,
311 exclude_flight_id=fe.id,
312 )
313 if dup and not force_duplicate:
314 return jsonify({"status": "duplicate"}), 409
316 apply_flight_fields(fe, values)
317 _apply_easa_fields(fe, effective)
319 db.session.commit()
320 if fe.aircraft_id:
321 _check_flight_hour_milestone(fe)
323 return jsonify({"status": "ok", "entry": canonical_entry(fe)})
326@offline_bp.route("/api/offline/pilot/logbook")
327@api_login_required
328@require_pilot_access
329def pilot_logbook_snapshot() -> ResponseReturnValue:
330 from sqlalchemy import or_ # pyright: ignore[reportMissingImports]
332 uid = int(session["user_id"])
333 entries = (
334 Flight.query.filter(
335 or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid),
336 Flight.aircraft_id.is_(None),
337 )
338 .order_by(Flight.date.asc(), Flight.id.asc())
339 .all()
340 )
341 return jsonify(
342 {
343 "snapshot_taken_at": datetime.now(UTC).isoformat(),
344 "entries": [
345 {"id": pe.id, "fields": canonical_pilot_entry(pe)} for pe in entries
346 ],
347 }
348 )
351_PILOT_EDITABLE_FIELD_SET = set(PILOT_EDITABLE_FIELDS)
354def _malformed_pilot_sync_body(fields: Any, base: Any) -> bool:
355 return (
356 not isinstance(fields, dict)
357 or not isinstance(base, dict)
358 or set(fields.keys()) != _PILOT_EDITABLE_FIELD_SET
359 or set(base.keys()) != _PILOT_EDITABLE_FIELD_SET
360 or not all(isinstance(v, str) for v in fields.values())
361 or not all(isinstance(v, str) for v in base.values())
362 )
365@offline_bp.route("/api/offline/pilot/logbook/<int:entry_id>/sync", methods=["POST"])
366@api_login_required
367@require_pilot_access
368def sync_pilot_entry(entry_id: int) -> ResponseReturnValue:
369 uid = int(session["user_id"])
370 pe = db.session.get(Flight, entry_id)
371 if (
372 not pe
373 or (pe.pic_user_id != uid and pe.second_crew_user_id != uid)
374 or pe.aircraft_id is not None
375 ):
376 abort(404)
378 body = request.get_json(silent=True)
379 if not isinstance(body, dict):
380 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
382 fields_raw = body.get("fields")
383 base_raw = body.get("base")
385 if _malformed_pilot_sync_body(fields_raw, base_raw):
386 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400
387 fields = cast("dict[str, str]", fields_raw)
388 base = cast("dict[str, str]", base_raw)
390 current = canonical_pilot_entry(pe)
392 conflicts: list[dict[str, str]] = []
393 effective = dict(current)
394 for key in PILOT_EDITABLE_FIELDS:
395 if fields[key] != base[key]:
396 if current[key] != base[key] and current[key] != fields[key]:
397 conflicts.append(
398 {
399 "field": key,
400 "base": base[key],
401 "local": fields[key],
402 "server": current[key],
403 }
404 )
405 else:
406 effective[key] = fields[key]
408 if conflicts:
409 return (
410 jsonify({"status": "conflict", "conflicts": conflicts, "entry": current}),
411 409,
412 )
414 values, errors = parse_pilot_fields(effective)
415 if errors:
416 return jsonify({"status": "invalid", "errors": errors}), 400
418 apply_pilot_fields(pe, values)
419 db.session.commit()
421 return jsonify({"status": "ok", "entry": canonical_pilot_entry(pe)})