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

1import decimal 

2from collections.abc import Callable 

3from datetime import UTC, datetime 

4from functools import wraps 

5from typing import Any, cast 

6 

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) 

53 

54from offline.serialize import ( # pyright: ignore[reportMissingImports] 

55 FLIGHT_EDITABLE_FIELDS, 

56 PILOT_EDITABLE_FIELDS, 

57 canonical_entry, 

58 canonical_pilot_entry, 

59) 

60 

61offline_bp = Blueprint("offline", __name__) 

62 

63 

64@offline_bp.errorhandler(CSRFError) 

65def _csrf_error(e: CSRFError) -> ResponseReturnValue: 

66 """Same-shape JSON for CSRF failures on any /api/offline/* POST. 

67 

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 

73 

74 

75def api_login_required(f: Callable[..., Any]) -> Callable[..., Any]: 

76 """Like @login_required, but returns JSON 401 instead of redirecting. 

77 

78 Fetch/IndexedDB-driven callers cannot follow a redirect to the login 

79 page usefully — they need a status they can branch on. 

80 """ 

81 

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) 

87 

88 return decorated 

89 

90 

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) 

96 

97 

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 

108 

109 

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 

126 

127 

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 ) 

171 

172 

173@offline_bp.route("/api/offline/csrf") 

174@api_login_required 

175def csrf_token() -> ResponseReturnValue: 

176 return jsonify({"csrf_token": generate_csrf()}) 

177 

178 

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) 

185 

186 

187@offline_bp.route("/offline/changes") 

188@login_required 

189def changes() -> ResponseReturnValue: 

190 return render_template("offline/changes.html") 

191 

192 

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

198 

199 

200_EDITABLE_FIELD_SET = set(FLIGHT_EDITABLE_FIELDS) 

201 

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

218 

219 

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 

228 

229 

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 

238 

239 

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

245 

246 

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 ) 

264 

265 

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 ) 

275 

276 

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() 

285 

286 body = request.get_json(silent=True) 

287 if not isinstance(body, dict): 

288 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400 

289 

290 fields_raw = body.get("fields") 

291 base_raw = body.get("base") 

292 force_duplicate = bool(body.get("force_duplicate", False)) 

293 

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) 

298 

299 ac = db.session.get(Aircraft, fe.aircraft_id) if fe.aircraft_id else None 

300 current = canonical_entry(fe) 

301 

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] 

320 

321 if conflicts: 

322 return ( 

323 jsonify({"status": "conflict", "conflicts": conflicts, "entry": current}), 

324 409, 

325 ) 

326 

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 

332 

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 

350 

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) 

356 

357 db.session.commit() 

358 notify_shared_changes(fe, uid, shared_before) 

359 if fe.aircraft_id: 

360 _check_flight_hour_milestone(fe) 

361 

362 return jsonify({"status": "ok", "entry": canonical_entry(fe)}) 

363 

364 

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] 

370 

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 ) 

388 

389 

390_PILOT_EDITABLE_FIELD_SET = set(PILOT_EDITABLE_FIELDS) 

391 

392 

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 ) 

402 

403 

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() 

418 

419 body = request.get_json(silent=True) 

420 if not isinstance(body, dict): 

421 return jsonify({"status": "invalid", "errors": [_("Malformed request.")]}), 400 

422 

423 fields_raw = body.get("fields") 

424 base_raw = body.get("base") 

425 

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) 

430 

431 current = canonical_pilot_entry(pe) 

432 

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] 

448 

449 if conflicts: 

450 return ( 

451 jsonify({"status": "conflict", "conflicts": conflicts, "entry": current}), 

452 409, 

453 ) 

454 

455 values, errors = parse_pilot_fields(effective) 

456 if errors: 

457 return jsonify({"status": "invalid", "errors": errors}), 400 

458 

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) 

465 

466 return jsonify({"status": "ok", "entry": canonical_pilot_entry(pe)})