Coverage for app/reservations/routes.py: 100%
802 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""
2Reservations blueprint — aircraft booking calendar, create/edit/cancel,
3owner approval workflow, and per-aircraft booking settings.
4"""
6import calendar
7from datetime import UTC, datetime, time, timedelta
8from datetime import date as _date
9from typing import Any
10from urllib.parse import urlparse
12from expenses.cost_dashboard import ( # pyright: ignore[reportMissingImports]
13 DEFAULT_PERIOD_MONTHS,
14 compute_cost_dashboard,
15)
16from flask import ( # pyright: ignore[reportMissingImports]
17 Blueprint,
18 abort,
19 flash,
20 redirect,
21 render_template,
22 request,
23 session,
24 url_for,
25)
26from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
27from models import ( # pyright: ignore[reportMissingImports]
28 Aircraft,
29 AircraftBookingSettings,
30 MaintenanceDowntime,
31 RateBasis,
32 RateType,
33 Reservation,
34 ReservationStatus,
35 Role,
36 TenantUser,
37 db,
38)
39from utils import ( # pyright: ignore[reportMissingImports]
40 login_required,
41 require_role,
42 user_can_access_aircraft,
43)
45reservations_bp = Blueprint("reservations", __name__)
47_OWNER_ROLES = (Role.ADMIN, Role.OWNER)
48_BOOKING_ROLES = (Role.ADMIN, Role.OWNER, Role.PILOT)
49_DOWNTIME_ROLES = (Role.ADMIN, Role.OWNER, Role.MAINTENANCE)
51# How far the manually-set booking rate may drift from the computed cost
52# dashboard's wet rate before the owner is nudged to review it.
53RATE_DIVERGENCE_WARN_PCT = 0.10
56def _safe_next(next_url: str, fallback: str) -> str:
57 """Return next_url only when it is a safe relative path, otherwise fallback."""
58 next_url = next_url.replace("\\", "")
59 try:
60 parsed = urlparse(next_url)
61 except ValueError:
62 # e.g. "//[" — urlparse rejects malformed IPv6-bracket syntax.
63 return fallback
64 if (
65 next_url
66 and not parsed.scheme
67 and not parsed.netloc
68 and next_url.startswith("/")
69 ):
70 return next_url
71 return fallback
74# ── Helpers ───────────────────────────────────────────────────────────────────
77def _tenant_id() -> int:
78 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
79 if not tu:
80 abort(403) # pragma: no cover
81 return tu.tenant_id
84def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
85 ac = db.session.get(Aircraft, aircraft_id)
86 if (
87 not ac
88 or ac.tenant_id != _tenant_id()
89 or not user_can_access_aircraft(aircraft_id)
90 ):
91 abort(404)
92 return ac
95def _get_reservation_or_404(ac: Aircraft, res_id: int) -> Reservation:
96 r = db.session.get(Reservation, res_id)
97 if not r or r.aircraft_id != ac.id:
98 abort(404)
99 return r
102def _has_conflict(
103 aircraft_id: int,
104 start_dt: datetime,
105 end_dt: datetime,
106 exclude_id: int | None = None,
107) -> bool:
108 """Return True if any confirmed reservation or maintenance downtime
109 overlaps [start_dt, end_dt)."""
110 q = Reservation.query.filter(
111 Reservation.aircraft_id == aircraft_id,
112 Reservation.status == ReservationStatus.CONFIRMED,
113 Reservation.start_dt < end_dt,
114 Reservation.end_dt > start_dt,
115 )
116 if exclude_id is not None:
117 q = q.filter(Reservation.id != exclude_id)
118 if q.first() is not None:
119 return True
120 dq = MaintenanceDowntime.query.filter(
121 MaintenanceDowntime.aircraft_id == aircraft_id,
122 MaintenanceDowntime.start_dt < end_dt,
123 MaintenanceDowntime.end_dt > start_dt,
124 )
125 return dq.first() is not None
128def _parse_datetime(s: str) -> datetime | None:
129 """Parse 'YYYY-MM-DDTHH:MM' (HTML datetime-local) → UTC-aware datetime."""
130 try:
131 return datetime.fromisoformat(s).replace(tzinfo=UTC)
132 except (ValueError, AttributeError):
133 return None
136def _computed_rate(ac: Aircraft) -> float | None:
137 """The cost dashboard's wet rate for this aircraft, or None with no history yet."""
138 return compute_cost_dashboard(ac, DEFAULT_PERIOD_MONTHS)["wet_per_hour"]
141def _effective_rate(
142 ac: Aircraft, settings: AircraftBookingSettings | None
143) -> tuple[float | None, str | None]:
144 """Return (rate, source): the manually-set rate wins when configured;
145 otherwise fall back to the computed cost dashboard rate, if available."""
146 if settings and settings.hourly_rate is not None:
147 return float(settings.hourly_rate), "manual"
148 computed = _computed_rate(ac)
149 if computed is not None:
150 return computed, "computed"
151 return None, None
154def _rate_terms_label(settings: AircraftBookingSettings | None) -> str:
155 """e.g. 'Wet (Engine time)' — falls back to the column defaults when no
156 settings row exists yet, matching AircraftBookingSettings' own defaults.
158 Labels are literal _() calls, not a RateType.LABELS[...] dict lookup —
159 pybabel's static extractor cannot see a translatable string reached
160 through a dynamic key, so a dict-lookup version would silently never
161 get translated in any locale.
162 """
163 rate_type = settings.rate_type if settings else RateType.WET
164 rate_basis = settings.rate_basis if settings else RateBasis.ENGINE_TIME
165 type_label = _("Wet") if rate_type == RateType.WET else _("Dry")
166 basis_label = (
167 _("Engine time") if rate_basis == RateBasis.ENGINE_TIME else _("Flight time")
168 )
169 return f"{type_label} ({basis_label})"
172def _is_owner_role(user_id: int) -> bool:
173 tu = TenantUser.query.filter_by(user_id=user_id).first()
174 return tu is not None and tu.role in _OWNER_ROLES
177def _rental_authorization_policy(tenant_id: int) -> str:
178 from models import TenantProfile # pyright: ignore[reportMissingImports]
180 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first()
181 return profile.rental_authorization_policy if profile else "warn"
184def _grounded_reservation_policy(tenant_id: int) -> str:
185 from models import TenantProfile # pyright: ignore[reportMissingImports]
187 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first()
188 return profile.grounded_reservation_policy if profile else "warn"
191def _has_open_grounding_snag(ac: Aircraft) -> bool:
192 return any(s.is_grounding and s.is_open for s in ac.snags)
195def _renter_authorization_ok(aircraft_id: int, pilot_user_id: int) -> bool:
196 from models import RenterAuthorization # pyright: ignore[reportMissingImports]
198 return RenterAuthorization.valid_for(pilot_user_id, aircraft_id) is not None
201def _rate_divergence_warning(
202 ac: Aircraft, settings: AircraftBookingSettings | None
203) -> str | None:
204 """Warn when the manual rate has drifted from the computed cost-basis rate."""
205 if not settings or settings.hourly_rate is None:
206 return None
207 computed = _computed_rate(ac)
208 if computed is None or computed == 0:
209 return None
210 manual = float(settings.hourly_rate)
211 pct_diff = abs(manual - computed) / computed
212 if pct_diff <= RATE_DIVERGENCE_WARN_PCT:
213 return None
214 return str(
215 _(
216 "Your manual rate (%(manual)s EUR/h) differs from the computed "
217 "cost-dashboard rate (%(computed)s EUR/h) by more than %(pct)s%% — "
218 "consider reviewing it.",
219 manual=f"{manual:.2f}",
220 computed=f"{computed:.2f}",
221 pct=int(RATE_DIVERGENCE_WARN_PCT * 100),
222 )
223 )
226def _chargeable_days(start_dt: datetime, end_dt: datetime) -> int:
227 """Number of distinct calendar dates touched by the half-open interval
228 [start_dt, end_dt) (tenant-local = UTC dates; the app runs in UTC).
230 A booking ending exactly at midnight does not touch that calendar date
231 (the interval is half-open), so e.g. 23:00 day1 → 00:00 day2 touches
232 only day1, while 23:00 day1 → 00:01 day2 touches both.
233 """
234 last_day = end_dt.date()
235 if end_dt.time() == time(0, 0):
236 last_day -= timedelta(days=1)
237 return (last_day - start_dt.date()).days + 1
240def _estimated_hours(
241 start_dt: datetime, end_dt: datetime, settings: AircraftBookingSettings | None
242) -> float:
243 """Wall-clock duration, floored at chargeable_days × min_hours_per_day
244 when that per-aircraft minimum is configured (standard multi-day rental
245 practice: a booking spanning N calendar days bills at least N days'
246 worth of minimum hours, even if the wall-clock duration is shorter)."""
247 wall_clock_hours = (end_dt - start_dt).total_seconds() / 3600
248 if settings and settings.min_hours_per_day:
249 floor_hours = _chargeable_days(start_dt, end_dt) * float(
250 settings.min_hours_per_day
251 )
252 return max(wall_clock_hours, floor_hours)
253 return wall_clock_hours
256def _compute_cost(
257 start_dt: datetime,
258 end_dt: datetime,
259 settings: AircraftBookingSettings | None,
260 ac: Aircraft,
261) -> tuple[float | None, float | None]:
262 """Return (hourly_rate, estimated_cost) or (None, None) if no rate available."""
263 rate, _source = _effective_rate(ac, settings)
264 if rate is None:
265 return None, None
266 hours = _estimated_hours(start_dt, end_dt, settings)
267 return rate, round(rate * hours, 2)
270def _build_calendar_grid(year: int, month: int):
271 """Return a list of weeks; each week is a list of date objects (Mon–Sun).
272 Days outside the month are included to complete the grid."""
273 cal = calendar.Calendar(firstweekday=0) # Monday first
274 return cal.monthdatescalendar(year, month)
277# ── Fleet reservations overview (admin/owner) ─────────────────────────────────
280@reservations_bp.route("/reservations/fleet/")
281@login_required
282@require_role(*_OWNER_ROLES)
283def fleet_reservations():
284 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
285 if not tu:
286 abort(403) # pragma: no cover
288 role = tu.role
289 from utils import accessible_aircraft # pyright: ignore[reportMissingImports]
291 aircraft_qs = accessible_aircraft(tu.tenant_id)
292 if role == Role.OWNER:
293 # Owners only see planes they explicitly have access to
294 from models import ( # pyright: ignore[reportMissingImports]
295 UserAircraftAccess,
296 UserAllAircraftAccess,
297 )
299 all_access = UserAllAircraftAccess.query.filter_by(user_id=tu.user_id).first()
300 if not all_access:
301 owned_ids = [
302 r.aircraft_id
303 for r in UserAircraftAccess.query.filter_by(user_id=tu.user_id).all()
304 ]
305 aircraft_qs = aircraft_qs.filter(Aircraft.id.in_(owned_ids))
307 aircraft_list = aircraft_qs.order_by(Aircraft.registration).all()
308 aircraft_ids = [a.id for a in aircraft_list]
310 now = datetime.now(UTC)
311 expired_cutoff = now - timedelta(days=60)
313 reservations = (
314 (
315 Reservation.query.filter(
316 Reservation.aircraft_id.in_(aircraft_ids),
317 # Exclude expired-pending older than 60 days — they're just noise
318 db.or_(
319 Reservation.status != ReservationStatus.PENDING,
320 Reservation.start_dt >= expired_cutoff,
321 ),
322 )
323 .order_by(Reservation.start_dt)
324 .all()
325 )
326 if aircraft_ids
327 else []
328 )
330 # SQLite returns naive datetimes even for DateTime(timezone=True) columns;
331 # PostgreSQL returns timezone-aware. Normalize `now` to match so that
332 # Python comparisons and Jinja2 template filters stay compatible with both.
333 if reservations and reservations[0].start_dt.tzinfo is None:
334 now = now.replace(tzinfo=None)
336 # Detect overlapping confirmed reservations per aircraft
337 overlapping_ids: set[int] = set()
338 from itertools import combinations
340 confirmed = [r for r in reservations if r.status == ReservationStatus.CONFIRMED]
341 by_aircraft: dict[int, list] = {}
342 for r in confirmed:
343 by_aircraft.setdefault(r.aircraft_id, []).append(r)
344 for group in by_aircraft.values():
345 for r1, r2 in combinations(group, 2):
346 if r1.start_dt < r2.end_dt and r1.end_dt > r2.start_dt:
347 overlapping_ids.add(r1.id)
348 overlapping_ids.add(r2.id)
350 # Find past confirmed reservations with no flight logged on that aircraft/date
351 from models import Flight # pyright: ignore[reportMissingImports]
353 missing_flight_ids: set[int] = set()
354 for r in reservations:
355 if r.status == ReservationStatus.CONFIRMED and r.end_dt <= now:
356 start_date = r.start_dt.date()
357 end_date = r.end_dt.date()
358 has_flight = (
359 Flight.query.filter(
360 Flight.aircraft_id == r.aircraft_id,
361 Flight.date >= start_date,
362 Flight.date <= end_date,
363 ).first()
364 is not None
365 )
366 if not has_flight:
367 missing_flight_ids.add(r.id)
369 aircraft_map = {a.id: a for a in aircraft_list}
371 return render_template(
372 "reservations/fleet.html",
373 reservations=reservations,
374 aircraft_map=aircraft_map,
375 overlapping_ids=overlapping_ids,
376 missing_flight_ids=missing_flight_ids,
377 now=now,
378 ReservationStatus=ReservationStatus,
379 )
382# ── Calendar view ─────────────────────────────────────────────────────────────
385@reservations_bp.route("/aircraft/<aircraft_ref:aircraft_id>/reservations/")
386@login_required
387def calendar_view(aircraft_id: int):
388 ac = _get_aircraft_or_404(aircraft_id)
389 today = datetime.now(UTC).date()
391 try:
392 year = int(request.args.get("year", today.year))
393 month = int(request.args.get("month", today.month))
394 except ValueError:
395 year, month = today.year, today.month
397 # Clamp to valid range
398 if month < 1:
399 year -= 1
400 month = 12
401 if month > 12:
402 year += 1
403 month = 1
405 # Month boundaries in UTC
406 month_start = datetime(year, month, 1, tzinfo=UTC)
407 last_day = calendar.monthrange(year, month)[1]
408 month_end = datetime(year, month, last_day, 23, 59, 59, tzinfo=UTC)
410 reservations = (
411 Reservation.query.filter(
412 Reservation.aircraft_id == ac.id,
413 Reservation.start_dt <= month_end,
414 Reservation.end_dt >= month_start,
415 )
416 .order_by(Reservation.start_dt)
417 .all()
418 )
420 downtimes = (
421 MaintenanceDowntime.query.filter(
422 MaintenanceDowntime.aircraft_id == ac.id,
423 MaintenanceDowntime.start_dt <= month_end,
424 MaintenanceDowntime.end_dt >= month_start,
425 )
426 .order_by(MaintenanceDowntime.start_dt)
427 .all()
428 )
430 # Build a dict day → list of reservations for fast template lookup
431 from collections import defaultdict
433 day_reservations: dict = defaultdict(list)
434 for r in reservations:
435 # A reservation may span multiple days — add it to each day it touches
436 cur = r.start_dt.date()
437 end = r.end_dt.date()
438 while cur <= end:
439 day_reservations[cur].append(r)
440 cur += timedelta(days=1)
442 day_downtimes: dict = defaultdict(list)
443 for d in downtimes:
444 cur = d.start_dt.date()
445 end = d.end_dt.date()
446 while cur <= end:
447 day_downtimes[cur].append(d)
448 cur += timedelta(days=1)
450 # Prev / next month navigation
451 prev_month = month - 1 or 12
452 prev_year = year - 1 if month == 1 else year
453 next_month = month % 12 + 1
454 next_year = year + 1 if month == 12 else year
456 weeks = _build_calendar_grid(year, month)
458 return render_template(
459 "reservations/calendar.html",
460 aircraft=ac,
461 weeks=weeks,
462 day_reservations=day_reservations,
463 day_downtimes=day_downtimes,
464 all_downtimes=downtimes,
465 year=year,
466 month=month,
467 month_name=datetime(year, month, 1).strftime("%B %Y"),
468 today=today,
469 prev_year=prev_year,
470 prev_month=prev_month,
471 next_year=next_year,
472 next_month=next_month,
473 ReservationStatus=ReservationStatus,
474 )
477# ── Maintenance downtime (Phase 37f) ──────────────────────────────────────────
480def _get_downtime_or_404(ac: Aircraft, downtime_id: int) -> MaintenanceDowntime:
481 d = db.session.get(MaintenanceDowntime, downtime_id)
482 if not d or d.aircraft_id != ac.id:
483 abort(404)
484 return d
487def _save_downtime(ac: Aircraft, d: MaintenanceDowntime | None):
488 start_raw = request.form.get("start_dt", "").strip()
489 end_raw = request.form.get("end_dt", "").strip()
490 reason = request.form.get("reason", "").strip() or None
491 start_dt = _parse_datetime(start_raw)
492 end_dt = _parse_datetime(end_raw)
494 errors = []
495 if not start_dt:
496 errors.append(_("Start date/time is required."))
497 if not end_dt:
498 errors.append(_("End date/time is required."))
499 if start_dt and end_dt and end_dt <= start_dt:
500 errors.append(_("End must be after start."))
502 if errors:
503 for msg in errors:
504 flash(msg, "danger")
505 return render_template(
506 "reservations/downtime_form.html", aircraft=ac, downtime=d, conflicts=[]
507 )
509 assert start_dt is not None and end_dt is not None
510 conflicting = (
511 Reservation.query.filter(
512 Reservation.aircraft_id == ac.id,
513 Reservation.status == ReservationStatus.CONFIRMED,
514 Reservation.start_dt < end_dt,
515 Reservation.end_dt > start_dt,
516 )
517 .order_by(Reservation.start_dt)
518 .all()
519 )
520 if conflicting and not request.form.get("confirm_conflicts"):
521 return render_template(
522 "reservations/downtime_form.html",
523 aircraft=ac,
524 downtime=d,
525 conflicts=conflicting,
526 )
528 is_new = d is None
529 if d is None:
530 d = MaintenanceDowntime(
531 aircraft_id=ac.id, created_by_id=int(session["user_id"])
532 )
533 db.session.add(d)
534 d.start_dt = start_dt
535 d.end_dt = end_dt
536 d.reason = reason
537 db.session.commit()
538 flash(
539 _("Downtime saved.") if is_new else _("Downtime updated."),
540 "success",
541 )
542 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
545@reservations_bp.route(
546 "/aircraft/<aircraft_ref:aircraft_id>/downtimes/new", methods=["GET", "POST"]
547)
548@login_required
549@require_role(*_DOWNTIME_ROLES)
550def downtime_new(aircraft_id: int):
551 ac = _get_aircraft_or_404(aircraft_id)
552 if request.method == "POST":
553 return _save_downtime(ac, None)
554 return render_template(
555 "reservations/downtime_form.html", aircraft=ac, downtime=None, conflicts=[]
556 )
559@reservations_bp.route(
560 "/aircraft/<aircraft_ref:aircraft_id>/downtimes/<int:downtime_id>/edit",
561 methods=["GET", "POST"],
562)
563@login_required
564@require_role(*_DOWNTIME_ROLES)
565def downtime_edit(aircraft_id: int, downtime_id: int):
566 ac = _get_aircraft_or_404(aircraft_id)
567 d = _get_downtime_or_404(ac, downtime_id)
568 if request.method == "POST":
569 return _save_downtime(ac, d)
570 return render_template(
571 "reservations/downtime_form.html", aircraft=ac, downtime=d, conflicts=[]
572 )
575@reservations_bp.route(
576 "/aircraft/<aircraft_ref:aircraft_id>/downtimes/<int:downtime_id>/delete",
577 methods=["POST"],
578)
579@login_required
580@require_role(*_DOWNTIME_ROLES)
581def downtime_delete(aircraft_id: int, downtime_id: int):
582 ac = _get_aircraft_or_404(aircraft_id)
583 d = _get_downtime_or_404(ac, downtime_id)
584 db.session.delete(d)
585 db.session.commit()
586 flash(_("Downtime removed."), "success")
587 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
590# ── Create reservation ────────────────────────────────────────────────────────
593@reservations_bp.route(
594 "/aircraft/<aircraft_ref:aircraft_id>/reservations/new", methods=["GET", "POST"]
595)
596@login_required
597@require_role(*_BOOKING_ROLES)
598def new_reservation(aircraft_id: int):
599 ac = _get_aircraft_or_404(aircraft_id)
600 if ac.is_archived:
601 abort(404)
602 settings = ac.booking_settings
603 if request.method == "POST":
604 return _save_reservation(ac, None, settings)
605 # Pre-fill start from query string (clicked day on calendar)
606 prefill_start = request.args.get("date", "")
607 effective_rate, rate_source = _effective_rate(ac, settings)
608 uid = int(session["user_id"])
609 renter_auth_blocked = (
610 not _is_owner_role(uid)
611 and _rental_authorization_policy(ac.tenant_id) == "block"
612 and not _renter_authorization_ok(ac.id, uid)
613 )
614 aircraft_grounded = _has_open_grounding_snag(ac)
615 grounded_blocked = (
616 aircraft_grounded
617 and not _is_owner_role(uid)
618 and _grounded_reservation_policy(ac.tenant_id) == "block"
619 )
620 return render_template(
621 "reservations/form.html",
622 aircraft=ac,
623 reservation=None,
624 settings=settings,
625 prefill_start=prefill_start,
626 effective_rate=effective_rate,
627 rate_source=rate_source,
628 rate_terms_label=_rate_terms_label(settings),
629 renter_auth_blocked=renter_auth_blocked,
630 aircraft_grounded=aircraft_grounded,
631 grounded_blocked=grounded_blocked,
632 )
635# ── Edit reservation ──────────────────────────────────────────────────────────
638@reservations_bp.route(
639 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/edit",
640 methods=["GET", "POST"],
641)
642@login_required
643def edit_reservation(aircraft_id: int, res_id: int):
644 ac = _get_aircraft_or_404(aircraft_id)
645 r = _get_reservation_or_404(ac, res_id)
647 # Pilots may only edit their own pending reservations
648 role = TenantUser.query.filter_by(user_id=session["user_id"]).first()
649 user_role = role.role if role else None
650 is_owner_role = user_role in _OWNER_ROLES
651 if not is_owner_role and (
652 r.pilot_user_id != session["user_id"] or r.status != ReservationStatus.PENDING
653 ):
654 abort(403)
656 settings = ac.booking_settings
657 if request.method == "POST":
658 return _save_reservation(ac, r, settings)
659 effective_rate, rate_source = _effective_rate(ac, settings)
660 return render_template(
661 "reservations/form.html",
662 aircraft=ac,
663 reservation=r,
664 settings=settings,
665 prefill_start="",
666 effective_rate=effective_rate,
667 rate_source=rate_source,
668 rate_terms_label=_rate_terms_label(settings),
669 renter_auth_blocked=False,
670 aircraft_grounded=_has_open_grounding_snag(ac),
671 grounded_blocked=False,
672 )
675# ── Cancel reservation ────────────────────────────────────────────────────────
678@reservations_bp.route(
679 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/cancel",
680 methods=["POST"],
681)
682@login_required
683def cancel_reservation(aircraft_id: int, res_id: int):
684 ac = _get_aircraft_or_404(aircraft_id)
685 r = _get_reservation_or_404(ac, res_id)
687 role = TenantUser.query.filter_by(user_id=session["user_id"]).first()
688 user_role = role.role if role else None
689 is_owner_role = user_role in _OWNER_ROLES
690 if not is_owner_role and r.pilot_user_id != session["user_id"]:
691 abort(403)
693 if r.status == ReservationStatus.CANCELLED:
694 flash(_("Reservation is already cancelled."), "warning")
695 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
697 if r.dispatch is not None and r.dispatch.is_checked_out:
698 flash(
699 _("Cannot cancel: this reservation has already been checked out."),
700 "danger",
701 )
702 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
704 r.status = ReservationStatus.CANCELLED
705 db.session.commit()
706 if r.pilot_user_id:
707 try:
708 from flask_babel import (
709 lazy_gettext as _l, # pyright: ignore[reportMissingImports]
710 )
711 from models import NotificationType # pyright: ignore[reportMissingImports]
712 from services.notification_service import (
713 dispatch, # pyright: ignore[reportMissingImports]
714 )
716 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
717 if tu:
718 dispatch(
719 NotificationType.RESERVATION_CANCELLED,
720 tu.tenant_id,
721 {
722 "subject_key": _l("Reservation cancelled — %(reg)s"),
723 "subject_args": {"reg": ac.registration},
724 "notification_title_key": _l("Reservation cancelled: %(reg)s"),
725 "notification_title_args": {"reg": ac.registration},
726 "notification_message_key": _l(
727 "Your reservation for %(reg)s from %(start)s to "
728 "%(end)s UTC has been cancelled."
729 ),
730 "notification_message_args": {
731 "reg": ac.registration,
732 "start": r.start_dt.strftime("%Y-%m-%d %H:%M"),
733 "end": r.end_dt.strftime("%Y-%m-%d %H:%M"),
734 },
735 "details": [
736 (_l("Aircraft"), ac.registration),
737 (_l("Start"), r.start_dt.strftime("%Y-%m-%d %H:%M UTC")),
738 (_l("End"), r.end_dt.strftime("%Y-%m-%d %H:%M UTC")),
739 ],
740 },
741 target_user_ids=[r.pilot_user_id],
742 )
743 except Exception:
744 import logging as _log
746 _log.getLogger(__name__).exception(
747 "Failed to dispatch reservation cancelled notification"
748 )
749 flash(_("Reservation cancelled."), "success")
750 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
753# ── Confirm / decline (owner only) ───────────────────────────────────────────
756@reservations_bp.route(
757 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/confirm",
758 methods=["POST"],
759)
760@login_required
761@require_role(*_OWNER_ROLES)
762def confirm_reservation(aircraft_id: int, res_id: int):
763 ac = _get_aircraft_or_404(aircraft_id)
764 r = _get_reservation_or_404(ac, res_id)
765 _next = request.form.get("next", "")
766 _fallback = url_for("reservations.calendar_view", aircraft_id=ac.id)
767 _dest = _safe_next(_next, _fallback)
769 if r.status != ReservationStatus.PENDING:
770 flash(_("Only pending reservations can be confirmed."), "warning")
771 return redirect(_dest)
773 if _has_conflict(ac.id, r.start_dt, r.end_dt, exclude_id=r.id):
774 flash(
775 _(
776 "Cannot confirm: overlaps an existing confirmed reservation "
777 "or maintenance downtime."
778 ),
779 "danger",
780 )
781 return redirect(_dest)
783 if (
784 r.pilot_user_id
785 and not _is_owner_role(r.pilot_user_id)
786 and _rental_authorization_policy(ac.tenant_id) == "block"
787 and not _renter_authorization_ok(ac.id, r.pilot_user_id)
788 ):
789 flash(
790 _(
791 "Cannot confirm: this renter does not have a valid rental "
792 "authorization for this aircraft."
793 ),
794 "danger",
795 )
796 return redirect(_dest)
798 if (
799 r.pilot_user_id
800 and not _is_owner_role(r.pilot_user_id)
801 and _grounded_reservation_policy(ac.tenant_id) == "block"
802 and _has_open_grounding_snag(ac)
803 ):
804 flash(
805 _("Cannot confirm: this aircraft is currently grounded."),
806 "danger",
807 )
808 return redirect(_dest)
810 r.status = ReservationStatus.CONFIRMED
811 db.session.commit()
812 if _has_open_grounding_snag(ac):
813 flash(
814 _(
815 "Aircraft is currently grounded — reservation confirmed, but "
816 "verify airworthiness status before dispatch."
817 ),
818 "warning",
819 )
820 if r.pilot_user_id:
821 try:
822 from flask_babel import (
823 lazy_gettext as _l, # pyright: ignore[reportMissingImports]
824 )
825 from models import NotificationType # pyright: ignore[reportMissingImports]
826 from services.notification_service import (
827 dispatch, # pyright: ignore[reportMissingImports]
828 )
830 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
831 if tu:
832 dispatch(
833 NotificationType.RESERVATION_CONFIRMED,
834 tu.tenant_id,
835 {
836 "subject_key": _l("Reservation confirmed — %(reg)s"),
837 "subject_args": {"reg": ac.registration},
838 "notification_title_key": _l("Reservation confirmed: %(reg)s"),
839 "notification_title_args": {"reg": ac.registration},
840 "notification_message_key": _l(
841 "Your reservation for %(reg)s from %(start)s to "
842 "%(end)s UTC has been confirmed."
843 ),
844 "notification_message_args": {
845 "reg": ac.registration,
846 "start": r.start_dt.strftime("%Y-%m-%d %H:%M"),
847 "end": r.end_dt.strftime("%Y-%m-%d %H:%M"),
848 },
849 "details": [
850 (_l("Aircraft"), ac.registration),
851 (_l("Start"), r.start_dt.strftime("%Y-%m-%d %H:%M UTC")),
852 (_l("End"), r.end_dt.strftime("%Y-%m-%d %H:%M UTC")),
853 ],
854 },
855 target_user_ids=[r.pilot_user_id],
856 )
857 except Exception:
858 import logging as _log
860 _log.getLogger(__name__).exception(
861 "Failed to dispatch reservation confirmed notification"
862 )
863 flash(_("Reservation confirmed."), "success")
864 return redirect(_dest)
867@reservations_bp.route(
868 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/decline",
869 methods=["POST"],
870)
871@login_required
872@require_role(*_OWNER_ROLES)
873def decline_reservation(aircraft_id: int, res_id: int):
874 ac = _get_aircraft_or_404(aircraft_id)
875 r = _get_reservation_or_404(ac, res_id)
876 _next = request.form.get("next", "")
877 _fallback = url_for("reservations.calendar_view", aircraft_id=ac.id)
878 _dest = _safe_next(_next, _fallback)
880 if r.status != ReservationStatus.PENDING:
881 flash(_("Only pending reservations can be declined."), "warning")
882 return redirect(_dest)
884 r.status = ReservationStatus.CANCELLED
885 db.session.commit()
886 flash(_("Reservation declined."), "success")
887 return redirect(_dest)
890# ── Booking settings (owner only) ─────────────────────────────────────────────
893@reservations_bp.route(
894 "/aircraft/<aircraft_ref:aircraft_id>/reservations/settings",
895 methods=["GET", "POST"],
896)
897@login_required
898@require_role(*_OWNER_ROLES)
899def booking_settings(aircraft_id: int):
900 ac = _get_aircraft_or_404(aircraft_id)
901 settings = ac.booking_settings
903 if request.method == "POST":
904 return _save_booking_settings(ac, settings)
906 return render_template(
907 "reservations/settings.html",
908 aircraft=ac,
909 settings=settings,
910 computed_rate=_computed_rate(ac),
911 rate_warning=_rate_divergence_warning(ac, settings),
912 )
915def _save_booking_settings(ac: Aircraft, settings: AircraftBookingSettings | None):
916 def _float_or_none(key: str) -> float | None:
917 val = request.form.get(key, "").strip()
918 try:
919 return float(val) if val else None
920 except ValueError:
921 return None
923 min_h = _float_or_none("min_booking_hours")
924 max_h = _float_or_none("max_booking_hours")
925 rate = _float_or_none("hourly_rate")
926 min_per_day = _float_or_none("min_hours_per_day")
927 # A real <select>/<radio> form always submits one of the valid values;
928 # a blank submission (e.g. an old cached form, or a direct API call that
929 # omits the field) falls back to the model's own default rather than
930 # being rejected — only a genuinely tampered, non-empty value is invalid.
931 rate_basis = request.form.get("rate_basis", "").strip() or RateBasis.ENGINE_TIME
932 rate_type = request.form.get("rate_type", "").strip() or RateType.WET
934 errors = []
935 if min_h is not None and min_h <= 0:
936 errors.append(_("Minimum booking duration must be positive."))
937 if max_h is not None and max_h <= 0:
938 errors.append(_("Maximum booking duration must be positive."))
939 if min_h is not None and max_h is not None and min_h > max_h:
940 errors.append(_("Minimum duration cannot exceed maximum duration."))
941 if rate is not None and rate < 0:
942 errors.append(_("Hourly rate cannot be negative."))
943 if min_per_day is not None and min_per_day <= 0:
944 errors.append(_("Minimum billed hours per day must be positive."))
945 if rate_basis not in RateBasis.ALL:
946 errors.append(_("Invalid rate basis selected."))
947 if rate_type not in RateType.ALL:
948 errors.append(_("Invalid rate type selected."))
950 if errors:
951 for msg in errors:
952 flash(msg, "danger")
953 return render_template(
954 "reservations/settings.html",
955 aircraft=ac,
956 settings=settings,
957 computed_rate=_computed_rate(ac),
958 rate_warning=_rate_divergence_warning(ac, settings),
959 )
961 if settings is None:
962 settings = AircraftBookingSettings(aircraft_id=ac.id)
963 db.session.add(settings)
965 settings.min_booking_hours = min_h
966 settings.max_booking_hours = max_h
967 settings.hourly_rate = rate
968 settings.min_hours_per_day = min_per_day
969 settings.rate_basis = rate_basis
970 settings.rate_type = rate_type
971 db.session.commit()
972 flash(_("Booking settings saved."), "success")
973 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
976# ── Shared save logic ─────────────────────────────────────────────────────────
979def _save_reservation(
980 ac: Aircraft, r: Reservation | None, settings: AircraftBookingSettings | None
981):
982 start_raw = request.form.get("start_dt", "").strip()
983 end_raw = request.form.get("end_dt", "").strip()
984 notes = request.form.get("notes", "").strip() or None
986 start_dt = _parse_datetime(start_raw)
987 end_dt = _parse_datetime(end_raw)
989 # Renter authorization guard — only for a brand-new booking made by a
990 # non-owner renter (an owner/admin booking on someone's behalf, or
991 # editing an existing pending reservation, is out of scope here).
992 uid = int(session["user_id"])
993 renter_auth_warning: str | None = None
994 if r is None and not _is_owner_role(uid):
995 policy = _rental_authorization_policy(ac.tenant_id)
996 if policy != "off" and not _renter_authorization_ok(ac.id, uid):
997 if policy == "block":
998 flash(
999 _(
1000 "You do not have a valid rental authorization for this "
1001 "aircraft. Contact the owner to be authorized before booking."
1002 ),
1003 "danger",
1004 )
1005 effective_rate, rate_source = _effective_rate(ac, settings)
1006 return render_template(
1007 "reservations/form.html",
1008 aircraft=ac,
1009 reservation=None,
1010 settings=settings,
1011 prefill_start="",
1012 effective_rate=effective_rate,
1013 rate_source=rate_source,
1014 rate_terms_label=_rate_terms_label(settings),
1015 renter_auth_blocked=True,
1016 aircraft_grounded=_has_open_grounding_snag(ac),
1017 grounded_blocked=False,
1018 )
1019 renter_auth_warning = str(
1020 _(
1021 "You do not have a valid rental authorization for this "
1022 "aircraft yet — your booking request was submitted, but "
1023 "check with the owner before flying."
1024 )
1025 )
1027 # Grounded-aircraft guard — same brand-new-booking scope as the renter
1028 # authorization guard above. Owners always get warn-level at most.
1029 grounded_warning: str | None = None
1030 if r is None and _has_open_grounding_snag(ac):
1031 if (
1032 not _is_owner_role(uid)
1033 and _grounded_reservation_policy(ac.tenant_id) == "block"
1034 ):
1035 flash(
1036 _(
1037 "This aircraft is currently grounded and this hangar "
1038 "requires bookings to be blocked while grounded. Contact "
1039 "the owner."
1040 ),
1041 "danger",
1042 )
1043 effective_rate, rate_source = _effective_rate(ac, settings)
1044 return render_template(
1045 "reservations/form.html",
1046 aircraft=ac,
1047 reservation=None,
1048 settings=settings,
1049 prefill_start="",
1050 effective_rate=effective_rate,
1051 rate_source=rate_source,
1052 rate_terms_label=_rate_terms_label(settings),
1053 renter_auth_blocked=False,
1054 aircraft_grounded=True,
1055 grounded_blocked=True,
1056 )
1057 grounded_warning = str(
1058 _(
1059 "This aircraft currently has an open grounding snag — your "
1060 "booking was submitted, but check its status before flying."
1061 )
1062 )
1064 errors = []
1065 if not start_dt:
1066 errors.append(_("Start date/time is required."))
1067 if not end_dt:
1068 errors.append(_("End date/time is required."))
1069 if start_dt and end_dt:
1070 if end_dt <= start_dt:
1071 errors.append(_("End must be after start."))
1072 else:
1073 duration = (end_dt - start_dt).total_seconds() / 3600
1074 if settings:
1075 if settings.min_booking_hours and duration < float(
1076 settings.min_booking_hours
1077 ):
1078 errors.append(
1079 _(
1080 "Minimum booking duration is %(h)s h.",
1081 h=settings.min_booking_hours,
1082 )
1083 )
1084 if settings.max_booking_hours and duration > float(
1085 settings.max_booking_hours
1086 ):
1087 errors.append(
1088 _(
1089 "Maximum booking duration is %(h)s h.",
1090 h=settings.max_booking_hours,
1091 )
1092 )
1094 if errors:
1095 for msg in errors:
1096 flash(msg, "danger")
1097 effective_rate, rate_source = _effective_rate(ac, settings)
1098 return render_template(
1099 "reservations/form.html",
1100 aircraft=ac,
1101 reservation=r,
1102 settings=settings,
1103 prefill_start="",
1104 effective_rate=effective_rate,
1105 rate_source=rate_source,
1106 rate_terms_label=_rate_terms_label(settings),
1107 renter_auth_blocked=False,
1108 aircraft_grounded=_has_open_grounding_snag(ac),
1109 grounded_blocked=False,
1110 )
1112 hourly_rate, estimated_cost = _compute_cost(start_dt, end_dt, settings, ac)
1114 _is_new_reservation = r is None
1115 if r is None:
1116 r = Reservation(
1117 aircraft_id=ac.id,
1118 pilot_user_id=session["user_id"],
1119 status=ReservationStatus.PENDING,
1120 )
1121 db.session.add(r)
1123 r.start_dt = start_dt
1124 r.end_dt = end_dt
1125 r.notes = notes
1126 r.hourly_rate = hourly_rate
1127 r.estimated_cost = estimated_cost
1128 db.session.commit()
1130 if _is_new_reservation:
1131 try:
1132 from flask_babel import (
1133 lazy_gettext as _l, # pyright: ignore[reportMissingImports]
1134 )
1135 from models import NotificationType # pyright: ignore[reportMissingImports]
1136 from services.notification_service import (
1137 dispatch, # pyright: ignore[reportMissingImports]
1138 )
1140 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
1141 if tu:
1142 details = [
1143 (_l("Aircraft"), ac.registration),
1144 (_l("Start"), r.start_dt.strftime("%Y-%m-%d %H:%M UTC")),
1145 (_l("End"), r.end_dt.strftime("%Y-%m-%d %H:%M UTC")),
1146 ]
1147 if renter_auth_warning:
1148 details.append(
1149 (_l("Note"), _l("No valid rental authorization on file"))
1150 )
1151 dispatch(
1152 NotificationType.RESERVATION_REQUEST,
1153 tu.tenant_id,
1154 {
1155 "subject_key": _l("New booking request — %(reg)s"),
1156 "subject_args": {"reg": ac.registration},
1157 "notification_title_key": _l("New booking request: %(reg)s"),
1158 "notification_title_args": {"reg": ac.registration},
1159 "notification_message_key": _l(
1160 "A new booking request was submitted for %(reg)s "
1161 "from %(start)s to %(end)s UTC."
1162 ),
1163 "notification_message_args": {
1164 "reg": ac.registration,
1165 "start": r.start_dt.strftime("%Y-%m-%d %H:%M"),
1166 "end": r.end_dt.strftime("%Y-%m-%d %H:%M"),
1167 },
1168 "details": details,
1169 },
1170 )
1171 except Exception:
1172 import logging as _log
1174 _log.getLogger(__name__).exception(
1175 "Failed to dispatch reservation request notification"
1176 )
1178 if renter_auth_warning:
1179 flash(renter_auth_warning, "warning")
1180 if grounded_warning:
1181 flash(grounded_warning, "warning")
1182 flash(_("Reservation saved."), "success")
1183 return redirect(url_for("reservations.calendar_view", aircraft_id=ac.id))
1186# ── Reservation detail + dispatch (Phase 37d) ─────────────────────────────────
1189def _can_dispatch(r: Reservation, user_id: int) -> bool:
1190 return _is_owner_role(user_id) or r.pilot_user_id == user_id
1193def _discrepancy_warning(ac: Aircraft, r: Reservation) -> str | None:
1194 """Compare the dispatch counter delta against the sum of linked
1195 flight-entry counter deltas; return a warning naming both figures when
1196 they differ, or None when they match (or there isn't enough data)."""
1197 d = r.dispatch
1198 if d is None or not d.is_checked_in:
1199 return None
1201 parts = []
1202 if d.out_flight_counter is not None and d.in_flight_counter is not None:
1203 dispatch_delta = float(d.in_flight_counter) - float(d.out_flight_counter)
1204 flights_sum = sum(
1205 float(fe.flight_time_counter_end) - float(fe.flight_time_counter_start)
1206 for fe in r.flights
1207 if fe.flight_time_counter_end is not None
1208 and fe.flight_time_counter_start is not None
1209 )
1210 if round(dispatch_delta, 1) != round(flights_sum, 1):
1211 parts.append(
1212 str(
1213 _(
1214 "flight time: dispatch shows %(d)s h, logged flights show %(f)s h",
1215 d=f"{dispatch_delta:.1f}",
1216 f=f"{flights_sum:.1f}",
1217 )
1218 )
1219 )
1220 if d.out_engine_counter is not None and d.in_engine_counter is not None:
1221 dispatch_delta = float(d.in_engine_counter) - float(d.out_engine_counter)
1222 flights_sum = sum(
1223 float(fe.engine_time_counter_end) - float(fe.engine_time_counter_start)
1224 for fe in r.flights
1225 if fe.engine_time_counter_end is not None
1226 and fe.engine_time_counter_start is not None
1227 )
1228 if round(dispatch_delta, 1) != round(flights_sum, 1):
1229 parts.append(
1230 str(
1231 _(
1232 "engine time: dispatch shows %(d)s h, logged flights show %(f)s h",
1233 d=f"{dispatch_delta:.1f}",
1234 f=f"{flights_sum:.1f}",
1235 )
1236 )
1237 )
1238 if not parts:
1239 return None
1240 return str(
1241 _(
1242 "Dispatch/logbook discrepancy — %(details)s.",
1243 details="; ".join(parts),
1244 )
1245 )
1248@reservations_bp.route("/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>")
1249@login_required
1250def reservation_detail(aircraft_id: int, res_id: int):
1251 ac = _get_aircraft_or_404(aircraft_id)
1252 r = _get_reservation_or_404(ac, res_id)
1253 uid = int(session["user_id"])
1254 is_owner = _is_owner_role(uid)
1255 if not is_owner and r.pilot_user_id != uid:
1256 abort(403)
1258 today_start = datetime.combine(
1259 r.start_dt.astimezone(UTC).date(), time.min, tzinfo=UTC
1260 )
1261 can_checkout = (
1262 _can_dispatch(r, uid)
1263 and r.status == ReservationStatus.CONFIRMED
1264 and (r.dispatch is None or not r.dispatch.is_checked_out)
1265 and datetime.now(UTC) >= today_start
1266 )
1267 can_checkin = (
1268 _can_dispatch(r, uid)
1269 and r.dispatch is not None
1270 and r.dispatch.is_checked_out
1271 and not r.dispatch.is_checked_in
1272 )
1274 return render_template(
1275 "reservations/detail.html",
1276 aircraft=ac,
1277 reservation=r,
1278 dispatch=r.dispatch,
1279 is_owner=is_owner,
1280 can_checkout=can_checkout,
1281 can_checkin=can_checkin,
1282 discrepancy_warning=_discrepancy_warning(ac, r),
1283 )
1286@reservations_bp.route(
1287 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/checkout",
1288 methods=["GET", "POST"],
1289)
1290@login_required
1291def checkout(aircraft_id: int, res_id: int):
1292 from models import DispatchRecord, Snag # pyright: ignore[reportMissingImports]
1294 ac = _get_aircraft_or_404(aircraft_id)
1295 r = _get_reservation_or_404(ac, res_id)
1296 uid = int(session["user_id"])
1297 if not _can_dispatch(r, uid):
1298 abort(403)
1299 if r.status != ReservationStatus.CONFIRMED:
1300 flash(_("Only confirmed reservations can be checked out."), "danger")
1301 return redirect(
1302 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1303 )
1305 dispatch_record = r.dispatch
1306 if dispatch_record is not None and dispatch_record.is_checked_out:
1307 flash(_("This reservation has already been checked out."), "warning")
1308 return redirect(
1309 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1310 )
1312 open_snags = (
1313 Snag.query.filter_by(aircraft_id=ac.id).filter(Snag.resolved_at.is_(None)).all()
1314 )
1316 if request.method == "POST":
1317 walkaround_ok = bool(request.form.get("walkaround_ok"))
1318 snags_acknowledged = bool(request.form.get("snags_acknowledged"))
1319 grounded_override = bool(request.form.get("grounded_override"))
1320 engine_counter = request.form.get("out_engine_counter", "").strip() or None
1321 flight_counter = request.form.get("out_flight_counter", "").strip() or None
1322 fuel_state = request.form.get("out_fuel_state", "").strip() or None
1324 errors = []
1325 if not walkaround_ok:
1326 errors.append(_("Walk-around confirmation is required."))
1327 if not snags_acknowledged:
1328 errors.append(_("You must acknowledge the open snag list."))
1329 if ac.is_grounded and not (_is_owner_role(uid) and grounded_override):
1330 errors.append(
1331 _(
1332 "This aircraft is grounded — dispatch is blocked. An owner "
1333 "may override with an explicit confirmation."
1334 )
1335 )
1337 try:
1338 engine_val = float(engine_counter) if engine_counter else None
1339 except ValueError:
1340 engine_val = None
1341 errors.append(_("Invalid engine counter value."))
1342 try:
1343 flight_val = float(flight_counter) if flight_counter else None
1344 except ValueError:
1345 flight_val = None
1346 errors.append(_("Invalid flight counter value."))
1348 if errors:
1349 for msg in errors:
1350 flash(msg, "danger")
1351 return render_template(
1352 "reservations/checkout.html",
1353 aircraft=ac,
1354 reservation=r,
1355 open_snags=open_snags,
1356 counter_hint=_checkout_counter_hint(ac.id),
1357 )
1359 if dispatch_record is None:
1360 dispatch_record = DispatchRecord(reservation_id=r.id)
1361 db.session.add(dispatch_record)
1363 dispatch_record.out_at = datetime.now(UTC)
1364 dispatch_record.out_by_id = uid
1365 dispatch_record.out_engine_counter = engine_val
1366 dispatch_record.out_flight_counter = flight_val
1367 dispatch_record.out_fuel_state = fuel_state
1368 dispatch_record.out_walkaround_ok = walkaround_ok
1369 dispatch_record.out_snags_acknowledged = snags_acknowledged
1370 dispatch_record.out_grounded_override = ac.is_grounded and grounded_override
1371 db.session.commit()
1372 flash(_("Checked out."), "success")
1373 return redirect(
1374 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1375 )
1377 return render_template(
1378 "reservations/checkout.html",
1379 aircraft=ac,
1380 reservation=r,
1381 open_snags=open_snags,
1382 counter_hint=_checkout_counter_hint(ac.id),
1383 )
1386def _checkout_counter_hint(aircraft_id: int) -> dict[str, float | None]:
1387 from flights.routes import (
1388 _get_counter_hint, # pyright: ignore[reportMissingImports]
1389 )
1391 return _get_counter_hint(aircraft_id)
1394def _draft_rental_charge(ac: Aircraft, r: Reservation, dispatch_record: Any) -> Any:
1395 """Build (but do not commit) the automatic RentalCharge draft for a
1396 reservation that has just been checked in."""
1397 from models import ( # pyright: ignore[reportMissingImports]
1398 Expense,
1399 ExpenseType,
1400 RentalCharge,
1401 )
1403 settings = ac.booking_settings
1404 rate_basis = settings.rate_basis if settings else RateBasis.ENGINE_TIME
1405 rate_type = settings.rate_type if settings else RateType.WET
1407 def _delta(out_val: Any, in_val: Any) -> float | None:
1408 if out_val is None or in_val is None:
1409 return None
1410 return float(in_val) - float(out_val)
1412 engine_delta = _delta(
1413 dispatch_record.out_engine_counter, dispatch_record.in_engine_counter
1414 )
1415 flight_delta = _delta(
1416 dispatch_record.out_flight_counter, dispatch_record.in_flight_counter
1417 )
1418 primary, fallback = (
1419 (engine_delta, flight_delta)
1420 if rate_basis == RateBasis.ENGINE_TIME
1421 else (flight_delta, engine_delta)
1422 )
1423 # Fall back to the other counter when the selected one was left blank at
1424 # dispatch — noted via fallback_counter_used for the draft view.
1425 fallback_counter_used = primary is None and fallback is not None
1426 counter_delta = primary if primary is not None else (fallback or 0.0)
1428 chargeable_days = 1
1429 if dispatch_record.out_at is not None and dispatch_record.in_at is not None:
1430 chargeable_days = _chargeable_days(
1431 dispatch_record.out_at, dispatch_record.in_at
1432 )
1433 min_per_day = (
1434 float(settings.min_hours_per_day)
1435 if settings and settings.min_hours_per_day
1436 else None
1437 )
1438 billable_hours = counter_delta
1439 if min_per_day:
1440 billable_hours = max(counter_delta, chargeable_days * min_per_day)
1442 hourly_rate, _source = _effective_rate(ac, settings)
1443 hourly_rate = hourly_rate or 0.0
1445 fuel_credit = 0.0
1446 if rate_type == RateType.WET and r.pilot_user_id is not None:
1447 flight_ids = [fe.id for fe in r.flights]
1448 if flight_ids:
1449 fuel_rows = Expense.query.filter(
1450 Expense.flight_entry_id.in_(flight_ids),
1451 Expense.expense_type == ExpenseType.FUEL,
1452 Expense.created_by_id == r.pilot_user_id,
1453 ).all()
1454 fuel_credit = sum(float(e.amount) for e in fuel_rows)
1456 total = round(billable_hours * hourly_rate - fuel_credit, 2)
1458 return RentalCharge(
1459 reservation_id=r.id,
1460 renter_user_id=r.pilot_user_id,
1461 billable_hours=round(billable_hours, 1),
1462 hourly_rate=round(hourly_rate, 2),
1463 rate_type=rate_type,
1464 fuel_credit=round(fuel_credit, 2),
1465 adjustment=0,
1466 fallback_counter_used=fallback_counter_used,
1467 total=total,
1468 )
1471@reservations_bp.route(
1472 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/checkin",
1473 methods=["GET", "POST"],
1474)
1475@login_required
1476def checkin(aircraft_id: int, res_id: int):
1477 ac = _get_aircraft_or_404(aircraft_id)
1478 r = _get_reservation_or_404(ac, res_id)
1479 uid = int(session["user_id"])
1480 if not _can_dispatch(r, uid):
1481 abort(403)
1483 dispatch_record = r.dispatch
1484 if dispatch_record is None or not dispatch_record.is_checked_out:
1485 flash(_("Check out before checking in."), "danger")
1486 return redirect(
1487 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1488 )
1489 if dispatch_record.is_checked_in:
1490 flash(_("This reservation has already been checked in."), "warning")
1491 return redirect(
1492 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1493 )
1495 if request.method == "POST":
1496 engine_counter = request.form.get("in_engine_counter", "").strip() or None
1497 flight_counter = request.form.get("in_flight_counter", "").strip() or None
1498 fuel_state = request.form.get("in_fuel_state", "").strip() or None
1499 notes = request.form.get("in_notes", "").strip() or None
1501 errors = []
1502 engine_val: float | None = None
1503 flight_val: float | None = None
1504 try:
1505 engine_val = float(engine_counter) if engine_counter else None
1506 except ValueError:
1507 errors.append(_("Invalid engine counter value."))
1508 try:
1509 flight_val = float(flight_counter) if flight_counter else None
1510 except ValueError:
1511 errors.append(_("Invalid flight counter value."))
1513 if (
1514 not errors
1515 and engine_val is not None
1516 and dispatch_record.out_engine_counter is not None
1517 and engine_val < float(dispatch_record.out_engine_counter)
1518 ):
1519 errors.append(
1520 _("Engine counter on return cannot be less than the check-out value.")
1521 )
1522 if (
1523 not errors
1524 and flight_val is not None
1525 and dispatch_record.out_flight_counter is not None
1526 and flight_val < float(dispatch_record.out_flight_counter)
1527 ):
1528 errors.append(
1529 _("Flight counter on return cannot be less than the check-out value.")
1530 )
1532 if errors:
1533 for msg in errors:
1534 flash(msg, "danger")
1535 return render_template(
1536 "reservations/checkin.html",
1537 aircraft=ac,
1538 reservation=r,
1539 dispatch=dispatch_record,
1540 )
1542 dispatch_record.in_at = datetime.now(UTC)
1543 dispatch_record.in_by_id = uid
1544 dispatch_record.in_engine_counter = engine_val
1545 dispatch_record.in_flight_counter = flight_val
1546 dispatch_record.in_fuel_state = fuel_state
1547 dispatch_record.in_notes = notes
1548 if r.pilot_user_id is not None:
1549 db.session.add(_draft_rental_charge(ac, r, dispatch_record))
1550 db.session.commit()
1551 flash(_("Checked in."), "success")
1552 return redirect(
1553 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1554 )
1556 return render_template(
1557 "reservations/checkin.html",
1558 aircraft=ac,
1559 reservation=r,
1560 dispatch=dispatch_record,
1561 )
1564# ── Rental charge review / finalization (Phase 37e) ───────────────────────────
1567@reservations_bp.route(
1568 "/aircraft/<aircraft_ref:aircraft_id>/reservations/<int:res_id>/charge",
1569 methods=["GET", "POST"],
1570)
1571@login_required
1572@require_role(*_OWNER_ROLES)
1573def rental_charge(aircraft_id: int, res_id: int):
1574 ac = _get_aircraft_or_404(aircraft_id)
1575 r = _get_reservation_or_404(ac, res_id)
1576 charge = r.rental_charge
1577 if charge is None:
1578 abort(404)
1579 uid = int(session["user_id"])
1581 if request.method == "POST":
1582 if charge.is_final:
1583 flash(_("This charge has already been finalized."), "warning")
1584 return redirect(
1585 url_for(
1586 "reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id
1587 )
1588 )
1590 action = request.form.get("action", "save")
1592 def _float_field(key: str, current: Any) -> tuple[float, bool]:
1593 raw = request.form.get(key, "").strip()
1594 try:
1595 return float(raw), True
1596 except ValueError:
1597 return float(current), False
1599 billable_hours, hours_ok = _float_field("billable_hours", charge.billable_hours)
1600 hourly_rate, rate_ok = _float_field("hourly_rate", charge.hourly_rate)
1601 fuel_credit_raw = request.form.get("fuel_credit", "").strip()
1602 try:
1603 fuel_credit = float(fuel_credit_raw) if fuel_credit_raw else 0.0
1604 credit_ok = True
1605 except ValueError:
1606 fuel_credit = float(charge.fuel_credit)
1607 credit_ok = False
1608 adjustment_raw = request.form.get("adjustment", "").strip()
1609 try:
1610 adjustment = float(adjustment_raw) if adjustment_raw else 0.0
1611 adj_ok = True
1612 except ValueError:
1613 adjustment = float(charge.adjustment)
1614 adj_ok = False
1615 adjustment_note = request.form.get("adjustment_note", "").strip() or None
1617 errors = []
1618 if not hours_ok:
1619 errors.append(_("Invalid billable hours."))
1620 if not rate_ok:
1621 errors.append(_("Invalid hourly rate."))
1622 if not credit_ok:
1623 errors.append(_("Invalid fuel credit."))
1624 if not adj_ok:
1625 errors.append(_("Invalid adjustment."))
1626 if billable_hours < 0:
1627 errors.append(_("Billable hours cannot be negative."))
1628 if hourly_rate < 0:
1629 errors.append(_("Hourly rate cannot be negative."))
1630 if fuel_credit < 0:
1631 errors.append(_("Fuel credit cannot be negative."))
1632 if adjustment != 0 and not adjustment_note:
1633 errors.append(_("An adjustment requires a note explaining it."))
1635 if errors:
1636 for msg in errors:
1637 flash(msg, "danger")
1638 return render_template(
1639 "reservations/charge.html", aircraft=ac, reservation=r, charge=charge
1640 )
1642 charge.billable_hours = round(billable_hours, 1)
1643 charge.hourly_rate = round(hourly_rate, 2)
1644 charge.fuel_credit = round(fuel_credit, 2)
1645 charge.adjustment = round(adjustment, 2)
1646 charge.adjustment_note = adjustment_note
1647 charge.total = round(
1648 float(charge.billable_hours) * float(charge.hourly_rate)
1649 - float(charge.fuel_credit)
1650 + float(charge.adjustment),
1651 2,
1652 )
1654 if action == "finalize":
1655 from models import (
1656 BillingAccountKind,
1657 LedgerEntryType,
1658 RentalChargeStatus,
1659 User,
1660 ) # pyright: ignore[reportMissingImports]
1661 from services.billing import (
1662 BillingService, # pyright: ignore[reportMissingImports]
1663 )
1665 charge.status = RentalChargeStatus.FINAL
1666 charge.finalized_at = datetime.now(UTC)
1667 charge.finalized_by_id = uid
1668 account = BillingService.get_or_create_account(
1669 ac.tenant_id, charge.renter_user_id, BillingAccountKind.RENTER
1670 )
1671 finalizer = db.session.get(User, uid)
1672 BillingService.post(
1673 account,
1674 LedgerEntryType.CHARGE,
1675 charge.total,
1676 str(_("Rental charge — reservation #%(id)s", id=r.id)),
1677 _date.today(),
1678 source_type="rental_charge",
1679 source_id=charge.id,
1680 created_by=finalizer,
1681 )
1682 db.session.commit()
1683 flash(_("Rental charge finalized."), "success")
1684 else:
1685 db.session.commit()
1686 flash(_("Draft saved."), "success")
1687 return redirect(
1688 url_for("reservations.reservation_detail", aircraft_id=ac.id, res_id=r.id)
1689 )
1691 return render_template(
1692 "reservations/charge.html", aircraft=ac, reservation=r, charge=charge
1693 )
1696# ── Renter self-service account (Phase 37e) ───────────────────────────────────
1699def _my_account_period(period_months_raw: str | None) -> tuple[_date, _date]:
1700 try:
1701 period_months = int(period_months_raw) if period_months_raw else 12
1702 except ValueError:
1703 period_months = 12
1704 if period_months <= 0:
1705 period_months = 12
1706 end = _date.today()
1707 start = end - timedelta(days=period_months * 30)
1708 return start, end
1711@reservations_bp.route("/my/account")
1712@login_required
1713def my_account():
1714 from models import BillingAccountKind # pyright: ignore[reportMissingImports]
1715 from services.billing import BillingService # pyright: ignore[reportMissingImports]
1717 uid = int(session["user_id"])
1718 tu = TenantUser.query.filter_by(user_id=uid).first()
1719 if not tu:
1720 abort(403) # pragma: no cover
1722 account = BillingService.get_or_create_account(
1723 tu.tenant_id, uid, BillingAccountKind.RENTER
1724 )
1725 db.session.commit()
1726 start, end = _my_account_period(request.args.get("period"))
1727 statement = BillingService.statement(account, start, end)
1729 return render_template(
1730 "config/renter_account.html",
1731 renter=tu.user,
1732 account=account,
1733 statement=statement,
1734 balance=BillingService.balance(account),
1735 is_owner_view=False,
1736 csv_url=url_for("reservations.my_account_statement_csv"),
1737 )
1740@reservations_bp.route("/my/account/statement.csv")
1741@login_required
1742def my_account_statement_csv():
1743 from flask import Response # pyright: ignore[reportMissingImports]
1744 from models import BillingAccountKind, User # pyright: ignore[reportMissingImports]
1745 from services.billing import BillingService # pyright: ignore[reportMissingImports]
1747 uid = int(session["user_id"])
1748 tu = TenantUser.query.filter_by(user_id=uid).first()
1749 if not tu:
1750 abort(403) # pragma: no cover
1752 account = BillingService.get_or_create_account(
1753 tu.tenant_id, uid, BillingAccountKind.RENTER
1754 )
1755 db.session.commit()
1756 start, end = _my_account_period(request.args.get("period"))
1757 statement = BillingService.statement(account, start, end)
1758 exporter = db.session.get(User, uid)
1759 csv_text = BillingService.statement_csv(statement, exported_by=exporter)
1760 return Response(
1761 csv_text,
1762 mimetype="text/csv",
1763 headers={
1764 "Content-Disposition": f"attachment; filename=my_statement_{start.isoformat()}_{end.isoformat()}.csv"
1765 },
1766 )