Skip to content

Commit e9925fb

Browse files
committed
feat: migrate billing.view permission to billing ownership
The permission model duplicated access granted by billing ownership. TODO: - migrate existing users with permissions to ownership model - add UI to manage ownership Fixes #17277
1 parent e343bcb commit e9925fb

File tree

8 files changed

+91
-40
lines changed

8 files changed

+91
-40
lines changed

ci/run-migrate

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ check
8787
./manage.py shell -c 'from weblate.memory.models import Memory; from weblate.lang.models import Language; Memory.objects.create(source_language=Language.objects.get(code="en"), target_language=Language.objects.get(code="cs"), source="source"*1000, target="target"*1000, origin="origin"*1000)'
8888
check
8989

90+
# TODO: Add users with billing permission
91+
9092
# Add a pending unit
9193
semver_compare "$1" "<" "5.12"
9294
pending_migration_test=$?

weblate/auth/data.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
SELECTION_ALL_PROTECTED = 4
1818

1919
PERMISSIONS = (
20-
# Translators: Permission name
21-
("billing.view", gettext_noop("View billing info")),
2220
# Translators: Permission name
2321
("change.download", gettext_noop("Download changes")),
2422
# Translators: Permission name
@@ -152,8 +150,6 @@
152150
# Translators: Permission name
153151
("componentlist.edit", gettext_noop("Manage component lists")),
154152
# Translators: Permission name
155-
("billing.manage", gettext_noop("Manage billing")),
156-
# Translators: Permission name
157153
("management.addons", gettext_noop("Manage site-wide add-ons")),
158154
)
159155

@@ -260,7 +256,6 @@ def filter_perms(prefix: str, exclude: set | None = None):
260256
pgettext_noop("Access-control role", "Manage repository"),
261257
filter_perms("vcs.") | {"component.lock"},
262258
),
263-
(pgettext_noop("Access-control role", "Billing"), filter_perms("billing.")),
264259
(pgettext_noop("Access-control role", "Add new projects"), {"project.add"}),
265260
)
266261

@@ -321,5 +316,4 @@ def filter_perms(prefix: str, exclude: set | None = None):
321316
"Per-project access-control team name", "Automatic translation"
322317
): "Automatic translation",
323318
pgettext_noop("Per-project access-control team name", "VCS"): "Manage repository",
324-
pgettext_noop("Per-project access-control team name", "Billing"): "Billing",
325319
}

weblate/auth/permissions.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from __future__ import annotations
66

7-
from typing import TYPE_CHECKING, cast
7+
from typing import TYPE_CHECKING
88

99
from django.conf import settings
1010
from django.utils.translation import gettext
@@ -27,16 +27,13 @@
2727
if TYPE_CHECKING:
2828
from collections.abc import Callable
2929

30-
from django.db.models import Model
30+
from django.db.models import Model, QuerySet
3131

3232
from weblate.auth.models import Group, User
3333
from weblate.billing.models import Billing
3434
from weblate.checks.models import Check
3535
from weblate.memory.models import Memory
36-
from weblate.trans.models import (
37-
Comment,
38-
Suggestion,
39-
)
36+
from weblate.trans.models import Comment, Suggestion
4037

4138
from .results import PermissionResult
4239

@@ -597,19 +594,23 @@ def check_team_edit_users(
597594
)
598595

599596

600-
@register_perm("billing.view")
597+
@register_perm("meta:billing.view")
601598
def check_billing_view(
602599
user: User, permission: str, obj: Billing | Project
603600
) -> bool | PermissionResult:
601+
if user.is_superuser:
602+
return True
603+
604+
billings: list[Billing] | QuerySet[Billing]
604605
# We check Billing by hasattr to avoid importing optional Django app. To make type
605606
# checker understand this, there is negative check on Project and cast in the
606607
# check_permission call.
607608
if hasattr(obj, "all_projects") and not isinstance(obj, Project):
608-
if user.has_perm("billing.manage") or obj.owners.filter(pk=user.pk).exists():
609-
return True
610-
# This is a billing object
611-
return any(check_permission(user, permission, prj) for prj in obj.all_projects)
612-
return check_permission(user, permission, cast("Project", obj))
609+
billings = [obj]
610+
else:
611+
billings = obj.billings
612+
613+
return any(billing.owners.filter(pk=user.pk).exists() for billing in billings)
613614

614615

615616
@register_perm("billing:project.permissions")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright © Michal Čihař <michal@weblate.org>
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
# Generated by Django 5.2.10 on 2026-01-22 11:46
6+
7+
from django.db import migrations
8+
9+
from weblate.auth.data import (
10+
SELECTION_ALL,
11+
SELECTION_ALL_PROTECTED,
12+
SELECTION_ALL_PUBLIC,
13+
SELECTION_COMPONENT_LIST,
14+
SELECTION_MANUAL,
15+
)
16+
17+
# Copied from weblate/trans/models/project.py
18+
ACCESS_PUBLIC = 0
19+
ACCESS_PROTECTED = 1
20+
21+
22+
def migrate_owners(apps, schema_editor) -> None:
23+
User = apps.get_model("weblate_auth", "User")
24+
Project = apps.get_model("trans", "Project")
25+
26+
all_projects = Project.objects.all()
27+
all_protected_projects = Project.objects.filter(access_control=ACCESS_PROTECTED)
28+
all_public_projects = Project.objects.filter(access_control=ACCESS_PUBLIC)
29+
30+
projects_cache = {}
31+
32+
for user in User.objects.filter(
33+
groups__roles__permissions__codename="billing.view"
34+
).prefetch_related("groups"):
35+
for group in user.groups:
36+
# Iterate over projects
37+
if group.id in projects_cache:
38+
projects = projects_cache[group.id]
39+
else:
40+
if group.project_selection == SELECTION_COMPONENT_LIST:
41+
continue
42+
if group.project_selection == SELECTION_ALL:
43+
projects = all_projects
44+
elif group.project_selection == SELECTION_ALL_PROTECTED:
45+
projects = all_protected_projects
46+
elif group.project_selection == SELECTION_ALL_PUBLIC:
47+
projects = all_public_projects
48+
elif group.project_selection == SELECTION_MANUAL:
49+
projects = group.projects.all()
50+
else:
51+
msg = f"Unsupported {group.project_selection=}"
52+
raise ValueError(msg)
53+
projects_cache[group.id] = projects
54+
55+
for project in projects:
56+
for billing in project.billing_set.all():
57+
billing.owners.add(user)
58+
59+
60+
class Migration(migrations.Migration):
61+
dependencies = [
62+
("billing", "0006_billinglog_details_alter_billinglog_event"),
63+
]
64+
65+
operations = [
66+
migrations.RunPython(migrate_owners, migrations.RunPython.noop, elidable=True),
67+
]

weblate/billing/models.py

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,10 @@ def get_valid(self):
120120

121121
def for_user(self, user: User):
122122
if user.has_perm("billing.manage"):
123-
return self.all().order_by("state")
124-
return (
125-
self.filter(
126-
Q(projects__in=user.projects_with_perm("billing.view")) | Q(owners=user)
127-
)
128-
.distinct()
129-
.order_by("state")
130-
)
123+
billings = self.all()
124+
else:
125+
billings = self.filter(owners=user).distinct()
126+
return billings.order_by("state")
131127

132128
def for_user_within_limits(self, user: User):
133129
"""Return billings for the given user which are valid and within project creation limits."""
@@ -471,10 +467,7 @@ def is_active(self):
471467
return self.state in Billing.ACTIVE_STATES
472468

473469
def get_notify_users(self):
474-
users = self.owners.distinct()
475-
for project in self.projects.iterator():
476-
users |= User.objects.having_perm("billing.view", project)
477-
return users.exclude(is_superuser=True)
470+
return self.owners.exclude(is_superuser=True).distinct()
478471

479472
def _get_libre_checklist(self):
480473
message = ngettext(
@@ -717,12 +710,6 @@ def record_project_bill(
717710
if isinstance(instance, Component):
718711
instance = instance.project
719712

720-
# Sync billing access upon project removal, otherwise users will lose access
721-
if isinstance(instance, Project):
722-
users = User.objects.having_perm("billing.view", instance)
723-
for billing in instance.billing_set.all():
724-
billing.owners.add(*users)
725-
726713
# Collect billings to update for delete_project_bill
727714
instance.billings_to_update = list(
728715
instance.billing_set.values_list("pk", flat=True)

weblate/billing/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def download_invoice(request: AuthenticatedHttpRequest, pk) -> HttpResponse:
5151
msg = "No reference!"
5252
raise Http404(msg)
5353

54-
if not request.user.has_perm("billing.view", invoice.billing):
54+
if not request.user.has_perm("meta:billing.view", invoice.billing):
5555
raise PermissionDenied
5656

5757
if not invoice.filename_valid:
@@ -182,7 +182,7 @@ def overview(request: AuthenticatedHttpRequest) -> HttpResponse:
182182
def detail(request: AuthenticatedHttpRequest, pk) -> HttpResponse:
183183
billing = get_object_or_404(Billing, pk=pk)
184184

185-
if not request.user.has_perm("billing.view", billing):
185+
if not request.user.has_perm("meta:billing.view", billing):
186186
raise PermissionDenied
187187

188188
if request.method == "POST":

weblate/templates/snippets/info.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ <h4 class="card-title">{% translate "Summary" %}</h4>
2727
</tr>
2828
{% endif %}
2929

30-
{% perm 'billing.view' project as user_can_view_billing %}
30+
{% perm 'meta:billing.view' project as user_can_view_billing %}
3131

3232
{% if user_can_view_billing %}
3333
{% if project.billings %}

weblate/templates/snippets/project/state.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{% show_message "warning" msg %}
66
{% endif %}
77

8-
{% perm 'billing.view' object as user_can_view_billing %}
8+
{% perm 'meta:billing.view' object as user_can_view_billing %}
99

1010
{% if object.is_libre_trial %}
1111
{% include "snippets/project/billing-libre-trial.html" %}

0 commit comments

Comments
 (0)