Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <rss>` can now use the same filters as the changes browsing page.

.. rubric:: Bug fixes

Expand Down
2 changes: 1 addition & 1 deletion weblate/templates/trans/change_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
class="btn btn-link">{% icon "download.svg" %}</a>
{% endif %}
{% endif %}
<a href="{{ changes_rss }}"
<a href="{{ changes_rss }}{% if query_string %}?{{ query_string }}{% endif %}"
title="{% translate "Follow using RSS" %}"
class="btn btn-link">{% icon "rss.svg" %}</a>
</div>
Expand Down
11 changes: 11 additions & 0 deletions weblate/trans/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@

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

if TYPE_CHECKING:
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()
Expand All @@ -34,11 +39,17 @@
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."""

def get_object(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> User:

Check failure on line 52 in weblate/trans/feeds.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 of "get_object" is incompatible with supertype "django.contrib.syndication.views.Feed"; supertype defines the argument type as "HttpRequest"
return request.user

def title(self):
Expand All @@ -55,7 +66,7 @@
return reverse("home")

def items(self, obj):
return Change.objects.last_changes(obj).recent()

Check failure on line 69 in weblate/trans/feeds.py

View workflow job for this annotation

GitHub Actions / mypy

"QuerySet[Change, Change]" has no attribute "recent"


class ObjectChangesFeed(BaseFeed):
Expand Down Expand Up @@ -83,7 +94,7 @@
return obj.change_set.prefetch().recent(skip_preload="translation")

# pylint: disable-next=arguments-differ
def get_object(self, request: AuthenticatedHttpRequest, path):

Check failure on line 97 in weblate/trans/feeds.py

View workflow job for this annotation

GitHub Actions / mypy

Signature of "get_object" incompatible with supertype "django.contrib.syndication.views.Feed"
return parse_path(
request,
path,
Expand All @@ -95,5 +106,5 @@
"""RSS feed for changes in language."""

# pylint: disable-next=arguments-differ
def get_object(self, request: AuthenticatedHttpRequest, lang):

Check failure on line 109 in weblate/trans/feeds.py

View workflow job for this annotation

GitHub Actions / mypy

Signature of "get_object" incompatible with supertype "django.contrib.syndication.views.Feed"
return get_object_or_404(Language, code=lang)
122 changes: 122 additions & 0 deletions weblate/trans/tests/test_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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, "<rss")

def add_filtered_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("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)
Expand Down Expand Up @@ -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"))
Expand All @@ -130,13 +184,76 @@ 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)
period = f"{start.strftime('%m/%d/%Y')} - {end.strftime('%m/%d/%Y')}"
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)
Expand All @@ -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"
Expand Down
Loading
Loading