Coverage for app/share/routes.py: 100%
87 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1"""
2Share blueprint — public read-only aircraft status pages via token.
3"""
5import io
6import secrets
8from flask import (
9 Blueprint,
10 abort,
11 make_response,
12 redirect,
13 render_template,
14 request,
15 session,
16 url_for,
17) # pyright: ignore[reportMissingImports]
18from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
20from models import (
21 Aircraft,
22 Document,
23 ExpenseType,
24 FlightEntry,
25 MaintenanceTrigger,
26 Role,
27 ShareToken,
28 TenantUser,
29 db,
30) # pyright: ignore[reportMissingImports]
31from utils import login_required, require_role # pyright: ignore[reportMissingImports]
33share_bp = Blueprint("share", __name__)
35_OWNER_ROLES = (Role.ADMIN, Role.OWNER)
37_TOKEN_LENGTH = 16
40def _generate_token() -> str:
41 """Return a unique 16-character URL-safe token (~72 bits of entropy)."""
42 while True:
43 candidate = secrets.token_urlsafe(12)[:_TOKEN_LENGTH]
44 if not ShareToken.query.filter_by(token=candidate).first():
45 return candidate
48def _get_aircraft_or_403(aircraft_id: int) -> Aircraft:
49 """Fetch an aircraft belonging to the logged-in user's tenant, or 403."""
50 from utils import login_required # noqa: F401 — guard already applied by decorator
52 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
53 if not tu:
54 abort(403) # pragma: no cover
55 ac = db.session.get(Aircraft, aircraft_id)
56 if not ac or ac.tenant_id != tu.tenant_id:
57 abort(404)
58 return ac
61# ── Token management (owner-facing) ──────────────────────────────────────────
64@share_bp.route("/aircraft/<int:aircraft_id>/share/create", methods=["POST"])
65@login_required
66@require_role(*_OWNER_ROLES)
67def create_token(aircraft_id: int) -> ResponseReturnValue:
68 ac = _get_aircraft_or_403(aircraft_id)
69 access_level = request.form.get("access_level", "summary")
70 if access_level not in ("summary", "full"):
71 access_level = "summary"
72 token = _generate_token()
73 db.session.add(
74 ShareToken(aircraft_id=ac.id, token=token, access_level=access_level)
75 )
76 db.session.commit()
77 return redirect(url_for("aircraft.detail", aircraft_id=aircraft_id))
80@share_bp.route(
81 "/aircraft/<int:aircraft_id>/share/<int:token_id>/revoke", methods=["POST"]
82)
83@login_required
84@require_role(*_OWNER_ROLES)
85def revoke_token(aircraft_id: int, token_id: int) -> ResponseReturnValue:
86 ac = _get_aircraft_or_403(aircraft_id)
87 from datetime import datetime, timezone
89 st = db.session.get(ShareToken, token_id)
90 if not st or st.aircraft_id != ac.id:
91 abort(404)
92 st.revoked_at = datetime.now(timezone.utc)
93 db.session.commit()
94 return redirect(url_for("aircraft.detail", aircraft_id=aircraft_id))
97@share_bp.route("/aircraft/<int:aircraft_id>/share/<int:token_id>/qr")
98@login_required
99@require_role(*_OWNER_ROLES)
100def token_qr(aircraft_id: int, token_id: int) -> ResponseReturnValue:
101 ac = _get_aircraft_or_403(aircraft_id)
102 st = db.session.get(ShareToken, token_id)
103 if not st or st.aircraft_id != ac.id or not st.is_active:
104 abort(404)
106 import qrcode # pyright: ignore[reportMissingImports]
108 share_url = request.host_url.rstrip("/") + url_for(
109 "share.public_view", token=st.token
110 )
111 qr = qrcode.QRCode(
112 error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=4
113 )
114 qr.add_data(share_url)
115 qr.make(fit=True)
116 img = qr.make_image(fill_color="black", back_color="white")
118 buf = io.BytesIO()
119 img.save(buf, format="PNG")
120 buf.seek(0)
122 resp = make_response(buf.read())
123 resp.headers["Content-Type"] = "image/png"
124 resp.headers["Content-Disposition"] = f'attachment; filename="share_{st.token}.png"'
125 return resp
128# ── Public view ───────────────────────────────────────────────────────────────
131@share_bp.route("/share/<token>")
132def public_view(token: str) -> ResponseReturnValue:
133 st = ShareToken.query.filter_by(token=token).first()
134 if not st or not st.is_active:
135 abort(404)
137 ac = st.aircraft
138 hobbs = ac.total_engine_hours
139 flight_hours = ac.total_flight_hours
140 triggers = MaintenanceTrigger.query.filter_by(aircraft_id=ac.id).all()
141 maintenance_summary = [(t, t.status(hobbs)) for t in triggers]
143 overdue = [(t, s) for t, s in maintenance_summary if s == "overdue"]
144 due_soon = [(t, s) for t, s in maintenance_summary if s == "due_soon"]
146 recent_flights = None
147 recent_documents = None
148 if st.access_level == "full":
149 recent_flights = (
150 FlightEntry.query.filter_by(aircraft_id=ac.id)
151 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc())
152 .limit(5)
153 .all()
154 )
155 recent_documents = (
156 Document.query.filter_by(aircraft_id=ac.id, is_sensitive=False)
157 .order_by(Document.uploaded_at.desc())
158 .limit(10)
159 .all()
160 )
162 resp = make_response(
163 render_template(
164 "share/public.html",
165 aircraft=ac,
166 token=st,
167 hobbs=hobbs,
168 flight_hours=flight_hours,
169 maintenance_summary=maintenance_summary,
170 overdue=overdue,
171 due_soon=due_soon,
172 recent_flights=recent_flights,
173 recent_documents=recent_documents,
174 expense_type_labels=ExpenseType.LABELS,
175 )
176 )
177 resp.headers["X-Robots-Tag"] = "noindex, nofollow"
178 return resp