Skip to content

Commit f2100de

Browse files
hc-sousacursoragent
andcommitted
feat(trails): enrich from Visit Azores only and drop azores-hub
Extend Trail with shape, duration, bilingual descriptions, download URLs, waypoints, and start coords parsed from trails.visitazores.com + GPX. Expose enriched v3 list/detail filters and nearest bus stop; remove all azores-hub.net feed merging from the sync pipeline. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2434ff2 commit f2100de

8 files changed

Lines changed: 551 additions & 78 deletions

File tree

src/trails/api_v3.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,42 @@ def trails_list_view(request: Request) -> Response:
2929
return err
3030

3131
difficulty = request.GET.get('difficulty', '').strip()
32+
shape = request.GET.get('shape', '').strip()
33+
min_length = None
34+
max_length = None
35+
min_length_raw = request.GET.get('min_length', '').strip()
36+
max_length_raw = request.GET.get('max_length', '').strip()
37+
if min_length_raw:
38+
try:
39+
min_length = float(min_length_raw)
40+
except ValueError:
41+
return Response(
42+
{'error': {'code': 'invalid_min_length', 'message': 'min_length must be a number'}},
43+
status=status.HTTP_400_BAD_REQUEST,
44+
)
45+
if max_length_raw:
46+
try:
47+
max_length = float(max_length_raw)
48+
except ValueError:
49+
return Response(
50+
{'error': {'code': 'invalid_max_length', 'message': 'max_length must be a number'}},
51+
status=status.HTTP_400_BAD_REQUEST,
52+
)
53+
3254
limit_raw = request.GET.get('limit', '50').strip()
3355
try:
3456
limit = int(limit_raw)
3557
except ValueError:
3658
limit = 50
3759

3860
with for_island(request.island):
39-
payload = list_trails(difficulty=difficulty, limit=limit)
61+
payload = list_trails(
62+
difficulty=difficulty,
63+
shape=shape,
64+
min_length=min_length,
65+
max_length=max_length,
66+
limit=limit,
67+
)
4068
return Response(payload)
4169

4270

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Trail enrichment fields from Visit Azores."""
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
('trails', '0003_periodic_task_sync_open_data'),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name='trail',
14+
name='shape',
15+
field=models.CharField(blank=True, default='', max_length=32),
16+
),
17+
migrations.AddField(
18+
model_name='trail',
19+
name='duration_min',
20+
field=models.PositiveIntegerField(blank=True, null=True),
21+
),
22+
migrations.AddField(
23+
model_name='trail',
24+
name='description_pt',
25+
field=models.TextField(blank=True, default=''),
26+
),
27+
migrations.AddField(
28+
model_name='trail',
29+
name='description_en',
30+
field=models.TextField(blank=True, default=''),
31+
),
32+
migrations.AddField(
33+
model_name='trail',
34+
name='gpx_url',
35+
field=models.CharField(blank=True, default='', max_length=500),
36+
),
37+
migrations.AddField(
38+
model_name='trail',
39+
name='kml_url',
40+
field=models.CharField(blank=True, default='', max_length=500),
41+
),
42+
migrations.AddField(
43+
model_name='trail',
44+
name='map_image_url',
45+
field=models.CharField(blank=True, default='', max_length=500),
46+
),
47+
migrations.AddField(
48+
model_name='trail',
49+
name='leaflet_url',
50+
field=models.CharField(blank=True, default='', max_length=500),
51+
),
52+
migrations.AddField(
53+
model_name='trail',
54+
name='start_lat',
55+
field=models.FloatField(blank=True, null=True),
56+
),
57+
migrations.AddField(
58+
model_name='trail',
59+
name='start_lon',
60+
field=models.FloatField(blank=True, null=True),
61+
),
62+
migrations.AddField(
63+
model_name='trail',
64+
name='waypoints',
65+
field=models.JSONField(blank=True, default=list),
66+
),
67+
]

src/trails/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ class Trail(TenantScopedModel):
1212
name = models.CharField(max_length=200)
1313
difficulty = models.CharField(max_length=32, blank=True, default='')
1414
distance_km = models.FloatField(null=True, blank=True)
15+
shape = models.CharField(max_length=32, blank=True, default='')
16+
duration_min = models.PositiveIntegerField(null=True, blank=True)
17+
description_pt = models.TextField(blank=True, default='')
18+
description_en = models.TextField(blank=True, default='')
19+
gpx_url = models.CharField(max_length=500, blank=True, default='')
20+
kml_url = models.CharField(max_length=500, blank=True, default='')
21+
map_image_url = models.CharField(max_length=500, blank=True, default='')
22+
leaflet_url = models.CharField(max_length=500, blank=True, default='')
23+
start_lat = models.FloatField(null=True, blank=True)
24+
start_lon = models.FloatField(null=True, blank=True)
25+
waypoints = models.JSONField(default=list, blank=True)
1526
geojson = models.JSONField(default=dict, blank=True)
1627

1728
class Meta:

src/trails/services.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,12 +529,39 @@ def sync_all_open_data(*, island_key: str | None = None) -> dict[str, int]:
529529
return totals
530530

531531

532+
def nearest_stop(island: Island, lat: float | None, lng: float | None) -> dict[str, Any] | None:
533+
if lat is None or lng is None:
534+
return None
535+
536+
from transit.models import Stop
537+
538+
best: Stop | None = None
539+
best_dist = float('inf')
540+
for stop in Stop.objects.filter(island=island):
541+
dist = _haversine_km(lat, lng, stop.latitude, stop.longitude)
542+
if dist < best_dist:
543+
best_dist = dist
544+
best = stop
545+
546+
if best is None:
547+
return None
548+
549+
return {
550+
'name': best.name,
551+
'distanceKm': round(best_dist, 2),
552+
'lat': best.latitude,
553+
'lng': best.longitude,
554+
}
555+
556+
532557
def serialize_trail_summary(trail: Trail) -> dict[str, Any]:
533558
return {
534559
'id': trail.id,
535560
'name': trail.name,
536561
'difficulty': trail.difficulty,
537562
'distanceKm': trail.distance_km,
563+
'shape': trail.shape,
564+
'durationMin': trail.duration_min,
538565
}
539566

540567

@@ -548,8 +575,19 @@ def serialize_trail_detail(trail: Trail) -> dict[str, Any]:
548575
}
549576
for stage in trail.stages.order_by('sequence')
550577
]
578+
nearest = nearest_stop(trail.island, trail.start_lat, trail.start_lon)
551579
return {
552580
**serialize_trail_summary(trail),
581+
'descriptionPt': trail.description_pt,
582+
'descriptionEn': trail.description_en,
583+
'gpxUrl': trail.gpx_url,
584+
'kmlUrl': trail.kml_url,
585+
'mapImageUrl': trail.map_image_url,
586+
'leafletUrl': trail.leaflet_url,
587+
'startLat': trail.start_lat,
588+
'startLng': trail.start_lon,
589+
'waypoints': trail.waypoints or [],
590+
'nearestStop': nearest,
553591
'geojson': trail.geojson,
554592
'stages': stages,
555593
'attribution': trails_attribution(),
@@ -575,10 +613,23 @@ def trails_attribution() -> str:
575613
return OPEN_DATA_ATTRIBUTION
576614

577615

578-
def list_trails(*, difficulty: str = '', limit: int = 50) -> dict[str, Any]:
616+
def list_trails(
617+
*,
618+
difficulty: str = '',
619+
shape: str = '',
620+
min_length: float | None = None,
621+
max_length: float | None = None,
622+
limit: int = 50,
623+
) -> dict[str, Any]:
579624
qs = Trail.objects.order_by('name')
580625
if difficulty:
581626
qs = qs.filter(difficulty__iexact=difficulty.strip())
627+
if shape:
628+
qs = qs.filter(shape__iexact=shape.strip())
629+
if min_length is not None:
630+
qs = qs.filter(distance_km__gte=min_length)
631+
if max_length is not None:
632+
qs = qs.filter(distance_km__lte=max_length)
582633
limit = max(1, min(limit, 100))
583634
return {
584635
'trails': [serialize_trail_summary(trail) for trail in qs[:limit]],

src/trails/tests/test_api_v3.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rest_framework.test import APIClient
55

66
from trails.models import POI, Trail
7+
from transit.models import Stop
78
from tenancy.services import get_or_create_default_island
89

910

@@ -24,6 +25,14 @@ def setUp(self):
2425
name='Alpha Trail',
2526
difficulty='easy',
2627
distance_km=3.2,
28+
shape='circular',
29+
duration_min=90,
30+
description_en='Alpha EN',
31+
description_pt='Alpha PT',
32+
gpx_url='https://example.test/a.gpx',
33+
start_lat=37.78,
34+
start_lon=-25.50,
35+
waypoints=[{'name': 'Start', 'lat': 37.78, 'lng': -25.50}],
2736
geojson={'type': 'LineString', 'coordinates': [[-25.5, 37.78], [-25.49, 37.79]]},
2837
)
2938
self.trail_b = Trail.objects.create(
@@ -32,6 +41,8 @@ def setUp(self):
3241
name='Beta Trail',
3342
difficulty='hard',
3443
distance_km=8.0,
44+
shape='linear',
45+
duration_min=240,
3546
geojson={'type': 'LineString', 'coordinates': [[-25.51, 37.77], [-25.48, 37.80]]},
3647
)
3748
self.poi = POI.objects.create(
@@ -42,6 +53,13 @@ def setUp(self):
4253
latitude=37.78,
4354
longitude=-25.50,
4455
)
56+
Stop.objects.create(
57+
island=self.island,
58+
name='Ponta Delgada',
59+
cleaned_name='ponta delgada',
60+
latitude=37.781,
61+
longitude=-25.501,
62+
)
4563

4664
def test_list_trails_ordered_by_name(self):
4765
response = self.client.get('/api/v3/trails/', **self.headers)
@@ -60,13 +78,38 @@ def test_list_trails_difficulty_filter(self):
6078
self.assertEqual(len(trails), 1)
6179
self.assertEqual(trails[0]['id'], self.trail_b.id)
6280

63-
def test_detail_includes_geojson(self):
81+
def test_list_trails_shape_and_length_filters(self):
82+
response = self.client.get('/api/v3/trails/?shape=circular&max_length=5', **self.headers)
83+
self.assertEqual(response.status_code, 200)
84+
trails = response.json()['trails']
85+
self.assertEqual(len(trails), 1)
86+
self.assertEqual(trails[0]['id'], self.trail_a.id)
87+
self.assertEqual(trails[0]['shape'], 'circular')
88+
self.assertEqual(trails[0]['durationMin'], 90)
89+
90+
def test_list_trails_invalid_length_returns_400(self):
91+
response = self.client.get('/api/v3/trails/?min_length=abc', **self.headers)
92+
self.assertEqual(response.status_code, 400)
93+
94+
def test_detail_includes_enriched_fields(self):
6495
response = self.client.get(f'/api/v3/trails/{self.trail_a.id}', **self.headers)
6596
self.assertEqual(response.status_code, 200)
6697
body = response.json()
6798
self.assertEqual(body['geojson']['type'], 'LineString')
6899
self.assertEqual(body['stages'], [])
69100
self.assertIn('attribution', body)
101+
self.assertEqual(body['descriptionEn'], 'Alpha EN')
102+
self.assertEqual(body['descriptionPt'], 'Alpha PT')
103+
self.assertEqual(body['gpxUrl'], 'https://example.test/a.gpx')
104+
self.assertEqual(body['startLat'], 37.78)
105+
self.assertEqual(len(body['waypoints']), 1)
106+
self.assertEqual(body['nearestStop']['name'], 'Ponta Delgada')
107+
108+
def test_detail_includes_geojson(self):
109+
response = self.client.get(f'/api/v3/trails/{self.trail_a.id}', **self.headers)
110+
self.assertEqual(response.status_code, 200)
111+
body = response.json()
112+
self.assertEqual(body['geojson']['type'], 'LineString')
70113

71114
def test_detail_not_found(self):
72115
response = self.client.get('/api/v3/trails/99999', **self.headers)

src/trails/tests/test_services.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
from trails.models import POI, Trail
88
from trails.services import (
99
feature_in_island,
10+
nearest_stop,
1011
parse_poi_feature,
1112
parse_trail_feature,
1213
sync_open_data_for_island,
1314
sync_pois_for_island,
1415
sync_trails_for_island,
1516
)
17+
from transit.models import Stop
1618
from tenancy.services import get_or_create_default_island
1719

1820
SAMPLE_TRAIL_COLLECTION = {
@@ -134,3 +136,26 @@ def test_fetch_dataset_geojson_http_error(self, mock_dataset):
134136
from trails.services import fetch_dataset_geojson
135137

136138
fetch_dataset_geojson('test-dataset')
139+
140+
def test_nearest_stop_returns_closest(self):
141+
Stop.objects.create(
142+
island=self.island,
143+
name='Far Stop',
144+
cleaned_name='far stop',
145+
latitude=37.70,
146+
longitude=-25.60,
147+
)
148+
near = Stop.objects.create(
149+
island=self.island,
150+
name='Near Stop',
151+
cleaned_name='near stop',
152+
latitude=37.781,
153+
longitude=-25.501,
154+
)
155+
result = nearest_stop(self.island, 37.78, -25.50)
156+
assert result is not None
157+
self.assertEqual(result['name'], near.name)
158+
self.assertLess(result['distanceKm'], 5)
159+
160+
def test_nearest_stop_none_without_coords(self):
161+
self.assertIsNone(nearest_stop(self.island, None, -25.5))

0 commit comments

Comments
 (0)