Skip to content

Commit aed9010

Browse files
committed
feat(changes): expose RSS feeds for all change views
This allows users to follow any changes search, not only these few predefined views. Fixes #7518
1 parent 19b71e6 commit aed9010

7 files changed

Lines changed: 282 additions & 45 deletions

File tree

docs/api.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3291,6 +3291,34 @@ RSS feeds
32913291

32923292
Changes in translations are exported in RSS feeds.
32933293

3294+
Filtered RSS feeds are available from the changes browser. These accept the same
3295+
filters as the changes page, for example ``action``, ``user``, ``exclude_user``,
3296+
and ``period``.
3297+
3298+
.. http:get:: /changes/rss/
3299+
3300+
Retrieves RSS feed with recent changes matching changes browsing filters.
3301+
3302+
.. http:get:: /changes/rss/(string:project)/(string:component)/(string:language)/
3303+
3304+
Retrieves RSS feed with recent changes matching changes browsing filters in a
3305+
translation.
3306+
3307+
.. http:get:: /changes/rss/(string:project)/(string:component)/
3308+
3309+
Retrieves RSS feed with recent changes matching changes browsing filters in a
3310+
component.
3311+
3312+
.. http:get:: /changes/rss/(string:project)/-/(string:language)/
3313+
3314+
Retrieves RSS feed with recent changes matching changes browsing filters in a
3315+
project language.
3316+
3317+
.. http:get:: /changes/rss/-/-/(string:language)/
3318+
3319+
Retrieves RSS feed with recent changes matching changes browsing filters in a
3320+
language.
3321+
32943322
.. http:get:: /exports/rss/(string:project)/(string:component)/(string:language)/
32953323
32963324
Retrieves RSS feed with recent changes for a translation.

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Weblate 2026.5
2828
* Documented Gettext-style :ref:`plural-formula` syntax and linked to the upstream GNU gettext references.
2929
* The Python wheel no longer ships source translation catalogs, test files, or deployment example files, reducing the installed package size.
3030
* The engage page now highlights actionable translation task buckets for newcomers.
31+
* :ref:`RSS feeds <rss>` can now use the same filters as the changes browsing page.
3132

3233
.. rubric:: Bug fixes
3334

weblate/templates/trans/change_list.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
class="btn btn-link">{% icon "download.svg" %}</a>
3939
{% endif %}
4040
{% endif %}
41-
<a href="{{ changes_rss }}"
41+
<a href="{{ changes_rss }}{% if query_string %}?{{ query_string }}{% endif %}"
4242
title="{% translate "Follow using RSS" %}"
4343
class="btn btn-link">{% icon "rss.svg" %}</a>
4444
</div>

weblate/trans/feeds.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@
1414

1515
from weblate.lang.models import Language
1616
from weblate.trans.models import Change, Component, Project, Translation, Unit
17+
from weblate.utils.site import get_site_url
1718
from weblate.utils.stats import ProjectLanguage
1819
from weblate.utils.views import parse_path
1920

2021
if TYPE_CHECKING:
2122
from weblate.auth.models import AuthenticatedHttpRequest, User
2223

2324

25+
def get_change_feed_guid(change: Change) -> str:
26+
return get_site_url(reverse("show_change", kwargs={"pk": change.pk}))
27+
28+
2429
class BaseFeed(Feed):
2530
def item_title(self, item):
2631
return item.get_action_display()
@@ -34,6 +39,12 @@ def item_author_name(self, item):
3439
def item_pubdate(self, item):
3540
return item.timestamp
3641

42+
def item_guid(self, item):
43+
return get_change_feed_guid(item)
44+
45+
def item_guid_is_permalink(self, item):
46+
return False
47+
3748

3849
class ChangesFeed(BaseFeed):
3950
"""Generic RSS feed for Weblate changes."""

weblate/trans/tests/test_changes.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
from django.utils import timezone
1212
from django.utils.http import urlencode
1313

14+
from weblate.trans.actions import ActionEvents
1415
from weblate.trans.feeds import TranslationChangesFeed
1516
from weblate.trans.models import Change, Unit
1617
from weblate.trans.tests.test_views import FixtureTestCase, ViewTestCase
18+
from weblate.utils.xml import parse_xml
1719

1820

1921
class FeedQueriesTest(FixtureTestCase):
@@ -61,10 +63,55 @@ def test_translation_feed_queries_do_not_scale_with_change_count(self) -> None:
6163

6264

6365
class ChangesTest(ViewTestCase):
66+
def assert_rss_response(self, response) -> None:
67+
self.assertEqual(response.status_code, 200)
68+
self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8")
69+
self.assertContains(response, "<rss")
70+
71+
def add_filtered_rss_changes(self) -> None:
72+
Change.objects.all().delete()
73+
self.change_unit("Initial RSS target\n", user=self.user)
74+
Change.objects.all().delete()
75+
self.change_unit("Current user RSS target\n", user=self.user)
76+
self.change_unit("Another user RSS target\n", user=self.anotheruser)
77+
78+
def add_same_unit_rss_changes(self) -> None:
79+
Change.objects.all().delete()
80+
self.change_unit("Initial RSS target\n", user=self.user)
81+
Change.objects.all().delete()
82+
self.change_unit("First RSS target\n", user=self.user)
83+
self.change_unit("Second RSS target\n", user=self.user)
84+
85+
def assert_per_change_rss_guids(self, url: str) -> None:
86+
self.add_same_unit_rss_changes()
87+
response = self.client.get(url)
88+
self.assert_rss_response(response)
89+
items = parse_xml(response.content).findall("./channel/item")
90+
self.assertGreaterEqual(len(items), 2)
91+
links = [item.findtext("link") for item in items[:2]]
92+
guids = [item.find("guid") for item in items[:2]]
93+
self.assertEqual(len(set(links)), 1)
94+
self.assertEqual(len({guid.text for guid in guids if guid is not None}), 2)
95+
for guid in guids:
96+
self.assertIsNotNone(guid)
97+
self.assertEqual(guid.attrib["isPermaLink"], "false")
98+
self.assertIn("/changes/render/", guid.text)
99+
64100
def test_basic(self) -> None:
65101
response = self.client.get(reverse("changes"))
66102
self.assertContains(response, "Resource update")
67103

104+
def test_basic_rss(self) -> None:
105+
response = self.client.get(reverse("changes-rss"))
106+
self.assert_rss_response(response)
107+
self.assertContains(response, "Resource update")
108+
109+
def test_changes_rss_guids_identify_changes(self) -> None:
110+
self.assert_per_change_rss_guids(reverse("changes-rss"))
111+
112+
def test_export_rss_guids_identify_changes(self) -> None:
113+
self.assert_per_change_rss_guids(reverse("rss"))
114+
68115
def test_basic_csv_denied(self) -> None:
69116
response = self.client.get(reverse("changes-csv"))
70117
self.assertEqual(response.status_code, 403)
@@ -113,6 +160,13 @@ def test_user(self) -> None:
113160
)
114161
self.assertNotContains(response, f'title="{self.user.full_name}"')
115162

163+
def test_user_rss(self) -> None:
164+
self.add_filtered_rss_changes()
165+
response = self.client.get(reverse("changes-rss"), {"user": self.user.username})
166+
self.assert_rss_response(response)
167+
self.assertContains(response, "testuser")
168+
self.assertNotContains(response, "Jane Doe")
169+
116170
def test_exclude_user(self) -> None:
117171
self.edit_unit("Hello, world!\n", "Nazdar svete!\n")
118172
response = self.client.get(reverse("changes"))
@@ -130,13 +184,76 @@ def test_exclude_user(self) -> None:
130184
)
131185
self.assertContains(response, f'title="{self.user.full_name}"')
132186

187+
def test_exclude_user_rss(self) -> None:
188+
self.add_filtered_rss_changes()
189+
response = self.client.get(
190+
reverse("changes-rss"), {"exclude_user": self.user.username}
191+
)
192+
self.assert_rss_response(response)
193+
self.assertNotContains(response, "Weblate Test")
194+
self.assertContains(response, "Jane Doe")
195+
196+
def test_action_rss(self) -> None:
197+
Change.objects.all().delete()
198+
self.change_unit("Initial RSS target\n", user=self.user)
199+
Change.objects.all().delete()
200+
self.change_unit("Current user RSS target\n", user=self.user)
201+
response = self.client.get(
202+
reverse("changes-rss"), {"action": ActionEvents.CHANGE}
203+
)
204+
self.assert_rss_response(response)
205+
self.assertContains(response, "Translation changed")
206+
response = self.client.get(
207+
reverse("changes-rss"), {"action": ActionEvents.UPDATE}
208+
)
209+
self.assert_rss_response(response)
210+
self.assertNotContains(response, "Translation changed")
211+
133212
def test_daterange(self) -> None:
134213
end = timezone.now()
135214
start = end - timedelta(days=1)
136215
period = f"{start.strftime('%m/%d/%Y')} - {end.strftime('%m/%d/%Y')}"
137216
response = self.client.get(reverse("changes"), {"period": period})
138217
self.assertContains(response, "Resource update")
139218

219+
def test_daterange_rss(self) -> None:
220+
Change.objects.all().delete()
221+
self.change_unit("Initial RSS target\n", user=self.user)
222+
Change.objects.all().delete()
223+
self.change_unit("Current user RSS target\n", user=self.user)
224+
end = timezone.now()
225+
start = end - timedelta(days=1)
226+
period = f"{start.strftime('%m/%d/%Y')} - {end.strftime('%m/%d/%Y')}"
227+
response = self.client.get(reverse("changes-rss"), {"period": period})
228+
self.assert_rss_response(response)
229+
self.assertContains(response, "Translation changed")
230+
response = self.client.get(
231+
reverse("changes-rss"), {"period": "01/01/2020 - 01/02/2020"}
232+
)
233+
self.assert_rss_response(response)
234+
self.assertNotContains(response, "Translation changed")
235+
236+
def test_scoped_rss(self) -> None:
237+
Change.objects.all().delete()
238+
self.change_unit("Initial RSS target\n", user=self.user)
239+
Change.objects.all().delete()
240+
unit = self.change_unit("Scoped RSS target\n", user=self.user)
241+
paths = (
242+
self.project.get_url_path(),
243+
self.component.get_url_path(),
244+
self.translation.get_url_path(),
245+
["-", "-", self.translation.language.code],
246+
[self.project.slug, "-", self.translation.language.code],
247+
unit.get_url_path(),
248+
)
249+
for path in paths:
250+
with self.subTest(path=path):
251+
response = self.client.get(
252+
reverse("changes-rss", kwargs={"path": path})
253+
)
254+
self.assert_rss_response(response)
255+
self.assertContains(response, "Translation changed")
256+
140257
def test_pagination(self) -> None:
141258
end = timezone.now()
142259
start = end - timedelta(days=1)
@@ -149,6 +266,11 @@ def test_pagination(self) -> None:
149266
)
150267
self.assertContains(response, "String added in the upload")
151268

269+
def test_rss_link_keeps_query_string(self) -> None:
270+
response = self.client.get(reverse("changes"), {"user": self.user.username})
271+
query_string = urlencode({"user": self.user.username})
272+
self.assertContains(response, f"{reverse('changes-rss')}?{query_string}")
273+
152274
def test_last_changes_display(self) -> None:
153275
unit_to_delete = self.get_unit("Orangutan has %d banana")
154276
unit_to_delete.context = "Orangutan unit context"

0 commit comments

Comments
 (0)