Skip to content

Commit 3d25937

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 1fd6931 commit 3d25937

6 files changed

Lines changed: 241 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
@@ -26,6 +26,7 @@ Weblate 2026.5
2626
* The translating page now separates screenshots from string information, collapses rarely used string details, and groups glossary and screenshot actions more consistently.
2727
* Project access management now paginates users and better explains site-wide automatic team assignments.
2828
* Documented Gettext-style :ref:`plural-formula` syntax and linked to the upstream GNU gettext references.
29+
* :ref:`RSS feeds <rss>` can now use the same filters as the changes browsing page.
2930

3031
.. rubric:: Bug fixes
3132

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/tests/test_changes.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
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
@@ -61,10 +62,27 @@ def test_translation_feed_queries_do_not_scale_with_change_count(self) -> None:
6162

6263

6364
class ChangesTest(ViewTestCase):
65+
def assert_rss_response(self, response) -> None:
66+
self.assertEqual(response.status_code, 200)
67+
self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8")
68+
self.assertContains(response, "<rss")
69+
70+
def add_filtered_rss_changes(self) -> None:
71+
Change.objects.all().delete()
72+
self.change_unit("Initial RSS target\n", user=self.user)
73+
Change.objects.all().delete()
74+
self.change_unit("Current user RSS target\n", user=self.user)
75+
self.change_unit("Another user RSS target\n", user=self.anotheruser)
76+
6477
def test_basic(self) -> None:
6578
response = self.client.get(reverse("changes"))
6679
self.assertContains(response, "Resource update")
6780

81+
def test_basic_rss(self) -> None:
82+
response = self.client.get(reverse("changes-rss"))
83+
self.assert_rss_response(response)
84+
self.assertContains(response, "Resource update")
85+
6886
def test_basic_csv_denied(self) -> None:
6987
response = self.client.get(reverse("changes-csv"))
7088
self.assertEqual(response.status_code, 403)
@@ -113,6 +131,13 @@ def test_user(self) -> None:
113131
)
114132
self.assertNotContains(response, f'title="{self.user.full_name}"')
115133

134+
def test_user_rss(self) -> None:
135+
self.add_filtered_rss_changes()
136+
response = self.client.get(reverse("changes-rss"), {"user": self.user.username})
137+
self.assert_rss_response(response)
138+
self.assertContains(response, "testuser")
139+
self.assertNotContains(response, "Jane Doe")
140+
116141
def test_exclude_user(self) -> None:
117142
self.edit_unit("Hello, world!\n", "Nazdar svete!\n")
118143
response = self.client.get(reverse("changes"))
@@ -130,13 +155,76 @@ def test_exclude_user(self) -> None:
130155
)
131156
self.assertContains(response, f'title="{self.user.full_name}"')
132157

158+
def test_exclude_user_rss(self) -> None:
159+
self.add_filtered_rss_changes()
160+
response = self.client.get(
161+
reverse("changes-rss"), {"exclude_user": self.user.username}
162+
)
163+
self.assert_rss_response(response)
164+
self.assertNotContains(response, "Weblate Test")
165+
self.assertContains(response, "Jane Doe")
166+
167+
def test_action_rss(self) -> None:
168+
Change.objects.all().delete()
169+
self.change_unit("Initial RSS target\n", user=self.user)
170+
Change.objects.all().delete()
171+
self.change_unit("Current user RSS target\n", user=self.user)
172+
response = self.client.get(
173+
reverse("changes-rss"), {"action": ActionEvents.CHANGE}
174+
)
175+
self.assert_rss_response(response)
176+
self.assertContains(response, "Translation changed")
177+
response = self.client.get(
178+
reverse("changes-rss"), {"action": ActionEvents.UPDATE}
179+
)
180+
self.assert_rss_response(response)
181+
self.assertNotContains(response, "Translation changed")
182+
133183
def test_daterange(self) -> None:
134184
end = timezone.now()
135185
start = end - timedelta(days=1)
136186
period = f"{start.strftime('%m/%d/%Y')} - {end.strftime('%m/%d/%Y')}"
137187
response = self.client.get(reverse("changes"), {"period": period})
138188
self.assertContains(response, "Resource update")
139189

190+
def test_daterange_rss(self) -> None:
191+
Change.objects.all().delete()
192+
self.change_unit("Initial RSS target\n", user=self.user)
193+
Change.objects.all().delete()
194+
self.change_unit("Current user RSS target\n", user=self.user)
195+
end = timezone.now()
196+
start = end - timedelta(days=1)
197+
period = f"{start.strftime('%m/%d/%Y')} - {end.strftime('%m/%d/%Y')}"
198+
response = self.client.get(reverse("changes-rss"), {"period": period})
199+
self.assert_rss_response(response)
200+
self.assertContains(response, "Translation changed")
201+
response = self.client.get(
202+
reverse("changes-rss"), {"period": "01/01/2020 - 01/02/2020"}
203+
)
204+
self.assert_rss_response(response)
205+
self.assertNotContains(response, "Translation changed")
206+
207+
def test_scoped_rss(self) -> None:
208+
Change.objects.all().delete()
209+
self.change_unit("Initial RSS target\n", user=self.user)
210+
Change.objects.all().delete()
211+
unit = self.change_unit("Scoped RSS target\n", user=self.user)
212+
paths = (
213+
self.project.get_url_path(),
214+
self.component.get_url_path(),
215+
self.translation.get_url_path(),
216+
["-", "-", self.translation.language.code],
217+
[self.project.slug, "-", self.translation.language.code],
218+
unit.get_url_path(),
219+
)
220+
for path in paths:
221+
with self.subTest(path=path):
222+
response = self.client.get(
223+
reverse("changes-rss", kwargs={"path": path})
224+
)
225+
self.assert_rss_response(response)
226+
self.assertContains(response, "Translation changed")
227+
140228
def test_pagination(self) -> None:
141229
end = timezone.now()
142230
start = end - timedelta(days=1)
@@ -149,6 +237,11 @@ def test_pagination(self) -> None:
149237
)
150238
self.assertContains(response, "String added in the upload")
151239

240+
def test_rss_link_keeps_query_string(self) -> None:
241+
response = self.client.get(reverse("changes"), {"user": self.user.username})
242+
query_string = urlencode({"user": self.user.username})
243+
self.assertContains(response, f"{reverse('changes-rss')}?{query_string}")
244+
152245
def test_last_changes_display(self) -> None:
153246
unit_to_delete = self.get_unit("Orangutan has %d banana")
154247
unit_to_delete.context = "Orangutan unit context"

weblate/trans/views/changes.py

Lines changed: 108 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import csv
88
from typing import TYPE_CHECKING
99

10+
from django.conf import settings
1011
from django.contrib.auth.decorators import login_required
1112
from django.core.exceptions import PermissionDenied
1213
from django.http import HttpResponse
1314
from django.shortcuts import get_object_or_404, redirect
1415
from django.urls import reverse
1516
from django.urls.exceptions import NoReverseMatch
16-
from django.utils.translation import activate, gettext, pgettext
17+
from django.utils import feedgenerator
18+
from django.utils.translation import activate, get_language, gettext, pgettext
1719
from django.views.generic.list import ListView
1820

1921
from weblate.accounts.notifications import NOTIFICATIONS_ACTIONS
@@ -45,6 +47,44 @@ class ChangesView(PathViewMixin, ListView):
4547
Unit,
4648
)
4749

50+
def get_changes_url(self, url_name: str = "changes") -> str:
51+
if self.path_object is None:
52+
return reverse(url_name)
53+
return reverse(url_name, kwargs={"path": self.path_object.get_url_path()})
54+
55+
def get_filtered_changes_url(self) -> str:
56+
url = self.get_changes_url()
57+
if self.changes_form.is_valid() and (
58+
query_string := self.changes_form.urlencode()
59+
):
60+
return f"{url}?{query_string}"
61+
return url
62+
63+
def get_title(self):
64+
if isinstance(self.path_object, Unit):
65+
return (
66+
pgettext(
67+
"Changes of string in a translation", "Changes of string in %s"
68+
)
69+
% self.path_object
70+
)
71+
if isinstance(self.path_object, Translation):
72+
return (
73+
pgettext("Changes in translation", "Changes in %s") % self.path_object
74+
)
75+
if isinstance(self.path_object, Component):
76+
return pgettext("Changes in component", "Changes in %s") % self.path_object
77+
if isinstance(self.path_object, Project):
78+
return pgettext("Changes in project", "Changes in %s") % self.path_object
79+
if isinstance(self.path_object, Language):
80+
return pgettext("Changes in language", "Changes in %s") % self.path_object
81+
if isinstance(self.path_object, ProjectLanguage):
82+
return pgettext("Changes in project", "Changes in %s") % self.path_object
83+
if self.path_object is None:
84+
return gettext("Changes")
85+
msg = f"Unsupported {self.path_object}"
86+
raise TypeError(msg)
87+
4888
def get_template_names(self):
4989
if digest := self.request.GET.get("digest"):
5090
if digest in {"pending_suggestions", "todo_strings"}:
@@ -56,46 +96,8 @@ def get_context_data(self, **kwargs):
5696
"""Create context for rendering page."""
5797
context = super().get_context_data(**kwargs)
5898
context["path_object"] = self.path_object
59-
60-
if isinstance(self.path_object, Unit):
61-
context["title"] = (
62-
pgettext(
63-
"Changes of string in a translation", "Changes of string in %s"
64-
)
65-
% self.path_object
66-
)
67-
elif isinstance(self.path_object, Translation):
68-
context["title"] = (
69-
pgettext("Changes in translation", "Changes in %s") % self.path_object
70-
)
71-
elif isinstance(self.path_object, Component):
72-
context["title"] = (
73-
pgettext("Changes in component", "Changes in %s") % self.path_object
74-
)
75-
elif isinstance(self.path_object, Project):
76-
context["title"] = (
77-
pgettext("Changes in project", "Changes in %s") % self.path_object
78-
)
79-
elif isinstance(self.path_object, Language):
80-
context["title"] = (
81-
pgettext("Changes in language", "Changes in %s") % self.path_object
82-
)
83-
elif isinstance(self.path_object, ProjectLanguage):
84-
context["title"] = (
85-
pgettext("Changes in project", "Changes in %s") % self.path_object
86-
)
87-
elif self.path_object is None:
88-
context["title"] = gettext("Changes")
89-
else:
90-
msg = f"Unsupported {self.path_object}"
91-
raise TypeError(msg)
92-
93-
if self.path_object is None:
94-
context["changes_rss"] = reverse("rss")
95-
else:
96-
context["changes_rss"] = reverse(
97-
"rss", kwargs={"path": self.path_object.get_url_path()}
98-
)
99+
context["title"] = self.get_title()
100+
context["changes_rss"] = self.get_changes_url("changes-rss")
99101

100102
if self.changes_form.is_valid():
101103
context["query_string"] = self.changes_form.urlencode()
@@ -126,7 +128,9 @@ def get_request_param(self, request: AuthenticatedHttpRequest, param: str) -> st
126128
return "-"
127129
return value
128130

129-
def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
131+
def get( # type: ignore[override]
132+
self, request: AuthenticatedHttpRequest, *args, **kwargs
133+
):
130134
if self.path_object is None and self.request.GET:
131135
# Handle GET params for filtering prior Weblate 5.0
132136
path = None
@@ -220,7 +224,9 @@ class ChangesCSVView(ChangesView):
220224

221225
paginate_by = None
222226

223-
def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
227+
def get( # type: ignore[override]
228+
self, request: AuthenticatedHttpRequest, *args, **kwargs
229+
):
224230
object_list = self.get_queryset()[:2000]
225231

226232
if not request.user.has_perm("change.download", self.path_object):
@@ -254,6 +260,65 @@ def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: igno
254260
return response
255261

256262

263+
class ChangesRSSView(ChangesView):
264+
"""RSS renderer for changes view."""
265+
266+
feed_count = 10
267+
paginate_by = None
268+
269+
def get_feed_title(self):
270+
if self.path_object is None:
271+
# Translators: %s is site title here
272+
return gettext("Recent changes on %s") % settings.SITE_TITLE
273+
# Translators: %s is translation/project/component/language name
274+
return gettext("Recent changes in %s") % self.path_object
275+
276+
def get_feed_description(self):
277+
if self.path_object is None:
278+
# Translators: %s is site title here
279+
return gettext("All recent changes made using Weblate on %s.") % (
280+
settings.SITE_TITLE
281+
)
282+
# Translators: %s is translation/project/component/language name
283+
return (
284+
gettext("All recent changes made using Weblate in %s.") % self.path_object
285+
)
286+
287+
def get( # type: ignore[override]
288+
self, request: AuthenticatedHttpRequest, *args, **kwargs
289+
):
290+
if self.changes_form.is_valid():
291+
object_list = Change.objects.preload_list(
292+
list(self.get_queryset()[: self.feed_count])
293+
)
294+
else:
295+
object_list = []
296+
297+
feed = feedgenerator.Rss201rev2Feed(
298+
title=self.get_feed_title(),
299+
link=get_site_url(self.get_filtered_changes_url()),
300+
description=self.get_feed_description(),
301+
language=get_language(),
302+
feed_url=get_site_url(request.get_full_path()),
303+
)
304+
305+
for change in object_list:
306+
link = get_site_url(change.get_absolute_url())
307+
feed.add_item(
308+
title=change.get_action_display(),
309+
link=link,
310+
description=str(change),
311+
author_name=change.get_user_display(False),
312+
pubdate=change.timestamp,
313+
unique_id=link,
314+
unique_id_is_permalink=True,
315+
)
316+
317+
response = HttpResponse(content_type=feed.content_type)
318+
feed.write(response, "utf-8")
319+
return response
320+
321+
257322
@login_required
258323
def show_change(request: AuthenticatedHttpRequest, pk: int):
259324
change = get_object_or_404(Change, pk=pk)

0 commit comments

Comments
 (0)