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

799 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-01 12:21 +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 date as _date, datetime, time, timedelta, timezone 

8from typing import Any 

9from urllib.parse import urlparse 

10 

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

12 Blueprint, 

13 abort, 

14 flash, 

15 redirect, 

16 render_template, 

17 request, 

18 session, 

19 url_for, 

20) 

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

22 

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

24 Aircraft, 

25 AircraftBookingSettings, 

26 MaintenanceDowntime, 

27 RateBasis, 

28 RateType, 

29 Reservation, 

30 ReservationStatus, 

31 Role, 

32 TenantUser, 

33 db, 

34) 

35from utils import login_required, require_role, user_can_access_aircraft # pyright: ignore[reportMissingImports] 

36 

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

38 DEFAULT_PERIOD_MONTHS, 

39 compute_cost_dashboard, 

40) 

41 

42reservations_bp = Blueprint("reservations", __name__) 

43 

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

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

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

47 

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

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

50RATE_DIVERGENCE_WARN_PCT = 0.10 

51 

52 

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

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

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

56 try: 

57 parsed = urlparse(next_url) 

58 except ValueError: 

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

60 return fallback 

61 if ( 

62 next_url 

63 and not parsed.scheme 

64 and not parsed.netloc 

65 and next_url.startswith("/") 

66 ): 

67 return next_url 

68 return fallback 

69 

70 

71# ── Helpers ─────────────────────────────────────────────────────────────────── 

72 

73 

74def _tenant_id() -> int: 

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

76 if not tu: 

77 abort(403) # pragma: no cover 

78 return tu.tenant_id 

79 

80 

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

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

83 if ( 

84 not ac 

85 or ac.tenant_id != _tenant_id() 

86 or not user_can_access_aircraft(aircraft_id) 

87 ): 

88 abort(404) 

89 return ac 

90 

91 

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

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

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

95 abort(404) 

96 return r 

97 

98 

99def _has_conflict( 

100 aircraft_id: int, 

101 start_dt: datetime, 

102 end_dt: datetime, 

103 exclude_id: int | None = None, 

104) -> bool: 

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

106 overlaps [start_dt, end_dt).""" 

107 q = Reservation.query.filter( 

108 Reservation.aircraft_id == aircraft_id, 

109 Reservation.status == ReservationStatus.CONFIRMED, 

110 Reservation.start_dt < end_dt, 

111 Reservation.end_dt > start_dt, 

112 ) 

113 if exclude_id is not None: 

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

115 if q.first() is not None: 

116 return True 

117 dq = MaintenanceDowntime.query.filter( 

118 MaintenanceDowntime.aircraft_id == aircraft_id, 

119 MaintenanceDowntime.start_dt < end_dt, 

120 MaintenanceDowntime.end_dt > start_dt, 

121 ) 

122 return dq.first() is not None 

123 

124 

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

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

127 try: 

128 return datetime.fromisoformat(s).replace(tzinfo=timezone.utc) 

129 except (ValueError, AttributeError): 

130 return None 

131 

132 

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

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

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

136 

137 

138def _effective_rate( 

139 ac: Aircraft, settings: AircraftBookingSettings | None 

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

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

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

143 if settings and settings.hourly_rate is not None: 

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

145 computed = _computed_rate(ac) 

146 if computed is not None: 

147 return computed, "computed" 

148 return None, None 

149 

150 

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

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

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

154 

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

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

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

158 get translated in any locale. 

159 """ 

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

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

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

163 basis_label = ( 

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

165 ) 

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

167 

168 

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

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

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

172 

173 

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

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

176 

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

178 return profile.rental_authorization_policy if profile else "warn" 

179 

180 

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

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

183 

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

185 return profile.grounded_reservation_policy if profile else "warn" 

186 

187 

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

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

190 

191 

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

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

194 

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

196 

197 

198def _rate_divergence_warning( 

199 ac: Aircraft, settings: AircraftBookingSettings | None 

200) -> str | None: 

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

202 if not settings or settings.hourly_rate is None: 

203 return None 

204 computed = _computed_rate(ac) 

205 if computed is None or computed == 0: 

206 return None 

207 manual = float(settings.hourly_rate) 

208 pct_diff = abs(manual - computed) / computed 

209 if pct_diff <= RATE_DIVERGENCE_WARN_PCT: 

210 return None 

211 return str( 

212 _( 

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

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

215 "consider reviewing it.", 

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

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

218 pct=int(RATE_DIVERGENCE_WARN_PCT * 100), 

219 ) 

220 ) 

221 

222 

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

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

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

226 

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

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

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

230 """ 

231 last_day = end_dt.date() 

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

233 last_day -= timedelta(days=1) 

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

235 

236 

237def _estimated_hours( 

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

239) -> float: 

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

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

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

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

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

245 if settings and settings.min_hours_per_day: 

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

247 settings.min_hours_per_day 

248 ) 

249 return max(wall_clock_hours, floor_hours) 

250 return wall_clock_hours 

251 

252 

253def _compute_cost( 

254 start_dt: datetime, 

255 end_dt: datetime, 

256 settings: AircraftBookingSettings | None, 

257 ac: Aircraft, 

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

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

260 rate, _source = _effective_rate(ac, settings) 

261 if rate is None: 

262 return None, None 

263 hours = _estimated_hours(start_dt, end_dt, settings) 

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

265 

266 

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

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

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

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

271 return cal.monthdatescalendar(year, month) 

272 

273 

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

275 

276 

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

278@login_required 

279@require_role(*_OWNER_ROLES) 

280def fleet_reservations(): 

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

282 if not tu: 

283 abort(403) # pragma: no cover 

284 

285 role = tu.role 

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

287 

288 aircraft_qs = accessible_aircraft(tu.tenant_id) 

289 if role == Role.OWNER: 

290 # Owners only see planes they explicitly have access to 

291 from models import UserAircraftAccess, UserAllAircraftAccess # pyright: ignore[reportMissingImports] 

292 

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

294 if not all_access: 

295 owned_ids = [ 

296 r.aircraft_id 

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

298 ] 

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

300 

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

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

303 

304 now = datetime.now(timezone.utc) 

305 expired_cutoff = now - timedelta(days=60) 

306 

307 reservations = ( 

308 ( 

309 Reservation.query.filter( 

310 Reservation.aircraft_id.in_(aircraft_ids), 

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

312 db.or_( 

313 Reservation.status != ReservationStatus.PENDING, 

314 Reservation.start_dt >= expired_cutoff, 

315 ), 

316 ) 

317 .order_by(Reservation.start_dt) 

318 .all() 

319 ) 

320 if aircraft_ids 

321 else [] 

322 ) 

323 

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

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

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

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

328 now = now.replace(tzinfo=None) 

329 

330 # Detect overlapping confirmed reservations per aircraft 

331 overlapping_ids: set[int] = set() 

332 from itertools import combinations 

333 

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

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

336 for r in confirmed: 

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

338 for group in by_aircraft.values(): 

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

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

341 overlapping_ids.add(r1.id) 

342 overlapping_ids.add(r2.id) 

343 

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

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

346 

347 missing_flight_ids: set[int] = set() 

348 for r in reservations: 

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

350 start_date = r.start_dt.date() 

351 end_date = r.end_dt.date() 

352 has_flight = ( 

353 Flight.query.filter( 

354 Flight.aircraft_id == r.aircraft_id, 

355 Flight.date >= start_date, 

356 Flight.date <= end_date, 

357 ).first() 

358 is not None 

359 ) 

360 if not has_flight: 

361 missing_flight_ids.add(r.id) 

362 

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

364 

365 return render_template( 

366 "reservations/fleet.html", 

367 reservations=reservations, 

368 aircraft_map=aircraft_map, 

369 overlapping_ids=overlapping_ids, 

370 missing_flight_ids=missing_flight_ids, 

371 now=now, 

372 ReservationStatus=ReservationStatus, 

373 ) 

374 

375 

376# ── Calendar view ───────────────────────────────────────────────────────────── 

377 

378 

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

380@login_required 

381def calendar_view(aircraft_id: int): 

382 ac = _get_aircraft_or_404(aircraft_id) 

383 today = datetime.now(timezone.utc).date() 

384 

385 try: 

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

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

388 except ValueError: 

389 year, month = today.year, today.month 

390 

391 # Clamp to valid range 

392 if month < 1: 

393 year -= 1 

394 month = 12 

395 if month > 12: 

396 year += 1 

397 month = 1 

398 

399 # Month boundaries in UTC 

400 month_start = datetime(year, month, 1, tzinfo=timezone.utc) 

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

402 month_end = datetime(year, month, last_day, 23, 59, 59, tzinfo=timezone.utc) 

403 

404 reservations = ( 

405 Reservation.query.filter( 

406 Reservation.aircraft_id == ac.id, 

407 Reservation.start_dt <= month_end, 

408 Reservation.end_dt >= month_start, 

409 ) 

410 .order_by(Reservation.start_dt) 

411 .all() 

412 ) 

413 

414 downtimes = ( 

415 MaintenanceDowntime.query.filter( 

416 MaintenanceDowntime.aircraft_id == ac.id, 

417 MaintenanceDowntime.start_dt <= month_end, 

418 MaintenanceDowntime.end_dt >= month_start, 

419 ) 

420 .order_by(MaintenanceDowntime.start_dt) 

421 .all() 

422 ) 

423 

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

425 from collections import defaultdict 

426 

427 day_reservations: dict = defaultdict(list) 

428 for r in reservations: 

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

430 cur = r.start_dt.date() 

431 end = r.end_dt.date() 

432 while cur <= end: 

433 day_reservations[cur].append(r) 

434 cur += timedelta(days=1) 

435 

436 day_downtimes: dict = defaultdict(list) 

437 for d in downtimes: 

438 cur = d.start_dt.date() 

439 end = d.end_dt.date() 

440 while cur <= end: 

441 day_downtimes[cur].append(d) 

442 cur += timedelta(days=1) 

443 

444 # Prev / next month navigation 

445 prev_month = month - 1 or 12 

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

447 next_month = month % 12 + 1 

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

449 

450 weeks = _build_calendar_grid(year, month) 

451 

452 return render_template( 

453 "reservations/calendar.html", 

454 aircraft=ac, 

455 weeks=weeks, 

456 day_reservations=day_reservations, 

457 day_downtimes=day_downtimes, 

458 all_downtimes=downtimes, 

459 year=year, 

460 month=month, 

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

462 today=today, 

463 prev_year=prev_year, 

464 prev_month=prev_month, 

465 next_year=next_year, 

466 next_month=next_month, 

467 ReservationStatus=ReservationStatus, 

468 ) 

469 

470 

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

472 

473 

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

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

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

477 abort(404) 

478 return d 

479 

480 

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

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

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

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

485 start_dt = _parse_datetime(start_raw) 

486 end_dt = _parse_datetime(end_raw) 

487 

488 errors = [] 

489 if not start_dt: 

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

491 if not end_dt: 

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

493 if start_dt and end_dt and end_dt <= start_dt: 

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

495 

496 if errors: 

497 for msg in errors: 

498 flash(msg, "danger") 

499 return render_template( 

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

501 ) 

502 

503 assert start_dt is not None and end_dt is not None 

504 conflicting = ( 

505 Reservation.query.filter( 

506 Reservation.aircraft_id == ac.id, 

507 Reservation.status == ReservationStatus.CONFIRMED, 

508 Reservation.start_dt < end_dt, 

509 Reservation.end_dt > start_dt, 

510 ) 

511 .order_by(Reservation.start_dt) 

512 .all() 

513 ) 

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

515 return render_template( 

516 "reservations/downtime_form.html", 

517 aircraft=ac, 

518 downtime=d, 

519 conflicts=conflicting, 

520 ) 

521 

522 is_new = d is None 

523 if d is None: 

524 d = MaintenanceDowntime( 

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

526 ) 

527 db.session.add(d) 

528 d.start_dt = start_dt 

529 d.end_dt = end_dt 

530 d.reason = reason 

531 db.session.commit() 

532 flash( 

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

534 "success", 

535 ) 

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

537 

538 

539@reservations_bp.route( 

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

541) 

542@login_required 

543@require_role(*_DOWNTIME_ROLES) 

544def downtime_new(aircraft_id: int): 

545 ac = _get_aircraft_or_404(aircraft_id) 

546 if request.method == "POST": 

547 return _save_downtime(ac, None) 

548 return render_template( 

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

550 ) 

551 

552 

553@reservations_bp.route( 

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

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

556) 

557@login_required 

558@require_role(*_DOWNTIME_ROLES) 

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

560 ac = _get_aircraft_or_404(aircraft_id) 

561 d = _get_downtime_or_404(ac, downtime_id) 

562 if request.method == "POST": 

563 return _save_downtime(ac, d) 

564 return render_template( 

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

566 ) 

567 

568 

569@reservations_bp.route( 

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

571 methods=["POST"], 

572) 

573@login_required 

574@require_role(*_DOWNTIME_ROLES) 

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

576 ac = _get_aircraft_or_404(aircraft_id) 

577 d = _get_downtime_or_404(ac, downtime_id) 

578 db.session.delete(d) 

579 db.session.commit() 

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

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

582 

583 

584# ── Create reservation ──────────────────────────────────────────────────────── 

585 

586 

587@reservations_bp.route( 

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

589) 

590@login_required 

591@require_role(*_BOOKING_ROLES) 

592def new_reservation(aircraft_id: int): 

593 ac = _get_aircraft_or_404(aircraft_id) 

594 if ac.is_archived: 

595 abort(404) 

596 settings = ac.booking_settings 

597 if request.method == "POST": 

598 return _save_reservation(ac, None, settings) 

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

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

601 effective_rate, rate_source = _effective_rate(ac, settings) 

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

603 renter_auth_blocked = ( 

604 not _is_owner_role(uid) 

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

606 and not _renter_authorization_ok(ac.id, uid) 

607 ) 

608 aircraft_grounded = _has_open_grounding_snag(ac) 

609 grounded_blocked = ( 

610 aircraft_grounded 

611 and not _is_owner_role(uid) 

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

613 ) 

614 return render_template( 

615 "reservations/form.html", 

616 aircraft=ac, 

617 reservation=None, 

618 settings=settings, 

619 prefill_start=prefill_start, 

620 effective_rate=effective_rate, 

621 rate_source=rate_source, 

622 rate_terms_label=_rate_terms_label(settings), 

623 renter_auth_blocked=renter_auth_blocked, 

624 aircraft_grounded=aircraft_grounded, 

625 grounded_blocked=grounded_blocked, 

626 ) 

627 

628 

629# ── Edit reservation ────────────────────────────────────────────────────────── 

630 

631 

632@reservations_bp.route( 

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

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

635) 

636@login_required 

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

638 ac = _get_aircraft_or_404(aircraft_id) 

639 r = _get_reservation_or_404(ac, res_id) 

640 

641 # Pilots may only edit their own pending reservations 

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

643 user_role = role.role if role else None 

644 is_owner_role = user_role in _OWNER_ROLES 

645 if not is_owner_role: 

646 if ( 

647 r.pilot_user_id != session["user_id"] 

648 or r.status != ReservationStatus.PENDING 

649 ): 

650 abort(403) 

651 

652 settings = ac.booking_settings 

653 if request.method == "POST": 

654 return _save_reservation(ac, r, settings) 

655 effective_rate, rate_source = _effective_rate(ac, settings) 

656 return render_template( 

657 "reservations/form.html", 

658 aircraft=ac, 

659 reservation=r, 

660 settings=settings, 

661 prefill_start="", 

662 effective_rate=effective_rate, 

663 rate_source=rate_source, 

664 rate_terms_label=_rate_terms_label(settings), 

665 renter_auth_blocked=False, 

666 aircraft_grounded=_has_open_grounding_snag(ac), 

667 grounded_blocked=False, 

668 ) 

669 

670 

671# ── Cancel reservation ──────────────────────────────────────────────────────── 

672 

673 

674@reservations_bp.route( 

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

676 methods=["POST"], 

677) 

678@login_required 

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

680 ac = _get_aircraft_or_404(aircraft_id) 

681 r = _get_reservation_or_404(ac, res_id) 

682 

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

684 user_role = role.role if role else None 

685 is_owner_role = user_role in _OWNER_ROLES 

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

687 abort(403) 

688 

689 if r.status == ReservationStatus.CANCELLED: 

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

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

692 

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

694 flash( 

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

696 "danger", 

697 ) 

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

699 

700 r.status = ReservationStatus.CANCELLED 

701 db.session.commit() 

702 if r.pilot_user_id: 

703 try: 

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

705 from services.notification_service import dispatch # pyright: ignore[reportMissingImports] 

706 

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

708 if tu: 

709 dispatch( 

710 NotificationType.RESERVATION_CANCELLED, 

711 tu.tenant_id, 

712 { 

713 "subject": f"Reservation cancelled — {ac.registration}", 

714 "notification_title": f"Reservation cancelled: {ac.registration}", 

715 "notification_message": f"Your reservation for {ac.registration} from {r.start_dt.strftime('%Y-%m-%d %H:%M')} to {r.end_dt.strftime('%Y-%m-%d %H:%M')} UTC has been cancelled.", 

716 "details": [ 

717 ("Aircraft", ac.registration), 

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

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

720 ], 

721 }, 

722 target_user_ids=[r.pilot_user_id], 

723 ) 

724 except Exception: 

725 import logging as _log 

726 

727 _log.getLogger(__name__).exception( 

728 "Failed to dispatch reservation cancelled notification" 

729 ) 

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

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

732 

733 

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

735 

736 

737@reservations_bp.route( 

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

739 methods=["POST"], 

740) 

741@login_required 

742@require_role(*_OWNER_ROLES) 

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

744 ac = _get_aircraft_or_404(aircraft_id) 

745 r = _get_reservation_or_404(ac, res_id) 

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

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

748 _dest = _safe_next(_next, _fallback) 

749 

750 if r.status != ReservationStatus.PENDING: 

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

752 return redirect(_dest) 

753 

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

755 flash( 

756 _( 

757 "Cannot confirm: overlaps an existing confirmed reservation " 

758 "or maintenance downtime." 

759 ), 

760 "danger", 

761 ) 

762 return redirect(_dest) 

763 

764 if ( 

765 r.pilot_user_id 

766 and not _is_owner_role(r.pilot_user_id) 

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

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

769 ): 

770 flash( 

771 _( 

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

773 "authorization for this aircraft." 

774 ), 

775 "danger", 

776 ) 

777 return redirect(_dest) 

778 

779 if ( 

780 r.pilot_user_id 

781 and not _is_owner_role(r.pilot_user_id) 

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

783 and _has_open_grounding_snag(ac) 

784 ): 

785 flash( 

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

787 "danger", 

788 ) 

789 return redirect(_dest) 

790 

791 r.status = ReservationStatus.CONFIRMED 

792 db.session.commit() 

793 if _has_open_grounding_snag(ac): 

794 flash( 

795 _( 

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

797 "verify airworthiness status before dispatch." 

798 ), 

799 "warning", 

800 ) 

801 if r.pilot_user_id: 

802 try: 

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

804 from services.notification_service import dispatch # pyright: ignore[reportMissingImports] 

805 

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

807 if tu: 

808 dispatch( 

809 NotificationType.RESERVATION_CONFIRMED, 

810 tu.tenant_id, 

811 { 

812 "subject": f"Reservation confirmed — {ac.registration}", 

813 "notification_title": f"Reservation confirmed: {ac.registration}", 

814 "notification_message": f"Your reservation for {ac.registration} from {r.start_dt.strftime('%Y-%m-%d %H:%M')} to {r.end_dt.strftime('%Y-%m-%d %H:%M')} UTC has been confirmed.", 

815 "details": [ 

816 ("Aircraft", ac.registration), 

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

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

819 ], 

820 }, 

821 target_user_ids=[r.pilot_user_id], 

822 ) 

823 except Exception: 

824 import logging as _log 

825 

826 _log.getLogger(__name__).exception( 

827 "Failed to dispatch reservation confirmed notification" 

828 ) 

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

830 return redirect(_dest) 

831 

832 

833@reservations_bp.route( 

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

835 methods=["POST"], 

836) 

837@login_required 

838@require_role(*_OWNER_ROLES) 

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

840 ac = _get_aircraft_or_404(aircraft_id) 

841 r = _get_reservation_or_404(ac, res_id) 

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

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

844 _dest = _safe_next(_next, _fallback) 

845 

846 if r.status != ReservationStatus.PENDING: 

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

848 return redirect(_dest) 

849 

850 r.status = ReservationStatus.CANCELLED 

851 db.session.commit() 

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

853 return redirect(_dest) 

854 

855 

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

857 

858 

859@reservations_bp.route( 

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

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

862) 

863@login_required 

864@require_role(*_OWNER_ROLES) 

865def booking_settings(aircraft_id: int): 

866 ac = _get_aircraft_or_404(aircraft_id) 

867 settings = ac.booking_settings 

868 

869 if request.method == "POST": 

870 return _save_booking_settings(ac, settings) 

871 

872 return render_template( 

873 "reservations/settings.html", 

874 aircraft=ac, 

875 settings=settings, 

876 computed_rate=_computed_rate(ac), 

877 rate_warning=_rate_divergence_warning(ac, settings), 

878 ) 

879 

880 

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

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

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

884 try: 

885 return float(val) if val else None 

886 except ValueError: 

887 return None 

888 

889 min_h = _float_or_none("min_booking_hours") 

890 max_h = _float_or_none("max_booking_hours") 

891 rate = _float_or_none("hourly_rate") 

892 min_per_day = _float_or_none("min_hours_per_day") 

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

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

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

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

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

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

899 

900 errors = [] 

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

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

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

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

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

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

907 if rate is not None and rate < 0: 

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

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

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

911 if rate_basis not in RateBasis.ALL: 

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

913 if rate_type not in RateType.ALL: 

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

915 

916 if errors: 

917 for msg in errors: 

918 flash(msg, "danger") 

919 return render_template( 

920 "reservations/settings.html", 

921 aircraft=ac, 

922 settings=settings, 

923 computed_rate=_computed_rate(ac), 

924 rate_warning=_rate_divergence_warning(ac, settings), 

925 ) 

926 

927 if settings is None: 

928 settings = AircraftBookingSettings(aircraft_id=ac.id) 

929 db.session.add(settings) 

930 

931 settings.min_booking_hours = min_h 

932 settings.max_booking_hours = max_h 

933 settings.hourly_rate = rate 

934 settings.min_hours_per_day = min_per_day 

935 settings.rate_basis = rate_basis 

936 settings.rate_type = rate_type 

937 db.session.commit() 

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

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

940 

941 

942# ── Shared save logic ───────────────────────────────────────────────────────── 

943 

944 

945def _save_reservation( 

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

947): 

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

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

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

951 

952 start_dt = _parse_datetime(start_raw) 

953 end_dt = _parse_datetime(end_raw) 

954 

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

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

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

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

959 renter_auth_warning: str | None = None 

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

961 policy = _rental_authorization_policy(ac.tenant_id) 

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

963 if policy == "block": 

964 flash( 

965 _( 

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

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

968 ), 

969 "danger", 

970 ) 

971 effective_rate, rate_source = _effective_rate(ac, settings) 

972 return render_template( 

973 "reservations/form.html", 

974 aircraft=ac, 

975 reservation=None, 

976 settings=settings, 

977 prefill_start="", 

978 effective_rate=effective_rate, 

979 rate_source=rate_source, 

980 rate_terms_label=_rate_terms_label(settings), 

981 renter_auth_blocked=True, 

982 aircraft_grounded=_has_open_grounding_snag(ac), 

983 grounded_blocked=False, 

984 ) 

985 renter_auth_warning = str( 

986 _( 

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

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

989 "check with the owner before flying." 

990 ) 

991 ) 

992 

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

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

995 grounded_warning: str | None = None 

996 if r is None and _has_open_grounding_snag(ac): 

997 if ( 

998 not _is_owner_role(uid) 

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

1000 ): 

1001 flash( 

1002 _( 

1003 "This aircraft is currently grounded and this hangar " 

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

1005 "the owner." 

1006 ), 

1007 "danger", 

1008 ) 

1009 effective_rate, rate_source = _effective_rate(ac, settings) 

1010 return render_template( 

1011 "reservations/form.html", 

1012 aircraft=ac, 

1013 reservation=None, 

1014 settings=settings, 

1015 prefill_start="", 

1016 effective_rate=effective_rate, 

1017 rate_source=rate_source, 

1018 rate_terms_label=_rate_terms_label(settings), 

1019 renter_auth_blocked=False, 

1020 aircraft_grounded=True, 

1021 grounded_blocked=True, 

1022 ) 

1023 grounded_warning = str( 

1024 _( 

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

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

1027 ) 

1028 ) 

1029 

1030 errors = [] 

1031 if not start_dt: 

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

1033 if not end_dt: 

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

1035 if start_dt and end_dt: 

1036 if end_dt <= start_dt: 

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

1038 else: 

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

1040 if settings: 

1041 if settings.min_booking_hours and duration < float( 

1042 settings.min_booking_hours 

1043 ): 

1044 errors.append( 

1045 _( 

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

1047 h=settings.min_booking_hours, 

1048 ) 

1049 ) 

1050 if settings.max_booking_hours and duration > float( 

1051 settings.max_booking_hours 

1052 ): 

1053 errors.append( 

1054 _( 

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

1056 h=settings.max_booking_hours, 

1057 ) 

1058 ) 

1059 

1060 if errors: 

1061 for msg in errors: 

1062 flash(msg, "danger") 

1063 effective_rate, rate_source = _effective_rate(ac, settings) 

1064 return render_template( 

1065 "reservations/form.html", 

1066 aircraft=ac, 

1067 reservation=r, 

1068 settings=settings, 

1069 prefill_start="", 

1070 effective_rate=effective_rate, 

1071 rate_source=rate_source, 

1072 rate_terms_label=_rate_terms_label(settings), 

1073 renter_auth_blocked=False, 

1074 aircraft_grounded=_has_open_grounding_snag(ac), 

1075 grounded_blocked=False, 

1076 ) 

1077 

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

1079 

1080 _is_new_reservation = r is None 

1081 if r is None: 

1082 r = Reservation( 

1083 aircraft_id=ac.id, 

1084 pilot_user_id=session["user_id"], 

1085 status=ReservationStatus.PENDING, 

1086 ) 

1087 db.session.add(r) 

1088 

1089 r.start_dt = start_dt 

1090 r.end_dt = end_dt 

1091 r.notes = notes 

1092 r.hourly_rate = hourly_rate 

1093 r.estimated_cost = estimated_cost 

1094 db.session.commit() 

1095 

1096 if _is_new_reservation: 

1097 try: 

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

1099 from services.notification_service import dispatch # pyright: ignore[reportMissingImports] 

1100 

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

1102 if tu: 

1103 details = [ 

1104 ("Aircraft", ac.registration), 

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

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

1107 ] 

1108 if renter_auth_warning: 

1109 details.append(("Note", "No valid rental authorization on file")) 

1110 dispatch( 

1111 NotificationType.RESERVATION_REQUEST, 

1112 tu.tenant_id, 

1113 { 

1114 "subject": f"New booking request — {ac.registration}", 

1115 "notification_title": f"New booking request: {ac.registration}", 

1116 "notification_message": f"A new booking request was submitted for {ac.registration} from {r.start_dt.strftime('%Y-%m-%d %H:%M')} to {r.end_dt.strftime('%Y-%m-%d %H:%M')} UTC.", 

1117 "details": details, 

1118 }, 

1119 ) 

1120 except Exception: 

1121 import logging as _log 

1122 

1123 _log.getLogger(__name__).exception( 

1124 "Failed to dispatch reservation request notification" 

1125 ) 

1126 

1127 if renter_auth_warning: 

1128 flash(renter_auth_warning, "warning") 

1129 if grounded_warning: 

1130 flash(grounded_warning, "warning") 

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

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

1133 

1134 

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

1136 

1137 

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

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

1140 

1141 

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

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

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

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

1146 d = r.dispatch 

1147 if d is None or not d.is_checked_in: 

1148 return None 

1149 

1150 parts = [] 

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

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

1153 flights_sum = sum( 

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

1155 for fe in r.flights 

1156 if fe.flight_time_counter_end is not None 

1157 and fe.flight_time_counter_start is not None 

1158 ) 

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

1160 parts.append( 

1161 str( 

1162 _( 

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

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

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

1166 ) 

1167 ) 

1168 ) 

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

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

1171 flights_sum = sum( 

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

1173 for fe in r.flights 

1174 if fe.engine_time_counter_end is not None 

1175 and fe.engine_time_counter_start is not None 

1176 ) 

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

1178 parts.append( 

1179 str( 

1180 _( 

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

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

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

1184 ) 

1185 ) 

1186 ) 

1187 if not parts: 

1188 return None 

1189 return str( 

1190 _( 

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

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

1193 ) 

1194 ) 

1195 

1196 

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

1198@login_required 

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

1200 ac = _get_aircraft_or_404(aircraft_id) 

1201 r = _get_reservation_or_404(ac, res_id) 

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

1203 is_owner = _is_owner_role(uid) 

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

1205 abort(403) 

1206 

1207 today_start = datetime.combine( 

1208 r.start_dt.astimezone(timezone.utc).date(), time.min, tzinfo=timezone.utc 

1209 ) 

1210 can_checkout = ( 

1211 _can_dispatch(r, uid) 

1212 and r.status == ReservationStatus.CONFIRMED 

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

1214 and datetime.now(timezone.utc) >= today_start 

1215 ) 

1216 can_checkin = ( 

1217 _can_dispatch(r, uid) 

1218 and r.dispatch is not None 

1219 and r.dispatch.is_checked_out 

1220 and not r.dispatch.is_checked_in 

1221 ) 

1222 

1223 return render_template( 

1224 "reservations/detail.html", 

1225 aircraft=ac, 

1226 reservation=r, 

1227 dispatch=r.dispatch, 

1228 is_owner=is_owner, 

1229 can_checkout=can_checkout, 

1230 can_checkin=can_checkin, 

1231 discrepancy_warning=_discrepancy_warning(ac, r), 

1232 ) 

1233 

1234 

1235@reservations_bp.route( 

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

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

1238) 

1239@login_required 

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

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

1242 

1243 ac = _get_aircraft_or_404(aircraft_id) 

1244 r = _get_reservation_or_404(ac, res_id) 

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

1246 if not _can_dispatch(r, uid): 

1247 abort(403) 

1248 if r.status != ReservationStatus.CONFIRMED: 

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

1250 return redirect( 

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

1252 ) 

1253 

1254 dispatch_record = r.dispatch 

1255 if dispatch_record is not None and dispatch_record.is_checked_out: 

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

1257 return redirect( 

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

1259 ) 

1260 

1261 open_snags = ( 

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

1263 ) 

1264 

1265 if request.method == "POST": 

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

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

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

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

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

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

1272 

1273 errors = [] 

1274 if not walkaround_ok: 

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

1276 if not snags_acknowledged: 

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

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

1279 errors.append( 

1280 _( 

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

1282 "may override with an explicit confirmation." 

1283 ) 

1284 ) 

1285 

1286 try: 

1287 engine_val = float(engine_counter) if engine_counter else None 

1288 except ValueError: 

1289 engine_val = None 

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

1291 try: 

1292 flight_val = float(flight_counter) if flight_counter else None 

1293 except ValueError: 

1294 flight_val = None 

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

1296 

1297 if errors: 

1298 for msg in errors: 

1299 flash(msg, "danger") 

1300 return render_template( 

1301 "reservations/checkout.html", 

1302 aircraft=ac, 

1303 reservation=r, 

1304 open_snags=open_snags, 

1305 counter_hint=_checkout_counter_hint(ac.id), 

1306 ) 

1307 

1308 if dispatch_record is None: 

1309 dispatch_record = DispatchRecord(reservation_id=r.id) 

1310 db.session.add(dispatch_record) 

1311 

1312 dispatch_record.out_at = datetime.now(timezone.utc) 

1313 dispatch_record.out_by_id = uid 

1314 dispatch_record.out_engine_counter = engine_val 

1315 dispatch_record.out_flight_counter = flight_val 

1316 dispatch_record.out_fuel_state = fuel_state 

1317 dispatch_record.out_walkaround_ok = walkaround_ok 

1318 dispatch_record.out_snags_acknowledged = snags_acknowledged 

1319 dispatch_record.out_grounded_override = ac.is_grounded and grounded_override 

1320 db.session.commit() 

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

1322 return redirect( 

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

1324 ) 

1325 

1326 return render_template( 

1327 "reservations/checkout.html", 

1328 aircraft=ac, 

1329 reservation=r, 

1330 open_snags=open_snags, 

1331 counter_hint=_checkout_counter_hint(ac.id), 

1332 ) 

1333 

1334 

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

1336 from flights.routes import _get_counter_hint # pyright: ignore[reportMissingImports] 

1337 

1338 return _get_counter_hint(aircraft_id) 

1339 

1340 

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

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

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

1344 from models import Expense, ExpenseType, RentalCharge # pyright: ignore[reportMissingImports] 

1345 

1346 settings = ac.booking_settings 

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

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

1349 

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

1351 if out_val is None or in_val is None: 

1352 return None 

1353 return float(in_val) - float(out_val) 

1354 

1355 engine_delta = _delta( 

1356 dispatch_record.out_engine_counter, dispatch_record.in_engine_counter 

1357 ) 

1358 flight_delta = _delta( 

1359 dispatch_record.out_flight_counter, dispatch_record.in_flight_counter 

1360 ) 

1361 primary, fallback = ( 

1362 (engine_delta, flight_delta) 

1363 if rate_basis == RateBasis.ENGINE_TIME 

1364 else (flight_delta, engine_delta) 

1365 ) 

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

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

1368 fallback_counter_used = primary is None and fallback is not None 

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

1370 

1371 chargeable_days = 1 

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

1373 chargeable_days = _chargeable_days( 

1374 dispatch_record.out_at, dispatch_record.in_at 

1375 ) 

1376 min_per_day = ( 

1377 float(settings.min_hours_per_day) 

1378 if settings and settings.min_hours_per_day 

1379 else None 

1380 ) 

1381 billable_hours = counter_delta 

1382 if min_per_day: 

1383 billable_hours = max(counter_delta, chargeable_days * min_per_day) 

1384 

1385 hourly_rate, _source = _effective_rate(ac, settings) 

1386 hourly_rate = hourly_rate or 0.0 

1387 

1388 fuel_credit = 0.0 

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

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

1391 if flight_ids: 

1392 fuel_rows = Expense.query.filter( 

1393 Expense.flight_entry_id.in_(flight_ids), 

1394 Expense.expense_type == ExpenseType.FUEL, 

1395 Expense.created_by_id == r.pilot_user_id, 

1396 ).all() 

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

1398 

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

1400 

1401 return RentalCharge( 

1402 reservation_id=r.id, 

1403 renter_user_id=r.pilot_user_id, 

1404 billable_hours=round(billable_hours, 1), 

1405 hourly_rate=round(hourly_rate, 2), 

1406 rate_type=rate_type, 

1407 fuel_credit=round(fuel_credit, 2), 

1408 adjustment=0, 

1409 fallback_counter_used=fallback_counter_used, 

1410 total=total, 

1411 ) 

1412 

1413 

1414@reservations_bp.route( 

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

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

1417) 

1418@login_required 

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

1420 ac = _get_aircraft_or_404(aircraft_id) 

1421 r = _get_reservation_or_404(ac, res_id) 

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

1423 if not _can_dispatch(r, uid): 

1424 abort(403) 

1425 

1426 dispatch_record = r.dispatch 

1427 if dispatch_record is None or not dispatch_record.is_checked_out: 

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

1429 return redirect( 

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

1431 ) 

1432 if dispatch_record.is_checked_in: 

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

1434 return redirect( 

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

1436 ) 

1437 

1438 if request.method == "POST": 

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

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

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

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

1443 

1444 errors = [] 

1445 engine_val: float | None = None 

1446 flight_val: float | None = None 

1447 try: 

1448 engine_val = float(engine_counter) if engine_counter else None 

1449 except ValueError: 

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

1451 try: 

1452 flight_val = float(flight_counter) if flight_counter else None 

1453 except ValueError: 

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

1455 

1456 if ( 

1457 not errors 

1458 and engine_val is not None 

1459 and dispatch_record.out_engine_counter is not None 

1460 and engine_val < float(dispatch_record.out_engine_counter) 

1461 ): 

1462 errors.append( 

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

1464 ) 

1465 if ( 

1466 not errors 

1467 and flight_val is not None 

1468 and dispatch_record.out_flight_counter is not None 

1469 and flight_val < float(dispatch_record.out_flight_counter) 

1470 ): 

1471 errors.append( 

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

1473 ) 

1474 

1475 if errors: 

1476 for msg in errors: 

1477 flash(msg, "danger") 

1478 return render_template( 

1479 "reservations/checkin.html", 

1480 aircraft=ac, 

1481 reservation=r, 

1482 dispatch=dispatch_record, 

1483 ) 

1484 

1485 dispatch_record.in_at = datetime.now(timezone.utc) 

1486 dispatch_record.in_by_id = uid 

1487 dispatch_record.in_engine_counter = engine_val 

1488 dispatch_record.in_flight_counter = flight_val 

1489 dispatch_record.in_fuel_state = fuel_state 

1490 dispatch_record.in_notes = notes 

1491 if r.pilot_user_id is not None: 

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

1493 db.session.commit() 

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

1495 return redirect( 

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

1497 ) 

1498 

1499 return render_template( 

1500 "reservations/checkin.html", 

1501 aircraft=ac, 

1502 reservation=r, 

1503 dispatch=dispatch_record, 

1504 ) 

1505 

1506 

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

1508 

1509 

1510@reservations_bp.route( 

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

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

1513) 

1514@login_required 

1515@require_role(*_OWNER_ROLES) 

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

1517 ac = _get_aircraft_or_404(aircraft_id) 

1518 r = _get_reservation_or_404(ac, res_id) 

1519 charge = r.rental_charge 

1520 if charge is None: 

1521 abort(404) 

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

1523 

1524 if request.method == "POST": 

1525 if charge.is_final: 

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

1527 return redirect( 

1528 url_for( 

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

1530 ) 

1531 ) 

1532 

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

1534 

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

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

1537 try: 

1538 return float(raw), True 

1539 except ValueError: 

1540 return float(current), False 

1541 

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

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

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

1545 try: 

1546 fuel_credit = float(fuel_credit_raw) if fuel_credit_raw else 0.0 

1547 credit_ok = True 

1548 except ValueError: 

1549 fuel_credit = float(charge.fuel_credit) 

1550 credit_ok = False 

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

1552 try: 

1553 adjustment = float(adjustment_raw) if adjustment_raw else 0.0 

1554 adj_ok = True 

1555 except ValueError: 

1556 adjustment = float(charge.adjustment) 

1557 adj_ok = False 

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

1559 

1560 errors = [] 

1561 if not hours_ok: 

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

1563 if not rate_ok: 

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

1565 if not credit_ok: 

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

1567 if not adj_ok: 

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

1569 if billable_hours < 0: 

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

1571 if hourly_rate < 0: 

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

1573 if fuel_credit < 0: 

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

1575 if adjustment != 0 and not adjustment_note: 

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

1577 

1578 if errors: 

1579 for msg in errors: 

1580 flash(msg, "danger") 

1581 return render_template( 

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

1583 ) 

1584 

1585 charge.billable_hours = round(billable_hours, 1) 

1586 charge.hourly_rate = round(hourly_rate, 2) 

1587 charge.fuel_credit = round(fuel_credit, 2) 

1588 charge.adjustment = round(adjustment, 2) 

1589 charge.adjustment_note = adjustment_note 

1590 charge.total = round( 

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

1592 - float(charge.fuel_credit) 

1593 + float(charge.adjustment), 

1594 2, 

1595 ) 

1596 

1597 if action == "finalize": 

1598 from models import ( 

1599 BillingAccountKind, 

1600 LedgerEntryType, 

1601 RentalChargeStatus, 

1602 User, 

1603 ) # pyright: ignore[reportMissingImports] 

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

1605 

1606 charge.status = RentalChargeStatus.FINAL 

1607 charge.finalized_at = datetime.now(timezone.utc) 

1608 charge.finalized_by_id = uid 

1609 account = BillingService.get_or_create_account( 

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

1611 ) 

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

1613 BillingService.post( 

1614 account, 

1615 LedgerEntryType.CHARGE, 

1616 charge.total, 

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

1618 _date.today(), 

1619 source_type="rental_charge", 

1620 source_id=charge.id, 

1621 created_by=finalizer, 

1622 ) 

1623 db.session.commit() 

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

1625 else: 

1626 db.session.commit() 

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

1628 return redirect( 

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

1630 ) 

1631 

1632 return render_template( 

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

1634 ) 

1635 

1636 

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

1638 

1639 

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

1641 try: 

1642 period_months = int(period_months_raw) if period_months_raw else 12 

1643 except ValueError: 

1644 period_months = 12 

1645 if period_months <= 0: 

1646 period_months = 12 

1647 end = _date.today() 

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

1649 return start, end 

1650 

1651 

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

1653@login_required 

1654def my_account(): 

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

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

1657 

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

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

1660 if not tu: 

1661 abort(403) # pragma: no cover 

1662 

1663 account = BillingService.get_or_create_account( 

1664 tu.tenant_id, uid, BillingAccountKind.RENTER 

1665 ) 

1666 db.session.commit() 

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

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

1669 

1670 return render_template( 

1671 "config/renter_account.html", 

1672 renter=tu.user, 

1673 account=account, 

1674 statement=statement, 

1675 balance=BillingService.balance(account), 

1676 is_owner_view=False, 

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

1678 ) 

1679 

1680 

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

1682@login_required 

1683def my_account_statement_csv(): 

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

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

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

1687 

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

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

1690 if not tu: 

1691 abort(403) # pragma: no cover 

1692 

1693 account = BillingService.get_or_create_account( 

1694 tu.tenant_id, uid, BillingAccountKind.RENTER 

1695 ) 

1696 db.session.commit() 

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

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

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

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

1701 return Response( 

1702 csv_text, 

1703 mimetype="text/csv", 

1704 headers={ 

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

1706 }, 

1707 )