42Cummer commited on
Commit
ff79c1b
·
verified ·
1 Parent(s): e76679e

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +322 -0
app.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime # type: ignore
2
+ import sys # type: ignore
3
+ from pathlib import Path
4
+
5
+ # Add parent directory to path to allow imports from api/
6
+ sys.path.insert(0, str(Path(__file__).parent.parent))
7
+ # Add src directory to path to allow imports from same directory
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+
10
+ from api.bus_cache import AsyncBusCache # type: ignore
11
+ from api.utils import hms_to_seconds, get_service_day_start_ts, translate_occupancy # type: ignore
12
+ from db_manager import init_db # type: ignore
13
+ from dotenv import load_dotenv # type: ignore
14
+ from fastapi import FastAPI, HTTPException # type: ignore
15
+ from fastapi.middleware.cors import CORSMiddleware # type: ignore
16
+
17
+ load_dotenv()
18
+
19
+ ttc_cache = AsyncBusCache(ttl=20)
20
+
21
+ # Initialize database connection globally
22
+ db = init_db()
23
+
24
+ app = FastAPI(title="WheresMyBus v2.0 API")
25
+
26
+ # Setup CORS for your React frontend
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"], # In production, use your actual React URL
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ @app.get("/")
35
+ async def health_check():
36
+ """Simple health check endpoint"""
37
+ return "backend is running"
38
+
39
+ @app.get("/api/vehicles")
40
+ async def get_vehicles():
41
+ data = await ttc_cache.get_data()
42
+ vehicles = data.get("vehicles", [])
43
+ return {
44
+ "status": "success",
45
+ "count": len(vehicles),
46
+ "vehicles": vehicles
47
+ }
48
+
49
+ @app.get("/api/routes")
50
+ async def get_all_routes():
51
+ """
52
+ Returns a complete list of TTC routes with their display names and colors.
53
+ """
54
+ try:
55
+ # Run the query against DuckDB
56
+ # We handle missing colors by providing defaults (TTC Red: #FF0000)
57
+ query = """
58
+ SELECT
59
+ route_id,
60
+ route_short_name,
61
+ route_long_name,
62
+ COALESCE(route_color, 'FF0000') as route_color,
63
+ COALESCE(route_text_color, 'FFFFFF') as route_text_color
64
+ FROM routes
65
+ ORDER BY
66
+ CASE
67
+ WHEN CAST(route_short_name AS VARCHAR) ~ '^[0-9]+$' THEN CAST(route_short_name AS INTEGER)
68
+ ELSE 999
69
+ END,
70
+ route_short_name;
71
+ """
72
+
73
+ results = db.execute(query).fetchall()
74
+
75
+ # Convert to a clean list of dictionaries
76
+ route_list = [
77
+ {
78
+ "id": r[0],
79
+ "number": r[1],
80
+ "name": r[2],
81
+ "color": f"#{r[3]}",
82
+ "text_color": f"#{r[4]}"
83
+ }
84
+ for r in results
85
+ ]
86
+
87
+ return {
88
+ "status": "success",
89
+ "count": len(route_list),
90
+ "routes": route_list
91
+ }
92
+
93
+ except Exception as e:
94
+ return {"status": "error", "message": str(e)}
95
+
96
+ @app.get("/api/routes/{route_id}")
97
+ async def get_route_view(route_id: str):
98
+ data = await ttc_cache.get_data()
99
+ all_buses = data.get("vehicles", [])
100
+ route_buses = [v for v in all_buses if v['route'] == route_id]
101
+
102
+ if not route_buses:
103
+ return {"route": route_id, "vehicles": []}
104
+
105
+ # IMPORTANT: Cast Trip IDs to strings to ensure they match the DB
106
+ trip_ids = [str(v['trip_id']) for v in route_buses]
107
+ placeholders = ','.join(['?'] * len(trip_ids))
108
+
109
+ # We use CAST(? AS VARCHAR) to force DuckDB to match strings to strings
110
+ query = f"""
111
+ SELECT
112
+ CAST(st.trip_id AS VARCHAR),
113
+ CAST(st.stop_id AS VARCHAR),
114
+ st.arrival_time as scheduled_time,
115
+ t.trip_headsign
116
+ FROM stop_times st
117
+ JOIN trips t ON CAST(st.trip_id AS VARCHAR) = CAST(t.trip_id AS VARCHAR)
118
+ WHERE CAST(st.trip_id AS VARCHAR) IN ({placeholders})
119
+ """
120
+ db_rows = db.execute(query, trip_ids).fetchall()
121
+
122
+ # Check if we got ANYTHING back from the DB
123
+ if not db_rows:
124
+ print(f"DEBUG: No matches in DB for Trip IDs: {trip_ids[:3]}")
125
+
126
+ schedule_map = {(r[0], r[1]): r[2] for r in db_rows}
127
+ name_map = {r[0]: r[3] for r in db_rows}
128
+
129
+ service_day_ts = get_service_day_start_ts()
130
+ enriched = []
131
+
132
+ for bus in route_buses:
133
+ # Default delay is 0 if no prediction exists
134
+ raw_delay_mins = 0
135
+
136
+ pred_time = bus.get('predicted_time')
137
+ stop_id = bus.get('next_stop_id')
138
+
139
+ if pred_time and stop_id:
140
+ sched_hms = schedule_map.get((str(bus['trip_id']), str(stop_id)))
141
+ if sched_hms:
142
+ # DELAY = SCHEDULED - PREDICTED (negative = late, positive = early)
143
+ plan_ts = service_day_ts + hms_to_seconds(sched_hms)
144
+ raw_delay_mins = round((plan_ts - pred_time) / 60)
145
+
146
+ enriched.append({
147
+ "number": bus['id'],
148
+ "name": name_map.get(str(bus['trip_id']), "Not in Schedule"), # This is the destination
149
+ "location": {"lat": bus['lat'], "lon": bus['lon']},
150
+ "delay_mins": raw_delay_mins, # Actual integer: 5 = 5m late, -2 = 2m early
151
+ "fullness": translate_occupancy(bus['occupancy'])
152
+ })
153
+
154
+ return {
155
+ "route": route_id,
156
+ "count": len(enriched),
157
+ "vehicles": enriched
158
+ }
159
+
160
+ @app.get("/api/vehicles/{vehicle_id}")
161
+ async def get_vehicle_view(vehicle_id: str):
162
+ # 1. Pull latest from cache
163
+ data = await ttc_cache.get_data()
164
+ vehicles = data.get("vehicles", [])
165
+
166
+ # 2. Find this specific bus in the list
167
+ bus = next((v for v in vehicles if str(v['id']) == vehicle_id), None)
168
+
169
+ if not bus:
170
+ raise HTTPException(status_code=404, detail="Vehicle not active or not found")
171
+
172
+ trip_id = str(bus['trip_id'])
173
+ next_stop_id = bus.get('next_stop_id')
174
+ predicted_time = bus.get('predicted_time')
175
+
176
+ # 3. Handshake with Database (Cast to VARCHAR to avoid type errors)
177
+ # We get the destination name and the specific scheduled arrival time
178
+ destination = "Not in Schedule"
179
+ delay_mins = 0
180
+
181
+ if next_stop_id:
182
+ query = """
183
+ SELECT
184
+ t.trip_headsign,
185
+ st.arrival_time as scheduled_time
186
+ FROM trips t
187
+ JOIN stop_times st ON CAST(t.trip_id AS VARCHAR) = CAST(st.trip_id AS VARCHAR)
188
+ WHERE CAST(t.trip_id AS VARCHAR) = ?
189
+ AND CAST(st.stop_id AS VARCHAR) = ?
190
+ LIMIT 1
191
+ """
192
+ row = db.execute(query, [trip_id, str(next_stop_id)]).fetchone()
193
+
194
+ if row:
195
+ destination = row[0]
196
+ scheduled_hms = row[1]
197
+
198
+ # DELAY = SCHEDULED - PREDICTED (negative = late, positive = early)
199
+ if predicted_time:
200
+ service_day_ts = get_service_day_start_ts()
201
+ plan_ts = service_day_ts + hms_to_seconds(scheduled_hms)
202
+ delay_mins = round((plan_ts - predicted_time) / 60)
203
+ else:
204
+ # If no next_stop_id, try to get destination from trip_id only
205
+ query = """
206
+ SELECT trip_headsign
207
+ FROM trips
208
+ WHERE CAST(trip_id AS VARCHAR) = ?
209
+ LIMIT 1
210
+ """
211
+ row = db.execute(query, [trip_id]).fetchone()
212
+ if row:
213
+ destination = row[0]
214
+
215
+ return {
216
+ "vehicle_number": vehicle_id,
217
+ "route_id": bus['route'],
218
+ "name": destination,
219
+ "location": {
220
+ "lat": bus['lat'],
221
+ "lon": bus['lon']
222
+ },
223
+ "delay_mins": delay_mins,
224
+ "fullness": translate_occupancy(bus['occupancy']),
225
+ "trip_id": trip_id
226
+ }
227
+
228
+ @app.get("/api/stop/{stop_code}")
229
+ async def get_stop_view(stop_code: str):
230
+ # 1. Translate Pole Number to Database ID
231
+ stop_info = db.execute("SELECT stop_id, stop_name FROM stops WHERE CAST(stop_code AS VARCHAR) = ? LIMIT 1", [str(stop_code)]).fetchone()
232
+ if not stop_info:
233
+ return {"error": "Stop code not found"}
234
+
235
+ target_id = str(stop_info[0])
236
+ stop_name = stop_info[1]
237
+
238
+ # 2. Get the Cache structure (dict with vehicles, predictions, alerts)
239
+ cached_data = await ttc_cache.get_data()
240
+ vehicles_list = cached_data.get("vehicles", [])
241
+ predictions = cached_data.get("predictions", {})
242
+
243
+ # Build vehicles map for quick lookup
244
+ vehicles = {str(v['trip_id']): v for v in vehicles_list}
245
+
246
+ now = datetime.now().timestamp()
247
+ two_hours_out = now + 7200
248
+ arrivals = []
249
+
250
+ # 3. Search the FULL itineraries for our target_id
251
+ for trip_id, itinerary in predictions.items():
252
+ if target_id in itinerary:
253
+ pred_time = itinerary[target_id]
254
+
255
+ # Only include if the bus hasn't passed the stop yet and is within 2 hours
256
+ if now <= pred_time <= two_hours_out:
257
+
258
+ # 4. Handshake with DB for destination and schedule
259
+ query = """
260
+ SELECT t.trip_headsign, st.arrival_time as scheduled_time, r.route_short_name
261
+ FROM trips t
262
+ JOIN stop_times st ON CAST(t.trip_id AS VARCHAR) = CAST(st.trip_id AS VARCHAR)
263
+ JOIN routes r ON t.route_id = r.route_id
264
+ WHERE CAST(t.trip_id AS VARCHAR) = ? AND CAST(st.stop_id AS VARCHAR) = ?
265
+ LIMIT 1
266
+ """
267
+ row = db.execute(query, [trip_id, target_id]).fetchone()
268
+
269
+ if row:
270
+ # Find the actual bus for fullness (if it's on the road)
271
+ bus = vehicles.get(trip_id)
272
+
273
+ # DELAY = SCHEDULED - PREDICTED (negative = late, positive = early)
274
+ plan_ts = get_service_day_start_ts() + hms_to_seconds(row[1])
275
+
276
+ arrivals.append({
277
+ "route": row[2],
278
+ "destination": row[0],
279
+ "eta_mins": round((pred_time - now) / 60),
280
+ "delay_mins": round((plan_ts - pred_time) / 60),
281
+ "fullness": translate_occupancy(bus['occupancy']) if bus else "Unknown",
282
+ "vehicle_id": bus['id'] if bus else "In Transit"
283
+ })
284
+
285
+ arrivals.sort(key=lambda x: x['eta_mins'])
286
+ return {"stop_name": stop_name, "stop_code": stop_code, "arrivals": arrivals}
287
+
288
+ @app.get("/api/alerts")
289
+ async def get_all_alerts():
290
+ """
291
+ Returns every active service alert for the entire TTC network.
292
+ """
293
+ data = await ttc_cache.get_data()
294
+ return {
295
+ "timestamp": datetime.now().timestamp(),
296
+ "count": len(data["alerts"]),
297
+ "alerts": data["alerts"]
298
+ }
299
+
300
+ @app.get("/api/alerts/{route_id}")
301
+ async def get_alerts_for_route(route_id: str):
302
+ data = await ttc_cache.get_data()
303
+ alerts = data.get("alerts", {})
304
+ route_alerts = alerts.get(route_id, [])
305
+
306
+ if not route_alerts:
307
+ return {
308
+ "route_id": route_id,
309
+ "count": 0,
310
+ "alerts": "No alerts"
311
+ }
312
+
313
+ return {
314
+ "route_id": route_id,
315
+ "count": len(route_alerts),
316
+ "alerts": route_alerts
317
+ }
318
+
319
+ if __name__ == "__main__":
320
+ import uvicorn # type: ignore
321
+ # Start the server
322
+ uvicorn.run(app, host="0.0.0.0", port=7860)