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
7 changes: 6 additions & 1 deletion docs/admin/announcements.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Announcements

In prior releases this feature was called whiteboard messages.

Provide info to your translators by posting announcements, site-wide, per project, component, or language.
Provide info to your translators by posting announcements, site-wide, per project,
category, component, or language.

Announce the purpose, deadlines, status, or specify targets for translation.

Expand Down Expand Up @@ -35,6 +36,10 @@ Project specified

Shown within the project, including all its components and translations.

Category specified

Shown within the category, including all its components and translations.

Component specified

Shown for a given component and all its translations.
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Weblate 2026.5
.. rubric:: Bug fixes

* Database error details are no longer exposed in upload failure messages.
* Category :doc:`/admin/announcements` no longer appear across the whole project.
* Merge request pushes now refresh stale fork remotes after changing repository hosting.
* :ref:`vcs-gerrit` now tracks the target branch on its Gerrit remote before invoking ``git-review``.
* :ref:`vcs-gerrit` branch validation now suggests short branch names when full refs are supplied.
Expand Down
3 changes: 1 addition & 2 deletions weblate/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10323,7 +10323,6 @@ def setUp(self) -> None:
project=self.component.project, message="Test project announcement"
)
self.category_announcement = Announcement.objects.create(
project=self.component.project,
category=self.category,
message="Test category announcement",
)
Expand Down Expand Up @@ -10585,7 +10584,7 @@ def test_create_category_announcement(self) -> None:
id=response.data["id"]
)
self.assertIsNotNone(announcement)
self.assertEqual(announcement.project, category.project)
self.assertIsNone(announcement.project)
self.assertEqual(announcement.category, category)
self.assertIsNone(announcement.component)
self.assertIsNone(announcement.language)
Expand Down
9 changes: 7 additions & 2 deletions weblate/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1281,7 +1281,6 @@ def get_context(self, obj):
if isinstance(obj, Project):
project = obj
if isinstance(obj, Category):
project = obj.project
category = obj
if isinstance(obj, Component):
project = obj.project
Expand All @@ -1295,6 +1294,12 @@ def get_context(self, obj):

def get_announcements(self, obj):
project, category, component, language = self.get_context(obj)
if category is not None:
return Announcement.objects.filter(
category=category,
component=component,
language=language,
)

return Announcement.objects.filter(
project=project,
Expand Down Expand Up @@ -1372,7 +1377,7 @@ def delete_announcement(self, request: Request, announcement_id, **kwargs):
msg = f"Announcement with ID {announcement_id} was not found"
raise Http404(msg) from error

if not request.user.has_perm("announcement.delete", obj):
if not request.user.has_perm("announcement.delete", announcement):
self.permission_denied(request, "Can not delete announcement")

announcement.delete()
Expand Down
13 changes: 12 additions & 1 deletion weblate/auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from weblate.formats.base import BilingualUpdateMixin
from weblate.lang.models import Language
from weblate.trans.models import (
Announcement,
Category,
Component,
ComponentList,
Expand Down Expand Up @@ -630,8 +631,18 @@ def check_billing(user: User, permission: str, obj: Project) -> bool | Permissio

@register_perm("announcement.delete")
def check_announcement_delete(
user: User, permission: str, obj: Project | Component | None
user: User,
permission: str,
obj: Announcement | Project | Category | Component | None,
) -> bool | PermissionResult:
if isinstance(obj, Announcement):
if obj.component_id is not None:
obj = obj.component
elif obj.category_id is not None:
obj = obj.category
else:
obj = obj.project

if obj is None:
return check_global_permission(user, permission)
return check_permission(user, permission, obj)
Expand Down
2 changes: 1 addition & 1 deletion weblate/templates/category.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@

{% block content %}

{% announcements project=object.project %}
{% announcements category=object %}

{% include "snippets/project/state.html" with object=object.project %}

Expand Down
69 changes: 60 additions & 9 deletions weblate/trans/models/announcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,84 @@


class AnnouncementManager(models.Manager["Announcement"]):
def context_filter(self, project=None, component=None, language=None):
@staticmethod
def _category_filter(category):
category_ids = []
while category is not None:
category_ids.append(category.pk)
category = category.category
if not category_ids:
return Q(pk__in=[])
return Q(category_id__in=category_ids)

def context_filter(
self, project=None, component=None, language=None, category=None
):
"""Filter announcements by context."""
base = self.filter(

Check failure on line 32 in weblate/trans/models/announcement.py

View workflow job for this annotation

GitHub Actions / mypy

"QuerySet[Announcement, Announcement]" has no attribute "order"; maybe "ordered" or "order_by"?
Q(expiry__isnull=True) | Q(expiry__gte=timezone.now())
).order()

if language and project is None and component is None:
return base.filter(project=None, component=None, language=language)
if language and project is None and component is None and category is None:
return base.filter(
project=None, category=None, component=None, language=language
)

if component:
category_filter = self._category_filter(component.category)
if language:
return base.filter(
(Q(component=component) & Q(language=language))
| (Q(project=None) & Q(component=None) & Q(language=language))
| (
Q(project=None)
& Q(category=None)
& Q(component=None)
& Q(language=language)
)
| (Q(component=component) & Q(language=None))
| (Q(project=component.project) & Q(component=None))
| (category_filter & Q(component=None))
| (
Q(project=component.project)
& Q(category=None)
& Q(component=None)
)
)

return base.filter(
(Q(component=component) & Q(language=None))
| (Q(project=component.project) & Q(component=None))
| (category_filter & Q(component=None))
| (Q(project=component.project) & Q(category=None) & Q(component=None))
)

if category:
category_filter = self._category_filter(category)
return base.filter(
(category_filter & Q(component=None))
| (Q(project=category.project) & Q(category=None) & Q(component=None))
)

if project:
return base.filter(Q(project=project) & Q(component=None))
return base.filter(
Q(project=project) & Q(category=None) & Q(component=None)
)

# All are None
return base.filter(project=None, component=None, language=None)
return base.filter(project=None, category=None, component=None, language=None)

def create(self, user=None, **kwargs):
from weblate.trans.models.change import Change # noqa: PLC0415

if kwargs.get("category") is not None and kwargs.get("component") is None:
kwargs["project"] = None

result = super().create(**kwargs)
project = result.project
if project is None and result.category is not None:
project = result.category.project

Change.objects.create(
action=ActionEvents.ANNOUNCEMENT,
project=result.project,
project=project,
category=result.category,
component=result.component,
language=result.language,
Expand Down Expand Up @@ -141,6 +182,16 @@
return self.message

def clean(self) -> None:
if self.category and self.component:
raise ValidationError(
gettext("Do not specify both component and category!")
)
if self.category:
if self.project and self.category.project != self.project:
raise ValidationError(
gettext("The category does not belong to the selected project.")
)
self.project = None
if self.project and self.component and self.component.project != self.project:
raise ValidationError(gettext("Do not specify both component and project!"))
if not self.project and self.component:
Expand Down
14 changes: 8 additions & 6 deletions weblate/trans/templatetags/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,9 @@ def get_location_links(user: User | None, unit):


@register.simple_tag(takes_context=True)
def announcements(context: Context, project=None, component=None, language=None):
def announcements(
context: Context, project=None, component=None, language=None, category=None
):
"""Display announcement messages for given context."""
user = context["user"]

Expand All @@ -980,16 +982,16 @@ def announcements(context: Context, project=None, component=None, language=None)
"message": render_markdown(announcement.message),
"announcement": announcement,
"can_delete": user.has_perm(
"announcement.delete",
announcement.component
if announcement.component is not None
else announcement.project,
"announcement.delete", announcement
),
},
),
)
for announcement in Announcement.objects.context_filter(
project, component, language
project=project,
component=component,
language=language,
category=category,
)
),
)
Expand Down
26 changes: 26 additions & 0 deletions weblate/trans/tests/test_manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ def test_category(self) -> None:
category.save()
url = reverse("announcement", kwargs={"path": category.get_url_path()})
self.perform_test(url)
announcement = Announcement.objects.get(message=self.data["message"])
self.assertEqual(announcement.category, category)
self.assertIsNone(announcement.project)

def test_delete_announcement(self) -> None:
second_component = self.create_link_existing()
Expand All @@ -425,6 +428,13 @@ def test_delete_announcement(self) -> None:
message="test project announcement",
project=self.project,
)
category = Category.objects.create(
project=self.project, name="Test Category", slug="test-category"
)
category_announcement = Announcement.objects.create(
message="test category announcement",
category=category,
)

group = Group.objects.create(
name="Component deleters",
Expand Down Expand Up @@ -458,6 +468,14 @@ def test_delete_announcement(self) -> None:
Announcement.objects.filter(pk=project_announcement.pk).count(), 1
)

response = self.client.post(
reverse("announcement-delete", kwargs={"pk": category_announcement.pk})
)
self.assertEqual(response.status_code, 403)
self.assertEqual(
Announcement.objects.filter(pk=category_announcement.pk).count(), 1
)

group.project_selection = SELECTION_ALL
group.components.clear()
group.save()
Expand All @@ -478,6 +496,14 @@ def test_delete_announcement(self) -> None:
self.assertEqual(
Announcement.objects.filter(pk=project_announcement.pk).count(), 0
)

response = self.client.post(
reverse("announcement-delete", kwargs={"pk": category_announcement.pk})
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
Announcement.objects.filter(pk=category_announcement.pk).count(), 0
)
self.assertEqual(Announcement.objects.count(), 0)

def test_delete_global_announcement(self) -> None:
Expand Down
47 changes: 47 additions & 0 deletions weblate/trans/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
self.assertTrue(os.path.exists(old_path))
self.assertTrue(
os.path.exists(
component.translation_set.get(language_code="cs").get_filename()

Check failure on line 130 in weblate/trans/tests/test_models.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 to "exists" has incompatible type "str | None"; expected "int | str | bytes | PathLike[str] | PathLike[bytes]"
)
)
project.name = "Changed"
Expand All @@ -144,7 +144,7 @@
component = Component.objects.get(pk=component.pk)
self.assertTrue(
os.path.exists(
component.translation_set.get(language_code="cs").get_filename()

Check failure on line 147 in weblate/trans/tests/test_models.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 to "exists" has incompatible type "str | None"; expected "int | str | bytes | PathLike[str] | PathLike[bytes]"
)
)

Expand Down Expand Up @@ -1167,6 +1167,53 @@
"test project",
)

def test_contextfilter_category(self) -> None:
category = self.create_category(self.component.project)
self.component.category = category
self.component.save(update_fields=["category"])
other_component = self.create_po(project=self.component.project, name="Other")
Announcement.objects.create(
category=category,
message="test category",
)

self.assertCountEqual(
[
announcement.message
for announcement in Announcement.objects.context_filter(
project=self.component.project
)
],
["test project"],
)
self.assertCountEqual(
[
announcement.message
for announcement in Announcement.objects.context_filter(
category=category
)
],
["test project", "test category"],
)
self.assertCountEqual(
[
announcement.message
for announcement in Announcement.objects.context_filter(
component=self.component
)
],
["test project", "test component", "test category"],
)
self.assertCountEqual(
[
announcement.message
for announcement in Announcement.objects.context_filter(
component=other_component
)
],
["test project"],
)

def test_contextfilter_component(self) -> None:
self.verify_filter(
Announcement.objects.context_filter(component=self.component), 2
Expand Down
2 changes: 1 addition & 1 deletion weblate/trans/views/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,12 @@
project_object = obj.project
user = request.user

last_changes = Change.objects.last_changes(

Check failure on line 286 in weblate/trans/views/basic.py

View workflow job for this annotation

GitHub Actions / mypy

"QuerySet[Change, Change]" has no attribute "recent"
user, project=project_object, language=language_object
).recent()

last_announcements = (
Change.objects.last_changes(

Check failure on line 291 in weblate/trans/views/basic.py

View workflow job for this annotation

GitHub Actions / mypy

"QuerySet[Change, Change]" has no attribute "filter_announcements"
user, project=project_object, language=language_object
)
.filter_announcements()
Expand Down Expand Up @@ -386,7 +386,7 @@
user = request.user

last_changes = (
Change.objects.last_changes(user, language=language_object)

Check failure on line 389 in weblate/trans/views/basic.py

View workflow job for this annotation

GitHub Actions / mypy

"QuerySet[Change, Change]" has no attribute "for_category"
.for_category(category_object)
.recent()
)
Expand Down Expand Up @@ -613,7 +613,7 @@
obj=obj,
),
"announcement_form": optional_form(
AnnouncementForm, user, "announcement.add", obj.project
AnnouncementForm, user, "announcement.add", obj
),
"delete_form": optional_form(
CategoryDeleteForm, user, "project.edit", obj, obj=obj
Expand Down Expand Up @@ -757,7 +757,7 @@
initial={"link_id": link.pk, "category": link.category},
project=link.project,
)
form.project_name = link.project.name

Check failure on line 760 in weblate/trans/views/basic.py

View workflow job for this annotation

GitHub Actions / mypy

"ComponentLinkCategoryForm" has no attribute "project_name"
forms.append(form)
return forms

Expand Down Expand Up @@ -1103,7 +1103,7 @@
"All languages have been added, updates of translations are in progress."
),
)
result = f"{reverse('show_progress', kwargs={'path': result.get_url_path()})}?info=1"

Check failure on line 1106 in weblate/trans/views/basic.py

View workflow job for this annotation

GitHub Actions / mypy

Item "str" of "Component | Translation | str" has no attribute "get_url_path"

if user.has_perm("component.edit", component):
reset_rate_limit("language", request)
Expand Down
8 changes: 1 addition & 7 deletions weblate/trans/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@

link_id = request.POST.get("link_id")
try:
link = ComponentLink.objects.get(pk=link_id, component=obj)

Check failure on line 349 in weblate/trans/views/settings.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible type for lookup 'pk': (got "str | None", expected "str | int")
except (ComponentLink.DoesNotExist, ValueError, TypeError) as e:
raise Http404 from e

Expand All @@ -364,7 +364,7 @@
# Look up the link first so we can pass the correct project to the form
link_id = request.POST.get("link_id")
try:
link = ComponentLink.objects.get(pk=link_id, component=obj)

Check failure on line 367 in weblate/trans/views/settings.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible type for lookup 'pk': (got "str | None", expected "str | int")
except (ComponentLink.DoesNotExist, ValueError, TypeError) as e:
raise Http404 from e

Expand Down Expand Up @@ -399,7 +399,6 @@
scope["project"] = obj.project
scope["language"] = obj.language
elif isinstance(obj, Category):
scope["project"] = obj.project
scope["category"] = obj
elif isinstance(obj, Translation):
scope["project"] = obj.component.project
Expand All @@ -425,12 +424,7 @@
def announcement_delete(request: AuthenticatedHttpRequest, pk):
announcement = get_object_or_404(Announcement, pk=pk)

obj = (
announcement.component
if announcement.component is not None
else announcement.project
)
if not request.user.has_perm("announcement.delete", obj):
if not request.user.has_perm("announcement.delete", announcement):
raise PermissionDenied

announcement.delete()
Expand Down
Loading