Coverage for app/reports/routes.py: 100%
86 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
1import csv
2import io
3from datetime import date as _date
4from typing import Any
6from flask import ( # pyright: ignore[reportMissingImports]
7 Blueprint,
8 Response,
9 abort,
10 render_template,
11 request,
12 session,
13)
14from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
15from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
16from models import Aircraft, TenantUser, db # pyright: ignore[reportMissingImports]
17from utils import ( # pyright: ignore[reportMissingImports]
18 login_required,
19 user_can_access_aircraft,
20)
22from reports.utilization import ( # pyright: ignore[reportMissingImports]
23 DEFAULT_PERIOD_MONTHS,
24 PERIOD_OPTIONS,
25 compute_utilization_report,
26 resolve_period,
27)
29reports_bp = Blueprint("reports", __name__)
32def _tenant_id() -> int:
33 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
34 if not tu:
35 abort(403)
36 return int(tu.tenant_id)
39def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
40 ac = db.session.get(Aircraft, aircraft_id)
41 if (
42 not ac
43 or ac.tenant_id != _tenant_id()
44 or not user_can_access_aircraft(aircraft_id)
45 ):
46 abort(404)
47 return ac
50def _resolve_report_range(
51 today: _date,
52) -> tuple[_date | None, _date, int | None, str, str]:
53 """Read `?from=&to=` (an arbitrary policy year) or `?period=` (a rolling
54 preset, default 12 months) from the query string. Returns
55 (period_start, period_end, period_months, from_raw, to_raw) —
56 period_months is None when a custom range was used, so the template
57 knows which selector mode was active."""
58 from_raw = request.args.get("from", "").strip()
59 to_raw = request.args.get("to", "").strip()
60 if from_raw and to_raw:
61 try:
62 custom_start = _date.fromisoformat(from_raw)
63 custom_end = _date.fromisoformat(to_raw)
64 except ValueError:
65 pass
66 else:
67 if custom_start <= custom_end:
68 return custom_start, custom_end, None, from_raw, to_raw
70 try:
71 period_months = int(request.args.get("period", DEFAULT_PERIOD_MONTHS))
72 except ValueError:
73 period_months = DEFAULT_PERIOD_MONTHS
74 period_start, period_end = resolve_period(period_months, today)
75 return period_start, period_end, period_months, from_raw, to_raw
78@reports_bp.route("/aircraft/<aircraft_ref:aircraft_id>/reports/utilization")
79@login_required
80def utilization_report(aircraft_id: int) -> ResponseReturnValue:
81 ac = _get_aircraft_or_404(aircraft_id)
82 today = _date.today()
83 period_start, period_end, period_months, from_raw, to_raw = _resolve_report_range(
84 today
85 )
86 report = compute_utilization_report(ac.id, period_start, period_end)
88 return render_template(
89 "reports/utilization.html",
90 aircraft=ac,
91 report=report,
92 period_months=period_months,
93 period_options=PERIOD_OPTIONS,
94 from_date=from_raw,
95 to_date=to_raw,
96 today=today.isoformat(),
97 )
100@reports_bp.route("/aircraft/<aircraft_ref:aircraft_id>/reports/utilization.csv")
101@login_required
102def utilization_report_csv(aircraft_id: int) -> ResponseReturnValue:
103 ac = _get_aircraft_or_404(aircraft_id)
104 today = _date.today()
105 period_start, period_end, _period_months, _from_raw, _to_raw = (
106 _resolve_report_range(today)
107 )
108 report = compute_utilization_report(ac.id, period_start, period_end)
110 buf = io.StringIO()
111 writer = csv.writer(buf)
112 period_start_str = period_start.isoformat() if period_start else _("all time")
113 period_label = f"{period_start_str} {_('to')} {period_end.isoformat()}"
114 writer.writerow([_("Aircraft"), ac.registration])
115 writer.writerow([_("Period"), period_label])
116 writer.writerow([_("Export date"), today.isoformat()])
117 writer.writerow([])
119 current = report["current"]
120 previous = report["previous"]
121 header = [_("Metric"), _("This period")]
122 if previous is not None:
123 header.append(_("Previous period"))
124 writer.writerow(header)
126 def _fuel_str(fuel: dict[str, float]) -> str:
127 if not fuel:
128 return "0"
129 return ", ".join(f"{qty:.1f} {unit}" for unit, qty in sorted(fuel.items()))
131 def _stat_row(stats: dict[str, Any], key: str) -> str:
132 value = stats[key]
133 if key == "fuel_added":
134 return _fuel_str(value)
135 if key in ("flight_count", "landings"):
136 return str(value)
137 return f"{value:.2f}" if key == "oil_added_l" else f"{value:.1f}"
139 metrics = [
140 (_("Flight hours"), "flight_hours"),
141 (_("Engine hours"), "engine_hours"),
142 (_("Number of flights"), "flight_count"),
143 (_("Landings"), "landings"),
144 (_("Fuel added"), "fuel_added"),
145 (_("Oil added (L)"), "oil_added_l"),
146 ]
147 for label, key in metrics:
148 row = [label, _stat_row(current, key)]
149 if previous is not None:
150 row.append(_stat_row(previous, key))
151 writer.writerow(row)
153 filename = f"{ac.registration}_utilization_{period_end.isoformat()}.csv"
154 return Response(
155 buf.getvalue(),
156 mimetype="text/csv",
157 headers={"Content-Disposition": f"attachment; filename={filename}"},
158 )