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

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

45 

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

47 FLIGHT_EDITABLE_FIELDS, 

48 PILOT_EDITABLE_FIELDS, 

49 canonical_entry, 

50 canonical_pilot_entry, 

51) 

52 

53offline_bp = Blueprint("offline", __name__) 

54 

55 

56@offline_bp.errorhandler(CSRFError) 

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

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

59 

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 

65 

66 

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

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

69 

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

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

72 """ 

73 

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) 

79 

80 return decorated 

81 

82 

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) 

88 

89 

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 

100 

101 

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 

118 

119 

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 ) 

157 

158 

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

160@api_login_required 

161def csrf_token() -> ResponseReturnValue: 

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

163 

164 

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) 

171 

172 

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

174@login_required 

175def changes() -> ResponseReturnValue: 

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

177 

178 

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

184 

185 

186_EDITABLE_FIELD_SET = set(FLIGHT_EDITABLE_FIELDS) 

187 

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

204 

205 

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 

214 

215 

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 

224 

225 

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

231 

232 

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 ) 

242 

243 

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

250 

251 body = request.get_json(silent=True) 

252 if not isinstance(body, dict): 

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

254 

255 fields_raw = body.get("fields") 

256 base_raw = body.get("base") 

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

258 

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) 

263 

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

265 current = canonical_entry(fe) 

266 

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] 

285 

286 if conflicts: 

287 return ( 

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

289 409, 

290 ) 

291 

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 

297 

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 

315 

316 apply_flight_fields(fe, values) 

317 _apply_easa_fields(fe, effective) 

318 

319 db.session.commit() 

320 if fe.aircraft_id: 

321 _check_flight_hour_milestone(fe) 

322 

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

324 

325 

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] 

331 

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 ) 

349 

350 

351_PILOT_EDITABLE_FIELD_SET = set(PILOT_EDITABLE_FIELDS) 

352 

353 

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 ) 

363 

364 

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) 

377 

378 body = request.get_json(silent=True) 

379 if not isinstance(body, dict): 

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

381 

382 fields_raw = body.get("fields") 

383 base_raw = body.get("base") 

384 

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) 

389 

390 current = canonical_pilot_entry(pe) 

391 

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] 

407 

408 if conflicts: 

409 return ( 

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

411 409, 

412 ) 

413 

414 values, errors = parse_pilot_fields(effective) 

415 if errors: 

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

417 

418 apply_pilot_fields(pe, values) 

419 db.session.commit() 

420 

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