Coverage for app/expenses/routes.py: 100%

136 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 23:33 +0000

1from datetime import date as _date, timedelta 

2 

3from flask import ( # pyright: ignore[reportMissingImports] 

4 Blueprint, 

5 abort, 

6 flash, 

7 redirect, 

8 render_template, 

9 request, 

10 session, 

11 url_for, 

12) 

13from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports] 

14 

15from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

16 

17from typing import Any 

18 

19from models import Aircraft, Expense, ExpenseType, FlightEntry, Role, TenantUser, db # pyright: ignore[reportMissingImports] 

20from utils import login_required, require_role, user_can_access_aircraft # pyright: ignore[reportMissingImports] 

21 

22expenses_bp = Blueprint("expenses", __name__) 

23 

24_OWNER_ROLES = (Role.ADMIN, Role.OWNER) 

25 

26_CURRENCIES = ["EUR", "USD", "GBP", "CHF"] 

27_UNITS = ["L", "gal"] 

28_DEFAULT_PERIOD = 12 # months 

29 

30 

31def _tenant_id() -> int: 

32 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

33 if not tu: 

34 abort(403) 

35 return int(tu.tenant_id) 

36 

37 

38def _get_aircraft_or_404(aircraft_id: int) -> Aircraft: 

39 ac = db.session.get(Aircraft, aircraft_id) 

40 if ( 

41 not ac 

42 or ac.tenant_id != _tenant_id() 

43 or not user_can_access_aircraft(aircraft_id) 

44 ): 

45 abort(404) 

46 return ac 

47 

48 

49def _get_expense_or_404(aircraft: Aircraft, expense_id: int) -> Expense: 

50 exp = db.session.get(Expense, expense_id) 

51 if not exp or exp.aircraft_id != aircraft.id: 

52 abort(404) 

53 return exp 

54 

55 

56def _compute_stats( 

57 expenses: list[Any], aircraft_id: int, period_months: int 

58) -> tuple[float, float | None, str]: 

59 """Return (total_cost, cost_per_hour, period_label) for the filtered expense list.""" 

60 total_cost = sum(float(e.amount) for e in expenses) 

61 

62 if period_months > 0: 

63 cutoff = _date.today() - timedelta(days=period_months * 30) 

64 flights = FlightEntry.query.filter( 

65 FlightEntry.aircraft_id == aircraft_id, 

66 FlightEntry.date >= cutoff, 

67 ).all() 

68 period_label = f"last {period_months} months" 

69 else: 

70 flights = FlightEntry.query.filter_by(aircraft_id=aircraft_id).all() 

71 period_label = "all time" 

72 

73 total_hours = sum( 

74 float(f.flight_time_counter_end) - float(f.flight_time_counter_start) 

75 for f in flights 

76 if f.flight_time_counter_end is not None 

77 and f.flight_time_counter_start is not None 

78 ) 

79 cost_per_hour = round(total_cost / total_hours, 2) if total_hours > 0 else None 

80 return total_cost, cost_per_hour, period_label 

81 

82 

83# ── Expense list ────────────────────────────────────────────────────────────── 

84 

85 

86@expenses_bp.route("/aircraft/<int:aircraft_id>/expenses") 

87@login_required 

88def list_expenses(aircraft_id: int) -> ResponseReturnValue: 

89 ac = _get_aircraft_or_404(aircraft_id) 

90 

91 type_filter = request.args.get("type", "") 

92 try: 

93 period_months = int(request.args.get("period", _DEFAULT_PERIOD)) 

94 except ValueError: 

95 period_months = _DEFAULT_PERIOD 

96 

97 query = Expense.query.filter_by(aircraft_id=ac.id) 

98 

99 if type_filter and type_filter in ExpenseType.ALL: 

100 query = query.filter_by(expense_type=type_filter) 

101 

102 if period_months > 0: 

103 cutoff = _date.today() - timedelta(days=period_months * 30) 

104 query = query.filter(Expense.date >= cutoff) 

105 

106 expenses = query.order_by(Expense.date.desc(), Expense.id.desc()).all() 

107 total_cost, cost_per_hour, period_label = _compute_stats( 

108 expenses, ac.id, period_months 

109 ) 

110 

111 return render_template( 

112 "expenses/list.html", 

113 aircraft=ac, 

114 expenses=expenses, 

115 type_filter=type_filter, 

116 period_months=period_months, 

117 total_cost=total_cost, 

118 cost_per_hour=cost_per_hour, 

119 period_label=period_label, 

120 expense_type_labels=ExpenseType.LABELS, 

121 currencies=_CURRENCIES, 

122 ) 

123 

124 

125# ── Add expense ─────────────────────────────────────────────────────────────── 

126 

127 

128@expenses_bp.route("/aircraft/<int:aircraft_id>/expenses/add", methods=["GET", "POST"]) 

129@login_required 

130@require_role(*_OWNER_ROLES) 

131def add_expense(aircraft_id: int) -> ResponseReturnValue: 

132 ac = _get_aircraft_or_404(aircraft_id) 

133 

134 if request.method == "POST": 

135 err = _validate_and_save(ac, expense=None) 

136 if err is None: 

137 flash(_("Expense recorded."), "success") 

138 return redirect(url_for("expenses.list_expenses", aircraft_id=ac.id)) 

139 flash(err, "danger") 

140 

141 return render_template( 

142 "expenses/expense_form.html", 

143 aircraft=ac, 

144 expense=None, 

145 expense_types=ExpenseType.LABELS, 

146 currencies=_CURRENCIES, 

147 units=_UNITS, 

148 today=_date.today().isoformat(), 

149 ) 

150 

151 

152# ── Edit expense ────────────────────────────────────────────────────────────── 

153 

154 

155@expenses_bp.route( 

156 "/aircraft/<int:aircraft_id>/expenses/<int:expense_id>/edit", 

157 methods=["GET", "POST"], 

158) 

159@login_required 

160@require_role(*_OWNER_ROLES) 

161def edit_expense(aircraft_id: int, expense_id: int) -> ResponseReturnValue: 

162 ac = _get_aircraft_or_404(aircraft_id) 

163 exp = _get_expense_or_404(ac, expense_id) 

164 

165 if request.method == "POST": 

166 err = _validate_and_save(ac, expense=exp) 

167 if err is None: 

168 flash(_("Expense updated."), "success") 

169 return redirect(url_for("expenses.list_expenses", aircraft_id=ac.id)) 

170 flash(err, "danger") 

171 

172 return render_template( 

173 "expenses/expense_form.html", 

174 aircraft=ac, 

175 expense=exp, 

176 expense_types=ExpenseType.LABELS, 

177 currencies=_CURRENCIES, 

178 units=_UNITS, 

179 today=_date.today().isoformat(), 

180 ) 

181 

182 

183# ── Delete expense ──────────────────────────────────────────────────────────── 

184 

185 

186@expenses_bp.route( 

187 "/aircraft/<int:aircraft_id>/expenses/<int:expense_id>/delete", methods=["POST"] 

188) 

189@login_required 

190@require_role(*_OWNER_ROLES) 

191def delete_expense(aircraft_id: int, expense_id: int) -> ResponseReturnValue: 

192 ac = _get_aircraft_or_404(aircraft_id) 

193 exp = _get_expense_or_404(ac, expense_id) 

194 db.session.delete(exp) 

195 db.session.commit() 

196 flash(_("Expense deleted."), "success") 

197 return redirect(url_for("expenses.list_expenses", aircraft_id=ac.id)) 

198 

199 

200# ── Shared save helper ──────────────────────────────────────────────────────── 

201 

202 

203def _validate_and_save(aircraft: Aircraft, expense: Expense | None) -> str | None: 

204 """Validate POST data, persist, return error string or None on success.""" 

205 date_str = request.form.get("date", "").strip() 

206 expense_type = request.form.get("expense_type", "").strip() 

207 description = request.form.get("description", "").strip() or None 

208 amount_str = request.form.get("amount", "").strip() 

209 currency = request.form.get("currency", "EUR").strip() 

210 quantity_str = request.form.get("quantity", "").strip() 

211 unit = request.form.get("unit", "").strip() or None 

212 

213 if not date_str: 

214 return str(_("Date is required.")) 

215 try: 

216 from datetime import date as _date_cls 

217 

218 date_val = _date_cls.fromisoformat(date_str) 

219 except ValueError: 

220 return str(_("Invalid date format.")) 

221 

222 if expense_type not in ExpenseType.ALL: 

223 return str(_("Invalid expense type.")) 

224 

225 if not amount_str: 

226 return str(_("Amount is required.")) 

227 try: 

228 amount = float(amount_str) 

229 if amount < 0: 

230 raise ValueError 

231 except ValueError: 

232 return str(_("Amount must be a non-negative number.")) 

233 

234 quantity = None 

235 if quantity_str: 

236 try: 

237 quantity = float(quantity_str) 

238 if quantity < 0: 

239 raise ValueError 

240 except ValueError: 

241 return str(_("Quantity must be a non-negative number.")) 

242 

243 if expense is None: 

244 expense = Expense(aircraft_id=aircraft.id) 

245 db.session.add(expense) 

246 

247 expense.date = date_val 

248 expense.expense_type = expense_type 

249 expense.description = description 

250 expense.amount = amount 

251 expense.currency = currency 

252 expense.quantity = quantity 

253 expense.unit = unit if quantity else None 

254 db.session.commit() 

255 return None