Coverage for app/reservations/routes.py: 100%

802 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1""" 

2Reservations blueprint — aircraft booking calendar, create/edit/cancel, 

3owner approval workflow, and per-aircraft booking settings. 

4""" 

5 

6import calendar 

7from datetime import UTC, datetime, time, timedelta 

8from datetime import date as _date 

9from typing import Any 

10from urllib.parse import urlparse 

11 

12from expenses.cost_dashboard import ( # pyright: ignore[reportMissingImports] 

13 DEFAULT_PERIOD_MONTHS, 

14 compute_cost_dashboard, 

15) 

16from flask import ( # pyright: ignore[reportMissingImports] 

17 Blueprint, 

18 abort, 

19 flash, 

20 redirect, 

21 render_template, 

22 request, 

23 session, 

24 url_for, 

25) 

26from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

27from models import ( # pyright: ignore[reportMissingImports] 

28 Aircraft, 

29 AircraftBookingSettings, 

30 MaintenanceDowntime, 

31 RateBasis, 

32 RateType, 

33 Reservation, 

34 ReservationStatus, 

35 Role, 

36 TenantUser, 

37 db, 

38) 

39from utils import ( # pyright: ignore[reportMissingImports] 

40 login_required, 

41 require_role, 

42 user_can_access_aircraft, 

43) 

44 

45reservations_bp = Blueprint("reservations", __name__) 

46 

47_OWNER_ROLES = (Role.ADMIN, Role.OWNER) 

48_BOOKING_ROLES = (Role.ADMIN, Role.OWNER, Role.PILOT) 

49_DOWNTIME_ROLES = (Role.ADMIN, Role.OWNER, Role.MAINTENANCE) 

50 

51# How far the manually-set booking rate may drift from the computed cost 

52# dashboard's wet rate before the owner is nudged to review it. 

53RATE_DIVERGENCE_WARN_PCT = 0.10 

54 

55 

56def _safe_next(next_url: str, fallback: str) -> str: 

57 """Return next_url only when it is a safe relative path, otherwise fallback.""" 

58 next_url = next_url.replace("\\", "") 

59 try: 

60 parsed = urlparse(next_url) 

61 except ValueError: 

62 # e.g. "//[" — urlparse rejects malformed IPv6-bracket syntax. 

63 return fallback 

64 if ( 

65 next_url 

66 and not parsed.scheme 

67 and not parsed.netloc 

68 and next_url.startswith("/") 

69 ): 

70 return next_url 

71 return fallback 

72 

73 

74# ── Helpers ─────────────────────────────────────────────────────────────────── 

75 

76 

77def _tenant_id() -> int: 

78 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

79 if not tu: 

80 abort(403) # pragma: no cover 

81 return tu.tenant_id 

82 

83 

84def _get_aircraft_or_404(aircraft_id: int) -> Aircraft: 

85 ac = db.session.get(Aircraft, aircraft_id) 

86 if ( 

87 not ac 

88 or ac.tenant_id != _tenant_id() 

89 or not user_can_access_aircraft(aircraft_id) 

90 ): 

91 abort(404) 

92 return ac 

93 

94 

95def _get_reservation_or_404(ac: Aircraft, res_id: int) -> Reservation: 

96 r = db.session.get(Reservation, res_id) 

97 if not r or r.aircraft_id != ac.id: 

98 abort(404) 

99 return r 

100 

101 

102def _has_conflict( 

103 aircraft_id: int, 

104 start_dt: datetime, 

105 end_dt: datetime, 

106 exclude_id: int | None = None, 

107) -> bool: 

108 """Return True if any confirmed reservation or maintenance downtime 

109 overlaps [start_dt, end_dt).""" 

110 q = Reservation.query.filter( 

111 Reservation.aircraft_id == aircraft_id, 

112 Reservation.status == ReservationStatus.CONFIRMED, 

113 Reservation.start_dt < end_dt, 

114 Reservation.end_dt > start_dt, 

115 ) 

116 if exclude_id is not None: 

117 q = q.filter(Reservation.id != exclude_id) 

118 if q.first() is not None: 

119 return True 

120 dq = MaintenanceDowntime.query.filter( 

121 MaintenanceDowntime.aircraft_id == aircraft_id, 

122 MaintenanceDowntime.start_dt < end_dt, 

123 MaintenanceDowntime.end_dt > start_dt, 

124 ) 

125 return dq.first() is not None 

126 

127 

128def _parse_datetime(s: str) -> datetime | None: 

129 """Parse 'YYYY-MM-DDTHH:MM' (HTML datetime-local) → UTC-aware datetime.""" 

130 try: 

131 return datetime.fromisoformat(s).replace(tzinfo=UTC) 

132 except (ValueError, AttributeError): 

133 return None 

134 

135 

136def _computed_rate(ac: Aircraft) -> float | None: 

137 """The cost dashboard's wet rate for this aircraft, or None with no history yet.""" 

138 return compute_cost_dashboard(ac, DEFAULT_PERIOD_MONTHS)["wet_per_hour"] 

139 

140 

141def _effective_rate( 

142 ac: Aircraft, settings: AircraftBookingSettings | None 

143) -> tuple[float | None, str | None]: 

144 """Return (rate, source): the manually-set rate wins when configured; 

145 otherwise fall back to the computed cost dashboard rate, if available.""" 

146 if settings and settings.hourly_rate is not None: 

147 return float(settings.hourly_rate), "manual" 

148 computed = _computed_rate(ac) 

149 if computed is not None: 

150 return computed, "computed" 

151 return None, None 

152 

153 

154def _rate_terms_label(settings: AircraftBookingSettings | None) -> str: 

155 """e.g. 'Wet (Engine time)' — falls back to the column defaults when no 

156 settings row exists yet, matching AircraftBookingSettings' own defaults. 

157 

158 Labels are literal _() calls, not a RateType.LABELS[...] dict lookup — 

159 pybabel's static extractor cannot see a translatable string reached 

160 through a dynamic key, so a dict-lookup version would silently never 

161 get translated in any locale. 

162 """ 

163 rate_type = settings.rate_type if settings else RateType.WET 

164 rate_basis = settings.rate_basis if settings else RateBasis.ENGINE_TIME 

165 type_label = _("Wet") if rate_type == RateType.WET else _("Dry") 

166 basis_label = ( 

167 _("Engine time") if rate_basis == RateBasis.ENGINE_TIME else _("Flight time") 

168 ) 

169 return f"{type_label} ({basis_label})" 

170 

171 

172def _is_owner_role(user_id: int) -> bool: 

173 tu = TenantUser.query.filter_by(user_id=user_id).first() 

174 return tu is not None and tu.role in _OWNER_ROLES 

175 

176 

177def _rental_authorization_policy(tenant_id: int) -> str: 

178 from models import TenantProfile # pyright: ignore[reportMissingImports] 

179 

180 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first() 

181 return profile.rental_authorization_policy if profile else "warn" 

182 

183 

184def _grounded_reservation_policy(tenant_id: int) -> str: 

185 from models import TenantProfile # pyright: ignore[reportMissingImports] 

186 

187 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first() 

188 return profile.grounded_reservation_policy if profile else "warn" 

189 

190 

191def _has_open_grounding_snag(ac: Aircraft) -> bool: 

192 return any(s.is_grounding and s.is_open for s in ac.snags) 

193 

194 

195def _renter_authorization_ok(aircraft_id: int, pilot_user_id: int) -> bool: 

196 from models import RenterAuthorization # pyright: ignore[reportMissingImports] 

197 

198 return RenterAuthorization.valid_for(pilot_user_id, aircraft_id) is not None 

199 

200 

201def _rate_divergence_warning( 

202 ac: Aircraft, settings: AircraftBookingSettings | None 

203) -> str | None: 

204 """Warn when the manual rate has drifted from the computed cost-basis rate.""" 

205 if not settings or settings.hourly_rate is None: 

206 return None 

207 computed = _computed_rate(ac) 

208 if computed is None or computed == 0: 

209 return None 

210 manual = float(settings.hourly_rate) 

211 pct_diff = abs(manual - computed) / computed 

212 if pct_diff <= RATE_DIVERGENCE_WARN_PCT: 

213 return None 

214 return str( 

215 _( 

216 "Your manual rate (%(manual)s EUR/h) differs from the computed " 

217 "cost-dashboard rate (%(computed)s EUR/h) by more than %(pct)s%% — " 

218 "consider reviewing it.", 

219 manual=f"{manual:.2f}", 

220 computed=f"{computed:.2f}", 

221 pct=int(RATE_DIVERGENCE_WARN_PCT * 100), 

222 ) 

223 ) 

224 

225 

226def _chargeable_days(start_dt: datetime, end_dt: datetime) -> int: 

227 """Number of distinct calendar dates touched by the half-open interval 

228 [start_dt, end_dt) (tenant-local = UTC dates; the app runs in UTC). 

229 

230 A booking ending exactly at midnight does not touch that calendar date 

231 (the interval is half-open), so e.g. 23:00 day1 → 00:00 day2 touches 

232 only day1, while 23:00 day1 → 00:01 day2 touches both. 

233 """ 

234 last_day = end_dt.date() 

235 if end_dt.time() == time(0, 0): 

236 last_day -= timedelta(days=1) 

237 return (last_day - start_dt.date()).days + 1 

238 

239 

240def _estimated_hours( 

241 start_dt: datetime, end_dt: datetime, settings: AircraftBookingSettings | None 

242) -> float: 

243 """Wall-clock duration, floored at chargeable_days × min_hours_per_day 

244 when that per-aircraft minimum is configured (standard multi-day rental 

245 practice: a booking spanning N calendar days bills at least N days' 

246 worth of minimum hours, even if the wall-clock duration is shorter).""" 

247 wall_clock_hours = (end_dt - start_dt).total_seconds() / 3600 

248 if settings and settings.min_hours_per_day: 

249 floor_hours = _chargeable_days(start_dt, end_dt) * float( 

250 settings.min_hours_per_day 

251 ) 

252 return max(wall_clock_hours, floor_hours) 

253 return wall_clock_hours 

254 

255 

256def _compute_cost( 

257 start_dt: datetime, 

258 end_dt: datetime, 

259 settings: AircraftBookingSettings | None, 

260 ac: Aircraft, 

261) -> tuple[float | None, float | None]: 

262 """Return (hourly_rate, estimated_cost) or (None, None) if no rate available.""" 

263 rate, _source = _effective_rate(ac, settings) 

264 if rate is None: 

265 return None, None 

266 hours = _estimated_hours(start_dt, end_dt, settings) 

267 return rate, round(rate * hours, 2) 

268 

269 

270def _build_calendar_grid(year: int, month: int): 

271 """Return a list of weeks; each week is a list of date objects (Mon–Sun). 

272 Days outside the month are included to complete the grid.""" 

273 cal = calendar.Calendar(firstweekday=0) # Monday first 

274 return cal.monthdatescalendar(year, month) 

275 

276 

277# ── Fleet reservations overview (admin/owner) ───────────────────────────────── 

278 

279 

280@reservations_bp.route("/reservations/fleet/") 

281@login_required 

282@require_role(*_OWNER_ROLES) 

283def fleet_reservations(): 

284 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

285 if not tu: 

286 abort(403) # pragma: no cover 

287 

288 role = tu.role 

289 from utils import accessible_aircraft # pyright: ignore[reportMissingImports] 

290 

291 aircraft_qs = accessible_aircraft(tu.tenant_id) 

292 if role == Role.OWNER: 

293 # Owners only see planes they explicitly have access to 

294 from models import ( # pyright: ignore[reportMissingImports] 

295 UserAircraftAccess, 

296 UserAllAircraftAccess, 

297 ) 

298 

299 all_access = UserAllAircraftAccess.query.filter_by(user_id=tu.user_id).first() 

300 if not all_access: 

301 owned_ids = [ 

302 r.aircraft_id 

303 for r in UserAircraftAccess.query.filter_by(user_id=tu.user_id).all() 

304 ] 

305 aircraft_qs = aircraft_qs.filter(Aircraft.id.in_(owned_ids)) 

306 

307 aircraft_list = aircraft_qs.order_by(Aircraft.registration).all() 

308 aircraft_ids = [a.id for a in aircraft_list] 

309 

310 now = datetime.now(UTC) 

311 expired_cutoff = now - timedelta(days=60) 

312 

313 reservations = ( 

314 ( 

315 Reservation.query.filter( 

316 Reservation.aircraft_id.in_(aircraft_ids), 

317 # Exclude expired-pending older than 60 days — they're just noise 

318 db.or_( 

319 Reservation.status != ReservationStatus.PENDING, 

320 Reservation.start_dt >= expired_cutoff, 

321 ), 

322 ) 

323 .order_by(Reservation.start_dt) 

324 .all() 

325 ) 

326 if aircraft_ids 

327 else [] 

328 ) 

329 

330 # SQLite returns naive datetimes even for DateTime(timezone=True) columns; 

331 # PostgreSQL returns timezone-aware. Normalize `now` to match so that 

332 # Python comparisons and Jinja2 template filters stay compatible with both. 

333 if reservations and reservations[0].start_dt.tzinfo is None: 

334 now = now.replace(tzinfo=None) 

335 

336 # Detect overlapping confirmed reservations per aircraft 

337 overlapping_ids: set[int] = set() 

338 from itertools import combinations 

339 

340 confirmed = [r for r in reservations if r.status == ReservationStatus.CONFIRMED] 

341 by_aircraft: dict[int, list] = {} 

342 for r in confirmed: 

343 by_aircraft.setdefault(r.aircraft_id, []).append(r) 

344 for group in by_aircraft.values(): 

345 for r1, r2 in combinations(group, 2): 

346 if r1.start_dt < r2.end_dt and r1.end_dt > r2.start_dt: 

347 overlapping_ids.add(r1.id) 

348 overlapping_ids.add(r2.id) 

349 

350 # Find past confirmed reservations with no flight logged on that aircraft/date 

351 from models import Flight # pyright: ignore[reportMissingImports] 

352 

353 missing_flight_ids: set[int] = set() 

354 for r in reservations: 

355 if r.status == ReservationStatus.CONFIRMED and r.end_dt <= now: 

356 start_date = r.start_dt.date() 

357 end_date = r.end_dt.date() 

358 has_flight = ( 

359 Flight.query.filter( 

360 Flight.aircraft_id == r.aircraft_id, 

361 Flight.date >= start_date, 

362 Flight.date <= end_date, 

363 ).first() 

364 is not None 

365 ) 

366 if not has_flight: 

367 missing_flight_ids.add(r.id) 

368 

369 aircraft_map = {a.id: a for a in aircraft_list} 

370 

371 return render_template( 

372 "reservations/fleet.html", 

373 reservations=reservations, 

374 aircraft_map=aircraft_map, 

375 overlapping_ids=overlapping_ids, 

376 missing_flight_ids=missing_flight_ids, 

377 now=now, 

378 ReservationStatus=ReservationStatus, 

379 ) 

380 

381 

382# ── Calendar view ───────────────────────────────────────────────────────────── 

383 

384 

385@reservations_bp.route("/aircraft/<aircraft_ref:aircraft_id>/reservations/") 

386@login_required 

387def calendar_view(aircraft_id: int): 

388 ac = _get_aircraft_or_404(aircraft_id) 

389 today = datetime.now(UTC).date() 

390 

391 try: 

392 year = int(request.args.get("year", today.year)) 

393 month = int(request.args.get("month", today.month)) 

394 except ValueError: 

395 year, month = today.year, today.month 

396 

397 # Clamp to valid range 

398 if month < 1: 

399 year -= 1 

400 month = 12 

401 if month > 12: 

402 year += 1 

403 month = 1 

404 

405 # Month boundaries in UTC 

406 month_start = datetime(year, month, 1, tzinfo=UTC) 

407 last_day = calendar.monthrange(year, month)[1] 

408 month_end = datetime(year, month, last_day, 23, 59, 59, tzinfo=UTC) 

409 

410 reservations = ( 

411 Reservation.query.filter( 

412 Reservation.aircraft_id == ac.id, 

413 Reservation.start_dt <= month_end, 

414 Reservation.end_dt >= month_start, 

415 ) 

416 .order_by(Reservation.start_dt) 

417 .all() 

418 ) 

419 

420 downtimes = ( 

421 MaintenanceDowntime.query.filter( 

422 MaintenanceDowntime.aircraft_id == ac.id, 

423 MaintenanceDowntime.start_dt <= month_end, 

424 MaintenanceDowntime.end_dt >= month_start, 

425 ) 

426 .order_by(MaintenanceDowntime.start_dt) 

427 .all() 

428 ) 

429 

430 # Build a dict day → list of reservations for fast template lookup 

431 from collections import defaultdict 

432 

433 day_reservations: dict = defaultdict(list) 

434 for r in reservations: 

435 # A reservation may span multiple days — add it to each day it touches 

436 cur = r.start_dt.date() 

437 end = r.end_dt.date() 

438 while cur <= end: 

439 day_reservations[cur].append(r) 

440 cur += timedelta(days=1) 

441 

442 day_downtimes: dict = defaultdict(list) 

443 for d in downtimes: 

444 cur = d.start_dt.date() 

445 end = d.end_dt.date() 

446 while cur <= end: 

447 day_downtimes[cur].append(d) 

448 cur += timedelta(days=1) 

449 

450 # Prev / next month navigation 

451 prev_month = month - 1 or 12 

452 prev_year = year - 1 if month == 1 else year 

453 next_month = month % 12 + 1 

454 next_year = year + 1 if month == 12 else year 

455 

456 weeks = _build_calendar_grid(year, month) 

457 

458 return render_template( 

459 "reservations/calendar.html", 

460 aircraft=ac, 

461 weeks=weeks, 

462 day_reservations=day_reservations, 

463 day_downtimes=day_downtimes, 

464 all_downtimes=downtimes, 

465 year=year, 

466 month=month, 

467 month_name=datetime(year, month, 1).strftime("%B %Y"), 

468 today=today, 

469 prev_year=prev_year, 

470 prev_month=prev_month, 

471 next_year=next_year, 

472 next_month=next_month, 

473 ReservationStatus=ReservationStatus, 

474 ) 

475 

476 

477# ── Maintenance downtime (Phase 37f) ────────────────────────────────────────── 

478 

479 

480def _get_downtime_or_404(ac: Aircraft, downtime_id: int) -> MaintenanceDowntime: 

481 d = db.session.get(MaintenanceDowntime, downtime_id) 

482 if not d or d.aircraft_id != ac.id: 

483 abort(404) 

484 return d 

485 

486 

487def _save_downtime(ac: Aircraft, d: MaintenanceDowntime | None): 

488 start_raw = request.form.get("start_dt", "").strip() 

489 end_raw = request.form.get("end_dt", "").strip() 

490 reason = request.form.get("reason", "").strip() or None 

491 start_dt = _parse_datetime(start_raw) 

492 end_dt = _parse_datetime(end_raw) 

493 

494 errors = [] 

495 if not start_dt: 

496 errors.append(_("Start date/time is required.")) 

497 if not end_dt: 

498 errors.append(_("End date/time is required.")) 

499 if start_dt and end_dt and end_dt <= start_dt: 

500 errors.append(_("End must be after start.")) 

501 

502 if errors: 

503 for msg in errors: 

504 flash(msg, "danger") 

505 return render_template( 

506 "reservations/downtime_form.html", aircraft=ac, downtime=d, conflicts=[] 

507 ) 

508 

509 assert start_dt is not None and end_dt is not None 

510 conflicting = ( 

511 Reservation.query.filter( 

512 Reservation.aircraft_id == ac.id, 

513 Reservation.status == ReservationStatus.CONFIRMED, 

514 Reservation.start_dt < end_dt, 

515 Reservation.end_dt > start_dt, 

516 ) 

517 .order_by(Reservation.start_dt) 

518 .all() 

519 ) 

520 if conflicting and not request.form.get("confirm_conflicts"): 

521 return render_template( 

522 "reservations/downtime_form.html", 

523 aircraft=ac, 

524 downtime=d, 

525 conflicts=conflicting, 

526 ) 

527 

528 is_new = d is None 

529 if d is None: 

530 d = MaintenanceDowntime( 

531 aircraft_id=ac.id, created_by_id=int(session["user_id"]) 

532 ) 

533 db.session.add(d) 

534 d.start_dt = start_dt 

535 d.end_dt = end_dt 

536 d.reason = reason 

537 db.session.commit() 

538 flash( 

539 _("Downtime saved.") if is_new else _("Downtime updated."), 

540 "success", 

541 ) 

542 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

543 

544 

545@reservations_bp.route( 

546 "/aircraft/<aircraft_ref:aircraft_id>/downtimes/new", methods=["GET", "POST"] 

547) 

548@login_required 

549@require_role(*_DOWNTIME_ROLES) 

550def downtime_new(aircraft_id: int): 

551 ac = _get_aircraft_or_404(aircraft_id) 

552 if request.method == "POST": 

553 return _save_downtime(ac, None) 

554 return render_template( 

555 "reservations/downtime_form.html", aircraft=ac, downtime=None, conflicts=[] 

556 ) 

557 

558 

559@reservations_bp.route( 

560 "/aircraft/<aircraft_ref:aircraft_id>/downtimes/<int:downtime_id>/edit", 

561 methods=["GET", "POST"], 

562) 

563@login_required 

564@require_role(*_DOWNTIME_ROLES) 

565def downtime_edit(aircraft_id: int, downtime_id: int): 

566 ac = _get_aircraft_or_404(aircraft_id) 

567 d = _get_downtime_or_404(ac, downtime_id) 

568 if request.method == "POST": 

569 return _save_downtime(ac, d) 

570 return render_template( 

571 "reservations/downtime_form.html", aircraft=ac, downtime=d, conflicts=[] 

572 ) 

573 

574 

575@reservations_bp.route( 

576 "/aircraft/<aircraft_ref:aircraft_id>/downtimes/<int:downtime_id>/delete", 

577 methods=["POST"], 

578) 

579@login_required 

580@require_role(*_DOWNTIME_ROLES) 

581def downtime_delete(aircraft_id: int, downtime_id: int): 

582 ac = _get_aircraft_or_404(aircraft_id) 

583 d = _get_downtime_or_404(ac, downtime_id) 

584 db.session.delete(d) 

585 db.session.commit() 

586 flash(_("Downtime removed."), "success") 

587 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

588 

589 

590# ── Create reservation ──────────────────────────────────────────────────────── 

591 

592 

593@reservations_bp.route( 

594 "/aircraft/<aircraft_ref:aircraft_id>/reservations/new", methods=["GET", "POST"] 

595) 

596@login_required 

597@require_role(*_BOOKING_ROLES) 

598def new_reservation(aircraft_id: int): 

599 ac = _get_aircraft_or_404(aircraft_id) 

600 if ac.is_archived: 

601 abort(404) 

602 settings = ac.booking_settings 

603 if request.method == "POST": 

604 return _save_reservation(ac, None, settings) 

605 # Pre-fill start from query string (clicked day on calendar) 

606 prefill_start = request.args.get("date", "") 

607 effective_rate, rate_source = _effective_rate(ac, settings) 

608 uid = int(session["user_id"]) 

609 renter_auth_blocked = ( 

610 not _is_owner_role(uid) 

611 and _rental_authorization_policy(ac.tenant_id) == "block" 

612 and not _renter_authorization_ok(ac.id, uid) 

613 ) 

614 aircraft_grounded = _has_open_grounding_snag(ac) 

615 grounded_blocked = ( 

616 aircraft_grounded 

617 and not _is_owner_role(uid) 

618 and _grounded_reservation_policy(ac.tenant_id) == "block" 

619 ) 

620 return render_template( 

621 "reservations/form.html", 

622 aircraft=ac, 

623 reservation=None, 

624 settings=settings, 

625 prefill_start=prefill_start, 

626 effective_rate=effective_rate, 

627 rate_source=rate_source, 

628 rate_terms_label=_rate_terms_label(settings), 

629 renter_auth_blocked=renter_auth_blocked, 

630 aircraft_grounded=aircraft_grounded, 

631 grounded_blocked=grounded_blocked, 

632 ) 

633 

634 

635# ── Edit reservation ────────────────────────────────────────────────────────── 

636 

637 

638@reservations_bp.route( 

639 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/edit", 

640 methods=["GET", "POST"], 

641) 

642@login_required 

643def edit_reservation(aircraft_id: int, res_id: int): 

644 ac = _get_aircraft_or_404(aircraft_id) 

645 r = _get_reservation_or_404(ac, res_id) 

646 

647 # Pilots may only edit their own pending reservations 

648 role = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

649 user_role = role.role if role else None 

650 is_owner_role = user_role in _OWNER_ROLES 

651 if not is_owner_role and ( 

652 r.pilot_user_id != session["user_id"] or r.status != ReservationStatus.PENDING 

653 ): 

654 abort(403) 

655 

656 settings = ac.booking_settings 

657 if request.method == "POST": 

658 return _save_reservation(ac, r, settings) 

659 effective_rate, rate_source = _effective_rate(ac, settings) 

660 return render_template( 

661 "reservations/form.html", 

662 aircraft=ac, 

663 reservation=r, 

664 settings=settings, 

665 prefill_start="", 

666 effective_rate=effective_rate, 

667 rate_source=rate_source, 

668 rate_terms_label=_rate_terms_label(settings), 

669 renter_auth_blocked=False, 

670 aircraft_grounded=_has_open_grounding_snag(ac), 

671 grounded_blocked=False, 

672 ) 

673 

674 

675# ── Cancel reservation ──────────────────────────────────────────────────────── 

676 

677 

678@reservations_bp.route( 

679 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/cancel", 

680 methods=["POST"], 

681) 

682@login_required 

683def cancel_reservation(aircraft_id: int, res_id: int): 

684 ac = _get_aircraft_or_404(aircraft_id) 

685 r = _get_reservation_or_404(ac, res_id) 

686 

687 role = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

688 user_role = role.role if role else None 

689 is_owner_role = user_role in _OWNER_ROLES 

690 if not is_owner_role and r.pilot_user_id != session["user_id"]: 

691 abort(403) 

692 

693 if r.status == ReservationStatus.CANCELLED: 

694 flash(_("Reservation is already cancelled."), "warning") 

695 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

696 

697 if r.dispatch is not None and r.dispatch.is_checked_out: 

698 flash( 

699 _("Cannot cancel: this reservation has already been checked out."), 

700 "danger", 

701 ) 

702 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

703 

704 r.status = ReservationStatus.CANCELLED 

705 db.session.commit() 

706 if r.pilot_user_id: 

707 try: 

708 from flask_babel import ( 

709 lazy_gettext as _l, # pyright: ignore[reportMissingImports] 

710 ) 

711 from models import NotificationType # pyright: ignore[reportMissingImports] 

712 from services.notification_service import ( 

713 dispatch, # pyright: ignore[reportMissingImports] 

714 ) 

715 

716 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

717 if tu: 

718 dispatch( 

719 NotificationType.RESERVATION_CANCELLED, 

720 tu.tenant_id, 

721 { 

722 "subject_key": _l("Reservation cancelled — %(reg)s"), 

723 "subject_args": {"reg": ac.registration}, 

724 "notification_title_key": _l("Reservation cancelled: %(reg)s"), 

725 "notification_title_args": {"reg": ac.registration}, 

726 "notification_message_key": _l( 

727 "Your reservation for %(reg)s from %(start)s to " 

728 "%(end)s UTC has been cancelled." 

729 ), 

730 "notification_message_args": { 

731 "reg": ac.registration, 

732 "start": r.start_dt.strftime("%Y-%m-%d %H:%M"), 

733 "end": r.end_dt.strftime("%Y-%m-%d %H:%M"), 

734 }, 

735 "details": [ 

736 (_l("Aircraft"), ac.registration), 

737 (_l("Start"), r.start_dt.strftime("%Y-%m-%d %H:%M UTC")), 

738 (_l("End"), r.end_dt.strftime("%Y-%m-%d %H:%M UTC")), 

739 ], 

740 }, 

741 target_user_ids=[r.pilot_user_id], 

742 ) 

743 except Exception: 

744 import logging as _log 

745 

746 _log.getLogger(__name__).exception( 

747 "Failed to dispatch reservation cancelled notification" 

748 ) 

749 flash(_("Reservation cancelled."), "success") 

750 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

751 

752 

753# ── Confirm / decline (owner only) ─────────────────────────────────────────── 

754 

755 

756@reservations_bp.route( 

757 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/confirm", 

758 methods=["POST"], 

759) 

760@login_required 

761@require_role(*_OWNER_ROLES) 

762def confirm_reservation(aircraft_id: int, res_id: int): 

763 ac = _get_aircraft_or_404(aircraft_id) 

764 r = _get_reservation_or_404(ac, res_id) 

765 _next = request.form.get("next", "") 

766 _fallback = url_for("reservations.calendar_view", aircraft_id=ac.id) 

767 _dest = _safe_next(_next, _fallback) 

768 

769 if r.status != ReservationStatus.PENDING: 

770 flash(_("Only pending reservations can be confirmed."), "warning") 

771 return redirect(_dest) 

772 

773 if _has_conflict(ac.id, r.start_dt, r.end_dt, exclude_id=r.id): 

774 flash( 

775 _( 

776 "Cannot confirm: overlaps an existing confirmed reservation " 

777 "or maintenance downtime." 

778 ), 

779 "danger", 

780 ) 

781 return redirect(_dest) 

782 

783 if ( 

784 r.pilot_user_id 

785 and not _is_owner_role(r.pilot_user_id) 

786 and _rental_authorization_policy(ac.tenant_id) == "block" 

787 and not _renter_authorization_ok(ac.id, r.pilot_user_id) 

788 ): 

789 flash( 

790 _( 

791 "Cannot confirm: this renter does not have a valid rental " 

792 "authorization for this aircraft." 

793 ), 

794 "danger", 

795 ) 

796 return redirect(_dest) 

797 

798 if ( 

799 r.pilot_user_id 

800 and not _is_owner_role(r.pilot_user_id) 

801 and _grounded_reservation_policy(ac.tenant_id) == "block" 

802 and _has_open_grounding_snag(ac) 

803 ): 

804 flash( 

805 _("Cannot confirm: this aircraft is currently grounded."), 

806 "danger", 

807 ) 

808 return redirect(_dest) 

809 

810 r.status = ReservationStatus.CONFIRMED 

811 db.session.commit() 

812 if _has_open_grounding_snag(ac): 

813 flash( 

814 _( 

815 "Aircraft is currently grounded — reservation confirmed, but " 

816 "verify airworthiness status before dispatch." 

817 ), 

818 "warning", 

819 ) 

820 if r.pilot_user_id: 

821 try: 

822 from flask_babel import ( 

823 lazy_gettext as _l, # pyright: ignore[reportMissingImports] 

824 ) 

825 from models import NotificationType # pyright: ignore[reportMissingImports] 

826 from services.notification_service import ( 

827 dispatch, # pyright: ignore[reportMissingImports] 

828 ) 

829 

830 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

831 if tu: 

832 dispatch( 

833 NotificationType.RESERVATION_CONFIRMED, 

834 tu.tenant_id, 

835 { 

836 "subject_key": _l("Reservation confirmed — %(reg)s"), 

837 "subject_args": {"reg": ac.registration}, 

838 "notification_title_key": _l("Reservation confirmed: %(reg)s"), 

839 "notification_title_args": {"reg": ac.registration}, 

840 "notification_message_key": _l( 

841 "Your reservation for %(reg)s from %(start)s to " 

842 "%(end)s UTC has been confirmed." 

843 ), 

844 "notification_message_args": { 

845 "reg": ac.registration, 

846 "start": r.start_dt.strftime("%Y-%m-%d %H:%M"), 

847 "end": r.end_dt.strftime("%Y-%m-%d %H:%M"), 

848 }, 

849 "details": [ 

850 (_l("Aircraft"), ac.registration), 

851 (_l("Start"), r.start_dt.strftime("%Y-%m-%d %H:%M UTC")), 

852 (_l("End"), r.end_dt.strftime("%Y-%m-%d %H:%M UTC")), 

853 ], 

854 }, 

855 target_user_ids=[r.pilot_user_id], 

856 ) 

857 except Exception: 

858 import logging as _log 

859 

860 _log.getLogger(__name__).exception( 

861 "Failed to dispatch reservation confirmed notification" 

862 ) 

863 flash(_("Reservation confirmed."), "success") 

864 return redirect(_dest) 

865 

866 

867@reservations_bp.route( 

868 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/decline", 

869 methods=["POST"], 

870) 

871@login_required 

872@require_role(*_OWNER_ROLES) 

873def decline_reservation(aircraft_id: int, res_id: int): 

874 ac = _get_aircraft_or_404(aircraft_id) 

875 r = _get_reservation_or_404(ac, res_id) 

876 _next = request.form.get("next", "") 

877 _fallback = url_for("reservations.calendar_view", aircraft_id=ac.id) 

878 _dest = _safe_next(_next, _fallback) 

879 

880 if r.status != ReservationStatus.PENDING: 

881 flash(_("Only pending reservations can be declined."), "warning") 

882 return redirect(_dest) 

883 

884 r.status = ReservationStatus.CANCELLED 

885 db.session.commit() 

886 flash(_("Reservation declined."), "success") 

887 return redirect(_dest) 

888 

889 

890# ── Booking settings (owner only) ───────────────────────────────────────────── 

891 

892 

893@reservations_bp.route( 

894 "/aircraft/<aircraft_ref:aircraft_id>/reservations/settings", 

895 methods=["GET", "POST"], 

896) 

897@login_required 

898@require_role(*_OWNER_ROLES) 

899def booking_settings(aircraft_id: int): 

900 ac = _get_aircraft_or_404(aircraft_id) 

901 settings = ac.booking_settings 

902 

903 if request.method == "POST": 

904 return _save_booking_settings(ac, settings) 

905 

906 return render_template( 

907 "reservations/settings.html", 

908 aircraft=ac, 

909 settings=settings, 

910 computed_rate=_computed_rate(ac), 

911 rate_warning=_rate_divergence_warning(ac, settings), 

912 ) 

913 

914 

915def _save_booking_settings(ac: Aircraft, settings: AircraftBookingSettings | None): 

916 def _float_or_none(key: str) -> float | None: 

917 val = request.form.get(key, "").strip() 

918 try: 

919 return float(val) if val else None 

920 except ValueError: 

921 return None 

922 

923 min_h = _float_or_none("min_booking_hours") 

924 max_h = _float_or_none("max_booking_hours") 

925 rate = _float_or_none("hourly_rate") 

926 min_per_day = _float_or_none("min_hours_per_day") 

927 # A real <select>/<radio> form always submits one of the valid values; 

928 # a blank submission (e.g. an old cached form, or a direct API call that 

929 # omits the field) falls back to the model's own default rather than 

930 # being rejected — only a genuinely tampered, non-empty value is invalid. 

931 rate_basis = request.form.get("rate_basis", "").strip() or RateBasis.ENGINE_TIME 

932 rate_type = request.form.get("rate_type", "").strip() or RateType.WET 

933 

934 errors = [] 

935 if min_h is not None and min_h <= 0: 

936 errors.append(_("Minimum booking duration must be positive.")) 

937 if max_h is not None and max_h <= 0: 

938 errors.append(_("Maximum booking duration must be positive.")) 

939 if min_h is not None and max_h is not None and min_h > max_h: 

940 errors.append(_("Minimum duration cannot exceed maximum duration.")) 

941 if rate is not None and rate < 0: 

942 errors.append(_("Hourly rate cannot be negative.")) 

943 if min_per_day is not None and min_per_day <= 0: 

944 errors.append(_("Minimum billed hours per day must be positive.")) 

945 if rate_basis not in RateBasis.ALL: 

946 errors.append(_("Invalid rate basis selected.")) 

947 if rate_type not in RateType.ALL: 

948 errors.append(_("Invalid rate type selected.")) 

949 

950 if errors: 

951 for msg in errors: 

952 flash(msg, "danger") 

953 return render_template( 

954 "reservations/settings.html", 

955 aircraft=ac, 

956 settings=settings, 

957 computed_rate=_computed_rate(ac), 

958 rate_warning=_rate_divergence_warning(ac, settings), 

959 ) 

960 

961 if settings is None: 

962 settings = AircraftBookingSettings(aircraft_id=ac.id) 

963 db.session.add(settings) 

964 

965 settings.min_booking_hours = min_h 

966 settings.max_booking_hours = max_h 

967 settings.hourly_rate = rate 

968 settings.min_hours_per_day = min_per_day 

969 settings.rate_basis = rate_basis 

970 settings.rate_type = rate_type 

971 db.session.commit() 

972 flash(_("Booking settings saved."), "success") 

973 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

974 

975 

976# ── Shared save logic ───────────────────────────────────────────────────────── 

977 

978 

979def _save_reservation( 

980 ac: Aircraft, r: Reservation | None, settings: AircraftBookingSettings | None 

981): 

982 start_raw = request.form.get("start_dt", "").strip() 

983 end_raw = request.form.get("end_dt", "").strip() 

984 notes = request.form.get("notes", "").strip() or None 

985 

986 start_dt = _parse_datetime(start_raw) 

987 end_dt = _parse_datetime(end_raw) 

988 

989 # Renter authorization guard — only for a brand-new booking made by a 

990 # non-owner renter (an owner/admin booking on someone's behalf, or 

991 # editing an existing pending reservation, is out of scope here). 

992 uid = int(session["user_id"]) 

993 renter_auth_warning: str | None = None 

994 if r is None and not _is_owner_role(uid): 

995 policy = _rental_authorization_policy(ac.tenant_id) 

996 if policy != "off" and not _renter_authorization_ok(ac.id, uid): 

997 if policy == "block": 

998 flash( 

999 _( 

1000 "You do not have a valid rental authorization for this " 

1001 "aircraft. Contact the owner to be authorized before booking." 

1002 ), 

1003 "danger", 

1004 ) 

1005 effective_rate, rate_source = _effective_rate(ac, settings) 

1006 return render_template( 

1007 "reservations/form.html", 

1008 aircraft=ac, 

1009 reservation=None, 

1010 settings=settings, 

1011 prefill_start="", 

1012 effective_rate=effective_rate, 

1013 rate_source=rate_source, 

1014 rate_terms_label=_rate_terms_label(settings), 

1015 renter_auth_blocked=True, 

1016 aircraft_grounded=_has_open_grounding_snag(ac), 

1017 grounded_blocked=False, 

1018 ) 

1019 renter_auth_warning = str( 

1020 _( 

1021 "You do not have a valid rental authorization for this " 

1022 "aircraft yet — your booking request was submitted, but " 

1023 "check with the owner before flying." 

1024 ) 

1025 ) 

1026 

1027 # Grounded-aircraft guard — same brand-new-booking scope as the renter 

1028 # authorization guard above. Owners always get warn-level at most. 

1029 grounded_warning: str | None = None 

1030 if r is None and _has_open_grounding_snag(ac): 

1031 if ( 

1032 not _is_owner_role(uid) 

1033 and _grounded_reservation_policy(ac.tenant_id) == "block" 

1034 ): 

1035 flash( 

1036 _( 

1037 "This aircraft is currently grounded and this hangar " 

1038 "requires bookings to be blocked while grounded. Contact " 

1039 "the owner." 

1040 ), 

1041 "danger", 

1042 ) 

1043 effective_rate, rate_source = _effective_rate(ac, settings) 

1044 return render_template( 

1045 "reservations/form.html", 

1046 aircraft=ac, 

1047 reservation=None, 

1048 settings=settings, 

1049 prefill_start="", 

1050 effective_rate=effective_rate, 

1051 rate_source=rate_source, 

1052 rate_terms_label=_rate_terms_label(settings), 

1053 renter_auth_blocked=False, 

1054 aircraft_grounded=True, 

1055 grounded_blocked=True, 

1056 ) 

1057 grounded_warning = str( 

1058 _( 

1059 "This aircraft currently has an open grounding snag — your " 

1060 "booking was submitted, but check its status before flying." 

1061 ) 

1062 ) 

1063 

1064 errors = [] 

1065 if not start_dt: 

1066 errors.append(_("Start date/time is required.")) 

1067 if not end_dt: 

1068 errors.append(_("End date/time is required.")) 

1069 if start_dt and end_dt: 

1070 if end_dt <= start_dt: 

1071 errors.append(_("End must be after start.")) 

1072 else: 

1073 duration = (end_dt - start_dt).total_seconds() / 3600 

1074 if settings: 

1075 if settings.min_booking_hours and duration < float( 

1076 settings.min_booking_hours 

1077 ): 

1078 errors.append( 

1079 _( 

1080 "Minimum booking duration is %(h)s h.", 

1081 h=settings.min_booking_hours, 

1082 ) 

1083 ) 

1084 if settings.max_booking_hours and duration > float( 

1085 settings.max_booking_hours 

1086 ): 

1087 errors.append( 

1088 _( 

1089 "Maximum booking duration is %(h)s h.", 

1090 h=settings.max_booking_hours, 

1091 ) 

1092 ) 

1093 

1094 if errors: 

1095 for msg in errors: 

1096 flash(msg, "danger") 

1097 effective_rate, rate_source = _effective_rate(ac, settings) 

1098 return render_template( 

1099 "reservations/form.html", 

1100 aircraft=ac, 

1101 reservation=r, 

1102 settings=settings, 

1103 prefill_start="", 

1104 effective_rate=effective_rate, 

1105 rate_source=rate_source, 

1106 rate_terms_label=_rate_terms_label(settings), 

1107 renter_auth_blocked=False, 

1108 aircraft_grounded=_has_open_grounding_snag(ac), 

1109 grounded_blocked=False, 

1110 ) 

1111 

1112 hourly_rate, estimated_cost = _compute_cost(start_dt, end_dt, settings, ac) 

1113 

1114 _is_new_reservation = r is None 

1115 if r is None: 

1116 r = Reservation( 

1117 aircraft_id=ac.id, 

1118 pilot_user_id=session["user_id"], 

1119 status=ReservationStatus.PENDING, 

1120 ) 

1121 db.session.add(r) 

1122 

1123 r.start_dt = start_dt 

1124 r.end_dt = end_dt 

1125 r.notes = notes 

1126 r.hourly_rate = hourly_rate 

1127 r.estimated_cost = estimated_cost 

1128 db.session.commit() 

1129 

1130 if _is_new_reservation: 

1131 try: 

1132 from flask_babel import ( 

1133 lazy_gettext as _l, # pyright: ignore[reportMissingImports] 

1134 ) 

1135 from models import NotificationType # pyright: ignore[reportMissingImports] 

1136 from services.notification_service import ( 

1137 dispatch, # pyright: ignore[reportMissingImports] 

1138 ) 

1139 

1140 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

1141 if tu: 

1142 details = [ 

1143 (_l("Aircraft"), ac.registration), 

1144 (_l("Start"), r.start_dt.strftime("%Y-%m-%d %H:%M UTC")), 

1145 (_l("End"), r.end_dt.strftime("%Y-%m-%d %H:%M UTC")), 

1146 ] 

1147 if renter_auth_warning: 

1148 details.append( 

1149 (_l("Note"), _l("No valid rental authorization on file")) 

1150 ) 

1151 dispatch( 

1152 NotificationType.RESERVATION_REQUEST, 

1153 tu.tenant_id, 

1154 { 

1155 "subject_key": _l("New booking request — %(reg)s"), 

1156 "subject_args": {"reg": ac.registration}, 

1157 "notification_title_key": _l("New booking request: %(reg)s"), 

1158 "notification_title_args": {"reg": ac.registration}, 

1159 "notification_message_key": _l( 

1160 "A new booking request was submitted for %(reg)s " 

1161 "from %(start)s to %(end)s UTC." 

1162 ), 

1163 "notification_message_args": { 

1164 "reg": ac.registration, 

1165 "start": r.start_dt.strftime("%Y-%m-%d %H:%M"), 

1166 "end": r.end_dt.strftime("%Y-%m-%d %H:%M"), 

1167 }, 

1168 "details": details, 

1169 }, 

1170 ) 

1171 except Exception: 

1172 import logging as _log 

1173 

1174 _log.getLogger(__name__).exception( 

1175 "Failed to dispatch reservation request notification" 

1176 ) 

1177 

1178 if renter_auth_warning: 

1179 flash(renter_auth_warning, "warning") 

1180 if grounded_warning: 

1181 flash(grounded_warning, "warning") 

1182 flash(_("Reservation saved."), "success") 

1183 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id)) 

1184 

1185 

1186# ── Reservation detail + dispatch (Phase 37d) ───────────────────────────────── 

1187 

1188 

1189def _can_dispatch(r: Reservation, user_id: int) -> bool: 

1190 return _is_owner_role(user_id) or r.pilot_user_id == user_id 

1191 

1192 

1193def _discrepancy_warning(ac: Aircraft, r: Reservation) -> str | None: 

1194 """Compare the dispatch counter delta against the sum of linked 

1195 flight-entry counter deltas; return a warning naming both figures when 

1196 they differ, or None when they match (or there isn't enough data).""" 

1197 d = r.dispatch 

1198 if d is None or not d.is_checked_in: 

1199 return None 

1200 

1201 parts = [] 

1202 if d.out_flight_counter is not None and d.in_flight_counter is not None: 

1203 dispatch_delta = float(d.in_flight_counter) - float(d.out_flight_counter) 

1204 flights_sum = sum( 

1205 float(fe.flight_time_counter_end) - float(fe.flight_time_counter_start) 

1206 for fe in r.flights 

1207 if fe.flight_time_counter_end is not None 

1208 and fe.flight_time_counter_start is not None 

1209 ) 

1210 if round(dispatch_delta, 1) != round(flights_sum, 1): 

1211 parts.append( 

1212 str( 

1213 _( 

1214 "flight time: dispatch shows %(d)s h, logged flights show %(f)s h", 

1215 d=f"{dispatch_delta:.1f}", 

1216 f=f"{flights_sum:.1f}", 

1217 ) 

1218 ) 

1219 ) 

1220 if d.out_engine_counter is not None and d.in_engine_counter is not None: 

1221 dispatch_delta = float(d.in_engine_counter) - float(d.out_engine_counter) 

1222 flights_sum = sum( 

1223 float(fe.engine_time_counter_end) - float(fe.engine_time_counter_start) 

1224 for fe in r.flights 

1225 if fe.engine_time_counter_end is not None 

1226 and fe.engine_time_counter_start is not None 

1227 ) 

1228 if round(dispatch_delta, 1) != round(flights_sum, 1): 

1229 parts.append( 

1230 str( 

1231 _( 

1232 "engine time: dispatch shows %(d)s h, logged flights show %(f)s h", 

1233 d=f"{dispatch_delta:.1f}", 

1234 f=f"{flights_sum:.1f}", 

1235 ) 

1236 ) 

1237 ) 

1238 if not parts: 

1239 return None 

1240 return str( 

1241 _( 

1242 "Dispatch/logbook discrepancy — %(details)s.", 

1243 details="; ".join(parts), 

1244 ) 

1245 ) 

1246 

1247 

1248@reservations_bp.route("/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>") 

1249@login_required 

1250def reservation_detail(aircraft_id: int, res_id: int): 

1251 ac = _get_aircraft_or_404(aircraft_id) 

1252 r = _get_reservation_or_404(ac, res_id) 

1253 uid = int(session["user_id"]) 

1254 is_owner = _is_owner_role(uid) 

1255 if not is_owner and r.pilot_user_id != uid: 

1256 abort(403) 

1257 

1258 today_start = datetime.combine( 

1259 r.start_dt.astimezone(UTC).date(), time.min, tzinfo=UTC 

1260 ) 

1261 can_checkout = ( 

1262 _can_dispatch(r, uid) 

1263 and r.status == ReservationStatus.CONFIRMED 

1264 and (r.dispatch is None or not r.dispatch.is_checked_out) 

1265 and datetime.now(UTC) >= today_start 

1266 ) 

1267 can_checkin = ( 

1268 _can_dispatch(r, uid) 

1269 and r.dispatch is not None 

1270 and r.dispatch.is_checked_out 

1271 and not r.dispatch.is_checked_in 

1272 ) 

1273 

1274 return render_template( 

1275 "reservations/detail.html", 

1276 aircraft=ac, 

1277 reservation=r, 

1278 dispatch=r.dispatch, 

1279 is_owner=is_owner, 

1280 can_checkout=can_checkout, 

1281 can_checkin=can_checkin, 

1282 discrepancy_warning=_discrepancy_warning(ac, r), 

1283 ) 

1284 

1285 

1286@reservations_bp.route( 

1287 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/checkout", 

1288 methods=["GET", "POST"], 

1289) 

1290@login_required 

1291def checkout(aircraft_id: int, res_id: int): 

1292 from models import DispatchRecord, Snag # pyright: ignore[reportMissingImports] 

1293 

1294 ac = _get_aircraft_or_404(aircraft_id) 

1295 r = _get_reservation_or_404(ac, res_id) 

1296 uid = int(session["user_id"]) 

1297 if not _can_dispatch(r, uid): 

1298 abort(403) 

1299 if r.status != ReservationStatus.CONFIRMED: 

1300 flash(_("Only confirmed reservations can be checked out."), "danger") 

1301 return redirect( 

1302 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1303 ) 

1304 

1305 dispatch_record = r.dispatch 

1306 if dispatch_record is not None and dispatch_record.is_checked_out: 

1307 flash(_("This reservation has already been checked out."), "warning") 

1308 return redirect( 

1309 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1310 ) 

1311 

1312 open_snags = ( 

1313 Snag.query.filter_by(aircraft_id=ac.id).filter(Snag.resolved_at.is_(None)).all() 

1314 ) 

1315 

1316 if request.method == "POST": 

1317 walkaround_ok = bool(request.form.get("walkaround_ok")) 

1318 snags_acknowledged = bool(request.form.get("snags_acknowledged")) 

1319 grounded_override = bool(request.form.get("grounded_override")) 

1320 engine_counter = request.form.get("out_engine_counter", "").strip() or None 

1321 flight_counter = request.form.get("out_flight_counter", "").strip() or None 

1322 fuel_state = request.form.get("out_fuel_state", "").strip() or None 

1323 

1324 errors = [] 

1325 if not walkaround_ok: 

1326 errors.append(_("Walk-around confirmation is required.")) 

1327 if not snags_acknowledged: 

1328 errors.append(_("You must acknowledge the open snag list.")) 

1329 if ac.is_grounded and not (_is_owner_role(uid) and grounded_override): 

1330 errors.append( 

1331 _( 

1332 "This aircraft is grounded — dispatch is blocked. An owner " 

1333 "may override with an explicit confirmation." 

1334 ) 

1335 ) 

1336 

1337 try: 

1338 engine_val = float(engine_counter) if engine_counter else None 

1339 except ValueError: 

1340 engine_val = None 

1341 errors.append(_("Invalid engine counter value.")) 

1342 try: 

1343 flight_val = float(flight_counter) if flight_counter else None 

1344 except ValueError: 

1345 flight_val = None 

1346 errors.append(_("Invalid flight counter value.")) 

1347 

1348 if errors: 

1349 for msg in errors: 

1350 flash(msg, "danger") 

1351 return render_template( 

1352 "reservations/checkout.html", 

1353 aircraft=ac, 

1354 reservation=r, 

1355 open_snags=open_snags, 

1356 counter_hint=_checkout_counter_hint(ac.id), 

1357 ) 

1358 

1359 if dispatch_record is None: 

1360 dispatch_record = DispatchRecord(reservation_id=r.id) 

1361 db.session.add(dispatch_record) 

1362 

1363 dispatch_record.out_at = datetime.now(UTC) 

1364 dispatch_record.out_by_id = uid 

1365 dispatch_record.out_engine_counter = engine_val 

1366 dispatch_record.out_flight_counter = flight_val 

1367 dispatch_record.out_fuel_state = fuel_state 

1368 dispatch_record.out_walkaround_ok = walkaround_ok 

1369 dispatch_record.out_snags_acknowledged = snags_acknowledged 

1370 dispatch_record.out_grounded_override = ac.is_grounded and grounded_override 

1371 db.session.commit() 

1372 flash(_("Checked out."), "success") 

1373 return redirect( 

1374 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1375 ) 

1376 

1377 return render_template( 

1378 "reservations/checkout.html", 

1379 aircraft=ac, 

1380 reservation=r, 

1381 open_snags=open_snags, 

1382 counter_hint=_checkout_counter_hint(ac.id), 

1383 ) 

1384 

1385 

1386def _checkout_counter_hint(aircraft_id: int) -> dict[str, float | None]: 

1387 from flights.routes import ( 

1388 _get_counter_hint, # pyright: ignore[reportMissingImports] 

1389 ) 

1390 

1391 return _get_counter_hint(aircraft_id) 

1392 

1393 

1394def _draft_rental_charge(ac: Aircraft, r: Reservation, dispatch_record: Any) -> Any: 

1395 """Build (but do not commit) the automatic RentalCharge draft for a 

1396 reservation that has just been checked in.""" 

1397 from models import ( # pyright: ignore[reportMissingImports] 

1398 Expense, 

1399 ExpenseType, 

1400 RentalCharge, 

1401 ) 

1402 

1403 settings = ac.booking_settings 

1404 rate_basis = settings.rate_basis if settings else RateBasis.ENGINE_TIME 

1405 rate_type = settings.rate_type if settings else RateType.WET 

1406 

1407 def _delta(out_val: Any, in_val: Any) -> float | None: 

1408 if out_val is None or in_val is None: 

1409 return None 

1410 return float(in_val) - float(out_val) 

1411 

1412 engine_delta = _delta( 

1413 dispatch_record.out_engine_counter, dispatch_record.in_engine_counter 

1414 ) 

1415 flight_delta = _delta( 

1416 dispatch_record.out_flight_counter, dispatch_record.in_flight_counter 

1417 ) 

1418 primary, fallback = ( 

1419 (engine_delta, flight_delta) 

1420 if rate_basis == RateBasis.ENGINE_TIME 

1421 else (flight_delta, engine_delta) 

1422 ) 

1423 # Fall back to the other counter when the selected one was left blank at 

1424 # dispatch — noted via fallback_counter_used for the draft view. 

1425 fallback_counter_used = primary is None and fallback is not None 

1426 counter_delta = primary if primary is not None else (fallback or 0.0) 

1427 

1428 chargeable_days = 1 

1429 if dispatch_record.out_at is not None and dispatch_record.in_at is not None: 

1430 chargeable_days = _chargeable_days( 

1431 dispatch_record.out_at, dispatch_record.in_at 

1432 ) 

1433 min_per_day = ( 

1434 float(settings.min_hours_per_day) 

1435 if settings and settings.min_hours_per_day 

1436 else None 

1437 ) 

1438 billable_hours = counter_delta 

1439 if min_per_day: 

1440 billable_hours = max(counter_delta, chargeable_days * min_per_day) 

1441 

1442 hourly_rate, _source = _effective_rate(ac, settings) 

1443 hourly_rate = hourly_rate or 0.0 

1444 

1445 fuel_credit = 0.0 

1446 if rate_type == RateType.WET and r.pilot_user_id is not None: 

1447 flight_ids = [fe.id for fe in r.flights] 

1448 if flight_ids: 

1449 fuel_rows = Expense.query.filter( 

1450 Expense.flight_entry_id.in_(flight_ids), 

1451 Expense.expense_type == ExpenseType.FUEL, 

1452 Expense.created_by_id == r.pilot_user_id, 

1453 ).all() 

1454 fuel_credit = sum(float(e.amount) for e in fuel_rows) 

1455 

1456 total = round(billable_hours * hourly_rate - fuel_credit, 2) 

1457 

1458 return RentalCharge( 

1459 reservation_id=r.id, 

1460 renter_user_id=r.pilot_user_id, 

1461 billable_hours=round(billable_hours, 1), 

1462 hourly_rate=round(hourly_rate, 2), 

1463 rate_type=rate_type, 

1464 fuel_credit=round(fuel_credit, 2), 

1465 adjustment=0, 

1466 fallback_counter_used=fallback_counter_used, 

1467 total=total, 

1468 ) 

1469 

1470 

1471@reservations_bp.route( 

1472 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/checkin", 

1473 methods=["GET", "POST"], 

1474) 

1475@login_required 

1476def checkin(aircraft_id: int, res_id: int): 

1477 ac = _get_aircraft_or_404(aircraft_id) 

1478 r = _get_reservation_or_404(ac, res_id) 

1479 uid = int(session["user_id"]) 

1480 if not _can_dispatch(r, uid): 

1481 abort(403) 

1482 

1483 dispatch_record = r.dispatch 

1484 if dispatch_record is None or not dispatch_record.is_checked_out: 

1485 flash(_("Check out before checking in."), "danger") 

1486 return redirect( 

1487 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1488 ) 

1489 if dispatch_record.is_checked_in: 

1490 flash(_("This reservation has already been checked in."), "warning") 

1491 return redirect( 

1492 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1493 ) 

1494 

1495 if request.method == "POST": 

1496 engine_counter = request.form.get("in_engine_counter", "").strip() or None 

1497 flight_counter = request.form.get("in_flight_counter", "").strip() or None 

1498 fuel_state = request.form.get("in_fuel_state", "").strip() or None 

1499 notes = request.form.get("in_notes", "").strip() or None 

1500 

1501 errors = [] 

1502 engine_val: float | None = None 

1503 flight_val: float | None = None 

1504 try: 

1505 engine_val = float(engine_counter) if engine_counter else None 

1506 except ValueError: 

1507 errors.append(_("Invalid engine counter value.")) 

1508 try: 

1509 flight_val = float(flight_counter) if flight_counter else None 

1510 except ValueError: 

1511 errors.append(_("Invalid flight counter value.")) 

1512 

1513 if ( 

1514 not errors 

1515 and engine_val is not None 

1516 and dispatch_record.out_engine_counter is not None 

1517 and engine_val < float(dispatch_record.out_engine_counter) 

1518 ): 

1519 errors.append( 

1520 _("Engine counter on return cannot be less than the check-out value.") 

1521 ) 

1522 if ( 

1523 not errors 

1524 and flight_val is not None 

1525 and dispatch_record.out_flight_counter is not None 

1526 and flight_val < float(dispatch_record.out_flight_counter) 

1527 ): 

1528 errors.append( 

1529 _("Flight counter on return cannot be less than the check-out value.") 

1530 ) 

1531 

1532 if errors: 

1533 for msg in errors: 

1534 flash(msg, "danger") 

1535 return render_template( 

1536 "reservations/checkin.html", 

1537 aircraft=ac, 

1538 reservation=r, 

1539 dispatch=dispatch_record, 

1540 ) 

1541 

1542 dispatch_record.in_at = datetime.now(UTC) 

1543 dispatch_record.in_by_id = uid 

1544 dispatch_record.in_engine_counter = engine_val 

1545 dispatch_record.in_flight_counter = flight_val 

1546 dispatch_record.in_fuel_state = fuel_state 

1547 dispatch_record.in_notes = notes 

1548 if r.pilot_user_id is not None: 

1549 db.session.add(_draft_rental_charge(ac, r, dispatch_record)) 

1550 db.session.commit() 

1551 flash(_("Checked in."), "success") 

1552 return redirect( 

1553 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1554 ) 

1555 

1556 return render_template( 

1557 "reservations/checkin.html", 

1558 aircraft=ac, 

1559 reservation=r, 

1560 dispatch=dispatch_record, 

1561 ) 

1562 

1563 

1564# ── Rental charge review / finalization (Phase 37e) ─────────────────────────── 

1565 

1566 

1567@reservations_bp.route( 

1568 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/charge", 

1569 methods=["GET", "POST"], 

1570) 

1571@login_required 

1572@require_role(*_OWNER_ROLES) 

1573def rental_charge(aircraft_id: int, res_id: int): 

1574 ac = _get_aircraft_or_404(aircraft_id) 

1575 r = _get_reservation_or_404(ac, res_id) 

1576 charge = r.rental_charge 

1577 if charge is None: 

1578 abort(404) 

1579 uid = int(session["user_id"]) 

1580 

1581 if request.method == "POST": 

1582 if charge.is_final: 

1583 flash(_("This charge has already been finalized."), "warning") 

1584 return redirect( 

1585 url_for( 

1586 "reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id 

1587 ) 

1588 ) 

1589 

1590 action = request.form.get("action", "save") 

1591 

1592 def _float_field(key: str, current: Any) -> tuple[float, bool]: 

1593 raw = request.form.get(key, "").strip() 

1594 try: 

1595 return float(raw), True 

1596 except ValueError: 

1597 return float(current), False 

1598 

1599 billable_hours, hours_ok = _float_field("billable_hours", charge.billable_hours) 

1600 hourly_rate, rate_ok = _float_field("hourly_rate", charge.hourly_rate) 

1601 fuel_credit_raw = request.form.get("fuel_credit", "").strip() 

1602 try: 

1603 fuel_credit = float(fuel_credit_raw) if fuel_credit_raw else 0.0 

1604 credit_ok = True 

1605 except ValueError: 

1606 fuel_credit = float(charge.fuel_credit) 

1607 credit_ok = False 

1608 adjustment_raw = request.form.get("adjustment", "").strip() 

1609 try: 

1610 adjustment = float(adjustment_raw) if adjustment_raw else 0.0 

1611 adj_ok = True 

1612 except ValueError: 

1613 adjustment = float(charge.adjustment) 

1614 adj_ok = False 

1615 adjustment_note = request.form.get("adjustment_note", "").strip() or None 

1616 

1617 errors = [] 

1618 if not hours_ok: 

1619 errors.append(_("Invalid billable hours.")) 

1620 if not rate_ok: 

1621 errors.append(_("Invalid hourly rate.")) 

1622 if not credit_ok: 

1623 errors.append(_("Invalid fuel credit.")) 

1624 if not adj_ok: 

1625 errors.append(_("Invalid adjustment.")) 

1626 if billable_hours < 0: 

1627 errors.append(_("Billable hours cannot be negative.")) 

1628 if hourly_rate < 0: 

1629 errors.append(_("Hourly rate cannot be negative.")) 

1630 if fuel_credit < 0: 

1631 errors.append(_("Fuel credit cannot be negative.")) 

1632 if adjustment != 0 and not adjustment_note: 

1633 errors.append(_("An adjustment requires a note explaining it.")) 

1634 

1635 if errors: 

1636 for msg in errors: 

1637 flash(msg, "danger") 

1638 return render_template( 

1639 "reservations/charge.html", aircraft=ac, reservation=r, charge=charge 

1640 ) 

1641 

1642 charge.billable_hours = round(billable_hours, 1) 

1643 charge.hourly_rate = round(hourly_rate, 2) 

1644 charge.fuel_credit = round(fuel_credit, 2) 

1645 charge.adjustment = round(adjustment, 2) 

1646 charge.adjustment_note = adjustment_note 

1647 charge.total = round( 

1648 float(charge.billable_hours) * float(charge.hourly_rate) 

1649 - float(charge.fuel_credit) 

1650 + float(charge.adjustment), 

1651 2, 

1652 ) 

1653 

1654 if action == "finalize": 

1655 from models import ( 

1656 BillingAccountKind, 

1657 LedgerEntryType, 

1658 RentalChargeStatus, 

1659 User, 

1660 ) # pyright: ignore[reportMissingImports] 

1661 from services.billing import ( 

1662 BillingService, # pyright: ignore[reportMissingImports] 

1663 ) 

1664 

1665 charge.status = RentalChargeStatus.FINAL 

1666 charge.finalized_at = datetime.now(UTC) 

1667 charge.finalized_by_id = uid 

1668 account = BillingService.get_or_create_account( 

1669 ac.tenant_id, charge.renter_user_id, BillingAccountKind.RENTER 

1670 ) 

1671 finalizer = db.session.get(User, uid) 

1672 BillingService.post( 

1673 account, 

1674 LedgerEntryType.CHARGE, 

1675 charge.total, 

1676 str(_("Rental charge — reservation #%(id)s", id=r.id)), 

1677 _date.today(), 

1678 source_type="rental_charge", 

1679 source_id=charge.id, 

1680 created_by=finalizer, 

1681 ) 

1682 db.session.commit() 

1683 flash(_("Rental charge finalized."), "success") 

1684 else: 

1685 db.session.commit() 

1686 flash(_("Draft saved."), "success") 

1687 return redirect( 

1688 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id) 

1689 ) 

1690 

1691 return render_template( 

1692 "reservations/charge.html", aircraft=ac, reservation=r, charge=charge 

1693 ) 

1694 

1695 

1696# ── Renter self-service account (Phase 37e) ─────────────────────────────────── 

1697 

1698 

1699def _my_account_period(period_months_raw: str | None) -> tuple[_date, _date]: 

1700 try: 

1701 period_months = int(period_months_raw) if period_months_raw else 12 

1702 except ValueError: 

1703 period_months = 12 

1704 if period_months <= 0: 

1705 period_months = 12 

1706 end = _date.today() 

1707 start = end - timedelta(days=period_months * 30) 

1708 return start, end 

1709 

1710 

1711@reservations_bp.route("/my/account") 

1712@login_required 

1713def my_account(): 

1714 from models import BillingAccountKind # pyright: ignore[reportMissingImports] 

1715 from services.billing import BillingService # pyright: ignore[reportMissingImports] 

1716 

1717 uid = int(session["user_id"]) 

1718 tu = TenantUser.query.filter_by(user_id=uid).first() 

1719 if not tu: 

1720 abort(403) # pragma: no cover 

1721 

1722 account = BillingService.get_or_create_account( 

1723 tu.tenant_id, uid, BillingAccountKind.RENTER 

1724 ) 

1725 db.session.commit() 

1726 start, end = _my_account_period(request.args.get("period")) 

1727 statement = BillingService.statement(account, start, end) 

1728 

1729 return render_template( 

1730 "config/renter_account.html", 

1731 renter=tu.user, 

1732 account=account, 

1733 statement=statement, 

1734 balance=BillingService.balance(account), 

1735 is_owner_view=False, 

1736 csv_url=url_for("reservations.my_account_statement_csv"), 

1737 ) 

1738 

1739 

1740@reservations_bp.route("/my/account/statement.csv") 

1741@login_required 

1742def my_account_statement_csv(): 

1743 from flask import Response # pyright: ignore[reportMissingImports] 

1744 from models import BillingAccountKind, User # pyright: ignore[reportMissingImports] 

1745 from services.billing import BillingService # pyright: ignore[reportMissingImports] 

1746 

1747 uid = int(session["user_id"]) 

1748 tu = TenantUser.query.filter_by(user_id=uid).first() 

1749 if not tu: 

1750 abort(403) # pragma: no cover 

1751 

1752 account = BillingService.get_or_create_account( 

1753 tu.tenant_id, uid, BillingAccountKind.RENTER 

1754 ) 

1755 db.session.commit() 

1756 start, end = _my_account_period(request.args.get("period")) 

1757 statement = BillingService.statement(account, start, end) 

1758 exporter = db.session.get(User, uid) 

1759 csv_text = BillingService.statement_csv(statement, exported_by=exporter) 

1760 return Response( 

1761 csv_text, 

1762 mimetype="text/csv", 

1763 headers={ 

1764 "Content-Disposition": f"attachment; filename=my_statement_{start.isoformat()}_{end.isoformat()}.csv" 

1765 }, 

1766 )