Skip to content

Commit e3710ff

Browse files
committed
Adds basic tracking status overview
Implements a home page displaying tracking status and adds a health check endpoint. The home page shows the last updated tracking time, the number of buses currently being tracked, the total number of vehicles, and the number of active trips. The update logic now uses select_for_update to avoid race conditions. This provides basic monitoring and status information for the tracking system.
1 parent 57777c2 commit e3710ff

7 files changed

Lines changed: 186 additions & 60 deletions

File tree

main/models.py

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -97,45 +97,6 @@ class CustomUser(AbstractUser):
9797
last_ip = models.GenericIPAddressField(blank=True, null=True)
9898
last_active = models.DateTimeField(blank=True, null=True)
9999
banned = models.BooleanField(default=False)
100-
101-
forum_banned = models.BooleanField(default=False)
102-
ticket_banned = models.BooleanField(default=False)
103-
messaging_banned = models.BooleanField(default=False)
104-
wiki_edit_banned = models.BooleanField(default=False)
105-
106-
banned_date = models.DateTimeField(blank=True, null=True)
107-
banned_reason = models.TextField(blank=True, null=True)
108-
discord_username = models.CharField(max_length=255, blank=True, null=True)
109-
total_user_reports = models.PositiveIntegerField(default=0)
110-
ad_free_until = models.DateTimeField(null=True, blank=True)
111-
pfp = models.ImageField(upload_to='images/profile_pics/', default='images/default_profile_pic.png', blank=True, null=True)
112-
banner = models.ImageField(upload_to='images/profile_banners/', default='images/default_banner.png', blank=True, null=True)
113-
stripe_subscription_id = models.CharField(max_length=255, blank=True, null=True)
114-
PLAN_CHOICES = [
115-
('free', 'Free'),
116-
('basic', 'Basic'),
117-
('pro', 'Pro'),
118-
]
119-
120-
sub_plan = models.CharField(
121-
max_length=10,
122-
choices=PLAN_CHOICES,
123-
default='free',
124-
)
125-
126-
#Bus Buying stuff
127-
buses_brought_count = models.PositiveIntegerField(default=0)
128-
last_bus_purchase = models.DateTimeField(null=True, blank=True)
129-
130-
admin_notes = models.TextField(blank=True, null=True, help_text="Internal notes for admins only")
131-
132-
def is_ad_free(self):
133-
return self.ad_free_until and self.ad_free_until > timezone.now()
134-
135-
history = HistoricalRecords()
136-
137-
def __str__(self):
138-
return self.username
139100

140101
User = get_user_model()
141102

mybustimes/settings.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@
149149
'django.template.context_processors.i18n',
150150
'django.contrib.auth.context_processors.auth',
151151
'django.contrib.messages.context_processors.messages',
152-
'main.context_processors.theme_settings',
153152
],
154153
},
155154
},

mybustimes/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
from django.urls import include, path
88
from django.views.decorators.cache import cache_control
99
from django.views.generic.base import RedirectView
10+
from tracking.views import home_view, healthz_view
1011

1112
urlpatterns = [
13+
path("", home_view, name="home"),
14+
path("healthz/", healthz_view, name="healthz"),
1215
path('api/', include('api.urls')), # Include your API app urls here
1316
]
1417

readme.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@
77
2. Keep debug enabled to disable captcha
88
3. Only test on python 3.11.0
99

10+
# API usage
11+
12+
## Update simulated positions
13+
14+
POST `/api/trips/update_positions/`
15+
16+
- Requires `X-Cron-Secret` header matching `CRON_SECRET` in your settings/env
17+
- Rate limited to 2 requests per minute per IP
18+
19+
```bash
20+
curl -X POST \
21+
-H "X-Cron-Secret: your_secret_here" \
22+
http://localhost:8000/api/trips/update_positions/
23+
```
24+
25+
### Possible responses
26+
- `200` `{ "status": "ok", "updating": true }`
27+
- `202` `{ "status": "already running" }`
28+
- `429` `{ "status": "rate limit exceeded" }`
29+
30+
## Health check
31+
32+
GET `/healthz/`
33+
34+
```bash
35+
curl http://localhost:8000/healthz/
36+
```
37+
38+
## Home page
39+
40+
GET `/`
41+
42+
Shows last tracking update, number of buses tracking, and summary stats.
43+
1044
## .env setup
1145

1246
```

tracking/management/commands/simulate_positions.py

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import timedelta
66
from tracking.models import Trip
77
from fleet.models import fleet
8-
from django.db import IntegrityError
8+
from django.db import IntegrityError, OperationalError, transaction
99
from routes.models import routeStop
1010
import time
1111

@@ -197,25 +197,74 @@ def handle(self, *args, **kwargs):
197197
# ---------------------------------------------------------
198198
if vehicles_to_update:
199199
t3 = time.time()
200-
try:
201-
fleet.objects.bulk_update(
202-
vehicles_to_update,
203-
["sim_lat", "sim_lon", "sim_heading", "current_trip", "updated_at"],
204-
batch_size=500
205-
)
206-
self.stdout.write(f"Updated {len(vehicles_to_update)} vehicles in {time.time() - t3:.2f}s")
207-
except IntegrityError as e:
208-
# Bulk update failed due to FK integrity (race or missing trip). Fall back
209-
# to per-vehicle save so we can skip problematic updates.
210-
self.stderr.write(f"Bulk update IntegrityError: {e}. Falling back to per-vehicle updates.")
211-
updated = 0
212-
for v in vehicles_to_update:
213-
try:
214-
v.save(update_fields=["sim_lat", "sim_lon", "sim_heading", "current_trip", "updated_at"])
215-
updated += 1
216-
except IntegrityError as e2:
217-
self.stderr.write(f"Skipping vehicle {v.id} due to IntegrityError: {e2}")
218-
self.stdout.write(f"Fallback updated {updated} vehicles in {time.time() - t3:.2f}s")
200+
vehicles_by_id = {v.id: v for v in vehicles_to_update}
201+
vehicle_ids = sorted(vehicles_by_id.keys())
202+
attempts = 0
203+
204+
while True:
205+
try:
206+
with transaction.atomic():
207+
# Lock rows in a consistent order and skip rows locked elsewhere.
208+
locked = list(
209+
fleet.objects.select_for_update(skip_locked=True)
210+
.filter(pk__in=vehicle_ids)
211+
.order_by("pk")
212+
)
213+
214+
if not locked:
215+
self.stdout.write("No vehicles available for update; skipping.")
216+
break
217+
218+
for v in locked:
219+
src = vehicles_by_id.get(v.id)
220+
if not src:
221+
continue
222+
v.sim_lat = src.sim_lat
223+
v.sim_lon = src.sim_lon
224+
v.sim_heading = src.sim_heading
225+
v.current_trip = src.current_trip
226+
v.updated_at = src.updated_at
227+
228+
try:
229+
fleet.objects.bulk_update(
230+
locked,
231+
["sim_lat", "sim_lon", "sim_heading", "current_trip", "updated_at"],
232+
batch_size=500
233+
)
234+
self.stdout.write(
235+
f"Updated {len(locked)} vehicles in {time.time() - t3:.2f}s"
236+
)
237+
except IntegrityError as e:
238+
# Bulk update failed due to FK integrity (race or missing trip). Fall back
239+
# to per-vehicle save so we can skip problematic updates.
240+
self.stderr.write(
241+
f"Bulk update IntegrityError: {e}. Falling back to per-vehicle updates."
242+
)
243+
updated = 0
244+
for v in locked:
245+
try:
246+
v.save(update_fields=[
247+
"sim_lat",
248+
"sim_lon",
249+
"sim_heading",
250+
"current_trip",
251+
"updated_at",
252+
])
253+
updated += 1
254+
except IntegrityError as e2:
255+
self.stderr.write(
256+
f"Skipping vehicle {v.id} due to IntegrityError: {e2}"
257+
)
258+
self.stdout.write(
259+
f"Fallback updated {updated} vehicles in {time.time() - t3:.2f}s"
260+
)
261+
break
262+
except OperationalError as e:
263+
if "deadlock detected" in str(e).lower() and attempts < 2:
264+
attempts += 1
265+
time.sleep(0.2 * attempts)
266+
continue
267+
raise
219268

220269
self.stdout.write(f"Total time: {time.time() - t0:.2f}s")
221270

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>MyBusTimes Tracking</title>
7+
<style>
8+
body { font-family: Arial, sans-serif; margin: 2rem; color: #222; }
9+
h1 { margin-bottom: 0.5rem; }
10+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
11+
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; background: #fafafa; }
12+
.label { color: #666; font-size: 0.9rem; }
13+
.value { font-size: 1.4rem; font-weight: bold; }
14+
</style>
15+
</head>
16+
<body>
17+
<h1>MyBusTimes Tracking</h1>
18+
<p class="label">Status overview</p>
19+
20+
<div class="grid">
21+
<div class="card">
22+
<div class="label">Last updated tracking</div>
23+
<div class="value">{{ last_updated|default:"No updates yet" }}</div>
24+
</div>
25+
<div class="card">
26+
<div class="label">Buses tracking</div>
27+
<div class="value">{{ tracking_count }}</div>
28+
</div>
29+
<div class="card">
30+
<div class="label">Active trips</div>
31+
<div class="value">{{ active_trips }}</div>
32+
</div>
33+
<div class="card">
34+
<div class="label">Total vehicles</div>
35+
<div class="value">{{ total_vehicles }}</div>
36+
</div>
37+
</div>
38+
</body>
39+
</html>

tracking/views.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
from django.http import JsonResponse
2+
from django.shortcuts import render
23
from django.views.decorators.csrf import csrf_exempt
34
from django.core.management import call_command
45
from django.core.cache import cache
56
from django.conf import settings
67
from django.views.decorators.http import require_POST
8+
from django.db.models import Max, Q, F
9+
from django.utils import timezone
10+
from datetime import timedelta
711
import time
812
import secrets
913

14+
from fleet.models import fleet
15+
from tracking.models import Trip
16+
1017
@require_POST
1118
@csrf_exempt
1219
def simulate_positions_view(request):
@@ -39,3 +46,37 @@ def simulate_positions_view(request):
3946
return JsonResponse({"status": "ok", "updating": True}, status=200)
4047
finally:
4148
cache.delete("simulate_positions_lock")
49+
50+
51+
def home_view(request):
52+
now = timezone.now()
53+
two_mins_ago = now - timedelta(minutes=2)
54+
eight_hours_ago = now - timedelta(hours=8)
55+
56+
last_updated = fleet.objects.exclude(updated_at__isnull=True).aggregate(
57+
last=Max("updated_at")
58+
)["last"]
59+
60+
tracking_count = fleet.objects.filter(current_trip__isnull=False).count()
61+
total_vehicles = fleet.objects.count()
62+
63+
active_trips = Trip.objects.filter(
64+
trip_missed=False,
65+
trip_start_at__lte=now,
66+
).filter(
67+
Q(trip_end_at__gte=two_mins_ago) |
68+
Q(trip_end_at__lt=F("trip_start_at"), trip_start_at__gte=eight_hours_ago)
69+
).count()
70+
71+
context = {
72+
"last_updated": last_updated,
73+
"tracking_count": tracking_count,
74+
"total_vehicles": total_vehicles,
75+
"active_trips": active_trips,
76+
}
77+
78+
return render(request, "tracking/home.html", context)
79+
80+
81+
def healthz_view(request):
82+
return JsonResponse({"status": "ok"}, status=200)

0 commit comments

Comments
 (0)