Coverage for app/expenses/routes.py: 100%
179 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
1from datetime import date as _date
2from datetime import timedelta
3from typing import Any
5from flask import ( # pyright: ignore[reportMissingImports]
6 Blueprint,
7 abort,
8 flash,
9 redirect,
10 render_template,
11 request,
12 session,
13 url_for,
14)
15from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
16from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
17from models import (
18 Aircraft,
19 Expense,
20 ExpenseType,
21 Flight,
22 Role,
23 TenantUser,
24 db,
25) # pyright: ignore[reportMissingImports]
26from utils import ( # pyright: ignore[reportMissingImports]
27 login_required,
28 require_role,
29 user_can_access_aircraft,
30)
32from expenses.cost_dashboard import ( # pyright: ignore[reportMissingImports]
33 DEFAULT_PERIOD_MONTHS,
34 PERIOD_OPTIONS,
35 compute_cost_dashboard,
36)
37from expenses.form_parsing import (
38 parse_expense_fields, # pyright: ignore[reportMissingImports]
39)
41expenses_bp = Blueprint("expenses", __name__)
43_OWNER_ROLES = (Role.ADMIN, Role.OWNER)
45_CURRENCIES = ["EUR", "USD", "GBP", "CHF"]
46_UNITS = ["L", "gal"]
47_DEFAULT_PERIOD = 12 # months
50def _tenant_id() -> int:
51 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
52 if not tu:
53 abort(403)
54 return int(tu.tenant_id)
57def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
58 ac = db.session.get(Aircraft, aircraft_id)
59 if (
60 not ac
61 or ac.tenant_id != _tenant_id()
62 or not user_can_access_aircraft(aircraft_id)
63 ):
64 abort(404)
65 return ac
68def _get_expense_or_404(aircraft: Aircraft, expense_id: int) -> Expense:
69 exp = db.session.get(Expense, expense_id)
70 if not exp or exp.aircraft_id != aircraft.id:
71 abort(404)
72 return exp
75def _fuel_expense_prefill(aircraft: Aircraft) -> dict[str, Any] | None:
76 """Build "add expense" form defaults from a "Log as expense" link off a
77 logged refuel — either flight-linked (`?flight_entry_id=...`, from
78 aircraft/flight_detail.html) or standalone (`?date=...`, from a Refuel
79 record with no flight, see refuels/list.html). A flight's before/after
80 refuel quantities, and a standalone Refuel, are all independent
81 purchases for cost-tracking purposes — only the date and aircraft
82 matter, not which record a given amount happens to be logged against."""
83 flight_entry_id_raw = request.args.get("flight_entry_id", "").strip()
84 date_raw = request.args.get("date", "").strip()
86 flight_id: int | None = None
87 prefill_date: str | None = None
89 if flight_entry_id_raw.isdigit():
90 flight = db.session.get(Flight, int(flight_entry_id_raw))
91 if flight is not None and flight.aircraft_id == aircraft.id:
92 flight_id = flight.id
93 prefill_date = flight.date.isoformat()
94 elif date_raw:
95 try:
96 _date.fromisoformat(date_raw)
97 except ValueError:
98 pass
99 else:
100 prefill_date = date_raw
102 if prefill_date is None:
103 return None
105 unit = request.args.get("unit", "").strip()
106 return {
107 "flight_entry_id": flight_id,
108 "date": prefill_date,
109 "quantity": request.args.get("quantity", "").strip() or None,
110 "unit": unit if unit in _UNITS else "L",
111 }
114def _compute_stats(
115 expenses: list[Any], aircraft_id: int, period_months: int
116) -> tuple[float, float | None, str]:
117 """Return (total_cost, cost_per_hour, period_label) for the filtered expense list."""
118 total_cost = sum(float(e.amount) for e in expenses)
120 if period_months > 0:
121 cutoff = _date.today() - timedelta(days=period_months * 30)
122 flights = Flight.query.filter(
123 Flight.aircraft_id == aircraft_id,
124 Flight.date >= cutoff,
125 ).all()
126 period_label = f"last {period_months} months"
127 else:
128 flights = Flight.query.filter_by(aircraft_id=aircraft_id).all()
129 period_label = "all time"
131 total_hours = sum(
132 float(f.flight_time)
133 if f.flight_time is not None
134 else float(f.flight_time_counter_end) - float(f.flight_time_counter_start)
135 for f in flights
136 if f.flight_time is not None
137 or (
138 f.flight_time_counter_end is not None
139 and f.flight_time_counter_start is not None
140 )
141 )
142 cost_per_hour = round(total_cost / total_hours, 2) if total_hours > 0 else None
143 return total_cost, cost_per_hour, period_label
146# ── Expense list ──────────────────────────────────────────────────────────────
149@expenses_bp.route("/aircraft/<aircraft_ref:aircraft_id>/expenses")
150@login_required
151def list_expenses(aircraft_id: int) -> ResponseReturnValue:
152 ac = _get_aircraft_or_404(aircraft_id)
154 type_filter = request.args.get("type", "")
155 try:
156 period_months = int(request.args.get("period", _DEFAULT_PERIOD))
157 except ValueError:
158 period_months = _DEFAULT_PERIOD
160 query = Expense.query.filter_by(aircraft_id=ac.id)
162 if type_filter and type_filter in ExpenseType.ALL:
163 query = query.filter_by(expense_type=type_filter)
165 if period_months > 0:
166 cutoff = _date.today() - timedelta(days=period_months * 30)
167 query = query.filter(Expense.date >= cutoff)
169 expenses = query.order_by(Expense.date.desc(), Expense.id.desc()).all()
170 total_cost, cost_per_hour, period_label = _compute_stats(
171 expenses, ac.id, period_months
172 )
174 return render_template(
175 "expenses/list.html",
176 aircraft=ac,
177 expenses=expenses,
178 type_filter=type_filter,
179 period_months=period_months,
180 total_cost=total_cost,
181 cost_per_hour=cost_per_hour,
182 period_label=period_label,
183 expense_type_labels=ExpenseType.LABELS,
184 currencies=_CURRENCIES,
185 )
188# ── Cost dashboard (Phase 36) ──────────────────────────────────────────────────
191@expenses_bp.route("/aircraft/<aircraft_ref:aircraft_id>/costs")
192@login_required
193def cost_dashboard(aircraft_id: int) -> ResponseReturnValue:
194 ac = _get_aircraft_or_404(aircraft_id)
196 try:
197 period_months = int(request.args.get("period", DEFAULT_PERIOD_MONTHS))
198 except ValueError:
199 period_months = DEFAULT_PERIOD_MONTHS
200 if period_months not in PERIOD_OPTIONS:
201 period_months = DEFAULT_PERIOD_MONTHS
203 dashboard = compute_cost_dashboard(ac, period_months)
205 return render_template(
206 "expenses/cost_dashboard.html",
207 aircraft=ac,
208 period_months=period_months,
209 period_options=PERIOD_OPTIONS,
210 **dashboard,
211 )
214# ── Add expense ───────────────────────────────────────────────────────────────
217@expenses_bp.route(
218 "/aircraft/<aircraft_ref:aircraft_id>/expenses/add", methods=["GET", "POST"]
219)
220@login_required
221@require_role(*_OWNER_ROLES)
222def add_expense(aircraft_id: int) -> ResponseReturnValue:
223 ac = _get_aircraft_or_404(aircraft_id)
225 if request.method == "POST":
226 err = _validate_and_save(ac, expense=None)
227 if err is None:
228 flash(_("Expense recorded."), "success")
229 return redirect(url_for("expenses.list_expenses", aircraft_id=ac.id))
230 flash(err, "danger")
232 return render_template(
233 "expenses/expense_form.html",
234 aircraft=ac,
235 expense=None,
236 expense_types=ExpenseType.LABELS,
237 currencies=_CURRENCIES,
238 units=_UNITS,
239 today=_date.today().isoformat(),
240 prefill=_fuel_expense_prefill(ac) if request.method == "GET" else None,
241 )
244# ── Edit expense ──────────────────────────────────────────────────────────────
247@expenses_bp.route(
248 "/aircraft/<aircraft_ref:aircraft_id>/expenses/<int:expense_id>/edit",
249 methods=["GET", "POST"],
250)
251@login_required
252@require_role(*_OWNER_ROLES)
253def edit_expense(aircraft_id: int, expense_id: int) -> ResponseReturnValue:
254 ac = _get_aircraft_or_404(aircraft_id)
255 exp = _get_expense_or_404(ac, expense_id)
257 if request.method == "POST":
258 err = _validate_and_save(ac, expense=exp)
259 if err is None:
260 flash(_("Expense updated."), "success")
261 return redirect(url_for("expenses.list_expenses", aircraft_id=ac.id))
262 flash(err, "danger")
264 return render_template(
265 "expenses/expense_form.html",
266 aircraft=ac,
267 expense=exp,
268 expense_types=ExpenseType.LABELS,
269 currencies=_CURRENCIES,
270 units=_UNITS,
271 today=_date.today().isoformat(),
272 prefill=None,
273 )
276# ── Delete expense ────────────────────────────────────────────────────────────
279@expenses_bp.route(
280 "/aircraft/<aircraft_ref:aircraft_id>/expenses/<int:expense_id>/delete",
281 methods=["POST"],
282)
283@login_required
284@require_role(*_OWNER_ROLES)
285def delete_expense(aircraft_id: int, expense_id: int) -> ResponseReturnValue:
286 ac = _get_aircraft_or_404(aircraft_id)
287 exp = _get_expense_or_404(ac, expense_id)
288 db.session.delete(exp)
289 db.session.commit()
290 flash(_("Expense deleted."), "success")
291 return redirect(url_for("expenses.list_expenses", aircraft_id=ac.id))
294# ── Shared save helper ────────────────────────────────────────────────────────
297def _validate_and_save(aircraft: Aircraft, expense: Expense | None) -> str | None:
298 """Validate POST data, persist, return error string or None on success.
300 Field validation (excluding the receipt file) is delegated to
301 parse_expense_fields(); note this checks the receipt file extension
302 *after* all other fields now rather than interleaved between coverage
303 and recurrence — a harmless reordering of which single error message
304 wins when multiple fields are simultaneously invalid, not a behaviour
305 change to what's ultimately accepted or persisted.
306 """
307 values, error = parse_expense_fields(request.form)
308 if error:
309 return error
311 receipt_file = request.files.get("receipt")
312 if receipt_file is not None and not receipt_file.filename:
313 receipt_file = None
314 if receipt_file is not None:
315 import os as _os
317 from documents.routes import (
318 _ALLOWED_EXTS, # pyright: ignore[reportMissingImports]
319 )
321 ext = _os.path.splitext(receipt_file.filename or "")[1].lower()
322 if ext not in _ALLOWED_EXTS:
323 return str(_("This file type is not allowed for receipts."))
325 date_val = values["date"]
326 description = values["description"]
327 quantity = values["quantity"]
328 recurrence = values["recurrence"]
330 if expense is None:
331 expense = Expense(aircraft_id=aircraft.id, created_by_id=session.get("user_id"))
332 flight_entry_id_raw = request.form.get("flight_entry_id", "").strip()
333 if flight_entry_id_raw.isdigit():
334 flight = db.session.get(Flight, int(flight_entry_id_raw))
335 if flight is not None and flight.aircraft_id == aircraft.id:
336 expense.flight_entry_id = flight.id
337 db.session.add(expense)
339 expense.date = date_val
340 expense.expense_type = values["expense_type"]
341 expense.expense_category = values["expense_category"]
342 expense.description = description
343 expense.amount = values["amount"]
344 expense.currency = values["currency"]
345 expense.quantity = quantity
346 expense.unit = values["unit"] if quantity else None
347 expense.coverage_start = values["coverage_start"]
348 expense.coverage_end = values["coverage_end"]
349 expense.recurrence = recurrence
350 expense.recurrence_end = values["recurrence_end"]
351 if recurrence is None:
352 expense.recurrence_last_date = None
354 if receipt_file is not None:
355 from documents.routes import ( # pyright: ignore[reportMissingImports]
356 _delete_file,
357 _save_upload_canonical,
358 )
359 from models import ( # pyright: ignore[reportMissingImports]
360 DocCategory,
361 Document,
362 Tenant,
363 )
364 from werkzeug.utils import (
365 secure_filename, # pyright: ignore[reportMissingImports]
366 )
368 db.session.flush()
369 title = description or _("Expense receipt %(date)s", date=date_val.isoformat())
370 tenant = db.session.get(Tenant, aircraft.tenant_id)
371 if tenant is None: # pragma: no cover — the aircraft FK guarantees it exists
372 abort(404)
373 relpath, mime, size = _save_upload_canonical(
374 receipt_file, tenant, aircraft, DocCategory.INVOICE, title
375 )
376 # A new upload replaces any previous receipt on this expense.
377 for old in Document.query.filter_by(expense_id=expense.id).all():
378 _delete_file(old.filename)
379 db.session.delete(old)
380 db.session.add(
381 Document(
382 aircraft_id=aircraft.id,
383 expense_id=expense.id,
384 filename=relpath,
385 original_filename=secure_filename(receipt_file.filename or "receipt"),
386 mime_type=mime,
387 size_bytes=size,
388 title=title,
389 category=DocCategory.INVOICE,
390 )
391 )
393 db.session.commit()
394 return None