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
« 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"""
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
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)
47reservations_bp = Blueprint("reservations", __name__)
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)
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
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)
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
90# ── Helpers ───────────────────────────────────────────────────────────────────
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
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
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
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
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
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"]
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
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.
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})"
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
204def _rental_authorization_policy(tenant_id: int) -> str:
205 from models import TenantProfile # pyright: ignore[reportMissingImports]
207 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first()
208 return profile.rental_authorization_policy if profile else "warn"
211def _grounded_reservation_policy(tenant_id: int) -> str:
212 from models import TenantProfile # pyright: ignore[reportMissingImports]
214 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first()
215 return profile.grounded_reservation_policy if profile else "warn"
218def _has_open_grounding_snag(ac: Aircraft) -> bool:
219 return any(s.is_grounding and s.is_open for s in ac.snags)
222def _renter_authorization_ok(aircraft_id: int, pilot_user_id: int) -> bool:
223 from models import RenterAuthorization # pyright: ignore[reportMissingImports]
225 return RenterAuthorization.valid_for(pilot_user_id, aircraft_id) is not None
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 )
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).
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
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
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)
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)
304# ── Fleet reservations overview (admin/owner) ─────────────────────────────────
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
315 role = tu.role
316 from utils import accessible_aircraft # pyright: ignore[reportMissingImports]
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 )
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))
334 aircraft_list = aircraft_qs.order_by(Aircraft.registration).all()
335 aircraft_ids = [a.id for a in aircraft_list]
337 now = datetime.now(UTC)
338 expired_cutoff = now - timedelta(days=60)
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 )
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)
363 # Detect overlapping confirmed reservations per aircraft
364 overlapping_ids: set[int] = set()
365 from itertools import combinations
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)
377 # Find past confirmed reservations with no flight logged on that aircraft/date
378 from models import Flight # pyright: ignore[reportMissingImports]
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)
396 aircraft_map = {a.id: a for a in aircraft_list}
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 )
409# ── Calendar view ─────────────────────────────────────────────────────────────
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()
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
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
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)
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 )
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 )
457 # Build a dict day → list of reservations for fast template lookup
458 from collections import defaultdict
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)
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)
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
483 weeks = _build_calendar_grid(year, month)
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 )
504# ── Maintenance downtime (Phase 37f) ──────────────────────────────────────────
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
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)
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."))
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 )
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 )
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))
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 )
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 )
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))
617# ── Create reservation ────────────────────────────────────────────────────────
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 )
662# ── Edit reservation ──────────────────────────────────────────────────────────
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)
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)
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 )
702# ── Cancel reservation ────────────────────────────────────────────────────────
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)
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)
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))
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))
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 )
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
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))
780# ── Confirm / decline (owner only) ───────────────────────────────────────────
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)
796 if r.status != ReservationStatus.PENDING:
797 flash(_("Only pending reservations can be confirmed."), "warning")
798 return redirect(_dest)
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)
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)
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)
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 )
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
887 _log.getLogger(__name__).exception(
888 "Failed to dispatch reservation confirmed notification"
889 )
890 flash(_("Reservation confirmed."), "success")
891 return redirect(_dest)
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)
907 if r.status != ReservationStatus.PENDING:
908 flash(_("Only pending reservations can be declined."), "warning")
909 return redirect(_dest)
911 r.status = ReservationStatus.CANCELLED
912 db.session.commit()
913 flash(_("Reservation declined."), "success")
914 return redirect(_dest)
917# ── Booking settings (owner only) ─────────────────────────────────────────────
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
930 if request.method == "POST":
931 return _save_booking_settings(ac, settings)
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 )
942def _save_booking_settings(ac: Aircraft, settings: AircraftBookingSettings | None):
943 errors = []
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
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
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."))
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 )
998 if settings is None:
999 settings = AircraftBookingSettings(aircraft_id=ac.id)
1000 db.session.add(settings)
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))
1013# ── Shared save logic ─────────────────────────────────────────────────────────
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
1023 start_dt = _parse_datetime(start_raw)
1024 end_dt = _parse_datetime(end_raw)
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 )
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 )
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 )
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 )
1149 hourly_rate, estimated_cost = _compute_cost(start_dt, end_dt, settings, ac)
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)
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()
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 )
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
1211 _log.getLogger(__name__).exception(
1212 "Failed to dispatch reservation request notification"
1213 )
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))
1223# ── Reservation detail + dispatch (Phase 37d) ─────────────────────────────────
1226def _can_dispatch(r: Reservation, user_id: int) -> bool:
1227 return _is_owner_role(user_id) or r.pilot_user_id == user_id
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
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 )
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)
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 )
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 )
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]
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 )
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 )
1351 open_snags = (
1352 Snag.query.filter_by(aircraft_id=ac.id).filter(Snag.resolved_at.is_(None)).all()
1353 )
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
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 )
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."))
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 )
1402 if dispatch_record is None:
1403 dispatch_record = DispatchRecord(reservation_id=r.id)
1404 db.session.add(dispatch_record)
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 )
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 )
1429def _checkout_counter_hint(aircraft_id: int) -> dict[str, float | None]:
1430 from flights.routes import (
1431 _get_counter_hint, # pyright: ignore[reportMissingImports]
1432 )
1434 return _get_counter_hint(aircraft_id)
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 )
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
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))
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))
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)
1483 hourly_rate, _source = _effective_rate(ac, settings)
1484 hourly_rate = hourly_rate or 0.0
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))
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"))
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 )
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)
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 )
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
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."))
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 )
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 )
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 )
1621 return render_template(
1622 "reservations/checkin.html",
1623 aircraft=ac,
1624 reservation=r,
1625 dispatch=dispatch_record,
1626 )
1629# ── Rental charge review / finalization (Phase 37e) ───────────────────────────
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"])
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 )
1655 action = request.form.get("action", "save")
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
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
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."))
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 )
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 )
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"))
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 )
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 )
1747 return render_template(
1748 "reservations/charge.html", aircraft=ac, reservation=r, charge=charge
1749 )
1752# ── Renter self-service account (Phase 37e) ───────────────────────────────────
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
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]
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
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)
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 )
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]
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
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 )