diff --git a/docs/api.rst b/docs/api.rst index e74bb0d34f74..b30beff12063 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3291,6 +3291,34 @@ RSS feeds Changes in translations are exported in RSS feeds. +Filtered RSS feeds are available from the changes browser. These accept the same +filters as the changes page, for example ``action``, ``user``, ``exclude_user``, +and ``period``. + +.. http:get:: /changes/rss/ + + Retrieves RSS feed with recent changes matching changes browsing filters. + +.. http:get:: /changes/rss/(string:project)/(string:component)/(string:language)/ + + Retrieves RSS feed with recent changes matching changes browsing filters in a + translation. + +.. http:get:: /changes/rss/(string:project)/(string:component)/ + + Retrieves RSS feed with recent changes matching changes browsing filters in a + component. + +.. http:get:: /changes/rss/(string:project)/-/(string:language)/ + + Retrieves RSS feed with recent changes matching changes browsing filters in a + project language. + +.. http:get:: /changes/rss/-/-/(string:language)/ + + Retrieves RSS feed with recent changes matching changes browsing filters in a + language. + .. http:get:: /exports/rss/(string:project)/(string:component)/(string:language)/ Retrieves RSS feed with recent changes for a translation. diff --git a/docs/changes.rst b/docs/changes.rst index 090a38c2defd..ec45be3ff3ce 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -28,6 +28,7 @@ Weblate 2026.5 * Documented Gettext-style :ref:`plural-formula` syntax and linked to the upstream GNU gettext references. * The Python wheel no longer ships source translation catalogs, test files, or deployment example files, reducing the installed package size. * The engage page now highlights actionable translation task buckets for newcomers. +* :ref:`RSS feeds ` can now use the same filters as the changes browsing page. .. rubric:: Bug fixes diff --git a/weblate/templates/trans/change_list.html b/weblate/templates/trans/change_list.html index b59cb7773e2b..7332170adb2f 100644 --- a/weblate/templates/trans/change_list.html +++ b/weblate/templates/trans/change_list.html @@ -38,7 +38,7 @@ class="btn btn-link">{% icon "download.svg" %} {% endif %} {% endif %} - {% icon "rss.svg" %} diff --git a/weblate/trans/feeds.py b/weblate/trans/feeds.py index 217b0d832037..7866e4d9bac1 100644 --- a/weblate/trans/feeds.py +++ b/weblate/trans/feeds.py @@ -14,6 +14,7 @@ from weblate.lang.models import Language from weblate.trans.models import Change, Component, Project, Translation, Unit +from weblate.utils.site import get_site_url from weblate.utils.stats import ProjectLanguage from weblate.utils.views import parse_path @@ -21,6 +22,10 @@ from weblate.auth.models import AuthenticatedHttpRequest, User +def get_change_feed_guid(change: Change) -> str: + return get_site_url(reverse("show_change", kwargs={"pk": change.pk})) + + class BaseFeed(Feed): def item_title(self, item): return item.get_action_display() @@ -34,6 +39,12 @@ def item_author_name(self, item): def item_pubdate(self, item): return item.timestamp + def item_guid(self, item): + return get_change_feed_guid(item) + + def item_guid_is_permalink(self, item): + return False + class ChangesFeed(BaseFeed): """Generic RSS feed for Weblate changes.""" diff --git a/weblate/trans/tests/test_changes.py b/weblate/trans/tests/test_changes.py index 290137cd24b6..00c78c930b8b 100644 --- a/weblate/trans/tests/test_changes.py +++ b/weblate/trans/tests/test_changes.py @@ -11,9 +11,11 @@ from django.utils import timezone from django.utils.http import urlencode +from weblate.trans.actions import ActionEvents from weblate.trans.feeds import TranslationChangesFeed from weblate.trans.models import Change, Unit from weblate.trans.tests.test_views import FixtureTestCase, ViewTestCase +from weblate.utils.xml import parse_xml class FeedQueriesTest(FixtureTestCase): @@ -61,10 +63,55 @@ def test_translation_feed_queries_do_not_scale_with_change_count(self) -> None: class ChangesTest(ViewTestCase): + def assert_rss_response(self, response) -> None: + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8") + self.assertContains(response, " None: + Change.objects.all().delete() + self.change_unit("Initial RSS target\n", user=self.user) + Change.objects.all().delete() + self.change_unit("Current user RSS target\n", user=self.user) + self.change_unit("Another user RSS target\n", user=self.anotheruser) + + def add_same_unit_rss_changes(self) -> None: + Change.objects.all().delete() + self.change_unit("Initial RSS target\n", user=self.user) + Change.objects.all().delete() + self.change_unit("First RSS target\n", user=self.user) + self.change_unit("Second RSS target\n", user=self.user) + + def assert_per_change_rss_guids(self, url: str) -> None: + self.add_same_unit_rss_changes() + response = self.client.get(url) + self.assert_rss_response(response) + items = parse_xml(response.content).findall("./channel/item") + self.assertGreaterEqual(len(items), 2) + links = [item.findtext("link") for item in items[:2]] + guids = [item.find("guid") for item in items[:2]] + self.assertEqual(len(set(links)), 1) + self.assertEqual(len({guid.text for guid in guids if guid is not None}), 2) + for guid in guids: + self.assertIsNotNone(guid) + self.assertEqual(guid.attrib["isPermaLink"], "false") + self.assertIn("/changes/render/", guid.text) + def test_basic(self) -> None: response = self.client.get(reverse("changes")) self.assertContains(response, "Resource update") + def test_basic_rss(self) -> None: + response = self.client.get(reverse("changes-rss")) + self.assert_rss_response(response) + self.assertContains(response, "Resource update") + + def test_changes_rss_guids_identify_changes(self) -> None: + self.assert_per_change_rss_guids(reverse("changes-rss")) + + def test_export_rss_guids_identify_changes(self) -> None: + self.assert_per_change_rss_guids(reverse("rss")) + def test_basic_csv_denied(self) -> None: response = self.client.get(reverse("changes-csv")) self.assertEqual(response.status_code, 403) @@ -113,6 +160,13 @@ def test_user(self) -> None: ) self.assertNotContains(response, f'title="{self.user.full_name}"') + def test_user_rss(self) -> None: + self.add_filtered_rss_changes() + response = self.client.get(reverse("changes-rss"), {"user": self.user.username}) + self.assert_rss_response(response) + self.assertContains(response, "testuser") + self.assertNotContains(response, "Jane Doe") + def test_exclude_user(self) -> None: self.edit_unit("Hello, world!\n", "Nazdar svete!\n") response = self.client.get(reverse("changes")) @@ -130,6 +184,31 @@ def test_exclude_user(self) -> None: ) self.assertContains(response, f'title="{self.user.full_name}"') + def test_exclude_user_rss(self) -> None: + self.add_filtered_rss_changes() + response = self.client.get( + reverse("changes-rss"), {"exclude_user": self.user.username} + ) + self.assert_rss_response(response) + self.assertNotContains(response, "Weblate Test") + self.assertContains(response, "Jane Doe") + + def test_action_rss(self) -> None: + Change.objects.all().delete() + self.change_unit("Initial RSS target\n", user=self.user) + Change.objects.all().delete() + self.change_unit("Current user RSS target\n", user=self.user) + response = self.client.get( + reverse("changes-rss"), {"action": ActionEvents.CHANGE} + ) + self.assert_rss_response(response) + self.assertContains(response, "Translation changed") + response = self.client.get( + reverse("changes-rss"), {"action": ActionEvents.UPDATE} + ) + self.assert_rss_response(response) + self.assertNotContains(response, "Translation changed") + def test_daterange(self) -> None: end = timezone.now() start = end - timedelta(days=1) @@ -137,6 +216,44 @@ def test_daterange(self) -> None: response = self.client.get(reverse("changes"), {"period": period}) self.assertContains(response, "Resource update") + def test_daterange_rss(self) -> None: + Change.objects.all().delete() + self.change_unit("Initial RSS target\n", user=self.user) + Change.objects.all().delete() + self.change_unit("Current user RSS target\n", user=self.user) + end = timezone.now() + start = end - timedelta(days=1) + period = f"{start.strftime('%m/%d/%Y')} - {end.strftime('%m/%d/%Y')}" + response = self.client.get(reverse("changes-rss"), {"period": period}) + self.assert_rss_response(response) + self.assertContains(response, "Translation changed") + response = self.client.get( + reverse("changes-rss"), {"period": "01/01/2020 - 01/02/2020"} + ) + self.assert_rss_response(response) + self.assertNotContains(response, "Translation changed") + + def test_scoped_rss(self) -> None: + Change.objects.all().delete() + self.change_unit("Initial RSS target\n", user=self.user) + Change.objects.all().delete() + unit = self.change_unit("Scoped RSS target\n", user=self.user) + paths = ( + self.project.get_url_path(), + self.component.get_url_path(), + self.translation.get_url_path(), + ["-", "-", self.translation.language.code], + [self.project.slug, "-", self.translation.language.code], + unit.get_url_path(), + ) + for path in paths: + with self.subTest(path=path): + response = self.client.get( + reverse("changes-rss", kwargs={"path": path}) + ) + self.assert_rss_response(response) + self.assertContains(response, "Translation changed") + def test_pagination(self) -> None: end = timezone.now() start = end - timedelta(days=1) @@ -149,6 +266,11 @@ def test_pagination(self) -> None: ) self.assertContains(response, "String added in the upload") + def test_rss_link_keeps_query_string(self) -> None: + response = self.client.get(reverse("changes"), {"user": self.user.username}) + query_string = urlencode({"user": self.user.username}) + self.assertContains(response, f"{reverse('changes-rss')}?{query_string}") + def test_last_changes_display(self) -> None: unit_to_delete = self.get_unit("Orangutan has %d banana") unit_to_delete.context = "Orangutan unit context" diff --git a/weblate/trans/views/changes.py b/weblate/trans/views/changes.py index 8d1f92ac50f4..b27d71adff4e 100644 --- a/weblate/trans/views/changes.py +++ b/weblate/trans/views/changes.py @@ -7,17 +7,20 @@ import csv from typing import TYPE_CHECKING +from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.urls.exceptions import NoReverseMatch -from django.utils.translation import activate, gettext, pgettext +from django.utils import feedgenerator +from django.utils.translation import activate, get_language, gettext, pgettext from django.views.generic.list import ListView from weblate.accounts.notifications import NOTIFICATIONS_ACTIONS from weblate.lang.models import Language +from weblate.trans.feeds import get_change_feed_guid from weblate.trans.forms import ChangesForm from weblate.trans.models import Component, Project, Translation, Unit from weblate.trans.models.change import Change @@ -45,6 +48,44 @@ class ChangesView(PathViewMixin, ListView): Unit, ) + def get_changes_url(self, url_name: str = "changes") -> str: + if self.path_object is None: + return reverse(url_name) + return reverse(url_name, kwargs={"path": self.path_object.get_url_path()}) + + def get_filtered_changes_url(self) -> str: + url = self.get_changes_url() + if self.changes_form.is_valid() and ( + query_string := self.changes_form.urlencode() + ): + return f"{url}?{query_string}" + return url + + def get_title(self): + if isinstance(self.path_object, Unit): + return ( + pgettext( + "Changes of string in a translation", "Changes of string in %s" + ) + % self.path_object + ) + if isinstance(self.path_object, Translation): + return ( + pgettext("Changes in translation", "Changes in %s") % self.path_object + ) + if isinstance(self.path_object, Component): + return pgettext("Changes in component", "Changes in %s") % self.path_object + if isinstance(self.path_object, Project): + return pgettext("Changes in project", "Changes in %s") % self.path_object + if isinstance(self.path_object, Language): + return pgettext("Changes in language", "Changes in %s") % self.path_object + if isinstance(self.path_object, ProjectLanguage): + return pgettext("Changes in project", "Changes in %s") % self.path_object + if self.path_object is None: + return gettext("Changes") + msg = f"Unsupported {self.path_object}" + raise TypeError(msg) + def get_template_names(self): if digest := self.request.GET.get("digest"): if digest in {"pending_suggestions", "todo_strings"}: @@ -56,46 +97,8 @@ def get_context_data(self, **kwargs): """Create context for rendering page.""" context = super().get_context_data(**kwargs) context["path_object"] = self.path_object - - if isinstance(self.path_object, Unit): - context["title"] = ( - pgettext( - "Changes of string in a translation", "Changes of string in %s" - ) - % self.path_object - ) - elif isinstance(self.path_object, Translation): - context["title"] = ( - pgettext("Changes in translation", "Changes in %s") % self.path_object - ) - elif isinstance(self.path_object, Component): - context["title"] = ( - pgettext("Changes in component", "Changes in %s") % self.path_object - ) - elif isinstance(self.path_object, Project): - context["title"] = ( - pgettext("Changes in project", "Changes in %s") % self.path_object - ) - elif isinstance(self.path_object, Language): - context["title"] = ( - pgettext("Changes in language", "Changes in %s") % self.path_object - ) - elif isinstance(self.path_object, ProjectLanguage): - context["title"] = ( - pgettext("Changes in project", "Changes in %s") % self.path_object - ) - elif self.path_object is None: - context["title"] = gettext("Changes") - else: - msg = f"Unsupported {self.path_object}" - raise TypeError(msg) - - if self.path_object is None: - context["changes_rss"] = reverse("rss") - else: - context["changes_rss"] = reverse( - "rss", kwargs={"path": self.path_object.get_url_path()} - ) + context["title"] = self.get_title() + context["changes_rss"] = self.get_changes_url("changes-rss") if self.changes_form.is_valid(): context["query_string"] = self.changes_form.urlencode() @@ -126,7 +129,9 @@ def get_request_param(self, request: AuthenticatedHttpRequest, param: str) -> st return "-" return value - def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] + def get( # type: ignore[override] + self, request: AuthenticatedHttpRequest, *args, **kwargs + ): if self.path_object is None and self.request.GET: # Handle GET params for filtering prior Weblate 5.0 path = None @@ -220,7 +225,9 @@ class ChangesCSVView(ChangesView): paginate_by = None - def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] + def get( # type: ignore[override] + self, request: AuthenticatedHttpRequest, *args, **kwargs + ): object_list = self.get_queryset()[:2000] if not request.user.has_perm("change.download", self.path_object): @@ -254,6 +261,65 @@ def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: igno return response +class ChangesRSSView(ChangesView): + """RSS renderer for changes view.""" + + feed_count = 10 + paginate_by = None + + def get_feed_title(self): + if self.path_object is None: + # Translators: %s is site title here + return gettext("Recent changes on %s") % settings.SITE_TITLE + # Translators: %s is translation/project/component/language name + return gettext("Recent changes in %s") % self.path_object + + def get_feed_description(self): + if self.path_object is None: + # Translators: %s is site title here + return gettext("All recent changes made using Weblate on %s.") % ( + settings.SITE_TITLE + ) + # Translators: %s is translation/project/component/language name + return ( + gettext("All recent changes made using Weblate in %s.") % self.path_object + ) + + def get( # type: ignore[override] + self, request: AuthenticatedHttpRequest, *args, **kwargs + ): + if self.changes_form.is_valid(): + object_list = Change.objects.preload_list( + list(self.get_queryset()[: self.feed_count]) + ) + else: + object_list = [] + + feed = feedgenerator.Rss201rev2Feed( + title=self.get_feed_title(), + link=get_site_url(self.get_filtered_changes_url()), + description=self.get_feed_description(), + language=get_language(), + feed_url=get_site_url(request.get_full_path()), + ) + + for change in object_list: + link = get_site_url(change.get_absolute_url()) + feed.add_item( + title=change.get_action_display(), + link=link, + description=str(change), + author_name=change.get_user_display(False), + pubdate=change.timestamp, + unique_id=get_change_feed_guid(change), + unique_id_is_permalink=False, + ) + + response = HttpResponse(content_type=feed.content_type) + feed.write(response, "utf-8") + return response + + @login_required def show_change(request: AuthenticatedHttpRequest, pk: int): change = get_object_or_404(Change, pk=pk) diff --git a/weblate/urls.py b/weblate/urls.py index 0cfdb033b276..0c80f698fb5a 100644 --- a/weblate/urls.py +++ b/weblate/urls.py @@ -57,7 +57,12 @@ from weblate.sitemaps import SITEMAPS from weblate.trans.feeds import ChangesFeed, LanguageChangesFeed, TranslationChangesFeed from weblate.trans.views.bulk_suggestions import bulk_accept_user_suggestions -from weblate.trans.views.changes import ChangesCSVView, ChangesView, show_change +from weblate.trans.views.changes import ( + ChangesCSVView, + ChangesRSSView, + ChangesView, + show_change, +) from weblate.trans.views.hooks import ServiceHookView from weblate.utils.version import VERSION @@ -666,6 +671,10 @@ path( "changes/csv//", ChangesCSVView.as_view(), name="changes-csv" ), + path("changes/rss/", ChangesRSSView.as_view(), name="changes-rss"), + path( + "changes/rss//", ChangesRSSView.as_view(), name="changes-rss" + ), path("changes/render//", show_change, name="show_change"), # Notification hooks path(