Phase 24: remote trip + dashcam portal
- Add Flask-Login browser session auth (separate from device X-Api-Key auth)
- modules/user_auth.py: User model, require_login decorator, bcrypt helpers
- /login with CSRF token, /logout
- scripts/create_user.py to provision admin user
- Add server-side trip computation from tracking_points
- db.compute_trips_for_device(): gap > 5 min = new trip, haversine distance,
upserts into trips table, auto-triggered in bg thread after each upload batch
- db.list_trips(), db.get_trip_route(): same JSON shape as Pi API
- db.get_overview_stats(): aggregated totals for overview page
- db.list_dashcam_files(), db.get_dashcam_file(): browse/stream helpers
- Add web portal to server/app.py
- Overview page (/): stat cards + recent trips + recent clips
- Trip history (/history): reuses Pi history.js + data-list.js unchanged
via /api/trips + /api/trip/<id> compatibility aliases
- Dashcam archive (/dashcam): file list + inline video player
- /api/v1/portal/dashcam/stream/<id>: Range-request streaming, path traversal guard
- /api/geocode: same Nominatim→reverse_geocoder offline fallback as Pi
- Add server templates (server/templates/)
- base.html: shared shell with 'Remote server' badge in sidebar + mobile topbar
- login.html, index.html, history.html, dashcam.html, components.html
- DB schema: users + trips tables added to init.sql (fresh deploys) +
migrate_phase24.sql (existing deployments)
- Infrastructure: Dockerfile uses repo-root build context to copy shared
static/ assets; docker-compose updated; SECRET_KEY + DEVICE_NAME in .env;
requirements: Flask-Login, bcrypt, reverse_geocoder
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -106,3 +106,268 @@ def insert_dashcam_file(device_id, relative_path, size_bytes, sha256, storage_pa
|
||||
""",
|
||||
(device_id, relative_path, size_bytes, sha256, storage_path),
|
||||
)
|
||||
|
||||
|
||||
# ── User auth ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_user_by_id(user_id):
|
||||
with get_cursor() as cur:
|
||||
cur.execute("SELECT id, username, password_hash FROM users WHERE id = %s", (user_id,))
|
||||
row = cur.fetchone()
|
||||
return {"id": row[0], "username": row[1], "password_hash": row[2]} if row else None
|
||||
|
||||
|
||||
def get_user_by_username(username):
|
||||
with get_cursor() as cur:
|
||||
cur.execute("SELECT id, username, password_hash FROM users WHERE username = %s", (username,))
|
||||
row = cur.fetchone()
|
||||
return {"id": row[0], "username": row[1], "password_hash": row[2]} if row else None
|
||||
|
||||
|
||||
def create_user(username, password_hash):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (%s, %s) ON CONFLICT (username) DO NOTHING RETURNING id",
|
||||
(username, password_hash),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# ── Server-side trips ──────────────────────────────────────────────────────
|
||||
# Gap threshold: if two consecutive points are more than this many seconds
|
||||
# apart, they belong to different trips.
|
||||
_TRIP_GAP_S = 300 # 5 minutes
|
||||
|
||||
|
||||
def compute_trips_for_device(device_id):
|
||||
"""Recompute trip boundaries from tracking_points and upsert into trips table."""
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recorded_at, gps_lat, gps_lon, speed
|
||||
FROM tracking_points
|
||||
WHERE device_id = %s AND gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||
ORDER BY recorded_at ASC
|
||||
""",
|
||||
(device_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371.0
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
# Split into segments by gap
|
||||
segments = []
|
||||
current = [rows[0]]
|
||||
for prev, curr in zip(rows, rows[1:]):
|
||||
gap = (curr[0] - prev[0]).total_seconds()
|
||||
if gap > _TRIP_GAP_S:
|
||||
segments.append(current)
|
||||
current = [curr]
|
||||
else:
|
||||
current.append(curr)
|
||||
segments.append(current)
|
||||
|
||||
# Build trip records
|
||||
trips = []
|
||||
for seg in segments:
|
||||
if len(seg) < 2:
|
||||
continue
|
||||
start_time = seg[0][0]
|
||||
end_time = seg[-1][0]
|
||||
duration_s = int((end_time - start_time).total_seconds())
|
||||
if duration_s < 30:
|
||||
continue
|
||||
dist = sum(
|
||||
haversine(seg[i][1], seg[i][2], seg[i+1][1], seg[i+1][2])
|
||||
for i in range(len(seg) - 1)
|
||||
)
|
||||
if dist < 0.05: # skip <50m blips
|
||||
continue
|
||||
speeds = [r[3] for r in seg if r[3] is not None]
|
||||
trips.append({
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"distance_km": round(dist, 3),
|
||||
"duration_s": duration_s,
|
||||
"avg_speed_kmh": round(sum(speeds) / len(speeds), 1) if speeds else None,
|
||||
"max_speed_kmh": round(max(speeds), 1) if speeds else None,
|
||||
"start_lat": seg[0][1],
|
||||
"start_lon": seg[0][2],
|
||||
"end_lat": seg[-1][1],
|
||||
"end_lon": seg[-1][2],
|
||||
"point_count": len(seg),
|
||||
})
|
||||
|
||||
with get_cursor(commit=True) as cur:
|
||||
for t in trips:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO trips (device_id, start_time, end_time, distance_km, duration_s,
|
||||
avg_speed_kmh, max_speed_kmh, start_lat, start_lon, end_lat, end_lon,
|
||||
point_count, computed_at)
|
||||
VALUES (%(device_id)s, %(start_time)s, %(end_time)s, %(distance_km)s, %(duration_s)s,
|
||||
%(avg_speed_kmh)s, %(max_speed_kmh)s, %(start_lat)s, %(start_lon)s,
|
||||
%(end_lat)s, %(end_lon)s, %(point_count)s, now())
|
||||
ON CONFLICT (device_id, start_time) DO UPDATE SET
|
||||
end_time = EXCLUDED.end_time,
|
||||
distance_km = EXCLUDED.distance_km,
|
||||
duration_s = EXCLUDED.duration_s,
|
||||
avg_speed_kmh = EXCLUDED.avg_speed_kmh,
|
||||
max_speed_kmh = EXCLUDED.max_speed_kmh,
|
||||
end_lat = EXCLUDED.end_lat,
|
||||
end_lon = EXCLUDED.end_lon,
|
||||
point_count = EXCLUDED.point_count,
|
||||
computed_at = now()
|
||||
""",
|
||||
{"device_id": device_id, **t},
|
||||
)
|
||||
return len(trips)
|
||||
|
||||
|
||||
def list_trips(device_id, limit=200):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, start_time, end_time, distance_km, duration_s,
|
||||
avg_speed_kmh, max_speed_kmh, start_lat, start_lon,
|
||||
end_lat, end_lon, point_count
|
||||
FROM trips
|
||||
WHERE device_id = %s
|
||||
ORDER BY start_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(device_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"start_datetime": r[1].isoformat(),
|
||||
"end_datetime": r[2].isoformat(),
|
||||
"distance_km": r[3],
|
||||
"duration_seconds": r[4],
|
||||
"average_speed_kmh": r[5],
|
||||
"max_speed_kmh": r[6],
|
||||
"start_lat": r[7],
|
||||
"start_lon": r[8],
|
||||
"end_lat": r[9],
|
||||
"end_lon": r[10],
|
||||
"point_count": r[11],
|
||||
"odo_start_km": None,
|
||||
"odo_end_km": None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_trip_route(device_id, trip_id):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT start_time, end_time, distance_km, duration_s, avg_speed_kmh, max_speed_kmh FROM trips WHERE id = %s AND device_id = %s",
|
||||
(trip_id, device_id),
|
||||
)
|
||||
trip_row = cur.fetchone()
|
||||
if trip_row is None:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recorded_at, gps_lat, gps_lon, speed
|
||||
FROM tracking_points
|
||||
WHERE device_id = %s AND recorded_at BETWEEN %s AND %s
|
||||
AND gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||
ORDER BY recorded_at ASC
|
||||
""",
|
||||
(device_id, trip_row[0], trip_row[1]),
|
||||
)
|
||||
points = cur.fetchall()
|
||||
return {
|
||||
"trip": {
|
||||
"distance_km": trip_row[2],
|
||||
"duration_seconds": trip_row[3],
|
||||
"average_speed_kmh": trip_row[4],
|
||||
"max_speed_kmh": trip_row[5],
|
||||
},
|
||||
"points": [
|
||||
{
|
||||
"datetime": p[0].isoformat(),
|
||||
"latitude": p[1],
|
||||
"longitude": p[2],
|
||||
"speed": p[3] or 0,
|
||||
"speed_source": "gps",
|
||||
}
|
||||
for p in points
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def list_dashcam_files(device_id, limit=500):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, relative_path, size_bytes, uploaded_at, storage_path
|
||||
FROM dashcam_files
|
||||
WHERE device_id = %s
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(device_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"relative_path": r[1],
|
||||
"size_bytes": r[2],
|
||||
"uploaded_at": r[3].isoformat(),
|
||||
"storage_path": r[4],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_dashcam_file(device_id, file_id):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, relative_path, size_bytes, uploaded_at, storage_path FROM dashcam_files WHERE id = %s AND device_id = %s",
|
||||
(file_id, device_id),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
return {"id": r[0], "relative_path": r[1], "size_bytes": r[2], "uploaded_at": r[3].isoformat(), "storage_path": r[4]}
|
||||
|
||||
|
||||
def get_overview_stats(device_id):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM trips WHERE device_id = %s) AS trip_count,
|
||||
(SELECT COALESCE(SUM(distance_km), 0) FROM trips WHERE device_id = %s) AS total_km,
|
||||
(SELECT COALESCE(SUM(duration_s), 0) FROM trips WHERE device_id = %s) AS total_s,
|
||||
(SELECT COALESCE(MAX(max_speed_kmh), 0) FROM trips WHERE device_id = %s) AS top_speed,
|
||||
(SELECT COUNT(*) FROM dashcam_files WHERE device_id = %s) AS video_count,
|
||||
(SELECT COALESCE(MAX(odometer), 0) FROM tracking_points WHERE device_id = %s) AS odometer_km
|
||||
""",
|
||||
(device_id,) * 6,
|
||||
)
|
||||
r = cur.fetchone()
|
||||
return {
|
||||
"trip_count": r[0],
|
||||
"total_distance_km": float(r[1]),
|
||||
"total_drive_time_s": int(r[2]),
|
||||
"top_speed_kmh": float(r[3]),
|
||||
"video_count": r[4],
|
||||
"odometer_km": float(r[5]),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user