From ff97e8a4f1ce9d999a6290ab3cbad27993c4bc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Sun, 1 Nov 2026 09:05:01 +0100 Subject: [PATCH] feat(accounts): control user visibility through role permissions Replace unreleased installation-wide visibility settings with editable global role permissions while preserving scoped contributor discovery and administrative access. Add frozen role and team migrations, regression coverage, and a privacy configuration guide covering profiles and private VCS identities. Refs #22370, #22498, #18054 --- ci/migrate-scripts/assert-editable-roles.py | 33 ++ ci/migrate-scripts/setup-editable-roles.py | 12 + docs/admin/access.rst | 11 +- docs/admin/config.rst | 70 +-- docs/admin/install/docker.rst | 14 +- docs/admin/privacy.rst | 108 ++++ docs/api.rst | 13 +- docs/changes.rst | 2 +- docs/index.rst | 1 + docs/security/threat-model.rst | 5 +- docs/snippets/permissions.rst | 8 +- docs/snippets/roles.rst | 7 + docs/specs/openapi.yaml | 4 +- docs/user/profile.rst | 2 + weblate/accounts/apps.py | 17 - weblate/accounts/avatar.py | 3 +- weblate/accounts/defaults.py | 2 - weblate/accounts/models.py | 2 - weblate/accounts/notifications.py | 3 + weblate/accounts/tests/test_user_display.py | 121 ++++- weblate/accounts/tests/test_views.py | 11 +- weblate/accounts/tests/test_visibility.py | 34 +- weblate/accounts/tests/visibility.py | 86 +++ weblate/accounts/views.py | 1 + weblate/accounts/visibility.py | 74 ++- weblate/api/tests.py | 26 +- weblate/api/views.py | 10 +- weblate/auth/data.py | 15 +- .../migrations/0015_update_builtin_roles.py | 506 ++++++++++++++++++ .../migrations/0016_user_visibility_teams.py | 35 ++ weblate/auth/tests/test_roles.py | 82 ++- weblate/settings_docker.py | 10 - weblate/trans/feeds.py | 8 + weblate/trans/tests/test_changes.py | 27 +- weblate/utils/checks.py | 1 - 35 files changed, 1126 insertions(+), 238 deletions(-) create mode 100644 docs/admin/privacy.rst create mode 100644 weblate/accounts/tests/visibility.py create mode 100644 weblate/auth/migrations/0015_update_builtin_roles.py create mode 100644 weblate/auth/migrations/0016_user_visibility_teams.py diff --git a/ci/migrate-scripts/assert-editable-roles.py b/ci/migrate-scripts/assert-editable-roles.py index 742bf659f0a8..160da30b3ca5 100644 --- a/ci/migrate-scripts/assert-editable-roles.py +++ b/ci/migrate-scripts/assert-editable-roles.py @@ -23,3 +23,36 @@ call_command("migrate", verbosity=0) assert not role.permissions.exists() assert team.roles.filter(pk=role_id).exists() + +users = Group.objects.get(name="Users", internal=True) +assert users.roles.filter(pk=custom_id).exists() +assert users.roles.filter(name="Power user").exists() +assert users.roles.filter(name="Discover users").exists() +assert users.roles.filter(name="View user profiles").exists() +assert ( + Group.objects.get(name="Guests", internal=True) + .roles.filter(name="View user profiles") + .exists() +) +assert set( + Role.objects.get(name="Discover users").permissions.values_list( + "codename", flat=True + ) +) == {"user.browse", "user.search"} +assert set( + Role.objects.get(name="View user profiles").permissions.values_list( + "codename", flat=True + ) +) == {"user.profile"} + + +for collision_team in Group.objects.filter(name__startswith="Visibility collision "): + role_id = int(collision_team.name.rsplit(" ", 1)[1]) + collision = collision_team.roles.get(pk=role_id) + assert collision.name in { + "Discover users (custom 2)", + "View user profiles (custom 2)", + } + assert list(collision.permissions.values_list("codename", flat=True)) == [ + "comment.add" + ] diff --git a/ci/migrate-scripts/setup-editable-roles.py b/ci/migrate-scripts/setup-editable-roles.py index 08635a92a8fc..9c1f13c3c478 100644 --- a/ci/migrate-scripts/setup-editable-roles.py +++ b/ci/migrate-scripts/setup-editable-roles.py @@ -14,3 +14,15 @@ # Persist original primary keys across the checkout to verify identity preservation. team.name = f"Role migration assignment {role.pk}:{custom.pk}" team.save() + +# Preserve a local default-team role assignment through visibility upgrades. +users = Group.objects.get(name="Users", internal=True) +users.roles.add(custom) + + +for name in ("Discover users", "View user profiles"): + collision = Role.objects.create(name=name) + collision.permissions.set(Permission.objects.filter(codename="comment.add")) + Role.objects.create(name=f"{name} (custom)") + collision_team = Group.objects.create(name=f"Visibility collision {collision.pk}") + collision_team.roles.add(collision) diff --git a/docs/admin/access.rst b/docs/admin/access.rst index af8b057e5efa..b20e62887f3e 100644 --- a/docs/admin/access.rst +++ b/docs/admin/access.rst @@ -709,6 +709,11 @@ applies the current upstream defaults to all teams using those roles. the current defaults. Earlier changes made through the API or database are replaced. Subsequent upgrades preserve local customizations. + Existing custom roles named `Discover users` or `View user profiles` are + renamed with a ``(custom)`` suffix before the new built-in roles are created. + A numbered suffix is used if that name is already taken. Their permissions + and team assignments are preserved. + .. _privileges: .. include:: /snippets/permissions.rst @@ -748,7 +753,7 @@ however, re-create them if you delete or rename them. Remove roles from this team to limit permissions for non-authenticated users. - Default roles: `Add suggestion`, `Access repository` + Default roles: `Add suggestion`, `Access repository`, `View user profiles` `Viewers` This role ensures the visibility of public projects to all users. @@ -765,7 +770,7 @@ however, re-create them if you delete or rename them. By default, :ref:`automatic team assignment ` makes all new accounts members of this team when they join. - Default roles: `Power user` + Default roles: `Power user`, `Discover users`, `View user profiles` `Reviewers` Group for reviewers (see :ref:`workflows`). @@ -815,3 +820,5 @@ Expiration of user accounts Each account can have an expiry set. After the expiration, the account will be automatically disabled. This is used for :ref:`project-api`, but can be utilized for regular users as well. + +See :doc:`privacy` to configure user discovery and profile access through roles. diff --git a/docs/admin/config.rst b/docs/admin/config.rst index 84c6f4b6e84f..d565a277ebca 100644 --- a/docs/admin/config.rst +++ b/docs/admin/config.rst @@ -1821,6 +1821,8 @@ PRIVATE_COMMIT_EMAIL_OPT_IN Configures whether the private commit e-mail is opt-in or opt-out. +See :doc:`/admin/privacy` for privacy configuration examples. + With ``True`` (the default), users use their account e-mail unless they choose a private commit e-mail. Set to ``False`` to use :setting:`PRIVATE_COMMIT_EMAIL_TEMPLATE` by default. @@ -2984,74 +2986,6 @@ When using a URL prefix, expose Weblate's prefixed ``robots.txt`` at the origin' include the configured prefix. If other applications share the origin, merge their crawl rules in the proxy-served document as appropriate. -.. setting:: USER_DIRECTORY_VISIBILITY - -USER_DIRECTORY_VISIBILITY -------------------------- - -.. versionadded:: 2026.11 - -Controls discovery of other users. Defaults to ``"all"`` for compatibility. - -``"all"`` - Keeps user browsing, username autocomplete, and site-wide user search enabled. -``"scoped"`` - Requires a resource-specific contributor search, such as - ``contributes:my-project`` or ``contributes:my-project/my-component``, on the - user directory page and REST API. Every matching branch of a search must - include a positive resource scope. Generic names, language filters, and - negated scopes do not grant directory access. -``"disabled"`` - Disables the user directory page. Resource-scoped REST searches and - autocomplete remain available. - -In both restricted modes, searches return contributors only within accessible -resources. Translation editor mentions use the current string's translation as -scope. Generic site-wide searches do not return users. The REST user list still -returns the caller's own details when no search is supplied. - -Users with the global ``user.view`` or ``user.edit`` permission retain directory -and administrative access in every mode. - -This setting controls discovery independently of -:setting:`USER_PROFILE_VISIBILITY`. Contributor identities can appear in scoped -results even when their profiles are restricted. It does not anonymize -contributors or change attribution in translation history. - -In Docker, use :envvar:`WEBLATE_USER_DIRECTORY_VISIBILITY`. - -.. setting:: USER_PROFILE_VISIBILITY - -USER_PROFILE_VISIBILITY ------------------------ - -.. versionadded:: 2026.11 - -Controls access to user profiles. Defaults to ``"public"`` for compatibility. - -``"public"`` - Keeps existing public profile access, subject to installation login rules. -``"authenticated"`` - Requires signing in to view another user's profile. -``"admins"`` - Requires the global ``user.view`` or ``user.edit`` permission to view another - user's profile. - -Users can always access their own profile, and users with either global -permission retain administrative access. The policy applies to direct profile -URLs, contribution and suggestion pages, external profile links, contact links, -avatars, and REST user details. Restricted URLs return HTTP 404. -Contributor displays retain names but use a generic avatar and omit profile -links when access is denied. Email and feed displays use public-access rules. - -Combine this with :setting:`USER_DIRECTORY_VISIBILITY` to control discovery. -For example, ``"scoped"`` directory visibility with ``"public"`` profile -visibility preserves public contributor profiles while limiting discovery to -accessible resources. Basic contributor identities remain visible in permitted -search results regardless of profile visibility. - -In Docker, use :envvar:`WEBLATE_USER_PROFILE_VISIBILITY`. - .. setting:: VCS_ALLOW_HOSTS VCS_ALLOW_HOSTS diff --git a/docs/admin/install/docker.rst b/docs/admin/install/docker.rst index 3d9c6ed34185..36b53f83f0c2 100644 --- a/docs/admin/install/docker.rst +++ b/docs/admin/install/docker.rst @@ -732,18 +732,6 @@ Generic settings Configures :setting:`ADMINS_CONTACT`. -.. envvar:: WEBLATE_USER_DIRECTORY_VISIBILITY - - Controls user discovery: ``all`` (default), ``scoped``, or ``disabled``. - Resource-scoped API searches and autocomplete remain available in both - restricted modes. See :setting:`USER_DIRECTORY_VISIBILITY`. - -.. envvar:: WEBLATE_USER_PROFILE_VISIBILITY - - Controls profile access: ``public`` (default), ``authenticated``, or ``admins``. - Self access and administrative access remain available. This is independent - of directory visibility. See :setting:`USER_PROFILE_VISIBILITY`. - .. envvar:: WEBLATE_CONTACT_FORM Configures contact form behavior, see :setting:`CONTACT_FORM`. @@ -1435,6 +1423,8 @@ Generic settings Configures :setting:`PRIVATE_COMMIT_NAME_OPT_IN`. +See :doc:`/admin/privacy` for privacy configuration examples. + To use private commit e-mails and names by default, set both opt-in variables to ``False`` in :file:`docker-compose.override.yml`: diff --git a/docs/admin/privacy.rst b/docs/admin/privacy.rst new file mode 100644 index 000000000000..766e5c961d59 --- /dev/null +++ b/docs/admin/privacy.rst @@ -0,0 +1,108 @@ +.. _privacy-configuration: + +Privacy configuration +===================== + +Administrators can limit user discovery and profile access through +:doc:`access`, and make private names and e-mail addresses the default for +version control commits. These controls address different kinds of exposure. + +User discovery and profile access +--------------------------------- + +The following global permissions can be assigned through editable roles: + +``user.browse`` + Browse and search the user directory without a resource scope. +``user.search`` + Search contributors through the user directory within accessible resources. + Every matching Boolean branch must contain a positive scope, such as + ``contributes:my-project`` or ``contributes:my-project/my-component``. + A negated scope or a scope on only one side of an OR is insufficient. +``user.profile`` + Access other users' profiles, including contribution and suggestion pages, + contact and external profile links, avatars, and REST user details. + +By default, the :guilabel:`Discover users` role grants ``user.browse`` and +``user.search``, and the :guilabel:`View user profiles` role grants ``user.profile``. +The :guilabel:`Users` team has both roles. The :guilabel:`Guests` team has +:guilabel:`View user profiles`, preserving public profile access subject to +installation login requirements. + +To restrict profiles to authenticated users, remove :guilabel:`View user profiles` from +:guilabel:`Guests`. To restrict profiles to administrators and each user's own profile, +also remove it from :guilabel:`Users` and any other teams granting ``user.profile``. +Restricted profile URLs return HTTP 404. Contributor displays retain names, +but omit profile links and use generic avatars when profile access is denied. +Email and feed displays follow the profile permissions available to guests. + +To require scoped HTML directory searches, remove :guilabel:`Discover users` from +:guilabel:`Users` and assign a custom role containing only ``user.search``. +To disable HTML directory access, grant neither ``user.browse`` nor +``user.search``. Generic site-wide search requires ``user.browse``. + +Permissions from roles are additive. Check all team assignments when restricting +access: removing one role does not cancel a grant from another. Users with +``user.view`` or ``user.edit`` retain administrative directory and profile +access. Users always retain access to their own profiles. + +Editing the :guilabel:`Discover users` role changes its permissions for every +team assigned that role. Removing the role from :guilabel:`Users` only removes +that team's grant; other teams using the role keep their permissions. To customize +discovery for one team, create a separate role and assign it to that team. + +Resource-scoped REST discovery and mention autocomplete remain available to +authenticated users with access to the resource, even without ``user.search``. +These results can include basic contributor identities without granting profile +access. See :doc:`/api`. + +Private version control identities +---------------------------------- + +To use private commit identities by default, configure: + +.. code-block:: python + + PRIVATE_COMMIT_EMAIL_OPT_IN = False + PRIVATE_COMMIT_NAME_OPT_IN = False + PRIVATE_COMMIT_EMAIL_TEMPLATE = "user-{user_id}@users.noreply.{site_domain}" + PRIVATE_COMMIT_NAME_TEMPLATE = "{site_title} user {user_id}" + +For Docker installations, use the corresponding environment variables: + +.. code-block:: yaml + + environment: + WEBLATE_PRIVATE_COMMIT_EMAIL_OPT_IN: "False" + WEBLATE_PRIVATE_COMMIT_NAME_OPT_IN: "False" + WEBLATE_PRIVATE_COMMIT_EMAIL_TEMPLATE: "user-{user_id}@users.noreply.{site_domain}" + WEBLATE_PRIVATE_COMMIT_NAME_TEMPLATE: "{site_title} user {user_id}" + +Both opt-in settings default to ``True``. Changing a template alone does not +enable private identities by default. The usual private e-mail template includes +the username; the example above uses an internal user identifier instead. + +These defaults apply to new and existing users who have not explicitly chosen +commit identities. Users' explicit choices override the defaults, and users can +still choose their account identities in :ref:`profile-account`. Bots retain +their account identities. A blank template disables the corresponding private +identity. + +See :setting:`PRIVATE_COMMIT_EMAIL_OPT_IN`, +:setting:`PRIVATE_COMMIT_NAME_OPT_IN`, +:setting:`PRIVATE_COMMIT_EMAIL_TEMPLATE`, and +:setting:`PRIVATE_COMMIT_NAME_TEMPLATE` for the full settings and template +placeholders. + +Limits of these controls +------------------------ + +Private identities affect future commits. Names and e-mail addresses already +recorded in repository history remain unchanged. Different commit identities +can prevent code hosting services from linking contributions to a user's account. + +User discovery and profile permissions do not hide contributor attribution or +translation history. Private VCS identities do not anonymize contributors within +Weblate. Resource-scoped discovery can still identify contributors to accessible +resources. These controls do not provide confidential collaboration or +contributor anonymity. diff --git a/docs/api.rst b/docs/api.rst index b500fc139c56..2a6ec3db6f4b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -327,13 +327,10 @@ Users username, and full name of matching non-bot users. Users with either permission can list all users and receive detailed information. - :setting:`USER_DIRECTORY_VISIBILITY` can restrict discovery to accessible - resources. In ``scoped`` and ``disabled`` modes, ordinary users must supply - a resource-bounded ``q`` query or an accessible ``unit`` context to discover - other users. Generic filters cannot expand that scope; blocked searches - return no results. Self access and global administrative access remain - available. Basic contributor discovery is independent of - :setting:`USER_PROFILE_VISIBILITY`. + Without ``user.browse``, discovery requires a resource-bounded ``q`` query + or accessible ``unit`` context. Generic filters cannot expand that scope. + Scoped REST discovery does not require ``user.search`` or ``user.profile``. + Self and administrative access remain available. See :doc:`admin/privacy`. :query string q: User search query, such as ``contributes:project`` or ``contributes:project/component``. Every matching Boolean branch must have a positive resource scope in restricted directory modes. :query int unit: Rank contributors to this string first. In restricted directory modes, results are limited to contributors to its translation. @@ -370,7 +367,7 @@ Users .. http:get:: /api/users/(str:username)/ - Access follows :setting:`USER_PROFILE_VISIBILITY`. Restricted profiles + Access follows the global ``user.profile`` permission. Restricted profiles return HTTP 404; self access and global administrative access are retained. Returns information about users. diff --git a/docs/changes.rst b/docs/changes.rst index d1fc4c6640c1..8cda7fccad09 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,7 +12,7 @@ Weblate 2026.11 * Added a :guilabel:`Languages` tab to :ref:`workspaces`, with translation and review across projects in a selected language. * Unified :ref:`bulk-edit` with search and replace, adding regular expressions and clearing translations in manual runs, Automations, and the bulk-edit add-on. Manual bulk edits offer optional previews. -* Added configurable :setting:`USER_DIRECTORY_VISIBILITY` and :setting:`USER_PROFILE_VISIBILITY` to restrict user discovery and profile access while preserving resource-scoped contributor discovery. +* Added role permissions for user discovery and profile access and a :doc:`privacy configuration guide `. * Translation service installations have a :ref:`Logs tab ` showing provider errors, including rate limits. diff --git a/docs/index.rst b/docs/index.rst index 4ce7ac44d551..e0c2642bc295 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -102,6 +102,7 @@ Learn more about :ref:`contributing`. admin/backup admin/auth admin/access + admin/privacy admin/workspaces admin/projects admin/languages diff --git a/docs/security/threat-model.rst b/docs/security/threat-model.rst index 3b2c5f96dcdf..826d11bc2c6a 100644 --- a/docs/security/threat-model.rst +++ b/docs/security/threat-model.rst @@ -677,9 +677,8 @@ Security properties Weblate provides - Conditions - Violation symptom - Severity tier - * - User discovery and profile access respect installation visibility - policies. *(documented)* (source: :setting:`USER_DIRECTORY_VISIBILITY`, - :setting:`USER_PROFILE_VISIBILITY`) + * - User discovery and profile access respect global role permissions. + *(documented)* (source: :doc:`/admin/privacy`) - Operators configure the desired policy. Resource-scoped discovery and contribution attribution remain available; this does not provide contributor anonymity. *(documented)* diff --git a/docs/snippets/permissions.rst b/docs/snippets/permissions.rst index a7329e3c4718..e7cb5b45b011 100644 --- a/docs/snippets/permissions.rst +++ b/docs/snippets/permissions.rst @@ -334,7 +334,13 @@ List of privileges | +-------------------------------------------+---------------------------------------+ | | Manage workspace access | :guilabel:`Workspace administration` | +----------------------+-------------------------------------------+---------------------------------------+ -| Site wide privileges | Use management interface | | +| Site wide privileges | Browse users | :guilabel:`Discover users` | +| +-------------------------------------------+---------------------------------------+ +| | Search resource contributors | :guilabel:`Discover users` | +| +-------------------------------------------+---------------------------------------+ +| | View user profiles | :guilabel:`View user profiles` | +| +-------------------------------------------+---------------------------------------+ +| | Use management interface | | | +-------------------------------------------+---------------------------------------+ | | Manage site configuration | | | +-------------------------------------------+---------------------------------------+ diff --git a/docs/snippets/roles.rst b/docs/snippets/roles.rst index 1b75dce9acba..44747f1f1a7b 100644 --- a/docs/snippets/roles.rst +++ b/docs/snippets/roles.rst @@ -7,6 +7,13 @@ List of built-in roles .. list-table:: + * - `Discover users` + - * :guilabel:`Browse users` + * :guilabel:`Search resource contributors` + + * - `View user profiles` + - * :guilabel:`View user profiles` + * - `Administration` - * :guilabel:`Post announcements` * :guilabel:`Delete announcements` diff --git a/docs/specs/openapi.yaml b/docs/specs/openapi.yaml index d11be9bc7e70..32f06b22fceb 100644 --- a/docs/specs/openapi.yaml +++ b/docs/specs/openapi.yaml @@ -32672,7 +32672,7 @@ paths: a username prefix of at least two characters after trimming surrounding whitespace; such searches return basic information about matching non-bot users. Users with either permission can list all users and receive detailed information. - USER_DIRECTORY_VISIBILITY can require resource-scoped discovery; a positive + Without user.browse, searches require resource-scoped discovery; a positive contributes scope or accessible unit context is then required to discover other users. summary: List users. @@ -33020,7 +33020,7 @@ paths: operationId: api_users_retrieve description: Restricted profiles return HTTP 404; self and administrative access are retained. - summary: Return information about users, subject to USER_PROFILE_VISIBILITY. + summary: Return information about users, subject to user.profile permission. parameters: - in: path name: username diff --git a/docs/user/profile.rst b/docs/user/profile.rst index 97f74c1310a1..35bd40a537a3 100644 --- a/docs/user/profile.rst +++ b/docs/user/profile.rst @@ -275,6 +275,8 @@ Administrators can make private identities the default by setting :setting:`PRIVATE_COMMIT_EMAIL_OPT_IN` and :setting:`PRIVATE_COMMIT_NAME_OPT_IN` to ``False``. Your explicit choices override these defaults. +See :doc:`/admin/privacy` for privacy configuration examples. + Changes to these settings affect future commits. Names and e-mail addresses already recorded in repository history remain unchanged. diff --git a/weblate/accounts/apps.py b/weblate/accounts/apps.py index 5b4507d23ab5..3f75667a4a3d 100644 --- a/weblate/accounts/apps.py +++ b/weblate/accounts/apps.py @@ -129,23 +129,6 @@ def check_notification_settings( return [weblate_check("weblate.C051", message) for message in errors] -@register() -def check_user_visibility( - *, - app_configs: Sequence[AppConfig] | None, - databases: Sequence[str] | None, - **kwargs: object, -) -> Iterable[CheckMessage]: - for name, choices in ( - ("USER_DIRECTORY_VISIBILITY", ("all", "scoped", "disabled")), - ("USER_PROFILE_VISIBILITY", ("public", "authenticated", "admins")), - ): - if getattr(settings, name) not in choices: - yield weblate_check( - "weblate.C052", f"{name} must be one of: {', '.join(sorted(choices))}" - ) - - class AccountsConfig(AppConfig): name = "weblate.accounts" label = "accounts" diff --git a/weblate/accounts/avatar.py b/weblate/accounts/avatar.py index 16a2aa8d0a16..7ce93af3dea7 100644 --- a/weblate/accounts/avatar.py +++ b/weblate/accounts/avatar.py @@ -17,7 +17,7 @@ from django.utils.html import format_html from django.utils.translation import gettext, pgettext -from weblate.accounts.visibility import can_view_profile +from weblate.accounts.visibility import can_view_profile, public_profile_render from weblate.utils.errors import report_error from weblate.utils.requests import fetch_url @@ -108,6 +108,7 @@ def get_user_avatar_url( return reverse("user_avatar", kwargs={"user": user.username, "size": size}) +@public_profile_render() def get_user_display( user: User | None, icon: bool = True, diff --git a/weblate/accounts/defaults.py b/weblate/accounts/defaults.py index e41c45a0336d..04cf45d16c3f 100644 --- a/weblate/accounts/defaults.py +++ b/weblate/accounts/defaults.py @@ -30,8 +30,6 @@ DEFAULT_SUPPORT_STATUS_CHECK = True DEFAULT_AUTO_WATCH = True DEFAULT_CONTACT_FORM = "reply-to" -DEFAULT_USER_DIRECTORY_VISIBILITY = "all" -DEFAULT_USER_PROFILE_VISIBILITY = "public" DEFAULT_PRIVATE_COMMIT_EMAIL_TEMPLATE = "{username}@users.noreply.{site_domain}" DEFAULT_PRIVATE_COMMIT_EMAIL_OPT_IN = True diff --git a/weblate/accounts/models.py b/weblate/accounts/models.py index 10e5c83c5e95..057014186669 100644 --- a/weblate/accounts/models.py +++ b/weblate/accounts/models.py @@ -155,8 +155,6 @@ class WeblateAccountsConf(AppConf): DEFAULT_AUTO_WATCH = defaults.DEFAULT_AUTO_WATCH CONTACT_FORM = defaults.DEFAULT_CONTACT_FORM - USER_DIRECTORY_VISIBILITY = defaults.DEFAULT_USER_DIRECTORY_VISIBILITY - USER_PROFILE_VISIBILITY = defaults.DEFAULT_USER_PROFILE_VISIBILITY PRIVATE_COMMIT_EMAIL_TEMPLATE = defaults.DEFAULT_PRIVATE_COMMIT_EMAIL_TEMPLATE PRIVATE_COMMIT_EMAIL_OPT_IN = defaults.DEFAULT_PRIVATE_COMMIT_EMAIL_OPT_IN diff --git a/weblate/accounts/notifications.py b/weblate/accounts/notifications.py index 8eed07667472..2654be1c8399 100644 --- a/weblate/accounts/notifications.py +++ b/weblate/accounts/notifications.py @@ -32,6 +32,7 @@ from weblate.accounts.data import NotificationFrequency, NotificationScope from weblate.accounts.tasks import EMAIL_BATCH_SIZE, queue_mails +from weblate.accounts.visibility import public_profile_render from weblate.auth.models import User from weblate.lang.models import Language from weblate.logger import LOGGER @@ -355,6 +356,7 @@ def send( queue_mails(self.outgoing) self.outgoing.clear() + @public_profile_render() def render_template(self, suffix: str, context: dict, digest: bool = False) -> str: """Render single mail template with given context.""" base_name = self.digest_template if digest else self.template_name @@ -1677,6 +1679,7 @@ def get_count(translation: Translation) -> int: return translation.stats.todo +@public_profile_render() def get_notification_emails( language: str | None, recipients: list[str], diff --git a/weblate/accounts/tests/test_user_display.py b/weblate/accounts/tests/test_user_display.py index ccd53406b806..129528f0217d 100644 --- a/weblate/accounts/tests/test_user_display.py +++ b/weblate/accounts/tests/test_user_display.py @@ -12,10 +12,12 @@ from urllib.parse import parse_qs, urlsplit from django.contrib.auth.models import AnonymousUser +from django.db import connection from django.db.models import Value from django.template import Context, Template from django.template.loader import render_to_string from django.test import RequestFactory, override_settings +from django.test.utils import CaptureQueriesContext from django.urls import reverse from lxml.html import fromstring @@ -26,9 +28,15 @@ ) from weblate.accounts.models import Profile, VerifiedEmail from weblate.accounts.notifications import NewAnnouncementNotificaton -from weblate.auth.models import User, get_anonymous +from weblate.accounts.tests.visibility import VisibilityPermissions +from weblate.accounts.visibility import ( + can_browse_users, + can_search_scoped_users, + can_view_profile, +) +from weblate.auth.models import Group, Permission, Role, User, get_anonymous from weblate.trans.actions import ActionEvents -from weblate.trans.feeds import BaseFeed +from weblate.trans.feeds import BaseFeed, ChangesFeed from weblate.trans.models import Announcement, Change from weblate.trans.templatetags.translations import format_last_changes_content from weblate.trans.tests.test_views import FixtureTestCase @@ -72,7 +80,7 @@ def test_display_policy_matrix(self) -> None: for viewer in (None, AnonymousUser(), self.user, self.target): with ( self.subTest(policy=policy, viewer=viewer), - override_settings(USER_PROFILE_VISIBILITY=policy), + VisibilityPermissions(profile=policy), ): accessible = ( policy == "public" @@ -88,7 +96,7 @@ def test_display_policy_matrix(self) -> None: self.assertIn("<Target & Name>", rendered) self.assertNotIn("", rendered) - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_global_permissions_and_self_access(self) -> None: for permission in ("user.view", "user.edit"): with ( @@ -108,7 +116,7 @@ def test_global_permissions_and_self_access(self) -> None: get_user_display(self.target, link=True, viewer=self.user), accessible=True ) - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_missing_user_disabled_icons_and_avatar_sizes(self) -> None: rendered = get_user_display(None, link=True) self.assertNotIn("href=", rendered) @@ -125,7 +133,7 @@ def test_missing_user_disabled_icons_and_avatar_sizes(self) -> None: self.assertNotIn("href=", rendered) self.assertIn(self.target.username, rendered) - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_template_uses_request_not_displayed_user(self) -> None: template = Template("{% load user_display %}{% user_display user %}") for viewer, accessible in ( @@ -146,7 +154,7 @@ def test_template_uses_request_not_displayed_user(self) -> None: template.render(Context({"user": self.target})), accessible=False ) - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_user_cards_and_contributor_list(self) -> None: for viewer, accessible in ((self.user, False), (self.target, True)): request = self.request_for(viewer) @@ -173,7 +181,7 @@ def test_user_cards_and_contributor_list(self) -> None: ) self.assert_profile_display(contributors, accessible=accessible) - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_management_row_uses_request_viewer(self) -> None: rendered = render_to_string( "trans/project-access-row.html", @@ -199,7 +207,7 @@ def test_information_panel_preserves_viewer(self) -> None: for viewer in (get_anonymous(), self.user, self.target, admin): with ( self.subTest(policy=policy, viewer=viewer), - override_settings(USER_PROFILE_VISIBILITY=policy), + VisibilityPermissions(profile=policy), ): rendered = template.render( Context( @@ -239,12 +247,14 @@ def test_information_panel_contributor_links(self) -> None: for permission in (None, "user.view", "user.edit"): with ( self.subTest(scope=scope, mode=mode, permission=permission), - override_settings(USER_DIRECTORY_VISIBILITY=mode), + VisibilityPermissions(directory=mode), patch.object( self.user, "has_perm", - side_effect=lambda name, _obj=None, expected=permission: ( + side_effect=lambda name, _obj=None, expected=permission, mode=mode: ( name == expected + or (name == "user.browse" and mode == "all") + or (name == "user.search" and mode in {"all", "scoped"}) ), ), ): @@ -284,12 +294,14 @@ def test_information_panel_browse_all_users(self) -> None: for permission in (None, "user.view", "user.edit"): with ( self.subTest(mode=mode, permission=permission), - override_settings(USER_DIRECTORY_VISIBILITY=mode), + VisibilityPermissions(directory=mode), patch.object( self.user, "has_perm", - side_effect=lambda name, _obj=None, expected=permission: ( + side_effect=lambda name, _obj=None, expected=permission, mode=mode: ( name == expected + or (name == "user.browse" and mode == "all") + or (name == "user.search" and mode in {"all", "scoped"}) ), ), ): @@ -308,7 +320,7 @@ def test_information_panel_browse_all_users(self) -> None: bool(permission) or mode == "all", ) - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_mentions_and_preview(self) -> None: text = f"@{self.target.username} مرحبا" for viewer, accessible in ( @@ -359,7 +371,7 @@ def test_announcement_banner_mentions(self) -> None: for viewer in viewers: with ( self.subTest(scope=scope, policy=policy, viewer=viewer), - override_settings(USER_PROFILE_VISIBILITY=policy), + VisibilityPermissions(profile=policy), ): rendered = template.render(Context({"user": viewer, **scope})) self.assertIn(f"@{self.target.username}", rendered) @@ -375,7 +387,7 @@ def test_announcement_banner_mentions(self) -> None: ) announcement.delete() - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_history_and_digest(self) -> None: change = self.create_comment_change() for viewer, accessible in ( @@ -407,7 +419,7 @@ def test_email_preview_and_feed_use_public_access(self) -> None: for policy in ("public", "authenticated", "admins"): with ( self.subTest(policy=policy), - override_settings(USER_PROFILE_VISIBILITY=policy), + VisibilityPermissions(profile=policy), ): email = render_to_string( "mail/new_announcement.html", @@ -426,7 +438,7 @@ def test_email_preview_and_feed_use_public_access(self) -> None: self.assertIn(self.target.username, feed_author) self.assertEqual("href=" in feed_author, policy == "public") - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_git_author(self) -> None: social = self.target.social_auth.create( provider="email", uid=self.target.email, id_key="email" @@ -443,3 +455,76 @@ def test_git_author(self) -> None: Context({"request": self.request_for(viewer), "commit": commit}) ) self.assert_profile_display(rendered, accessible=accessible) + + @VisibilityPermissions(directory="disabled", profile="admins") + def test_independent_custom_visibility_permissions(self) -> None: + role = Role.objects.create(name="Custom visibility") + team = Group.objects.create(name="Custom visibility", project_selection=1) + team.roles.add(role) + self.user.groups.add(team) + for permission in ( + "user.browse", + "user.search", + "user.profile", + "user.view", + "user.edit", + ): + with self.subTest(permission=permission): + role.permissions.set(Permission.objects.filter(codename=permission)) + self.assertEqual( + can_browse_users(self.user), + permission in {"user.browse", "user.view", "user.edit"}, + ) + self.assertEqual( + can_search_scoped_users(self.user), permission != "user.profile" + ) + self.assertEqual( + can_view_profile(self.user, self.target), + permission in {"user.profile", "user.view", "user.edit"}, + ) + self.assertTrue(can_view_profile(self.user, self.user)) + self.assertFalse(can_view_profile(get_anonymous(), self.target)) + + def test_public_render_permission_queries(self) -> None: + changes = [self.create_comment_change() for _ in range(3)] + notification = NewAnnouncementNotificaton([]) + context = { + **notification.get_context(changes=changes), + "user": self.target, + "subject": "Digest", + } + # Warm the anonymous prototype, but never its per-render permissions. + get_anonymous() + _ = self.user.global_permissions + profile_permission = Permission.objects.get(codename="user.profile") + public_role = Role.objects.get(name="View user profiles") + public_role.permissions.add(profile_permission) + Group.objects.get(name="Guests", internal=True).roles.add(public_role) + for public in (True, False): + if not public: + public_role.permissions.remove(profile_permission) + for output in ("email", "feed"): + with self.subTest(public=public, output=output): + with CaptureQueriesContext(connection) as queries: + if output == "email": + rendered = notification.render_template( + ".html", context, digest=True + ) + else: + rendered = ( + ChangesFeed() + .get_feed(self.user, self.request_for(self.user)) + .writeString("utf-8") + ) + permission_queries = [ + query + for query in queries.captured_queries + if 'SELECT "weblate_auth_permission"."codename"' in query["sql"] + ] + self.assertEqual(len(permission_queries), 1) + self.assertIn(self.target.username, rendered) + self.assertEqual(self.target.get_absolute_url() in rendered, public) + + def test_unknown_user_display_needs_no_public_viewer(self) -> None: + with self.assertNumQueries(0): + self.assertIn("None", get_user_display(None)) diff --git a/weblate/accounts/tests/test_views.py b/weblate/accounts/tests/test_views.py index 242f157665d3..9230bce7086a 100644 --- a/weblate/accounts/tests/test_views.py +++ b/weblate/accounts/tests/test_views.py @@ -48,6 +48,7 @@ NotificationFrequency, NotificationScope, ) +from weblate.accounts.tests.visibility import VisibilityPermissions from weblate.accounts.views import auth_token_error_message, log_handled_auth_failure from weblate.auth.models import Group, Permission, Role, User from weblate.billing.defines import TRIAL_PLAN_SLUG @@ -349,12 +350,14 @@ def test_profile_visibility_policy(self) -> None: for mode in ("authenticated", "admins"): with ( self.subTest(mode=mode), - override_settings(USER_PROFILE_VISIBILITY=mode), + VisibilityPermissions(profile=mode), ): self.client.logout() for url in urls: with self.subTest(url=url): - self.assertEqual(self.client.get(url).status_code, 404) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + self.assertIn("no-store", response["Cache-Control"]) self.client.force_login(viewer) with mock.patch( "weblate.accounts.views.get_avatar_image", return_value=b"avatar" @@ -380,7 +383,7 @@ def test_profile_visibility_policy(self) -> None: viewer.is_superuser = False viewer.save() - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_restricted_notification_diagnostics(self) -> None: viewer = self.get_user() target = User.objects.create_user( @@ -406,7 +409,7 @@ def test_directory_visibility_policy(self) -> None: for mode in ("scoped", "disabled"): with ( self.subTest(mode=mode), - override_settings(USER_DIRECTORY_VISIBILITY=mode), + VisibilityPermissions(directory=mode), ): for query in ( "", diff --git a/weblate/accounts/tests/test_visibility.py b/weblate/accounts/tests/test_visibility.py index 7c46e37ddbcc..3c10f625b189 100644 --- a/weblate/accounts/tests/test_visibility.py +++ b/weblate/accounts/tests/test_visibility.py @@ -6,9 +6,8 @@ from types import SimpleNamespace -from django.test import SimpleTestCase, override_settings +from django.test import SimpleTestCase -from weblate.accounts.apps import check_user_visibility from weblate.accounts.visibility import is_resource_bounded from weblate.utils.search import SearchQueryError, parse_query @@ -45,34 +44,3 @@ def test_nonconcrete_scope(self) -> None: parse_query( query, parser="user", user=SimpleNamespace(allowed_projects=[]) ) - - -class VisibilitySettingsTest(SimpleTestCase): - def test_valid_values(self) -> None: - for directory in ("all", "scoped", "disabled"): - for profile in ("public", "authenticated", "admins"): - with ( - self.subTest(directory=directory, profile=profile), - override_settings( - USER_DIRECTORY_VISIBILITY=directory, - USER_PROFILE_VISIBILITY=profile, - ), - ): - self.assertEqual( - list(check_user_visibility(app_configs=None, databases=None)), - [], - ) - - def test_invalid_values(self) -> None: - values: tuple[object, ...] = ("typo", "", None, []) - for name in ("USER_DIRECTORY_VISIBILITY", "USER_PROFILE_VISIBILITY"): - for value in values: - with ( - self.subTest(name=name, value=value), - override_settings(**{name: value}), - ): - errors = list( - check_user_visibility(app_configs=None, databases=None) - ) - self.assertEqual(len(errors), 1) - self.assertIn(name, errors[0].msg) diff --git a/weblate/accounts/tests/visibility.py b/weblate/accounts/tests/visibility.py new file mode 100644 index 000000000000..45547c530132 --- /dev/null +++ b/weblate/accounts/tests/visibility.py @@ -0,0 +1,86 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Role assignments for visibility regression matrices.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from django.test.utils import TestContextDecorator + +from weblate.auth.models import Group, Permission, Role, User +from weblate.trans.tests.utils import clear_users_cache + +if TYPE_CHECKING: + from django.db.models import Model + + +class VisibilityPermissions(TestContextDecorator): + """Configure actual roles while refreshing cached test-user permissions.""" + + def __init__( + self, *, directory: str | None = None, profile: str | None = None + ) -> None: + super().__init__() + self.directory = directory + self.profile = profile + + def enable(self) -> None: + self.saved = [] + permissions = set() + if self.directory is not None: + permissions.update({"user.browse", "user.search"}) + if self.profile is not None: + permissions.add("user.profile") + for role in Role.objects.filter( + permissions__codename__in=permissions + ).distinct(): + current = list(role.permissions.all()) + self.saved.append((role, current)) + role.permissions.remove(*[p for p in current if p.codename in permissions]) + self.roles = [] + for team, codenames in ( + ( + "Users", + ( + ( + {"user.browse", "user.search"} + if self.directory == "all" + else {"user.search"} + if self.directory == "scoped" + else set() + ) + | ( + {"user.profile"} + if self.profile in {"public", "authenticated"} + else set() + ) + ), + ), + ("Guests", {"user.profile"} if self.profile == "public" else set()), + ): + if codenames: + role = Role.objects.create(name=f"Visibility test {team}") + role.permissions.set(Permission.objects.filter(codename__in=codenames)) + Group.objects.get(name=team, internal=True).roles.add(role) + self.roles.append(role) + original = User.has_perm + + def has_perm(user: User, perm: str, obj: Model | None = None) -> bool: + user.clear_permissions_cache() + return bool(original(user, perm, obj)) + + self.patcher = patch.object(User, "has_perm", has_perm) + self.patcher.start() + clear_users_cache() + + def disable(self) -> None: + self.patcher.stop() + for role in self.roles: + role.delete() + for role, permissions in self.saved: + role.permissions.set(permissions) + clear_users_cache() diff --git a/weblate/accounts/views.py b/weblate/accounts/views.py index c6884fe5c25f..d9002931298c 100644 --- a/weblate/accounts/views.py +++ b/weblate/accounts/views.py @@ -2394,6 +2394,7 @@ def saml_metadata(request: AuthenticatedHttpRequest): @method_decorator(login_required, name="dispatch") +@method_decorator(profile_response, name="dispatch") class UserList(ListView): paginate_by = 50 model = User diff --git a/weblate/accounts/visibility.py b/weblate/accounts/visibility.py index 510181a0823a..47618e4e4728 100644 --- a/weblate/accounts/visibility.py +++ b/weblate/accounts/visibility.py @@ -2,51 +2,77 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Installation policies for discovering users and accessing their profiles.""" +"""Permissions for discovering users and accessing their profiles.""" from __future__ import annotations -from functools import wraps -from typing import TYPE_CHECKING +from contextlib import contextmanager +from contextvars import ContextVar +from functools import cache, wraps +from typing import TYPE_CHECKING, cast -from django.conf import settings +from django.core.handlers.exception import response_for_exception from django.db.models import Q from django.http import Http404 from django.utils.cache import add_never_cache_headers if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Generator from django.contrib.auth.models import AnonymousUser + from django.http import HttpRequest from django.http.response import HttpResponseBase from weblate.auth.models import User +_public_viewer: ContextVar[Callable[[], User] | None] = ContextVar( + "public_profile_viewer", default=None +) + + +@contextmanager +def public_profile_render() -> Generator[None, None, None]: + """Share public permissions within a render, never across renders.""" + if _public_viewer.get() is not None: + yield + return + # Models are unavailable while the accounts app imports this module. + from weblate.auth.models import get_anonymous # ruff: ignore[import-outside-top-level] + + # Resolve lazily so rendering text without profile links needs no user query. + token = _public_viewer.set(cache(get_anonymous)) + try: + yield + finally: + _public_viewer.reset(token) + + def can_manage_users(user: User | AnonymousUser) -> bool: return bool(user.has_perm("user.view") or user.has_perm("user.edit")) def can_browse_users(user: User | AnonymousUser) -> bool: - return settings.USER_DIRECTORY_VISIBILITY == "all" or can_manage_users(user) + return bool(user.has_perm("user.browse") or can_manage_users(user)) def can_search_scoped_users(user: User | AnonymousUser) -> bool: - return settings.USER_DIRECTORY_VISIBILITY in {"all", "scoped"} or can_manage_users( - user - ) + return bool(user.has_perm("user.search") or can_browse_users(user)) def can_view_profile(viewer: User | AnonymousUser | None, target: User) -> bool: - if settings.USER_PROFILE_VISIBILITY == "public": - return True - if viewer is None: - return False - if viewer.pk == target.pk: - return True - if settings.USER_PROFILE_VISIBILITY == "authenticated" and viewer.is_authenticated: - return True - return can_manage_users(viewer) + # Output without a request (feeds and email) follows public access. + if viewer is None or viewer.pk is None: + # Models are unavailable when the accounts app imports this module. + from weblate.auth.models import get_anonymous # ruff: ignore[import-outside-top-level] + + get_public_viewer = _public_viewer.get() or get_anonymous + viewer = get_public_viewer() + return bool( + viewer.pk == target.pk + or viewer.has_perm("user.profile") + or can_manage_users(viewer) + ) def check_profile_access(viewer: User | AnonymousUser, target: User) -> None: @@ -55,13 +81,17 @@ def check_profile_access(viewer: User | AnonymousUser, target: User) -> None: def profile_response[**P, R: HttpResponseBase](view: Callable[P, R]) -> Callable[P, R]: - """Do not cache profile responses across viewers under restricted policy.""" + """Do not cache permission-dependent responses across viewers.""" @wraps(view) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - response = view(*args, **kwargs) - if settings.USER_PROFILE_VISIBILITY != "public": - add_never_cache_headers(response) + try: + response = view(*args, **kwargs) + except Http404 as error: + # Render through the normal error handler, then protect denials too. + request = cast("HttpRequest", args[0]) + response = cast("R", response_for_exception(request, error)) + add_never_cache_headers(response) return response return wrapper diff --git a/weblate/api/tests.py b/weblate/api/tests.py index 08d5402e92f5..0080dcbe3229 100644 --- a/weblate/api/tests.py +++ b/weblate/api/tests.py @@ -55,6 +55,7 @@ NotificationFrequency, NotificationScope, ) +from weblate.accounts.tests.visibility import VisibilityPermissions from weblate.addons.autotranslate import AutoTranslateAddon from weblate.addons.base import build_addon_change_details from weblate.addons.cdn import CDNJSAddon @@ -539,7 +540,7 @@ def test_directory_visibility(self) -> None: for mode in ("scoped", "disabled"): with ( self.subTest(mode=mode), - override_settings(USER_DIRECTORY_VISIBILITY=mode), + VisibilityPermissions(directory=mode), ): for params in ( {"username": "visibility"}, @@ -582,7 +583,7 @@ def test_directory_visibility(self) -> None: response.data["results"][0]["username"], self.user.username ) - @override_settings(USER_DIRECTORY_VISIBILITY="scoped") + @VisibilityPermissions(directory="scoped") def test_discovery_resource_access(self) -> None: contributor, unit = self.create_visibility_contributor() outsider = User.objects.create_user( @@ -667,7 +668,7 @@ def test_project_discovery_component_visibility(self) -> None: for mode in ("all", "scoped", "disabled"): with ( self.subTest(mode=mode), - override_settings(USER_DIRECTORY_VISIBILITY=mode), + VisibilityPermissions(directory=mode), ): response = self.client.get(reverse("api:user-list"), {"q": query}) self.assertEqual(response.status_code, 200) @@ -675,7 +676,7 @@ def test_project_discovery_component_visibility(self) -> None: {item["username"] for item in response.data["results"]}, expected ) self.client.force_login(self.user) - with override_settings(USER_DIRECTORY_VISIBILITY="scoped"): + with VisibilityPermissions(directory="scoped"): response = self.client.get(reverse("user_list"), {"q": query}) self.assertEqual(response.status_code, 200) for name, contributor in contributors.items(): @@ -685,7 +686,7 @@ def test_project_discovery_component_visibility(self) -> None: else: self.assertContains(response, contributor.get_absolute_url()) - @override_settings(USER_DIRECTORY_VISIBILITY="scoped") + @VisibilityPermissions(directory="scoped") def test_project_discovery_component_access_grant(self) -> None: restricted, contributors = self.create_project_discovery_contributors() self.authenticate(True) @@ -718,9 +719,9 @@ def test_profile_visibility(self) -> None: for profile in ("public", "authenticated", "admins"): with ( self.subTest(directory=directory, profile=profile), - override_settings( - USER_DIRECTORY_VISIBILITY=directory, - USER_PROFILE_VISIBILITY=profile, + VisibilityPermissions( + directory=directory, + profile=profile, ), ): self.client.credentials() @@ -750,7 +751,7 @@ def test_profile_visibility(self) -> None: self.assertEqual(self.client.get(url).status_code, 200) self.authenticate() - @override_settings(USER_PROFILE_VISIBILITY="admins") + @VisibilityPermissions(profile="admins") def test_profile_subresources_do_not_reveal_restricted_users(self) -> None: contributor, _ = self.create_visibility_contributor() self.authenticate() @@ -765,7 +766,7 @@ def test_profile_subresources_do_not_reveal_restricted_users(self) -> None: getattr(self.client, method)(url + suffix).status_code, 404 ) - @override_settings(USER_DIRECTORY_VISIBILITY="scoped") + @VisibilityPermissions(directory="scoped") def test_site_search_hides_generic_user_results(self) -> None: contributor, _ = self.create_visibility_contributor() self.authenticate() @@ -4631,7 +4632,10 @@ def test_list_prefetches_permissions(self) -> None: def test_list_roles(self) -> None: response = self.client.get(reverse("api:role-list")) - self.assertEqual(response.data["count"], 2) + self.assertEqual( + {role["name"] for role in response.data["results"]}, + {"Add suggestion", "Access repository", "View user profiles"}, + ) self.authenticate(True) response = self.client.get(reverse("api:role-list")) diff --git a/weblate/api/views.py b/weblate/api/views.py index ae303760dc1b..0a0cedc6497a 100644 --- a/weblate/api/views.py +++ b/weblate/api/views.py @@ -1171,7 +1171,7 @@ def get_delete_memory_option(request: Request) -> bool: "after trimming surrounding whitespace; " "such searches return basic information about matching non-bot users. " "Users with either permission can list all users and receive detailed " - "information. USER_DIRECTORY_VISIBILITY can require resource-scoped " + "information. Without user.browse, searches require resource-scoped " "discovery; a positive contributes scope or accessible unit context is " "then required to discover other users." ), @@ -1221,7 +1221,7 @@ def get_delete_memory_option(request: Request) -> bool: ), retrieve=extend_schema( description=( - "Return information about users, subject to USER_PROFILE_VISIBILITY. " + "Return information about users, subject to user.profile permission. " "Restricted profiles return HTTP 404; self and administrative access " "are retained." ), @@ -1281,8 +1281,7 @@ def get_object(self): def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) - if settings.USER_PROFILE_VISIBILITY != "public": - add_never_cache_headers(response) + add_never_cache_headers(response) return response @cached_property @@ -1339,6 +1338,9 @@ def get_discovery_queryset(self): ).distinct() bounded = True if not bounded: + # Directory restrictions never hide the caller's own account. + if "username" in self.request.GET and not scoped: + return queryset.filter(pk=user.pk) return User.objects.none() if ( not privileged diff --git a/weblate/auth/data.py b/weblate/auth/data.py index 6a68a50919de..4967cdbd69c7 100644 --- a/weblate/auth/data.py +++ b/weblate/auth/data.py @@ -127,6 +127,12 @@ # Permissions which are not scoped per project GLOBAL_PERMISSIONS = ( + # Translators: Permission name + ("user.browse", gettext_noop("Browse users")), + # Translators: Permission name + ("user.search", gettext_noop("Search resource contributors")), + # Translators: Permission name + ("user.profile", gettext_noop("View user profiles")), # Translators: Permission name ("management.use", gettext_noop("Use management interface")), # Translators: Permission name @@ -195,6 +201,11 @@ def filter_perms(prefix: str, exclude: set[str] | None = None) -> set[str]: # Default set of roles ROLES = ( + ( + pgettext_noop("Access-control role", "Discover users"), + {"user.browse", "user.search"}, + ), + (pgettext_noop("Access-control role", "View user profiles"), {"user.profile"}), ( pgettext_noop("Access-control role", "Administration"), [x[0] for x in PERMISSIONS if not x[0].startswith("workspace.")], @@ -289,13 +300,13 @@ def filter_perms(prefix: str, exclude: set[str] | None = None) -> set[str]: GROUPS = ( ( pgettext_noop("Access-control team name", "Guests"), - ("Add suggestion", "Access repository"), + ("Add suggestion", "Access repository", "View user profiles"), SELECTION_ALL_PUBLIC, ), (pgettext_noop("Access-control team name", "Viewers"), (), SELECTION_ALL_PROTECTED), ( pgettext_noop("Access-control team name", "Users"), - ("Power user",), + ("Power user", "Discover users", "View user profiles"), SELECTION_ALL_PUBLIC, ), ( diff --git a/weblate/auth/migrations/0015_update_builtin_roles.py b/weblate/auth/migrations/0015_update_builtin_roles.py new file mode 100644 index 000000000000..5f4ea5e3e89b --- /dev/null +++ b/weblate/auth/migrations/0015_update_builtin_roles.py @@ -0,0 +1,506 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import migrations + +if TYPE_CHECKING: + from django.db.backends.base.schema import BaseDatabaseSchemaEditor + from django.db.migrations.state import StateApps + + +OLD_DEFAULTS: dict[str, list[str]] = { + "Access repository": ["translation.download", "vcs.access", "vcs.view"], + "Add new projects": ["project.add", "workspace.add"], + "Add suggestion": ["suggestion.add"], + "Add workspace projects": ["workspace.add_project"], + "Administration": [ + "announcement.add", + "announcement.delete", + "change.download", + "comment.add", + "comment.delete", + "comment.resolve", + "component.edit", + "component.lock", + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.terminology", + "glossary.upload", + "machinery.view", + "memory.delete", + "memory.edit", + "project.edit", + "project.permissions", + "reports.view", + "screenshot.add", + "screenshot.delete", + "screenshot.edit", + "source.edit", + "suggestion.accept", + "suggestion.add", + "suggestion.delete", + "suggestion.vote", + "translation.add", + "translation.add_more", + "translation.auto", + "translation.delete", + "translation.download", + "unit.add", + "unit.bulk_edit", + "unit.check", + "unit.delete", + "unit.edit", + "unit.override", + "unit.review", + "unit.template", + "upload.authorship", + "upload.overwrite", + "upload.perform", + "vcs.access", + "vcs.commit", + "vcs.push", + "vcs.reset", + "vcs.update", + "vcs.view", + ], + "Automatic translation": ["translation.auto"], + "Bulk editing": ["unit.bulk_edit"], + "Edit source": [ + "comment.add", + "machinery.view", + "source.edit", + "suggestion.accept", + "suggestion.add", + "suggestion.vote", + "translation.download", + "unit.check", + "unit.edit", + "unit.template", + "upload.overwrite", + "upload.perform", + ], + "Manage glossary": [ + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.terminology", + "glossary.upload", + ], + "Manage languages": [ + "translation.add", + "translation.add_more", + "translation.delete", + "translation.download", + ], + "Manage repository": [ + "component.lock", + "vcs.access", + "vcs.commit", + "vcs.push", + "vcs.reset", + "vcs.update", + "vcs.view", + ], + "Manage screenshots": ["screenshot.add", "screenshot.delete", "screenshot.edit"], + "Manage translation memory": ["memory.delete", "memory.edit"], + "Power user": [ + "comment.add", + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.upload", + "machinery.view", + "suggestion.accept", + "suggestion.add", + "suggestion.delete", + "suggestion.vote", + "translation.add", + "translation.download", + "unit.check", + "unit.edit", + "unit.template", + "upload.overwrite", + "upload.perform", + "vcs.access", + "vcs.view", + ], + "Review strings": [ + "comment.add", + "comment.resolve", + "machinery.view", + "suggestion.accept", + "suggestion.add", + "suggestion.vote", + "translation.download", + "unit.check", + "unit.edit", + "unit.override", + "unit.review", + "upload.overwrite", + "upload.perform", + ], + "Translate": [ + "comment.add", + "machinery.view", + "suggestion.accept", + "suggestion.add", + "suggestion.vote", + "translation.download", + "unit.check", + "unit.edit", + "upload.overwrite", + "upload.perform", + ], + "Translation coordinator": [ + "announcement.add", + "announcement.delete", + "comment.add", + "comment.resolve", + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.terminology", + "glossary.upload", + "machinery.view", + "screenshot.add", + "screenshot.delete", + "screenshot.edit", + "suggestion.accept", + "suggestion.add", + "suggestion.delete", + "suggestion.vote", + "translation.add", + "translation.download", + "unit.check", + "unit.edit", + "unit.override", + "unit.review", + "unit.template", + "upload.overwrite", + "upload.perform", + "vcs.access", + "vcs.view", + ], + "Workspace administration": [ + "reports.view", + "workspace.add_project", + "workspace.edit", + "workspace.edit_members", + ], +} + +NEW_DEFAULTS = { + "Access repository": ["translation.download", "vcs.access", "vcs.view"], + "Add new projects": ["project.add", "workspace.add"], + "Add suggestion": ["suggestion.add"], + "Add workspace projects": ["workspace.add_project"], + "Administration": [ + "announcement.add", + "announcement.delete", + "change.download", + "comment.add", + "comment.delete", + "comment.resolve", + "component.edit", + "component.lock", + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.terminology", + "glossary.upload", + "machinery.view", + "memory.delete", + "memory.edit", + "project.edit", + "project.permissions", + "reports.view", + "screenshot.add", + "screenshot.delete", + "screenshot.edit", + "source.edit", + "suggestion.accept", + "suggestion.add", + "suggestion.delete", + "suggestion.vote", + "translation.add", + "translation.add_more", + "translation.auto", + "translation.delete", + "translation.download", + "unit.add", + "unit.bulk_edit", + "unit.check", + "unit.delete", + "unit.edit", + "unit.override", + "unit.review", + "unit.template", + "upload.authorship", + "upload.overwrite", + "upload.perform", + "vcs.access", + "vcs.commit", + "vcs.push", + "vcs.reset", + "vcs.update", + "vcs.view", + ], + "Automatic translation": ["translation.auto"], + "Bulk editing": ["unit.bulk_edit"], + "Discover users": ["user.browse", "user.search"], + "Edit source": [ + "comment.add", + "machinery.view", + "source.edit", + "suggestion.accept", + "suggestion.add", + "suggestion.vote", + "translation.download", + "unit.check", + "unit.edit", + "unit.template", + "upload.overwrite", + "upload.perform", + ], + "Manage glossary": [ + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.terminology", + "glossary.upload", + ], + "Manage languages": [ + "translation.add", + "translation.add_more", + "translation.delete", + "translation.download", + ], + "Manage repository": [ + "component.lock", + "vcs.access", + "vcs.commit", + "vcs.push", + "vcs.reset", + "vcs.update", + "vcs.view", + ], + "Manage screenshots": ["screenshot.add", "screenshot.delete", "screenshot.edit"], + "Manage translation memory": ["memory.delete", "memory.edit"], + "Power user": [ + "comment.add", + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.upload", + "machinery.view", + "suggestion.accept", + "suggestion.add", + "suggestion.delete", + "suggestion.vote", + "translation.add", + "translation.download", + "unit.check", + "unit.edit", + "unit.template", + "upload.overwrite", + "upload.perform", + "vcs.access", + "vcs.view", + ], + "Review strings": [ + "comment.add", + "comment.resolve", + "machinery.view", + "suggestion.accept", + "suggestion.add", + "suggestion.vote", + "translation.download", + "unit.check", + "unit.edit", + "unit.override", + "unit.review", + "upload.overwrite", + "upload.perform", + ], + "Translate": [ + "comment.add", + "machinery.view", + "suggestion.accept", + "suggestion.add", + "suggestion.vote", + "translation.download", + "unit.check", + "unit.edit", + "upload.overwrite", + "upload.perform", + ], + "Translation coordinator": [ + "announcement.add", + "announcement.delete", + "comment.add", + "comment.resolve", + "glossary.add", + "glossary.delete", + "glossary.edit", + "glossary.terminology", + "glossary.upload", + "machinery.view", + "screenshot.add", + "screenshot.delete", + "screenshot.edit", + "suggestion.accept", + "suggestion.add", + "suggestion.delete", + "suggestion.vote", + "translation.add", + "translation.download", + "unit.check", + "unit.edit", + "unit.override", + "unit.review", + "unit.template", + "upload.overwrite", + "upload.perform", + "vcs.access", + "vcs.view", + ], + "View user profiles": ["user.profile"], + "Workspace administration": [ + "reports.view", + "workspace.add_project", + "workspace.edit", + "workspace.edit_members", + ], +} + +PERMISSION_LABELS = { + "announcement.add": "Post announcements", + "announcement.delete": "Delete announcements", + "announcement.edit": "Manage announcements", + "billing.manage": "Manage billing", + "change.download": "Download changes", + "comment.add": "Post comment", + "comment.delete": "Delete comment", + "comment.resolve": "Resolve comment", + "component.edit": "Edit component settings", + "component.lock": "Lock component, preventing translations", + "componentlist.edit": "Manage component lists", + "glossary.add": "Add glossary entry", + "glossary.delete": "Delete glossary entry", + "glossary.edit": "Edit glossary entry", + "glossary.terminology": "Add glossary terminology", + "glossary.upload": "Upload glossary entries", + "group.edit": "Manage teams", + "group.view": "View team info", + "language.add": "Add language definitions", + "language.edit": "Manage language definitions", + "machinery.edit": "Manage translation services", + "machinery.view": "Use automatic suggestions", + "management.addons": "Manage site-wide add-ons", + "management.configure": "Manage site configuration", + "management.use": "Use management interface", + "memory.delete": "Delete translation memory", + "memory.edit": "Edit translation memory", + "memory.manage": "Manage translation memory", + "project.add": "Add new projects", + "project.edit": "Edit project settings", + "project.permissions": "Manage project access", + "reports.view": "Manage reports", + "role.edit": "Manage roles", + "role.view": "View role info", + "screenshot.add": "Add screenshot", + "screenshot.delete": "Delete screenshot", + "screenshot.edit": "Edit screenshot", + "source.edit": "Edit additional string info", + "suggestion.accept": "Accept suggestion", + "suggestion.add": "Add suggestion", + "suggestion.delete": "Delete suggestion", + "suggestion.vote": "Vote on suggestion", + "translation.add": "Add language for translation", + "translation.add_more": "Add several languages for translation", + "translation.auto": "Perform automatic translation", + "translation.delete": "Delete existing translation", + "translation.download": "Download translation file", + "unit.add": "Add new string", + "unit.bulk_edit": "Bulk edit strings", + "unit.check": "Dismiss failing check", + "unit.delete": "Remove a string", + "unit.edit": "Edit strings", + "unit.override": "Edit string when suggestions are enforced", + "unit.review": "Review strings", + "unit.template": "Edit source strings", + "upload.authorship": "Define author of uploaded translation", + "upload.overwrite": "Overwrite existing strings with upload", + "upload.perform": "Upload translations", + "user.browse": "Browse users", + "user.edit": "Manage users", + "user.profile": "View user profiles", + "user.search": "Search resource contributors", + "user.view": "View user info", + "vcs.access": "Access the internal repository", + "vcs.commit": "Commit changes to the internal repository", + "vcs.push": "Push change from the internal repository", + "vcs.reset": "Reset changes in the internal repository", + "vcs.update": "Update the internal repository", + "vcs.view": "View upstream repository location", + "workspace.add": "Add new workspaces", + "workspace.add_project": "Add projects to workspace", + "workspace.edit": "Edit workspace settings", + "workspace.edit_members": "Manage workspace access", +} + + +def merge_roles(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + """Merge frozen defaults without changing local permission differences.""" + alias = schema_editor.connection.alias + Role = apps.get_model("weblate_auth", "Role") + Permission = apps.get_model("weblate_auth", "Permission") + for codename, name in PERMISSION_LABELS.items(): + Permission.objects.using(alias).get_or_create( + codename=codename, defaults={"name": name} + ) + for name, defaults in NEW_DEFAULTS.items(): + role, created = Role.objects.using(alias).get_or_create(name=name) + local = set(role.permissions.using(alias).values_list("codename", flat=True)) + new = set(defaults) + if created or name not in OLD_DEFAULTS: + merged = new + else: + old = set(OLD_DEFAULTS[name]) + merged = (new - (old - local)) | (local - old) + if merged != local: + role.permissions.set( + Permission.objects.using(alias).filter(codename__in=merged) + ) + + +def rename_conflicting_roles( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + """Keep custom role identities and assignments before reserving new names.""" + Role = apps.get_model("weblate_auth", "Role") + roles = Role.objects.using(schema_editor.connection.alias) + for name in ("Discover users", "View user profiles"): + for role in roles.filter(name=name): + replacement = f"{name} (custom)" + number = 2 + while roles.filter(name=replacement).exists(): + replacement = f"{name} (custom {number})" + number += 1 + role.name = replacement + role.save(using=schema_editor.connection.alias, update_fields=["name"]) + + +class Migration(migrations.Migration): + dependencies = [("weblate_auth", "0014_editable_builtin_roles")] + + operations = [ + migrations.RunPython(rename_conflicting_roles), + migrations.RunPython(merge_roles), + ] diff --git a/weblate/auth/migrations/0016_user_visibility_teams.py b/weblate/auth/migrations/0016_user_visibility_teams.py new file mode 100644 index 000000000000..2410f373fdaf --- /dev/null +++ b/weblate/auth/migrations/0016_user_visibility_teams.py @@ -0,0 +1,35 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import migrations + +if TYPE_CHECKING: + from django.db.backends.base.schema import BaseDatabaseSchemaEditor + from django.db.migrations.state import StateApps + + +def assign_visibility_roles( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + alias = schema_editor.connection.alias + Group = apps.get_model("weblate_auth", "Group") + Role = apps.get_model("weblate_auth", "Role") + for name, roles in ( + ("Users", ("Discover users", "View user profiles")), + ("Guests", ("View user profiles",)), + ): + for team in Group.objects.using(alias).filter( + name=name, internal=True, defining_project=None, defining_workspace=None + ): + team.roles.add(*Role.objects.using(alias).filter(name__in=roles)) + + +class Migration(migrations.Migration): + dependencies = [("weblate_auth", "0015_update_builtin_roles")] + + operations = [migrations.RunPython(assign_visibility_roles)] diff --git a/weblate/auth/tests/test_roles.py b/weblate/auth/tests/test_roles.py index 359135109314..c3b84d48c592 100644 --- a/weblate/auth/tests/test_roles.py +++ b/weblate/auth/tests/test_roles.py @@ -102,21 +102,22 @@ def test_new_permissions_and_roles(self) -> None: def test_generated_migration(self) -> None: command_module = "weblate.auth.management.commands.generate_role_migration" - source_file = migration.__file__ + latest = import_module("weblate.auth.migrations.0015_update_builtin_roles") + source_file = latest.__file__ assert source_file is not None with TemporaryDirectory() as directory: snapshot = Path(directory) / "0014_editable_builtin_roles.py" snapshot.write_text(Path(source_file).read_text(encoding="utf-8")) with ( - patch.object(migration, "__file__", str(snapshot)), + patch.object(latest, "__file__", str(snapshot)), patch( f"{command_module}.ROLES", [*ROLES, ("New role", {"comment.add"})] ), ): call_command("generate_role_migration", stdout=StringIO()) - generated = Path(directory) / "0015_update_builtin_roles.py" + generated = Path(directory) / "0017_update_builtin_roles.py" namespace = run_path(str(generated)) - self.assertEqual(namespace["OLD_DEFAULTS"], migration.NEW_DEFAULTS) + self.assertEqual(namespace["OLD_DEFAULTS"], latest.NEW_DEFAULTS) self.assertEqual(namespace["NEW_DEFAULTS"]["New role"], ["comment.add"]) with connection.schema_editor() as editor: namespace["merge_roles"](apps, editor) @@ -145,7 +146,7 @@ def test_consecutive_generated_upgrades(self) -> None: copytree( Path(source_file).parent, Path(directory) / package, - ignore=ignore_patterns("__pycache__"), + ignore=ignore_patterns("__pycache__", "0015_*", "0016_*"), ) try: with ( @@ -353,3 +354,74 @@ def test_admin_permissions_and_reset(self) -> None: ) self.assertEqual(response.status_code, 302) self.assertFalse(role.is_customized) + + def test_visibility_team_upgrade_preserves_assignments(self) -> None: + state = MigrationLoader(None).project_state( + [("weblate_auth", "0015_update_builtin_roles")] + ) + historical_group = state.apps.get_model("weblate_auth", "Group") + historical_role = state.apps.get_model("weblate_auth", "Role") + custom = historical_role.objects.create(name="Local visibility upgrade") + users = historical_group.objects.get(name="Users", internal=True) + guests = historical_group.objects.get(name="Guests", internal=True) + users.roles.remove( + *historical_role.objects.filter( + name__in={"Discover users", "View user profiles"} + ) + ) + guests.roles.remove(historical_role.objects.get(name="View user profiles")) + users.roles.add(custom) + before = set(users.roles.values_list("pk", flat=True)) + upgrade = import_module("weblate.auth.migrations.0016_user_visibility_teams") + with connection.schema_editor() as editor: + upgrade.assign_visibility_roles(state.apps, editor) + upgrade.assign_visibility_roles(state.apps, editor) + self.assertEqual( + set(users.roles.values_list("pk", flat=True)), + before + | set( + historical_role.objects.filter( + name__in={"Discover users", "View user profiles"} + ).values_list("pk", flat=True) + ), + ) + self.assertTrue(guests.roles.filter(name="View user profiles").exists()) + + def test_new_builtin_role_name_collisions(self) -> None: + upgrade = import_module("weblate.auth.migrations.0015_update_builtin_roles") + records = [] + for name in ("Discover users", "View user profiles"): + Role.objects.filter(name=name).delete() + custom = Role.objects.create(name=name) + custom.permissions.set(Permission.objects.filter(codename="comment.add")) + occupied = Role.objects.create(name=f"{name} (custom)") + team = Group.objects.create(name=f"Custom {name}") + team.roles.add(custom) + records.append((name, custom, occupied, team)) + with connection.schema_editor() as editor: + upgrade.rename_conflicting_roles(apps, editor) + upgrade.merge_roles(apps, editor) + for name, custom, occupied, team in records: + with self.subTest(name=name): + role_id = custom.pk + custom.refresh_from_db() + self.assertEqual(custom.name, f"{name} (custom 2)") + self.assertEqual(custom.pk, role_id) + self.assertEqual( + list(custom.permissions.values_list("codename", flat=True)), + ["comment.add"], + ) + self.assertEqual( + list(team.roles.values_list("pk", flat=True)), [role_id] + ) + self.assertTrue( + Role.objects.filter( + pk=occupied.pk, name=f"{name} (custom)" + ).exists() + ) + builtin = Role.objects.get(name=name) + self.assertNotEqual(builtin.pk, role_id) + self.assertEqual( + set(builtin.permissions.values_list("codename", flat=True)), + set(dict(ROLES)[name]), + ) diff --git a/weblate/settings_docker.py b/weblate/settings_docker.py index 94926227871b..fd3f1458fc9a 100644 --- a/weblate/settings_docker.py +++ b/weblate/settings_docker.py @@ -1615,16 +1615,6 @@ CONTACT_FORM = get_env_str( "WEBLATE_CONTACT_FORM", accounts_defaults.DEFAULT_CONTACT_FORM, required=True ) -USER_DIRECTORY_VISIBILITY = get_env_str( - "WEBLATE_USER_DIRECTORY_VISIBILITY", - accounts_defaults.DEFAULT_USER_DIRECTORY_VISIBILITY, - required=True, -) -USER_PROFILE_VISIBILITY = get_env_str( - "WEBLATE_USER_PROFILE_VISIBILITY", - accounts_defaults.DEFAULT_USER_PROFILE_VISIBILITY, - required=True, -) ADMINS_CONTACT = get_env_list( "WEBLATE_ADMINS_CONTACT", list(trans_defaults.DEFAULT_ADMINS_CONTACT) ) diff --git a/weblate/trans/feeds.py b/weblate/trans/feeds.py index aeabbaf85490..b7c3b449f3a7 100644 --- a/weblate/trans/feeds.py +++ b/weblate/trans/feeds.py @@ -13,6 +13,7 @@ from django.urls import reverse from django.utils.translation import gettext +from weblate.accounts.visibility import public_profile_render from weblate.lang.models import Language from weblate.trans.models import Change, Component, Project, Translation, Unit from weblate.utils.site import get_site_url @@ -20,6 +21,9 @@ from weblate.utils.views import parse_path if TYPE_CHECKING: + from django.http import HttpRequest + from django.utils.feedgenerator import SyndicationFeed + from weblate.auth.models import AuthenticatedHttpRequest, User @@ -37,6 +41,10 @@ def get_change_feed_guid(change: Change) -> str: class BaseFeed(Feed): + @public_profile_render() + def get_feed(self, obj: object, request: HttpRequest) -> SyndicationFeed: + return super().get_feed(obj, request) + def item_title(self, item): return item.get_action_display() diff --git a/weblate/trans/tests/test_changes.py b/weblate/trans/tests/test_changes.py index 73706b6bdb25..193923890b84 100644 --- a/weblate/trans/tests/test_changes.py +++ b/weblate/trans/tests/test_changes.py @@ -24,6 +24,7 @@ from django.utils.http import urlencode from lxml import html +from weblate.accounts.visibility import public_profile_render from weblate.auth.models import User, get_anonymous from weblate.lang.models import Language from weblate.screenshots.models import Screenshot @@ -40,7 +41,7 @@ ReusableViewTestCase, ViewTestCase, ) -from weblate.trans.tests.utils import get_test_actor +from weblate.trans.tests.utils import clear_users_cache, get_test_actor from weblate.trans.views.changes import ChangesView from weblate.utils.change_origin import acting_user_context, change_origin_context from weblate.utils.state import STATE_EMPTY, STATE_READONLY, STATE_TRANSLATED @@ -711,10 +712,11 @@ def test_history_render_does_not_fetch_translation_metadata(self) -> None: class FeedQueriesTest(FixtureTestCase): # Reverse managers populate different related objects up front, so the # fixed query budget differs between project/component/translation feeds. - # This includes two bulk queries for source translations and their plurals. - PROJECT_FEED_QUERIES = 13 - COMPONENT_FEED_QUERIES = 12 - TRANSLATION_FEED_QUERIES = 12 + # This includes two bulk queries for source translations and their plurals, + # plus the public viewer lookup and its fixed permission/2FA query cost. + PROJECT_FEED_QUERIES = 18 + COMPONENT_FEED_QUERIES = 17 + TRANSLATION_FEED_QUERIES = 17 def setUp(self) -> None: super().setUp() @@ -732,24 +734,31 @@ def add_feed_changes(self, count: int) -> None: def assert_feed_queries(self, obj, expected_queries: int) -> None: feed = TranslationChangesFeed() - with self.assertNumQueries(expected_queries): + clear_users_cache() + with self.assertNumQueries(expected_queries), public_profile_render(): items = list(feed.items(ChangeFeedScope(self.user, obj))) for item in items: str(item) def test_project_feed_queries_do_not_scale_with_change_count(self) -> None: - self.add_feed_changes(6) + self.add_feed_changes(1) + self.assert_feed_queries(self.project, self.PROJECT_FEED_QUERIES) + self.add_feed_changes(5) self.assert_feed_queries(self.project, self.PROJECT_FEED_QUERIES) def test_component_feed_queries_do_not_scale_with_change_count(self) -> None: - self.add_feed_changes(6) + self.add_feed_changes(1) + self.assert_feed_queries(self.component, self.COMPONENT_FEED_QUERIES) + self.add_feed_changes(5) self.assert_feed_queries( self.component, self.COMPONENT_FEED_QUERIES, ) def test_translation_feed_queries_do_not_scale_with_change_count(self) -> None: - self.add_feed_changes(6) + self.add_feed_changes(1) + self.assert_feed_queries(self.translation, self.TRANSLATION_FEED_QUERIES) + self.add_feed_changes(5) self.assert_feed_queries( self.translation, self.TRANSLATION_FEED_QUERIES, diff --git a/weblate/utils/checks.py b/weblate/utils/checks.py index f30ea60636da..5a6adacad8d4 100644 --- a/weblate/utils/checks.py +++ b/weblate/utils/checks.py @@ -88,7 +88,6 @@ "weblate.W049": ("admin/install/docker", "docker-startup-warnings"), "weblate.E050": ("api", "api-rate"), "weblate.C051": ("admin/config", "default-notifications"), - "weblate.C052": ("admin/config", "std-setting-USER_DIRECTORY_VISIBILITY"), }