Skip to content

Commit 19700f0

Browse files
hc-sousacursoragent
andcommitted
feat(news): Phase 3 feeds — news RSS, seismic/trails scaffold, Beat schedules
Add news/seismic/trails apps with v3 news API and hourly RSS poll, GDPR Beat migration, consentPolicyVersion on bootstrap, and sao-miguel news flag. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 00392d2 commit 19700f0

32 files changed

Lines changed: 998 additions & 0 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Register GDPR Celery Beat periodic tasks (idempotent)."""
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 _upsert_task(apps, *, name: str, task: str, schedule) -> None:
23+
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
24+
PeriodicTask.objects.update_or_create(
25+
name=name,
26+
defaults={
27+
'task': task,
28+
'crontab': schedule,
29+
'enabled': True,
30+
},
31+
)
32+
33+
34+
def register_gdpr_periodic_tasks(apps, schema_editor):
35+
daily = _daily_schedule(apps)
36+
_upsert_task(apps, name='consent.rotate_session_salt', task='consent.rotate_session_salt', schedule=daily)
37+
_upsert_task(apps, name='consent.expire_consent', task='consent.expire_consent', schedule=daily)
38+
_upsert_task(apps, name='analytics.anonymize_events', task='analytics.anonymize_events', schedule=daily)
39+
40+
41+
class Migration(migrations.Migration):
42+
dependencies = [
43+
('consent', '0001_initial'),
44+
('django_celery_beat', '__latest__'),
45+
]
46+
47+
operations = [
48+
migrations.RunPython(register_gdpr_periodic_tasks, migrations.RunPython.noop),
49+
]

src/news/__init__.py

Whitespace-only changes.

src/news/admin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from django.contrib import admin
2+
3+
from news.models import NewsArticle, NewsSource
4+
5+
6+
@admin.register(NewsSource)
7+
class NewsSourceAdmin(admin.ModelAdmin):
8+
list_display = ('name', 'island', 'language', 'active', 'rss_url')
9+
list_filter = ('active', 'language', 'island')
10+
search_fields = ('name', 'rss_url')
11+
12+
13+
@admin.register(NewsArticle)
14+
class NewsArticleAdmin(admin.ModelAdmin):
15+
list_display = ('title', 'source', 'island', 'published_at', 'category')
16+
list_filter = ('island', 'category', 'source')
17+
search_fields = ('title', 'link')
18+
date_hierarchy = 'published_at'

src/news/api_v3.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""News 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 news.services import get_article, list_articles
12+
from tenancy.services import for_island
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 news_articles_view(request: Request) -> Response:
27+
err = _require_island(request)
28+
if err:
29+
return err
30+
31+
category = request.GET.get('category', '').strip()
32+
query = request.GET.get('q', '').strip()
33+
source_raw = request.GET.get('source', '').strip()
34+
source_id = int(source_raw) if source_raw.isdigit() else None
35+
limit_raw = request.GET.get('limit', '50').strip()
36+
try:
37+
limit = int(limit_raw)
38+
except ValueError:
39+
limit = 50
40+
41+
with for_island(request.island):
42+
articles = list_articles(
43+
category=category,
44+
source_id=source_id,
45+
query=query,
46+
limit=limit,
47+
)
48+
return Response({'articles': articles})
49+
50+
51+
@api_view(['GET'])
52+
@permission_classes([AllowAny])
53+
def news_article_detail_view(request: Request, article_id: int) -> Response:
54+
err = _require_island(request)
55+
if err:
56+
return err
57+
58+
with for_island(request.island):
59+
payload = get_article(article_id)
60+
if payload is None:
61+
return Response(
62+
{'error': {'code': 'not_found', 'message': 'Article not found'}},
63+
status=status.HTTP_404_NOT_FOUND,
64+
)
65+
return Response(payload)

src/news/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.apps import AppConfig
2+
3+
4+
class NewsConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'news'
7+
verbose_name = 'Local news'
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Generated by Django 4.2.29 on 2026-06-02 13:08
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
initial = True
10+
11+
dependencies = [
12+
("tenancy", "0005_enable_news_feature_flag"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="NewsSource",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
("legacy_ref", models.JSONField(blank=True, default=dict)),
29+
("name", models.CharField(max_length=120)),
30+
("rss_url", models.URLField(max_length=500)),
31+
("language", models.CharField(default="pt", max_length=8)),
32+
("active", models.BooleanField(default=True)),
33+
(
34+
"island",
35+
models.ForeignKey(
36+
on_delete=django.db.models.deletion.PROTECT, to="tenancy.island"
37+
),
38+
),
39+
],
40+
options={
41+
"ordering": ["name"],
42+
"unique_together": {("island", "rss_url")},
43+
},
44+
),
45+
migrations.CreateModel(
46+
name="NewsArticle",
47+
fields=[
48+
(
49+
"id",
50+
models.BigAutoField(
51+
auto_created=True,
52+
primary_key=True,
53+
serialize=False,
54+
verbose_name="ID",
55+
),
56+
),
57+
("legacy_ref", models.JSONField(blank=True, default=dict)),
58+
("title", models.CharField(max_length=500)),
59+
("summary", models.TextField(blank=True, default="")),
60+
("link", models.URLField(max_length=1000)),
61+
("published_at", models.DateTimeField(db_index=True)),
62+
("category", models.CharField(blank=True, default="", max_length=64)),
63+
("content_hash", models.CharField(db_index=True, max_length=64)),
64+
(
65+
"island",
66+
models.ForeignKey(
67+
on_delete=django.db.models.deletion.PROTECT, to="tenancy.island"
68+
),
69+
),
70+
(
71+
"source",
72+
models.ForeignKey(
73+
on_delete=django.db.models.deletion.CASCADE,
74+
related_name="articles",
75+
to="news.newssource",
76+
),
77+
),
78+
],
79+
options={
80+
"ordering": ["-published_at"],
81+
"indexes": [
82+
models.Index(
83+
fields=["island", "-published_at"],
84+
name="news_newsar_island__239080_idx",
85+
),
86+
models.Index(
87+
fields=["island", "category"],
88+
name="news_newsar_island__e4cf61_idx",
89+
),
90+
],
91+
"unique_together": {("island", "link")},
92+
},
93+
),
94+
]
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Seed Azores news RSS sources and register hourly poll task."""
2+
3+
from django.db import migrations
4+
5+
DEFAULT_SOURCES = (
6+
{
7+
'name': 'Açoriano Oriental',
8+
'rss_url': 'https://www.acorianooriental.pt/rss/',
9+
'language': 'pt',
10+
},
11+
{
12+
'name': 'Jornal dos Açores',
13+
'rss_url': 'https://www.jornaldosacores.com/feed/',
14+
'language': 'pt',
15+
},
16+
)
17+
18+
19+
def _hourly_schedule(apps):
20+
CrontabSchedule = apps.get_model('django_celery_beat', 'CrontabSchedule')
21+
schedule, _ = CrontabSchedule.objects.get_or_create(
22+
minute='0',
23+
hour='*',
24+
day_of_week='*',
25+
day_of_month='*',
26+
month_of_year='*',
27+
defaults={'timezone': 'Atlantic/Azores'},
28+
)
29+
if schedule.timezone != 'Atlantic/Azores':
30+
schedule.timezone = 'Atlantic/Azores'
31+
schedule.save(update_fields=['timezone'])
32+
return schedule
33+
34+
35+
def seed_sources_and_beat(apps, schema_editor):
36+
Island = apps.get_model('tenancy', 'Island')
37+
NewsSource = apps.get_model('news', 'NewsSource')
38+
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
39+
40+
island = Island.objects.filter(key='sao-miguel').first()
41+
if island:
42+
for row in DEFAULT_SOURCES:
43+
NewsSource.objects.update_or_create(
44+
island=island,
45+
rss_url=row['rss_url'],
46+
defaults={
47+
'name': row['name'],
48+
'language': row['language'],
49+
'active': True,
50+
},
51+
)
52+
53+
hourly = _hourly_schedule(apps)
54+
PeriodicTask.objects.update_or_create(
55+
name='news.poll_sources',
56+
defaults={
57+
'task': 'news.poll_sources',
58+
'crontab': hourly,
59+
'enabled': True,
60+
},
61+
)
62+
63+
64+
class Migration(migrations.Migration):
65+
dependencies = [
66+
('news', '0001_initial'),
67+
('tenancy', '0005_enable_news_feature_flag'),
68+
('django_celery_beat', '__latest__'),
69+
]
70+
71+
operations = [
72+
migrations.RunPython(seed_sources_and_beat, migrations.RunPython.noop),
73+
]

src/news/migrations/__init__.py

Whitespace-only changes.

src/news/models.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""News RSS sources and articles."""
2+
3+
from __future__ import annotations
4+
5+
from django.db import models
6+
7+
from tenancy.models import TenantScopedModel
8+
9+
10+
class NewsSource(TenantScopedModel):
11+
name = models.CharField(max_length=120)
12+
rss_url = models.URLField(max_length=500)
13+
language = models.CharField(max_length=8, default='pt')
14+
active = models.BooleanField(default=True)
15+
16+
class Meta:
17+
ordering = ['name']
18+
unique_together = [('island', 'rss_url')]
19+
20+
def __str__(self) -> str:
21+
return self.name
22+
23+
24+
class NewsArticle(TenantScopedModel):
25+
source = models.ForeignKey(NewsSource, on_delete=models.CASCADE, related_name='articles')
26+
title = models.CharField(max_length=500)
27+
summary = models.TextField(blank=True, default='')
28+
link = models.URLField(max_length=1000)
29+
published_at = models.DateTimeField(db_index=True)
30+
category = models.CharField(max_length=64, blank=True, default='')
31+
content_hash = models.CharField(max_length=64, db_index=True)
32+
33+
class Meta:
34+
ordering = ['-published_at']
35+
unique_together = [('island', 'link')]
36+
indexes = [
37+
models.Index(fields=['island', '-published_at']),
38+
models.Index(fields=['island', 'category']),
39+
]
40+
41+
def __str__(self) -> str:
42+
return self.title[:80]

0 commit comments

Comments
 (0)