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