Skip to content

Commit 2434ff2

Browse files
hc-sousacursoragent
andcommitted
fix(trails): sync from Visit Azores instead of unreachable ArcGIS WFS
Switch trail ingestion to trails.visitazores.com (same source as azores-hub.net), filtered to São Miguel, with optional azores-hub metadata merge. Deploy bootstrap now queues every enabled external feed (news, seismic, trails) via feature flags. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2089fea commit 2434ff2

7 files changed

Lines changed: 522 additions & 11 deletions

File tree

src/shared/feed_syncs.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,33 @@ def _run_trails(island_key: str | None) -> dict[str, Any]:
3636
return {'status': 'ok', **sync_all_open_data(island_key=island_key)}
3737

3838

39+
FEED_MODULE_FLAGS: dict[str, str] = {
40+
'news': 'news',
41+
'seismic': 'seismic',
42+
'trails': 'trails',
43+
}
44+
3945
FEED_RUNNERS: dict[str, FeedRunner] = {
4046
'news': _run_news,
4147
'seismic': _run_seismic,
4248
'trails': _run_trails,
4349
}
4450

4551

52+
def enabled_feed_labels() -> list[str]:
53+
"""Feeds to refresh on deploy when at least one live island enables the module."""
54+
from tenancy.models import Island
55+
56+
enabled: set[str] = set()
57+
for island in Island.objects.filter(is_live=True):
58+
flags = island.feature_flags or {}
59+
for label, module_key in FEED_MODULE_FLAGS.items():
60+
if flags.get(module_key):
61+
enabled.add(label)
62+
labels = [label for label in FEED_LABELS if label in enabled]
63+
return labels or list(FEED_LABELS)
64+
65+
4666
def _queue_news(island_key: str | None):
4767
from news.tasks import poll_sources_task
4868

src/shared/management/commands/bootstrap_feed_syncs.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from django.core.management.base import BaseCommand
88

9-
from shared.feed_syncs import FEED_LABELS, queue_feed_sync
9+
from shared.feed_syncs import enabled_feed_labels, queue_feed_sync
1010

1111
logger = logging.getLogger(__name__)
1212

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

1717
def handle(self, *args: object, **options: object) -> None:
18+
labels = enabled_feed_labels()
1819
queued: list[str] = []
19-
for label in FEED_LABELS:
20+
for label in labels:
2021
try:
2122
info = queue_feed_sync(label)
2223
queued.append(info['task'])
2324
except Exception:
2425
logger.exception('bootstrap_feed_syncs failed to queue %s', label)
2526

2627
if queued:
27-
self.stdout.write(f'Queued feed sync tasks: {", ".join(queued)}')
28+
self.stdout.write(
29+
f'Queued feed sync tasks ({", ".join(labels)}): {", ".join(queued)}',
30+
)
2831
else:
2932
self.stdout.write('No feed sync tasks were queued (check Celery broker).')
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Deploy bootstrap feed sync command tests."""
2+
3+
from io import StringIO
4+
from unittest.mock import patch
5+
6+
from django.core.management import call_command
7+
from django.test import TestCase
8+
9+
from shared.feed_syncs import enabled_feed_labels
10+
from tenancy.services import get_or_create_default_island
11+
12+
13+
class BootstrapFeedSyncsTestCase(TestCase):
14+
def setUp(self):
15+
self.island = get_or_create_default_island()
16+
self.island.is_live = True
17+
self.island.feature_flags = {
18+
**(self.island.feature_flags or {}),
19+
'news': True,
20+
'seismic': True,
21+
'trails': True,
22+
}
23+
self.island.save()
24+
25+
def test_enabled_feed_labels_includes_all_external_modules(self):
26+
labels = enabled_feed_labels()
27+
self.assertEqual(labels, ['news', 'seismic', 'trails'])
28+
29+
def test_enabled_feed_labels_omits_disabled_modules(self):
30+
self.island.feature_flags = {
31+
**(self.island.feature_flags or {}),
32+
'news': True,
33+
'seismic': False,
34+
'trails': False,
35+
}
36+
self.island.save(update_fields=['feature_flags'])
37+
self.assertEqual(enabled_feed_labels(), ['news'])
38+
39+
@patch('shared.management.commands.bootstrap_feed_syncs.queue_feed_sync')
40+
def test_bootstrap_command_queues_enabled_feeds(self, mock_queue):
41+
mock_queue.side_effect = [
42+
{'task': 'news.poll_sources', 'celery_task_id': 'a'},
43+
{'task': 'seismic.sync_events', 'celery_task_id': 'b'},
44+
{'task': 'trails.sync_open_data', 'celery_task_id': 'c'},
45+
]
46+
out = StringIO()
47+
call_command('bootstrap_feed_syncs', stdout=out)
48+
self.assertEqual(mock_queue.call_count, 3)
49+
output = out.getvalue()
50+
self.assertIn('news.poll_sources', output)
51+
self.assertIn('trails.sync_open_data', output)

src/trails/services.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -490,13 +490,14 @@ def sync_open_data_for_island(island: Island) -> dict[str, int]:
490490
'skipped': 0,
491491
}
492492
try:
493-
trails_collection = fetch_dataset_geojson(_trails_dataset_id())
494-
trail_counts = sync_trails_for_island(island, collection=trails_collection)
493+
from trails.visitazores_sync import sync_visitazores_trails_for_island
494+
495+
trail_counts = sync_visitazores_trails_for_island(island)
495496
totals['trails_created'] += trail_counts['created']
496497
totals['trails_updated'] += trail_counts['updated']
497498
totals['skipped'] += trail_counts['skipped']
498499
except Exception:
499-
logger.exception('trails sync failed for island=%s', island.key)
500+
logger.exception('visitazores trails sync failed for island=%s', island.key)
500501
raise
501502

502503
try:
@@ -551,7 +552,7 @@ def serialize_trail_detail(trail: Trail) -> dict[str, Any]:
551552
**serialize_trail_summary(trail),
552553
'geojson': trail.geojson,
553554
'stages': stages,
554-
'attribution': OPEN_DATA_ATTRIBUTION,
555+
'attribution': trails_attribution(),
555556
}
556557

557558

@@ -565,14 +566,23 @@ def serialize_poi(poi: POI) -> dict[str, Any]:
565566
}
566567

567568

569+
def trails_attribution() -> str:
570+
try:
571+
from trails.visitazores_sync import VISITAZORES_ATTRIBUTION
572+
573+
return VISITAZORES_ATTRIBUTION
574+
except ImportError:
575+
return OPEN_DATA_ATTRIBUTION
576+
577+
568578
def list_trails(*, difficulty: str = '', limit: int = 50) -> dict[str, Any]:
569579
qs = Trail.objects.order_by('name')
570580
if difficulty:
571581
qs = qs.filter(difficulty__iexact=difficulty.strip())
572582
limit = max(1, min(limit, 100))
573583
return {
574584
'trails': [serialize_trail_summary(trail) for trail in qs[:limit]],
575-
'attribution': OPEN_DATA_ATTRIBUTION,
585+
'attribution': trails_attribution(),
576586
}
577587

578588

@@ -591,5 +601,5 @@ def list_pois(*, category: str = '', limit: int = 50) -> dict[str, Any]:
591601
limit = max(1, min(limit, 100))
592602
return {
593603
'pois': [serialize_poi(poi) for poi in qs[:limit]],
594-
'attribution': OPEN_DATA_ATTRIBUTION,
604+
'attribution': trails_attribution(),
595605
}

src/trails/tests/test_services.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ def test_sync_empty_collection(self):
119119
self.assertEqual(Trail.objects.count(), 0)
120120

121121
@patch('trails.services.fetch_dataset_geojson')
122-
def test_sync_open_data_for_island(self, mock_fetch):
123-
mock_fetch.side_effect = [SAMPLE_TRAIL_COLLECTION, SAMPLE_POI_COLLECTION]
122+
@patch('trails.visitazores_sync.sync_visitazores_trails_for_island')
123+
def test_sync_open_data_for_island(self, mock_trails, mock_fetch):
124+
mock_trails.return_value = {'created': 1, 'updated': 0, 'skipped': 0}
125+
mock_fetch.return_value = SAMPLE_POI_COLLECTION
124126
totals = sync_open_data_for_island(self.island)
125127
self.assertEqual(totals['trails_created'], 1)
126128
self.assertEqual(totals['pois_created'], 1)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Visit Azores trails sync tests."""
2+
3+
from unittest.mock import patch
4+
5+
from django.test import TestCase
6+
7+
from trails.visitazores_sync import (
8+
gpx_to_linestring,
9+
parse_trail_detail_page,
10+
sync_visitazores_trails_for_island,
11+
)
12+
from tenancy.services import get_or_create_default_island
13+
14+
SAMPLE_DETAIL_HTML = """
15+
<html><head>
16+
<meta property="og:title" content="Caldeiras da Ribeira Grande - Salto do Cabrito | Azores Trails" />
17+
<script>jQuery.extend(Drupal.settings, {"geofieldMap":{"map":{"data":{"type":"LineString","coordinates":[[-25.50,37.78],[-25.49,37.79]],"properties":{"description":"Caldeiras"}}}}});</script>
18+
</head><body>
19+
<div class="field field-name-field-difficulty"><div class="field-item even">Difficulty - Medium</div></div>
20+
<div class="field field-name-field-extension"><div class="field-item even">Extension - 8.6 km</div></div>
21+
PRC29SMI
22+
<a href="https://trails.visitazores.com/sites/default/files/trails/sao-miguel/prc29smi.gpx">GPS</a>
23+
</body></html>
24+
"""
25+
26+
SAMPLE_GPX = """<?xml version="1.0"?>
27+
<gpx xmlns="http://www.topografix.com/GPX/1/1">
28+
<trk><trkseg>
29+
<trkpt lat="37.78" lon="-25.50"/>
30+
<trkpt lat="37.79" lon="-25.49"/>
31+
</trkseg></trk>
32+
</gpx>"""
33+
34+
35+
class VisitAzoresSyncTestCase(TestCase):
36+
def setUp(self):
37+
self.island = get_or_create_default_island()
38+
self.island.feature_flags = {**self.island.feature_flags, 'trails': True}
39+
self.island.save()
40+
41+
def test_parse_trail_detail_page_extracts_fields(self):
42+
row = parse_trail_detail_page(SAMPLE_DETAIL_HTML, page_url='https://example.test/trail')
43+
assert row is not None
44+
self.assertEqual(row['source_ref'], 'PRC29SMI')
45+
self.assertEqual(row['name'], 'Caldeiras da Ribeira Grande - Salto do Cabrito')
46+
self.assertEqual(row['difficulty'], 'moderate')
47+
self.assertEqual(row['distance_km'], 8.6)
48+
self.assertEqual(row['geojson']['type'], 'LineString')
49+
50+
def test_gpx_to_linestring(self):
51+
geometry = gpx_to_linestring(SAMPLE_GPX)
52+
assert geometry is not None
53+
self.assertEqual(geometry['coordinates'][0], [-25.50, 37.78])
54+
55+
@patch('trails.visitazores_sync.fetch_feed_trail_summaries', return_value={})
56+
@patch('trails.visitazores_sync.fetch_island_trail_paths', return_value=['/en/trails-azores/sao-miguel/test'])
57+
@patch('trails.visitazores_sync._get_html', return_value=SAMPLE_DETAIL_HTML)
58+
def test_sync_visitazores_trails_for_island(self, *_mocks):
59+
counts = sync_visitazores_trails_for_island(self.island)
60+
self.assertEqual(counts['created'], 1)
61+
self.assertEqual(counts['skipped'], 0)

0 commit comments

Comments
 (0)