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

810 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-13 03:30 +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 decimal import Decimal, InvalidOperation 

10from typing import Any 

11from urllib.parse import urlparse 

12 

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

14 DEFAULT_PERIOD_MONTHS, 

15 compute_cost_dashboard, 

16) 

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

18 Blueprint, 

19 abort, 

20 flash, 

21 redirect, 

22 render_template, 

23 request, 

24 session, 

25 url_for, 

26) 

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

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

29 Aircraft, 

30 AircraftBookingSettings, 

31 DispatchRecord, 

32 MaintenanceDowntime, 

33 RateBasis, 

34 RateType, 

35 Reservation, 

36 ReservationStatus, 

37 Role, 

38 TenantUser, 

39 db, 

40) 

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

42 login_required, 

43 require_role, 

44 user_can_access_aircraft, 

45) 

46 

47reservations_bp = Blueprint("reservations", __name__) 

48 

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

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

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

52 

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

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

55RATE_DIVERGENCE_WARN_PCT = 0.10 

56 

57 

58def _booking_number( 

59 raw: str, precision: int, scale: int, *, signed: bool = False 

60) -> Decimal: 

61 """Validate before rounding; bound values to the destination NUMERIC column.""" 

62 try: 

63 value = Decimal(raw) 

64 except InvalidOperation as exc: 

65 raise ValueError from exc 

66 limit = Decimal(10) ** (precision - scale) - Decimal(10) ** -scale 

67 if not value.is_finite() or value.copy_abs() > limit or (not signed and value < 0): 

68 raise ValueError 

69 return value.quantize(Decimal(10) ** -scale) 

70 

71 

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

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

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

75 try: 

76 parsed = urlparse(next_url) 

77 except ValueError: 

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

79 return fallback 

80 if ( 

81 next_url 

82 and not parsed.scheme 

83 and not parsed.netloc 

84 and next_url.startswith("/") 

85 ): 

86 return next_url 

87 return fallback 

88 

89 

90# ── Helpers ─────────────────────────────────────────────────────────────────── 

91 

92 

93def _tenant_id() -> int: 

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

95 if not tu: 

96 abort(403) # pragma: no cover 

97 return tu.tenant_id 

98 

99 

100def _get_aircraft_or_404(aircraft_id: int, *, for_update: bool = False) -> Aircraft: 

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

102 if ( 

103 not ac 

104 or ac.tenant_id != _tenant_id() 

105 or not user_can_access_aircraft(aircraft_id) 

106 ): 

107 abort(404) 

108 if for_update: 

109 # All booking/downtime/dispatch writers lock the same stable row before 

110 # reading state, including when no conflicting booking/dispatch exists. 

111 # READ COMMITTED then sees the preceding writer's committed state. Refresh 

112 # the identity map too: the URL converter may already have loaded aircraft. 

113 ac = ( 

114 Aircraft.query.filter_by(id=aircraft_id) 

115 .populate_existing() 

116 .with_for_update() 

117 .one() 

118 ) 

119 return ac 

120 

121 

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

123 r = db.session.get(Reservation, res_id, populate_existing=True) 

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

125 abort(404) 

126 return r 

127 

128 

129def _has_conflict( 

130 aircraft_id: int, 

131 start_dt: datetime, 

132 end_dt: datetime, 

133 exclude_id: int | None = None, 

134) -> bool: 

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

136 overlaps [start_dt, end_dt).""" 

137 q = Reservation.query.filter( 

138 Reservation.aircraft_id == aircraft_id, 

139 Reservation.status == ReservationStatus.CONFIRMED, 

140 Reservation.start_dt < end_dt, 

141 Reservation.end_dt > start_dt, 

142 ) 

143 if exclude_id is not None: 

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

145 if q.first() is not None: 

146 return True 

147 dq = MaintenanceDowntime.query.filter( 

148 MaintenanceDowntime.aircraft_id == aircraft_id, 

149 MaintenanceDowntime.start_dt < end_dt, 

150 MaintenanceDowntime.end_dt > start_dt, 

151 ) 

152 return dq.first() is not None 

153 

154 

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

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

157 try: 

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

159 except (ValueError, AttributeError): 

160 return None 

161 

162 

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

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

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

166 

167 

168def _effective_rate( 

169 ac: Aircraft, settings: AircraftBookingSettings | None 

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

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

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

173 if settings and settings.hourly_rate is not None: 

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

175 computed = _computed_rate(ac) 

176 if computed is not None: 

177 return computed, "computed" 

178 return None, None 

179 

180 

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

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

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

184 

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

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

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

188 get translated in any locale. 

189 """ 

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

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

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

193 basis_label = ( 

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

195 ) 

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

197 

198 

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

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

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

202 

203 

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

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

206 

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

208 return profile.rental_authorization_policy if profile else "warn" 

209 

210 

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

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

213 

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

215 return profile.grounded_reservation_policy if profile else "warn" 

216 

217 

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

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

220 

221 

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

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

224 

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

226 

227 

228def _rate_divergence_warning( 

229 ac: Aircraft, settings: AircraftBookingSettings | None 

230) -> str | None: 

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

232 if not settings or settings.hourly_rate is None: 

233 return None 

234 computed = _computed_rate(ac) 

235 if computed is None or computed == 0: 

236 return None 

237 manual = float(settings.hourly_rate) 

238 pct_diff = abs(manual - computed) / computed 

239 if pct_diff <= RATE_DIVERGENCE_WARN_PCT: 

240 return None 

241 return str( 

242 _( 

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

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

245 "consider reviewing it.", 

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

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

248 pct=int(RATE_DIVERGENCE_WARN_PCT * 100), 

249 ) 

250 ) 

251 

252 

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

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

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

256 

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

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

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

260 """ 

261 last_day = end_dt.date() 

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

263 last_day -= timedelta(days=1) 

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

265 

266 

267def _estimated_hours( 

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

269) -> float: 

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

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

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

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

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

275 if settings and settings.min_hours_per_day: 

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

277 settings.min_hours_per_day 

278 ) 

279 return max(wall_clock_hours, floor_hours) 

280 return wall_clock_hours 

281 

282 

283def _compute_cost( 

284 start_dt: datetime, 

285 end_dt: datetime, 

286 settings: AircraftBookingSettings | None, 

287 ac: Aircraft, 

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

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

290 rate, _source = _effective_rate(ac, settings) 

291 if rate is None: 

292 return None, None 

293 hours = _estimated_hours(start_dt, end_dt, settings) 

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

295 

296 

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

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

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

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

301 return cal.monthdatescalendar(year, month) 

302 

303 

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

305 

306 

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

308@login_required 

309@require_role(*_OWNER_ROLES) 

310def fleet_reservations(): 

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

312 if not tu: 

313 abort(403) # pragma: no cover 

314 

315 role = tu.role 

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

317 

318 aircraft_qs = accessible_aircraft(tu.tenant_id) 

319 if role == Role.OWNER: 

320 # Owners only see planes they explicitly have access to 

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

322 UserAircraftAccess, 

323 UserAllAircraftAccess, 

324 ) 

325 

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

327 if not all_access: 

328 owned_ids = [ 

329 r.aircraft_id 

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

331 ] 

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

333 

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

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

336 

337 now = datetime.now(UTC) 

338 expired_cutoff = now - timedelta(days=60) 

339 

340 reservations = ( 

341 ( 

342 Reservation.query.filter( 

343 Reservation.aircraft_id.in_(aircraft_ids), 

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

345 db.or_( 

346 Reservation.status != ReservationStatus.PENDING, 

347 Reservation.start_dt >= expired_cutoff, 

348 ), 

349 ) 

350 .order_by(Reservation.start_dt) 

351 .all() 

352 ) 

353 if aircraft_ids 

354 else [] 

355 ) 

356 

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

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

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

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

361 now = now.replace(tzinfo=None) 

362 

363 # Detect overlapping confirmed reservations per aircraft 

364 overlapping_ids: set[int] = set() 

365 from itertools import combinations 

366 

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

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

369 for r in confirmed: 

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

371 for group in by_aircraft.values(): 

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

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

374 overlapping_ids.add(r1.id) 

375 overlapping_ids.add(r2.id) 

376 

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

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

379 

380 missing_flight_ids: set[int] = set() 

381 for r in reservations: 

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

383 start_date = r.start_dt.date() 

384 end_date = r.end_dt.date() 

385 has_flight = ( 

386 Flight.query.filter( 

387 Flight.aircraft_id == r.aircraft_id, 

388 Flight.date >= start_date, 

389 Flight.date <= end_date, 

390 ).first() 

391 is not None 

392 ) 

393 if not has_flight: 

394 missing_flight_ids.add(r.id) 

395 

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

397 

398 return render_template( 

399 "reservations/fleet.html", 

400 reservations=reservations, 

401 aircraft_map=aircraft_map, 

402 overlapping_ids=overlapping_ids, 

403 missing_flight_ids=missing_flight_ids, 

404 now=now, 

405 ReservationStatus=ReservationStatus, 

406 ) 

407 

408 

409# ── Calendar view ───────────────────────────────────────────────────────────── 

410 

411 

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

413@login_required 

414def calendar_view(aircraft_id: int): 

415 ac = _get_aircraft_or_404(aircraft_id) 

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

417 

418 try: 

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

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

421 except ValueError: 

422 year, month = today.year, today.month 

423 

424 # Clamp to valid range 

425 if month < 1: 

426 year -= 1 

427 month = 12 

428 if month > 12: 

429 year += 1 

430 month = 1 

431 

432 # Month boundaries in UTC 

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

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

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

436 

437 reservations = ( 

438 Reservation.query.filter( 

439 Reservation.aircraft_id == ac.id, 

440 Reservation.start_dt <= month_end, 

441 Reservation.end_dt >= month_start, 

442 ) 

443 .order_by(Reservation.start_dt) 

444 .all() 

445 ) 

446 

447 downtimes = ( 

448 MaintenanceDowntime.query.filter( 

449 MaintenanceDowntime.aircraft_id == ac.id, 

450 MaintenanceDowntime.start_dt <= month_end, 

451 MaintenanceDowntime.end_dt >= month_start, 

452 ) 

453 .order_by(MaintenanceDowntime.start_dt) 

454 .all() 

455 ) 

456 

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

458 from collections import defaultdict 

459 

460 day_reservations: dict = defaultdict(list) 

461 for r in reservations: 

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

463 cur = r.start_dt.date() 

464 end = r.end_dt.date() 

465 while cur <= end: 

466 day_reservations[cur].append(r) 

467 cur += timedelta(days=1) 

468 

469 day_downtimes: dict = defaultdict(list) 

470 for d in downtimes: 

471 cur = d.start_dt.date() 

472 end = d.end_dt.date() 

473 while cur <= end: 

474 day_downtimes[cur].append(d) 

475 cur += timedelta(days=1) 

476 

477 # Prev / next month navigation 

478 prev_month = month - 1 or 12 

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

480 next_month = month % 12 + 1 

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

482 

483 weeks = _build_calendar_grid(year, month) 

484 

485 return render_template( 

486 "reservations/calendar.html", 

487 aircraft=ac, 

488 weeks=weeks, 

489 day_reservations=day_reservations, 

490 day_downtimes=day_downtimes, 

491 all_downtimes=downtimes, 

492 year=year, 

493 month=month, 

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

495 today=today, 

496 prev_year=prev_year, 

497 prev_month=prev_month, 

498 next_year=next_year, 

499 next_month=next_month, 

500 ReservationStatus=ReservationStatus, 

501 ) 

502 

503 

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

505 

506 

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

508 d = db.session.get(MaintenanceDowntime, downtime_id, populate_existing=True) 

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

510 abort(404) 

511 return d 

512 

513 

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

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

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

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

518 start_dt = _parse_datetime(start_raw) 

519 end_dt = _parse_datetime(end_raw) 

520 

521 errors = [] 

522 if not start_dt: 

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

524 if not end_dt: 

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

526 if start_dt and end_dt and end_dt <= start_dt: 

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

528 

529 if errors: 

530 for msg in errors: 

531 flash(msg, "danger") 

532 return render_template( 

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

534 ) 

535 

536 assert start_dt is not None and end_dt is not None 

537 conflicting = ( 

538 Reservation.query.filter( 

539 Reservation.aircraft_id == ac.id, 

540 Reservation.status == ReservationStatus.CONFIRMED, 

541 Reservation.start_dt < end_dt, 

542 Reservation.end_dt > start_dt, 

543 ) 

544 .order_by(Reservation.start_dt) 

545 .all() 

546 ) 

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

548 return render_template( 

549 "reservations/downtime_form.html", 

550 aircraft=ac, 

551 downtime=d, 

552 conflicts=conflicting, 

553 ) 

554 

555 is_new = d is None 

556 if d is None: 

557 d = MaintenanceDowntime( 

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

559 ) 

560 db.session.add(d) 

561 d.start_dt = start_dt 

562 d.end_dt = end_dt 

563 d.reason = reason 

564 db.session.commit() 

565 flash( 

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

567 "success", 

568 ) 

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

570 

571 

572@reservations_bp.route( 

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

574) 

575@login_required 

576@require_role(*_DOWNTIME_ROLES) 

577def downtime_new(aircraft_id: int): 

578 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

579 if request.method == "POST": 

580 return _save_downtime(ac, None) 

581 return render_template( 

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

583 ) 

584 

585 

586@reservations_bp.route( 

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

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

589) 

590@login_required 

591@require_role(*_DOWNTIME_ROLES) 

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

593 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

594 d = _get_downtime_or_404(ac, downtime_id) 

595 if request.method == "POST": 

596 return _save_downtime(ac, d) 

597 return render_template( 

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

599 ) 

600 

601 

602@reservations_bp.route( 

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

604 methods=["POST"], 

605) 

606@login_required 

607@require_role(*_DOWNTIME_ROLES) 

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

609 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

610 d = _get_downtime_or_404(ac, downtime_id) 

611 db.session.delete(d) 

612 db.session.commit() 

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

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

615 

616 

617# ── Create reservation ──────────────────────────────────────────────────────── 

618 

619 

620@reservations_bp.route( 

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

622) 

623@login_required 

624@require_role(*_BOOKING_ROLES) 

625def new_reservation(aircraft_id: int): 

626 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

627 if ac.is_archived: 

628 abort(404) 

629 settings = ac.booking_settings 

630 if request.method == "POST": 

631 return _save_reservation(ac, None, settings) 

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

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

634 effective_rate, rate_source = _effective_rate(ac, settings) 

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

636 renter_auth_blocked = ( 

637 not _is_owner_role(uid) 

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

639 and not _renter_authorization_ok(ac.id, uid) 

640 ) 

641 aircraft_grounded = _has_open_grounding_snag(ac) 

642 grounded_blocked = ( 

643 aircraft_grounded 

644 and not _is_owner_role(uid) 

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

646 ) 

647 return render_template( 

648 "reservations/form.html", 

649 aircraft=ac, 

650 reservation=None, 

651 settings=settings, 

652 prefill_start=prefill_start, 

653 effective_rate=effective_rate, 

654 rate_source=rate_source, 

655 rate_terms_label=_rate_terms_label(settings), 

656 renter_auth_blocked=renter_auth_blocked, 

657 aircraft_grounded=aircraft_grounded, 

658 grounded_blocked=grounded_blocked, 

659 ) 

660 

661 

662# ── Edit reservation ────────────────────────────────────────────────────────── 

663 

664 

665@reservations_bp.route( 

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

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

668) 

669@login_required 

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

671 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

672 r = _get_reservation_or_404(ac, res_id) 

673 

674 # Pilots may only edit their own pending reservations 

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

676 user_role = role.role if role else None 

677 is_owner_role = user_role in _OWNER_ROLES 

678 if not is_owner_role and ( 

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

680 ): 

681 abort(403) 

682 

683 settings = ac.booking_settings 

684 if request.method == "POST": 

685 return _save_reservation(ac, r, settings) 

686 effective_rate, rate_source = _effective_rate(ac, settings) 

687 return render_template( 

688 "reservations/form.html", 

689 aircraft=ac, 

690 reservation=r, 

691 settings=settings, 

692 prefill_start="", 

693 effective_rate=effective_rate, 

694 rate_source=rate_source, 

695 rate_terms_label=_rate_terms_label(settings), 

696 renter_auth_blocked=False, 

697 aircraft_grounded=_has_open_grounding_snag(ac), 

698 grounded_blocked=False, 

699 ) 

700 

701 

702# ── Cancel reservation ──────────────────────────────────────────────────────── 

703 

704 

705@reservations_bp.route( 

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

707 methods=["POST"], 

708) 

709@login_required 

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

711 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

712 r = _get_reservation_or_404(ac, res_id) 

713 

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

715 user_role = role.role if role else None 

716 is_owner_role = user_role in _OWNER_ROLES 

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

718 abort(403) 

719 

720 if r.status == ReservationStatus.CANCELLED: 

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

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

723 

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

725 flash( 

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

727 "danger", 

728 ) 

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

730 

731 r.status = ReservationStatus.CANCELLED 

732 db.session.commit() 

733 if r.pilot_user_id: 

734 try: 

735 from flask_babel import ( 

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

737 ) 

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

739 from services.notification_service import ( 

740 dispatch, # pyright: ignore[reportMissingImports] 

741 ) 

742 

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

744 if tu: 

745 dispatch( 

746 NotificationType.RESERVATION_CANCELLED, 

747 tu.tenant_id, 

748 { 

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

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

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

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

753 "notification_message_key": _l( 

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

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

756 ), 

757 "notification_message_args": { 

758 "reg": ac.registration, 

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

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

761 }, 

762 "details": [ 

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

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

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

766 ], 

767 }, 

768 target_user_ids=[r.pilot_user_id], 

769 ) 

770 except Exception: 

771 import logging as _log 

772 

773 _log.getLogger(__name__).exception( 

774 "Failed to dispatch reservation cancelled notification" 

775 ) 

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

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

778 

779 

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

781 

782 

783@reservations_bp.route( 

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

785 methods=["POST"], 

786) 

787@login_required 

788@require_role(*_OWNER_ROLES) 

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

790 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

791 r = _get_reservation_or_404(ac, res_id) 

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

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

794 _dest = _safe_next(_next, _fallback) 

795 

796 if r.status != ReservationStatus.PENDING: 

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

798 return redirect(_dest) 

799 

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

801 flash( 

802 _( 

803 "Cannot confirm: overlaps an existing confirmed reservation " 

804 "or maintenance downtime." 

805 ), 

806 "danger", 

807 ) 

808 return redirect(_dest) 

809 

810 if ( 

811 r.pilot_user_id 

812 and not _is_owner_role(r.pilot_user_id) 

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

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

815 ): 

816 flash( 

817 _( 

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

819 "authorization for this aircraft." 

820 ), 

821 "danger", 

822 ) 

823 return redirect(_dest) 

824 

825 if ( 

826 r.pilot_user_id 

827 and not _is_owner_role(r.pilot_user_id) 

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

829 and _has_open_grounding_snag(ac) 

830 ): 

831 flash( 

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

833 "danger", 

834 ) 

835 return redirect(_dest) 

836 

837 r.status = ReservationStatus.CONFIRMED 

838 db.session.commit() 

839 if _has_open_grounding_snag(ac): 

840 flash( 

841 _( 

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

843 "verify airworthiness status before dispatch." 

844 ), 

845 "warning", 

846 ) 

847 if r.pilot_user_id: 

848 try: 

849 from flask_babel import ( 

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

851 ) 

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

853 from services.notification_service import ( 

854 dispatch, # pyright: ignore[reportMissingImports] 

855 ) 

856 

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

858 if tu: 

859 dispatch( 

860 NotificationType.RESERVATION_CONFIRMED, 

861 tu.tenant_id, 

862 { 

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

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

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

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

867 "notification_message_key": _l( 

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

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

870 ), 

871 "notification_message_args": { 

872 "reg": ac.registration, 

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

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

875 }, 

876 "details": [ 

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

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

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

880 ], 

881 }, 

882 target_user_ids=[r.pilot_user_id], 

883 ) 

884 except Exception: 

885 import logging as _log 

886 

887 _log.getLogger(__name__).exception( 

888 "Failed to dispatch reservation confirmed notification" 

889 ) 

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

891 return redirect(_dest) 

892 

893 

894@reservations_bp.route( 

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

896 methods=["POST"], 

897) 

898@login_required 

899@require_role(*_OWNER_ROLES) 

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

901 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

902 r = _get_reservation_or_404(ac, res_id) 

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

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

905 _dest = _safe_next(_next, _fallback) 

906 

907 if r.status != ReservationStatus.PENDING: 

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

909 return redirect(_dest) 

910 

911 r.status = ReservationStatus.CANCELLED 

912 db.session.commit() 

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

914 return redirect(_dest) 

915 

916 

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

918 

919 

920@reservations_bp.route( 

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

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

923) 

924@login_required 

925@require_role(*_OWNER_ROLES) 

926def booking_settings(aircraft_id: int): 

927 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

928 settings = ac.booking_settings 

929 

930 if request.method == "POST": 

931 return _save_booking_settings(ac, settings) 

932 

933 return render_template( 

934 "reservations/settings.html", 

935 aircraft=ac, 

936 settings=settings, 

937 computed_rate=_computed_rate(ac), 

938 rate_warning=_rate_divergence_warning(ac, settings), 

939 ) 

940 

941 

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

943 errors = [] 

944 

945 def _number_or_none( 

946 key: str, precision: int, scale: int, message: str 

947 ) -> Decimal | None: 

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

949 if not val: 

950 return None 

951 try: 

952 return _booking_number(val, precision, scale) 

953 except ValueError: 

954 errors.append(message) 

955 return None 

956 

957 min_h = _number_or_none( 

958 "min_booking_hours", 4, 1, _("Minimum booking duration must be positive.") 

959 ) 

960 max_h = _number_or_none( 

961 "max_booking_hours", 4, 1, _("Maximum booking duration must be positive.") 

962 ) 

963 rate = _number_or_none("hourly_rate", 8, 2, _("Invalid hourly rate.")) 

964 min_per_day = _number_or_none( 

965 "min_hours_per_day", 4, 1, _("Minimum billed hours per day must be positive.") 

966 ) 

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

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

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

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

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

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

973 

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

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

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

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

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

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

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

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

982 if rate_basis not in RateBasis.ALL: 

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

984 if rate_type not in RateType.ALL: 

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

986 

987 if errors: 

988 for msg in errors: 

989 flash(msg, "danger") 

990 return render_template( 

991 "reservations/settings.html", 

992 aircraft=ac, 

993 settings=settings, 

994 computed_rate=_computed_rate(ac), 

995 rate_warning=_rate_divergence_warning(ac, settings), 

996 ) 

997 

998 if settings is None: 

999 settings = AircraftBookingSettings(aircraft_id=ac.id) 

1000 db.session.add(settings) 

1001 

1002 settings.min_booking_hours = min_h 

1003 settings.max_booking_hours = max_h 

1004 settings.hourly_rate = rate 

1005 settings.min_hours_per_day = min_per_day 

1006 settings.rate_basis = rate_basis 

1007 settings.rate_type = rate_type 

1008 db.session.commit() 

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

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

1011 

1012 

1013# ── Shared save logic ───────────────────────────────────────────────────────── 

1014 

1015 

1016def _save_reservation( 

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

1018): 

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

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

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

1022 

1023 start_dt = _parse_datetime(start_raw) 

1024 end_dt = _parse_datetime(end_raw) 

1025 

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

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

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

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

1030 renter_auth_warning: str | None = None 

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

1032 policy = _rental_authorization_policy(ac.tenant_id) 

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

1034 if policy == "block": 

1035 flash( 

1036 _( 

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

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

1039 ), 

1040 "danger", 

1041 ) 

1042 effective_rate, rate_source = _effective_rate(ac, settings) 

1043 return render_template( 

1044 "reservations/form.html", 

1045 aircraft=ac, 

1046 reservation=None, 

1047 settings=settings, 

1048 prefill_start="", 

1049 effective_rate=effective_rate, 

1050 rate_source=rate_source, 

1051 rate_terms_label=_rate_terms_label(settings), 

1052 renter_auth_blocked=True, 

1053 aircraft_grounded=_has_open_grounding_snag(ac), 

1054 grounded_blocked=False, 

1055 ) 

1056 renter_auth_warning = str( 

1057 _( 

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

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

1060 "check with the owner before flying." 

1061 ) 

1062 ) 

1063 

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

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

1066 grounded_warning: str | None = None 

1067 if r is None and _has_open_grounding_snag(ac): 

1068 if ( 

1069 not _is_owner_role(uid) 

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

1071 ): 

1072 flash( 

1073 _( 

1074 "This aircraft is currently grounded and this hangar " 

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

1076 "the owner." 

1077 ), 

1078 "danger", 

1079 ) 

1080 effective_rate, rate_source = _effective_rate(ac, settings) 

1081 return render_template( 

1082 "reservations/form.html", 

1083 aircraft=ac, 

1084 reservation=None, 

1085 settings=settings, 

1086 prefill_start="", 

1087 effective_rate=effective_rate, 

1088 rate_source=rate_source, 

1089 rate_terms_label=_rate_terms_label(settings), 

1090 renter_auth_blocked=False, 

1091 aircraft_grounded=True, 

1092 grounded_blocked=True, 

1093 ) 

1094 grounded_warning = str( 

1095 _( 

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

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

1098 ) 

1099 ) 

1100 

1101 errors = [] 

1102 if not start_dt: 

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

1104 if not end_dt: 

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

1106 if start_dt and end_dt: 

1107 if end_dt <= start_dt: 

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

1109 else: 

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

1111 if settings: 

1112 if settings.min_booking_hours and duration < float( 

1113 settings.min_booking_hours 

1114 ): 

1115 errors.append( 

1116 _( 

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

1118 h=settings.min_booking_hours, 

1119 ) 

1120 ) 

1121 if settings.max_booking_hours and duration > float( 

1122 settings.max_booking_hours 

1123 ): 

1124 errors.append( 

1125 _( 

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

1127 h=settings.max_booking_hours, 

1128 ) 

1129 ) 

1130 

1131 if errors: 

1132 for msg in errors: 

1133 flash(msg, "danger") 

1134 effective_rate, rate_source = _effective_rate(ac, settings) 

1135 return render_template( 

1136 "reservations/form.html", 

1137 aircraft=ac, 

1138 reservation=r, 

1139 settings=settings, 

1140 prefill_start="", 

1141 effective_rate=effective_rate, 

1142 rate_source=rate_source, 

1143 rate_terms_label=_rate_terms_label(settings), 

1144 renter_auth_blocked=False, 

1145 aircraft_grounded=_has_open_grounding_snag(ac), 

1146 grounded_blocked=False, 

1147 ) 

1148 

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

1150 

1151 _is_new_reservation = r is None 

1152 if r is None: 

1153 r = Reservation( 

1154 aircraft_id=ac.id, 

1155 pilot_user_id=session["user_id"], 

1156 status=ReservationStatus.PENDING, 

1157 ) 

1158 db.session.add(r) 

1159 

1160 r.start_dt = start_dt 

1161 r.end_dt = end_dt 

1162 r.notes = notes 

1163 r.hourly_rate = hourly_rate 

1164 r.estimated_cost = estimated_cost 

1165 db.session.commit() 

1166 

1167 if _is_new_reservation: 

1168 try: 

1169 from flask_babel import ( 

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

1171 ) 

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

1173 from services.notification_service import ( 

1174 dispatch, # pyright: ignore[reportMissingImports] 

1175 ) 

1176 

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

1178 if tu: 

1179 details = [ 

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

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

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

1183 ] 

1184 if renter_auth_warning: 

1185 details.append( 

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

1187 ) 

1188 dispatch( 

1189 NotificationType.RESERVATION_REQUEST, 

1190 tu.tenant_id, 

1191 { 

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

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

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

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

1196 "notification_message_key": _l( 

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

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

1199 ), 

1200 "notification_message_args": { 

1201 "reg": ac.registration, 

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

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

1204 }, 

1205 "details": details, 

1206 }, 

1207 ) 

1208 except Exception: 

1209 import logging as _log 

1210 

1211 _log.getLogger(__name__).exception( 

1212 "Failed to dispatch reservation request notification" 

1213 ) 

1214 

1215 if renter_auth_warning: 

1216 flash(renter_auth_warning, "warning") 

1217 if grounded_warning: 

1218 flash(grounded_warning, "warning") 

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

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

1221 

1222 

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

1224 

1225 

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

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

1228 

1229 

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

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

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

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

1234 d = r.dispatch 

1235 if d is None or not d.is_checked_in: 

1236 return None 

1237 

1238 parts = [] 

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

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

1241 flights_sum = sum( 

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

1243 for fe in r.flights 

1244 if fe.flight_time_counter_end is not None 

1245 and fe.flight_time_counter_start is not None 

1246 ) 

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

1248 parts.append( 

1249 str( 

1250 _( 

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

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

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

1254 ) 

1255 ) 

1256 ) 

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

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

1259 flights_sum = sum( 

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

1261 for fe in r.flights 

1262 if fe.engine_time_counter_end is not None 

1263 and fe.engine_time_counter_start is not None 

1264 ) 

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

1266 parts.append( 

1267 str( 

1268 _( 

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

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

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

1272 ) 

1273 ) 

1274 ) 

1275 if not parts: 

1276 return None 

1277 return str( 

1278 _( 

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

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

1281 ) 

1282 ) 

1283 

1284 

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

1286@login_required 

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

1288 ac = _get_aircraft_or_404(aircraft_id) 

1289 r = _get_reservation_or_404(ac, res_id) 

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

1291 is_owner = _is_owner_role(uid) 

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

1293 abort(403) 

1294 

1295 today_start = datetime.combine( 

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

1297 ) 

1298 can_checkout = ( 

1299 _can_dispatch(r, uid) 

1300 and r.status == ReservationStatus.CONFIRMED 

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

1302 and datetime.now(UTC) >= today_start 

1303 ) 

1304 can_checkin = ( 

1305 _can_dispatch(r, uid) 

1306 and r.dispatch is not None 

1307 and r.dispatch.is_checked_out 

1308 and not r.dispatch.is_checked_in 

1309 ) 

1310 

1311 return render_template( 

1312 "reservations/detail.html", 

1313 aircraft=ac, 

1314 reservation=r, 

1315 dispatch=r.dispatch, 

1316 is_owner=is_owner, 

1317 can_checkout=can_checkout, 

1318 can_checkin=can_checkin, 

1319 discrepancy_warning=_discrepancy_warning(ac, r), 

1320 ) 

1321 

1322 

1323@reservations_bp.route( 

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

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

1326) 

1327@login_required 

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

1329 from models import Snag # pyright: ignore[reportMissingImports] 

1330 

1331 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

1332 r = _get_reservation_or_404(ac, res_id) 

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

1334 if not _can_dispatch(r, uid): 

1335 abort(403) 

1336 if r.status != ReservationStatus.CONFIRMED: 

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

1338 return redirect( 

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

1340 ) 

1341 

1342 dispatch_record = ( 

1343 DispatchRecord.query.filter_by(reservation_id=r.id).populate_existing().first() 

1344 ) 

1345 if dispatch_record is not None and dispatch_record.is_checked_out: 

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

1347 return redirect( 

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

1349 ) 

1350 

1351 open_snags = ( 

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

1353 ) 

1354 

1355 if request.method == "POST": 

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

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

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

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

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

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

1362 

1363 errors = [] 

1364 if not walkaround_ok: 

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

1366 if not snags_acknowledged: 

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

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

1369 errors.append( 

1370 _( 

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

1372 "may override with an explicit confirmation." 

1373 ) 

1374 ) 

1375 

1376 try: 

1377 engine_val = ( 

1378 _booking_number(engine_counter, 8, 1) if engine_counter else None 

1379 ) 

1380 except ValueError: 

1381 engine_val = None 

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

1383 try: 

1384 flight_val = ( 

1385 _booking_number(flight_counter, 8, 1) if flight_counter else None 

1386 ) 

1387 except ValueError: 

1388 flight_val = None 

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

1390 

1391 if errors: 

1392 for msg in errors: 

1393 flash(msg, "danger") 

1394 return render_template( 

1395 "reservations/checkout.html", 

1396 aircraft=ac, 

1397 reservation=r, 

1398 open_snags=open_snags, 

1399 counter_hint=_checkout_counter_hint(ac.id), 

1400 ) 

1401 

1402 if dispatch_record is None: 

1403 dispatch_record = DispatchRecord(reservation_id=r.id) 

1404 db.session.add(dispatch_record) 

1405 

1406 dispatch_record.out_at = datetime.now(UTC) 

1407 dispatch_record.out_by_id = uid 

1408 dispatch_record.out_engine_counter = engine_val 

1409 dispatch_record.out_flight_counter = flight_val 

1410 dispatch_record.out_fuel_state = fuel_state 

1411 dispatch_record.out_walkaround_ok = walkaround_ok 

1412 dispatch_record.out_snags_acknowledged = snags_acknowledged 

1413 dispatch_record.out_grounded_override = ac.is_grounded and grounded_override 

1414 db.session.commit() 

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

1416 return redirect( 

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

1418 ) 

1419 

1420 return render_template( 

1421 "reservations/checkout.html", 

1422 aircraft=ac, 

1423 reservation=r, 

1424 open_snags=open_snags, 

1425 counter_hint=_checkout_counter_hint(ac.id), 

1426 ) 

1427 

1428 

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

1430 from flights.routes import ( 

1431 _get_counter_hint, # pyright: ignore[reportMissingImports] 

1432 ) 

1433 

1434 return _get_counter_hint(aircraft_id) 

1435 

1436 

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

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

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

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

1441 Expense, 

1442 ExpenseType, 

1443 RentalCharge, 

1444 ) 

1445 

1446 settings = ac.booking_settings 

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

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

1449 

1450 def _delta(out_val: Any, in_val: Any) -> Decimal | None: 

1451 if out_val is None or in_val is None: 

1452 return None 

1453 return Decimal(str(in_val)) - Decimal(str(out_val)) 

1454 

1455 engine_delta = _delta( 

1456 dispatch_record.out_engine_counter, dispatch_record.in_engine_counter 

1457 ) 

1458 flight_delta = _delta( 

1459 dispatch_record.out_flight_counter, dispatch_record.in_flight_counter 

1460 ) 

1461 primary, fallback = ( 

1462 (engine_delta, flight_delta) 

1463 if rate_basis == RateBasis.ENGINE_TIME 

1464 else (flight_delta, engine_delta) 

1465 ) 

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

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

1468 fallback_counter_used = primary is None and fallback is not None 

1469 counter_delta = primary if primary is not None else (fallback or Decimal(0)) 

1470 

1471 chargeable_days = 1 

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

1473 chargeable_days = _chargeable_days( 

1474 dispatch_record.out_at, dispatch_record.in_at 

1475 ) 

1476 min_per_day = ( 

1477 settings.min_hours_per_day if settings and settings.min_hours_per_day else None 

1478 ) 

1479 billable_hours = counter_delta 

1480 if min_per_day: 

1481 billable_hours = max(counter_delta, chargeable_days * min_per_day) 

1482 

1483 hourly_rate, _source = _effective_rate(ac, settings) 

1484 hourly_rate = hourly_rate or 0.0 

1485 

1486 fuel_credit_decimal = Decimal(0) 

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

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

1489 if flight_ids: 

1490 fuel_rows = Expense.query.filter( 

1491 Expense.flight_entry_id.in_(flight_ids), 

1492 Expense.expense_type == ExpenseType.FUEL, 

1493 Expense.created_by_id == r.pilot_user_id, 

1494 ).all() 

1495 fuel_credit_decimal = sum((e.amount for e in fuel_rows), Decimal(0)) 

1496 

1497 billable_hours_decimal = Decimal(str(billable_hours)).quantize(Decimal("0.1")) 

1498 hourly_rate_decimal = Decimal(str(hourly_rate)).quantize(Decimal("0.01")) 

1499 total = ( 

1500 billable_hours_decimal * hourly_rate_decimal - fuel_credit_decimal 

1501 ).quantize(Decimal("0.01")) 

1502 

1503 return RentalCharge( 

1504 reservation_id=r.id, 

1505 renter_user_id=r.pilot_user_id, 

1506 billable_hours=_booking_number(str(billable_hours_decimal), 6, 1), 

1507 hourly_rate=_booking_number(str(hourly_rate_decimal), 8, 2), 

1508 rate_type=rate_type, 

1509 fuel_credit=_booking_number(str(fuel_credit_decimal), 10, 2), 

1510 adjustment=0, 

1511 fallback_counter_used=fallback_counter_used, 

1512 total=_booking_number(str(total), 10, 2, signed=True), 

1513 ) 

1514 

1515 

1516@reservations_bp.route( 

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

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

1519) 

1520@login_required 

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

1522 ac = _get_aircraft_or_404(aircraft_id, for_update=request.method == "POST") 

1523 r = _get_reservation_or_404(ac, res_id) 

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

1525 if not _can_dispatch(r, uid): 

1526 abort(403) 

1527 

1528 dispatch_record = ( 

1529 DispatchRecord.query.filter_by(reservation_id=r.id).populate_existing().first() 

1530 ) 

1531 if dispatch_record is None or not dispatch_record.is_checked_out: 

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

1533 return redirect( 

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

1535 ) 

1536 if dispatch_record.is_checked_in: 

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

1538 return redirect( 

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

1540 ) 

1541 

1542 if request.method == "POST": 

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

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

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

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

1547 

1548 errors = [] 

1549 engine_val: Decimal | None = None 

1550 flight_val: Decimal | None = None 

1551 try: 

1552 engine_val = ( 

1553 _booking_number(engine_counter, 8, 1) if engine_counter else None 

1554 ) 

1555 except ValueError: 

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

1557 try: 

1558 flight_val = ( 

1559 _booking_number(flight_counter, 8, 1) if flight_counter else None 

1560 ) 

1561 except ValueError: 

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

1563 

1564 if ( 

1565 not errors 

1566 and engine_val is not None 

1567 and dispatch_record.out_engine_counter is not None 

1568 and engine_val < dispatch_record.out_engine_counter 

1569 ): 

1570 errors.append( 

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

1572 ) 

1573 if ( 

1574 not errors 

1575 and flight_val is not None 

1576 and dispatch_record.out_flight_counter is not None 

1577 and flight_val < dispatch_record.out_flight_counter 

1578 ): 

1579 errors.append( 

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

1581 ) 

1582 

1583 if errors: 

1584 for msg in errors: 

1585 flash(msg, "danger") 

1586 return render_template( 

1587 "reservations/checkin.html", 

1588 aircraft=ac, 

1589 reservation=r, 

1590 dispatch=dispatch_record, 

1591 ) 

1592 

1593 dispatch_record.in_at = datetime.now(UTC) 

1594 dispatch_record.in_by_id = uid 

1595 dispatch_record.in_engine_counter = engine_val 

1596 dispatch_record.in_flight_counter = flight_val 

1597 dispatch_record.in_fuel_state = fuel_state 

1598 dispatch_record.in_notes = notes 

1599 if r.pilot_user_id is not None: 

1600 try: 

1601 draft = _draft_rental_charge(ac, r, dispatch_record) 

1602 except ValueError: 

1603 db.session.rollback() 

1604 flash( 

1605 _("The rental charge is out of range. Check the values entered."), 

1606 "danger", 

1607 ) 

1608 return render_template( 

1609 "reservations/checkin.html", 

1610 aircraft=ac, 

1611 reservation=r, 

1612 dispatch=dispatch_record, 

1613 ) 

1614 db.session.add(draft) 

1615 db.session.commit() 

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

1617 return redirect( 

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

1619 ) 

1620 

1621 return render_template( 

1622 "reservations/checkin.html", 

1623 aircraft=ac, 

1624 reservation=r, 

1625 dispatch=dispatch_record, 

1626 ) 

1627 

1628 

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

1630 

1631 

1632@reservations_bp.route( 

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

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

1635) 

1636@login_required 

1637@require_role(*_OWNER_ROLES) 

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

1639 ac = _get_aircraft_or_404(aircraft_id) 

1640 r = _get_reservation_or_404(ac, res_id) 

1641 charge = r.rental_charge 

1642 if charge is None: 

1643 abort(404) 

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

1645 

1646 if request.method == "POST": 

1647 if charge.is_final: 

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

1649 return redirect( 

1650 url_for( 

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

1652 ) 

1653 ) 

1654 

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

1656 

1657 def _number_field( 

1658 key: str, current: Any, precision: int, scale: int, *, signed: bool = False 

1659 ) -> tuple[Decimal, bool]: 

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

1661 if key in {"fuel_credit", "adjustment"} and not raw: 

1662 raw = "0" 

1663 try: 

1664 return _booking_number(raw, precision, scale, signed=signed), True 

1665 except ValueError: 

1666 return Decimal(str(current)), False 

1667 

1668 billable_hours, hours_ok = _number_field( 

1669 "billable_hours", charge.billable_hours, 6, 1 

1670 ) 

1671 hourly_rate, rate_ok = _number_field("hourly_rate", charge.hourly_rate, 8, 2) 

1672 fuel_credit, credit_ok = _number_field("fuel_credit", charge.fuel_credit, 10, 2) 

1673 adjustment, adj_ok = _number_field( 

1674 "adjustment", charge.adjustment, 10, 2, signed=True 

1675 ) 

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

1677 

1678 errors = [] 

1679 if not hours_ok: 

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

1681 if not rate_ok: 

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

1683 if not credit_ok: 

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

1685 if not adj_ok: 

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

1687 if adjustment != 0 and not adjustment_note: 

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

1689 

1690 total = billable_hours * hourly_rate - fuel_credit + adjustment 

1691 if abs(total) > Decimal("99999999.99"): 

1692 errors.append( 

1693 _("The rental charge is out of range. Check the values entered.") 

1694 ) 

1695 

1696 if errors: 

1697 for msg in errors: 

1698 flash(msg, "danger") 

1699 return render_template( 

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

1701 ) 

1702 

1703 charge.billable_hours = round(billable_hours, 1) 

1704 charge.hourly_rate = round(hourly_rate, 2) 

1705 charge.fuel_credit = round(fuel_credit, 2) 

1706 charge.adjustment = round(adjustment, 2) 

1707 charge.adjustment_note = adjustment_note 

1708 charge.total = total.quantize(Decimal("0.01")) 

1709 

1710 if action == "finalize": 

1711 from models import ( 

1712 BillingAccountKind, 

1713 LedgerEntryType, 

1714 RentalChargeStatus, 

1715 User, 

1716 ) # pyright: ignore[reportMissingImports] 

1717 from services.billing import ( 

1718 BillingService, # pyright: ignore[reportMissingImports] 

1719 ) 

1720 

1721 charge.status = RentalChargeStatus.FINAL 

1722 charge.finalized_at = datetime.now(UTC) 

1723 charge.finalized_by_id = uid 

1724 account = BillingService.get_or_create_account( 

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

1726 ) 

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

1728 BillingService.post( 

1729 account, 

1730 LedgerEntryType.CHARGE, 

1731 charge.total, 

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

1733 _date.today(), 

1734 source_type="rental_charge", 

1735 source_id=charge.id, 

1736 created_by=finalizer, 

1737 ) 

1738 db.session.commit() 

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

1740 else: 

1741 db.session.commit() 

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

1743 return redirect( 

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

1745 ) 

1746 

1747 return render_template( 

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

1749 ) 

1750 

1751 

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

1753 

1754 

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

1756 try: 

1757 period_months = int(period_months_raw) if period_months_raw else 12 

1758 except ValueError: 

1759 period_months = 12 

1760 if period_months <= 0: 

1761 period_months = 12 

1762 end = _date.today() 

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

1764 return start, end 

1765 

1766 

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

1768@login_required 

1769def my_account(): 

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

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

1772 

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

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

1775 if not tu: 

1776 abort(403) # pragma: no cover 

1777 

1778 account = BillingService.get_or_create_account( 

1779 tu.tenant_id, uid, BillingAccountKind.RENTER 

1780 ) 

1781 db.session.commit() 

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

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

1784 

1785 return render_template( 

1786 "config/renter_account.html", 

1787 renter=tu.user, 

1788 account=account, 

1789 statement=statement, 

1790 balance=BillingService.balance(account), 

1791 is_owner_view=False, 

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

1793 ) 

1794 

1795 

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

1797@login_required 

1798def my_account_statement_csv(): 

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

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

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

1802 

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

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

1805 if not tu: 

1806 abort(403) # pragma: no cover 

1807 

1808 account = BillingService.get_or_create_account( 

1809 tu.tenant_id, uid, BillingAccountKind.RENTER 

1810 ) 

1811 db.session.commit() 

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

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

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

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

1816 return Response( 

1817 csv_text, 

1818 mimetype="text/csv", 

1819 headers={ 

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

1821 }, 

1822 )