Skip to content

Commit 14df5d4

Browse files
hc-sousacursoragent
andcommitted
feat(trails): dados.gov.pt sync, v3 API, and deploy bootstrap
Sync Azores trail/POI open data via udata API and WFS, expose read endpoints, enable the sao-miguel feature flag, and queue initial sync on deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0f00a10 commit 14df5d4

15 files changed

Lines changed: 1044 additions & 3 deletions

src/shared/management/commands/bootstrap_feed_syncs.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
FEED_TASKS: tuple[tuple[str, str], ...] = (
1212
('news', 'news.poll_sources'),
1313
('seismic', 'seismic.sync_events'),
14+
('trails', 'trails.sync_open_data'),
1415
)
1516

1617

1718
class Command(BaseCommand):
18-
help = 'Queue initial news and seismic feed sync tasks (runs after migrate on deploy).'
19+
help = 'Queue initial news, seismic, and trails feed sync tasks (runs after migrate on deploy).'
1920

2021
def handle(self, *args: object, **options: object) -> None:
2122
queued: list[str] = []
@@ -29,6 +30,10 @@ def handle(self, *args: object, **options: object) -> None:
2930
from seismic.tasks import sync_events_task
3031

3132
sync_events_task.delay()
33+
elif label == 'trails':
34+
from trails.tasks import sync_open_data_task
35+
36+
sync_open_data_task.delay()
3237
queued.append(task_name)
3338
except Exception:
3439
logger.exception('bootstrap_feed_syncs failed to queue %s', task_name)

src/src/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
path('api/v3/transit/', include('transit.urls_v3')),
1919
path('api/v3/news/', include('news.urls_v3')),
2020
path('api/v3/seismic/', include('seismic.urls_v3')),
21+
path('api/v3/trails/', include('trails.urls_v3')),
2122
]
2223

2324
if 'legal' in settings.INSTALLED_APPS:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Enable trails module on default island."""
2+
3+
from django.db import migrations
4+
5+
6+
def enable_trails(apps, schema_editor):
7+
Island = apps.get_model('tenancy', 'Island')
8+
for island in Island.objects.filter(key='sao-miguel'):
9+
flags = dict(island.feature_flags or {})
10+
if not flags.get('trails'):
11+
flags['trails'] = True
12+
island.feature_flags = flags
13+
island.save(update_fields=['feature_flags'])
14+
15+
16+
class Migration(migrations.Migration):
17+
dependencies = [
18+
('tenancy', '0006_enable_seismic_feature_flag'),
19+
]
20+
21+
operations = [
22+
migrations.RunPython(enable_trails, migrations.RunPython.noop),
23+
]

src/tenancy/tests/test_bootstrap.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,19 @@ def test_enabled_modules_omits_seismic_when_flag_false(self):
2222
island.save(update_fields=['feature_flags'])
2323
modules = enabled_modules(island)
2424
self.assertNotIn('seismic', modules)
25+
26+
def test_enabled_modules_includes_trails_when_flag_set(self):
27+
island = get_or_create_default_island()
28+
island.feature_flags = {**island.feature_flags, 'trails': True, 'transit': True}
29+
island.save(update_fields=['feature_flags'])
30+
modules = enabled_modules(island)
31+
self.assertIn('trails', modules)
32+
33+
def test_enabled_modules_omits_trails_when_flag_false(self):
34+
island = get_or_create_default_island()
35+
flags = dict(island.feature_flags or {})
36+
flags['trails'] = False
37+
island.feature_flags = flags
38+
island.save(update_fields=['feature_flags'])
39+
modules = enabled_modules(island)
40+
self.assertNotIn('trails', modules)

src/trails/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ class TrailAdmin(admin.ModelAdmin):
1818

1919
@admin.register(POI)
2020
class POIAdmin(admin.ModelAdmin):
21-
list_display = ('name', 'category', 'island')
21+
list_display = ('name', 'category', 'source_ref', 'island')
2222
list_filter = ('island', 'category')
23-
search_fields = ('name',)
23+
search_fields = ('name', 'source_ref')

src/trails/api_v3.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Trails v3 API."""
2+
3+
from __future__ import annotations
4+
5+
from rest_framework import status
6+
from rest_framework.decorators import api_view, permission_classes
7+
from rest_framework.permissions import AllowAny
8+
from rest_framework.request import Request
9+
from rest_framework.response import Response
10+
11+
from tenancy.services import for_island
12+
from trails.services import get_trail, list_pois, list_trails
13+
14+
15+
def _require_island(request: Request) -> Response | None:
16+
if request.island is None:
17+
return Response(
18+
{'error': {'code': 'island_required', 'message': 'Island context required'}},
19+
status=status.HTTP_400_BAD_REQUEST,
20+
)
21+
return None
22+
23+
24+
@api_view(['GET'])
25+
@permission_classes([AllowAny])
26+
def trails_list_view(request: Request) -> Response:
27+
err = _require_island(request)
28+
if err:
29+
return err
30+
31+
difficulty = request.GET.get('difficulty', '').strip()
32+
limit_raw = request.GET.get('limit', '50').strip()
33+
try:
34+
limit = int(limit_raw)
35+
except ValueError:
36+
limit = 50
37+
38+
with for_island(request.island):
39+
payload = list_trails(difficulty=difficulty, limit=limit)
40+
return Response(payload)
41+
42+
43+
@api_view(['GET'])
44+
@permission_classes([AllowAny])
45+
def trails_pois_view(request: Request) -> Response:
46+
err = _require_island(request)
47+
if err:
48+
return err
49+
50+
category = request.GET.get('category', '').strip()
51+
limit_raw = request.GET.get('limit', '50').strip()
52+
try:
53+
limit = int(limit_raw)
54+
except ValueError:
55+
limit = 50
56+
57+
with for_island(request.island):
58+
payload = list_pois(category=category, limit=limit)
59+
return Response(payload)
60+
61+
62+
@api_view(['GET'])
63+
@permission_classes([AllowAny])
64+
def trail_detail_view(request: Request, trail_id: int) -> Response:
65+
err = _require_island(request)
66+
if err:
67+
return err
68+
69+
with for_island(request.island):
70+
payload = get_trail(trail_id)
71+
if payload is None:
72+
return Response(
73+
{'error': {'code': 'not_found', 'message': 'Trail not found'}},
74+
status=status.HTTP_404_NOT_FOUND,
75+
)
76+
return Response(payload)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Add source_ref to POI for idempotent open-data sync."""
2+
3+
from django.db import migrations, models
4+
5+
6+
def backfill_poi_source_refs(apps, schema_editor):
7+
POI = apps.get_model('trails', 'POI')
8+
for poi in POI.objects.filter(source_ref__isnull=True):
9+
poi.source_ref = f'legacy-{poi.pk}'
10+
poi.save(update_fields=['source_ref'])
11+
12+
13+
class Migration(migrations.Migration):
14+
dependencies = [
15+
('trails', '0001_initial'),
16+
]
17+
18+
operations = [
19+
migrations.AddField(
20+
model_name='poi',
21+
name='source_ref',
22+
field=models.CharField(max_length=128, null=True),
23+
),
24+
migrations.RunPython(backfill_poi_source_refs, migrations.RunPython.noop),
25+
migrations.AlterField(
26+
model_name='poi',
27+
name='source_ref',
28+
field=models.CharField(max_length=128),
29+
),
30+
migrations.AlterUniqueTogether(
31+
name='poi',
32+
unique_together={('island', 'source_ref')},
33+
),
34+
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Register daily trails open-data sync task."""
2+
3+
from django.db import migrations
4+
5+
6+
def _daily_schedule(apps):
7+
CrontabSchedule = apps.get_model('django_celery_beat', 'CrontabSchedule')
8+
schedule, _ = CrontabSchedule.objects.get_or_create(
9+
minute='0',
10+
hour='3',
11+
day_of_week='*',
12+
day_of_month='*',
13+
month_of_year='*',
14+
defaults={'timezone': 'Atlantic/Azores'},
15+
)
16+
if schedule.timezone != 'Atlantic/Azores':
17+
schedule.timezone = 'Atlantic/Azores'
18+
schedule.save(update_fields=['timezone'])
19+
return schedule
20+
21+
22+
def register_sync_task(apps, schema_editor):
23+
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
24+
daily = _daily_schedule(apps)
25+
PeriodicTask.objects.update_or_create(
26+
name='trails.sync_open_data',
27+
defaults={
28+
'task': 'trails.sync_open_data',
29+
'crontab': daily,
30+
'enabled': True,
31+
},
32+
)
33+
34+
35+
class Migration(migrations.Migration):
36+
dependencies = [
37+
('trails', '0002_poi_source_ref'),
38+
('django_celery_beat', '__latest__'),
39+
]
40+
41+
operations = [
42+
migrations.RunPython(register_sync_task, migrations.RunPython.noop),
43+
]

src/trails/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ def __str__(self) -> str:
3636

3737

3838
class POI(TenantScopedModel):
39+
source_ref = models.CharField(max_length=128)
3940
name = models.CharField(max_length=200)
4041
category = models.CharField(max_length=64, blank=True, default='')
4142
latitude = models.FloatField()
4243
longitude = models.FloatField()
4344

4445
class Meta:
4546
ordering = ['name']
47+
unique_together = [('island', 'source_ref')]
4648
verbose_name = 'POI'
4749
verbose_name_plural = 'POIs'
4850

0 commit comments

Comments
 (0)