Coverage for app/venv/lib/python3.14/site-packages/weblate/auth/permissions.py: 49%
285 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from typing import TYPE_CHECKING, cast
9from django.conf import settings
10from django.utils.translation import gettext
12from weblate.lang.models import Language
13from weblate.trans.models import (
14 Category,
15 Component,
16 ComponentList,
17 ContributorAgreement,
18 Project,
19 Translation,
20 Unit,
21)
22from weblate.utils.stats import CategoryLanguage, ProjectLanguage
24from .results import Allowed, Denied
26if TYPE_CHECKING: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true
27 from collections.abc import Callable
29 from django.db.models import Model
31 from weblate.auth.models import Group, User
32 from weblate.billing.models import Billing
33 from weblate.checks.models import Check
34 from weblate.memory.models import Memory
35 from weblate.trans.models import (
36 Announcement,
37 Comment,
38 Suggestion,
39 )
41 from .results import PermissionResult
43SPECIALS: dict[str, Callable[[User, str, Model], bool | PermissionResult]] = {}
46def register_perm(*perms: str):
47 def wrap_perm(function: Callable[[User, str, Model], bool | PermissionResult]):
48 for perm in perms:
49 SPECIALS[perm] = function
50 return function
52 return wrap_perm
55def check_global_permission(user: User, permission: str) -> bool:
56 """Check whether the user has a global permission."""
57 if user.is_superuser:
58 return True
59 return permission in user.global_permissions
62def check_enforced_2fa(user: User, project: Project) -> bool:
63 """Check whether the user has 2FA configured, in case it is enforced by the project."""
64 return user.is_bot or not project.enforced_2fa or user.profile.has_2fa
67def check_permission(
68 user: User,
69 permission: str,
70 obj: Unit
71 | Translation
72 | CategoryLanguage
73 | Component
74 | ProjectLanguage
75 | Category
76 | Project
77 | ComponentList,
78) -> bool:
79 """Check whether user has a object-specific permission."""
80 if user.is_superuser: 80 ↛ 82line 80 didn't jump to line 82 because the condition on line 80 was always true
81 return True
82 if isinstance(obj, ProjectLanguage):
83 obj = obj.project
84 if isinstance(obj, CategoryLanguage):
85 obj = obj.category.project
86 if isinstance(obj, Category):
87 obj = obj.project
88 if isinstance(obj, Project):
89 return any(
90 permission in permissions
91 for permissions, _langs in user.get_project_permissions(obj)
92 ) and check_enforced_2fa(user, obj)
93 if isinstance(obj, ComponentList):
94 return all(
95 check_permission(user, permission, component)
96 and check_enforced_2fa(user, component.project)
97 for component in obj.components.iterator()
98 )
99 if isinstance(obj, Component):
100 return (
101 (
102 not obj.restricted
103 and any(
104 permission in permissions
105 for permissions, _langs in user.get_project_permissions(obj.project)
106 )
107 )
108 or any(
109 permission in permissions
110 for permissions, _langs in user.component_permissions[obj.pk]
111 )
112 ) and check_enforced_2fa(user, obj.project)
113 if isinstance(obj, Unit):
114 obj = obj.translation
115 if isinstance(obj, Translation):
116 lang = obj.language_id
117 return (
118 (
119 not obj.component.restricted
120 and any(
121 permission in permissions and (langs is None or lang in langs)
122 for permissions, langs in user.get_project_permissions(
123 obj.component.project
124 )
125 )
126 )
127 or any(
128 permission in permissions and (langs is None or lang in langs)
129 for permissions, langs in user.component_permissions[obj.component_id]
130 )
131 ) and check_enforced_2fa(user, obj.component.project)
132 msg = f"Permission {permission} does not support: {obj.__class__}: {obj!r}"
133 raise TypeError(msg)
136@register_perm("comment.resolve", "comment.delete", "suggestion.delete")
137def check_delete_own(
138 user: User, permission: str, obj: Comment | Suggestion
139) -> bool | PermissionResult:
140 if user.is_authenticated and obj.user == user:
141 return True
142 return check_permission(user, permission, obj.unit.translation)
145@register_perm("unit.check")
146def check_ignore_check(
147 user: User, permission: str, check: Check
148) -> bool | PermissionResult:
149 if check.is_enforced():
150 return False
151 return check_permission(user, permission, check.unit.translation)
154def check_can_edit( # noqa: C901
155 user: User,
156 permission: str,
157 obj: Translation
158 | CategoryLanguage
159 | Component
160 | ProjectLanguage
161 | Category
162 | Project,
163 *,
164 is_vote: bool = False,
165) -> bool | PermissionResult:
166 translation = component = None
168 if isinstance(obj, Translation): 168 ↛ 172line 168 didn't jump to line 172 because the condition on line 168 was always true
169 translation = obj
170 component = obj.component
171 project = component.project
172 elif isinstance(obj, Component):
173 component = obj
174 project = component.project
175 elif isinstance(obj, Category):
176 project = obj.project
177 elif isinstance(obj, Project):
178 project = obj
179 elif isinstance(obj, ProjectLanguage):
180 project = obj.project
181 elif isinstance(obj, CategoryLanguage):
182 project = obj.category.project
183 else:
184 msg = f"Unknown object for permission check: {obj.__class__}"
185 raise TypeError(msg)
187 # Email is needed for user to be able to edit
188 if user.is_authenticated and not user.email: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 return Denied(
190 gettext("Can not perform this operation without an e-mail address.")
191 )
193 if project and not check_enforced_2fa(user, project):
194 # This would later fail in check_permission, but we can give a nicer error
195 # message here when checking this specifically.
196 return Denied(
197 gettext(
198 "This project requires two-factor authentication; configure it in your profile."
199 )
200 )
202 if component: 202 ↛ 220line 202 didn't jump to line 220 because the condition on line 202 was always true
203 # Check component lock
204 if component.locked:
205 return Denied(gettext("This translation is currently locked."))
207 # Check contributor license agreement
208 if ( 208 ↛ 213line 208 didn't jump to line 213 because the condition on line 208 was never true
209 not user.is_bot
210 and component.agreement
211 and not ContributorAgreement.objects.has_agreed(user, component)
212 ):
213 return Denied(
214 gettext(
215 "Contributing to this translation requires agreeing to its contributor license agreement."
216 )
217 )
219 # Perform usual permission check
220 if not check_permission(user, permission, obj): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 if not user.is_authenticated:
222 # Signing in might help, but user still might need additional privileges
223 return Denied(gettext("Sign in to save translations."))
224 if permission == "unit.review":
225 return Denied(
226 gettext("Insufficient privileges for approving translations.")
227 )
228 return Denied(gettext("Insufficient privileges for saving translations."))
230 # Special check for source strings (templates)
231 if ( 231 ↛ 236line 231 didn't jump to line 236 because the condition on line 231 was never true
232 translation
233 and translation.is_template
234 and not check_permission(user, "unit.template", obj)
235 ):
236 return Denied(gettext("Insufficient privileges for editing source strings."))
238 # Special checks for voting
239 if is_vote and translation and not translation.suggestion_voting: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 return Denied(gettext("Suggestion voting is disabled."))
241 if ( 241 ↛ 248line 241 didn't jump to line 248 because the condition on line 241 was never true
242 not is_vote
243 and translation
244 and translation.suggestion_voting
245 and translation.suggestion_autoaccept > 0
246 and not check_permission(user, "unit.override", obj)
247 ):
248 return Denied(
249 gettext(
250 "This translation only accepts suggestions, in turn approved by voting."
251 )
252 )
254 # Billing limits
255 if not project.paid: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true
256 return Denied(gettext("Pay the bills to unlock this project."))
258 return Allowed()
261@register_perm("unit.review")
262def check_unit_review(
263 user: User,
264 permission: str,
265 obj: Unit
266 | Translation
267 | CategoryLanguage
268 | Component
269 | ProjectLanguage
270 | Category
271 | Project,
272 *,
273 skip_enabled: bool = False,
274) -> bool | PermissionResult:
275 if isinstance(obj, Unit): 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 obj = obj.translation
277 if not skip_enabled: 277 ↛ 295line 277 didn't jump to line 295 because the condition on line 277 was always true
278 if isinstance(obj, Translation): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 if not obj.enable_review:
280 if obj.is_source:
281 return Denied(gettext("Source-string reviews are turned off."))
282 return Denied(gettext("Translation reviews are turned off."))
283 else:
284 if isinstance(obj, CategoryLanguage): 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 project = obj.category.project
286 elif isinstance( 286 ↛ 292line 286 didn't jump to line 292 because the condition on line 286 was always true
287 obj,
288 Component | ProjectLanguage | Category,
289 ):
290 project = obj.project
291 else:
292 project = obj
293 if not project.source_review and not project.translation_review: 293 ↛ 295line 293 didn't jump to line 295 because the condition on line 293 was always true
294 return Denied(gettext("Reviewing is turned off."))
295 return check_can_edit(user, permission, obj)
298@register_perm("unit.edit", "suggestion.accept")
299def check_edit_approved(
300 user: User, permission: str, obj: Unit | Translation | Component | Project
301) -> bool | PermissionResult:
302 component = None
303 if isinstance(obj, Unit): 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 unit = obj
305 obj = unit.translation
306 # Read-only check is unconditional as there is another one
307 # in PluralTextarea.render
308 if unit.readonly:
309 if not unit.source_unit.translated:
310 return Denied(gettext("The source string needs review."))
311 return Denied(gettext("The string is read-only."))
312 # Ignore approved state if review is not disabled. This might
313 # happen after disabling them.
314 if (
315 unit.approved
316 and obj.enable_review
317 and not check_unit_review(user, "unit.review", obj, skip_enabled=True)
318 ):
319 return Denied(
320 gettext(
321 "Only reviewers can change approved strings. Please add a suggestion if you think the string should be changed."
322 )
323 )
324 if isinstance(obj, Translation): 324 ↛ 328line 324 didn't jump to line 328 because the condition on line 324 was always true
325 component = obj.component
326 if obj.is_readonly:
327 return Denied(gettext("The translation is read-only."))
328 elif isinstance(obj, Component):
329 component = obj
330 if component is not None and component.is_glossary:
331 permission = "glossary.edit"
332 return check_can_edit(user, permission, obj)
335def check_manage_units(
336 translation: Translation, component: Component
337) -> PermissionResult:
338 if not isinstance(component, Component): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 return Denied("Invalid scope")
340 source = translation.is_source
341 template = component.has_template()
342 # Add only to source in monolingual
343 if not source and template: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 return Denied(gettext("Add the string to the source language instead."))
345 # Check if adding is generally allowed
346 if not component.manage_units or (template and not component.edit_template):
347 return Denied(
348 gettext("Adding strings is disabled in the component configuration.")
349 )
350 return Allowed()
353@register_perm("unit.delete")
354def check_unit_delete(
355 user: User, permission: str, obj: Unit | Translation
356) -> bool | PermissionResult:
357 if isinstance(obj, Unit): 357 ↛ 369line 357 didn't jump to line 369 because the condition on line 357 was always true
358 if ( 358 ↛ 363line 358 didn't jump to line 363 because the condition on line 358 was never true
359 obj.translation.component.is_glossary
360 and not obj.translation.is_source
361 and "terminology" in obj.all_flags
362 ):
363 return Denied(
364 gettext(
365 "Cannot remove terminology translation. Remove the source string instead."
366 )
367 )
368 obj = obj.translation
369 component = obj.component
370 # Check if removing is generally allowed
371 can_manage = check_manage_units(obj, component)
372 if not can_manage:
373 return can_manage
375 # Does file format support removing?
376 if not component.file_format_cls.can_delete_unit: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true
377 return Denied(gettext("The file format does not support this."))
379 if component.is_glossary: 379 ↛ 381line 379 didn't jump to line 381 because the condition on line 379 was always true
380 permission = "glossary.delete"
381 return check_can_edit(user, permission, obj)
384@register_perm("unit.add")
385def check_unit_add(
386 user: User, permission: str, translation: Translation
387) -> bool | PermissionResult:
388 component = translation.component
389 # Check if adding is generally allowed
390 can_manage = check_manage_units(translation, component)
391 if not can_manage:
392 return can_manage
394 # Does file format support adding?
395 if not component.file_format_cls.can_add_unit: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true
396 return Denied(gettext("The file format does not support this."))
398 if component.is_glossary: 398 ↛ 401line 398 didn't jump to line 401 because the condition on line 398 was always true
399 permission = "glossary.add"
401 return check_can_edit(user, permission, translation)
404@register_perm("translation.add")
405def check_translation_add(
406 user: User, permission: str, obj: Component | Project
407) -> bool | PermissionResult:
408 if (
409 isinstance(obj, Component)
410 and obj.new_lang == "none"
411 and not obj.can_add_new_language(user, fast=True)
412 ):
413 return Denied(
414 gettext(
415 "Adding new translations is turned off in the component configuration."
416 )
417 )
418 if obj.locked:
419 return Denied(gettext("This component is currently locked."))
420 return check_permission(user, permission, obj)
423@register_perm("translation.auto", "unit.bulk_edit")
424def check_autotranslate(
425 user: User, permission: str, translation: Unit | Translation | Component | Project
426) -> bool | PermissionResult:
427 if isinstance(translation, Unit): 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 translation = translation.translation
429 if isinstance(translation, Translation) and (
430 (translation.is_source and not translation.component.intermediate)
431 or translation.is_readonly
432 ):
433 return False
434 return check_can_edit(user, permission, translation)
437@register_perm("suggestion.vote")
438def check_suggestion_vote(
439 user: User, permission: str, obj: Unit | Translation
440) -> bool | PermissionResult:
441 if isinstance(obj, Unit):
442 obj = obj.translation
443 return check_can_edit(user, permission, obj, is_vote=True)
446@register_perm("suggestion.add")
447def check_suggestion_add(
448 user: User, permission: str, obj: Unit | Translation
449) -> bool | PermissionResult:
450 if isinstance(obj, Unit): 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true
451 obj = obj.translation
452 if not obj.enable_suggestions or obj.is_readonly: 452 ↛ 455line 452 didn't jump to line 455 because the condition on line 452 was always true
453 return False
454 # Check contributor license agreement
455 if (
456 not user.is_bot
457 and obj.component.agreement
458 and not ContributorAgreement.objects.has_agreed(user, obj.component)
459 ):
460 return False
461 return check_permission(user, permission, obj)
464@register_perm("upload.perform")
465def check_upload(
466 user: User, permission: str, translation: Translation
467) -> bool | PermissionResult:
468 """
469 Check whether user can perform any upload operation.
471 The actual check for the method is implemented in
472 weblate.trans.util.check_upload_method_permissions.
473 """
474 # Source upload
475 if translation.is_source and not user.has_perm("source.edit", translation): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 return Denied(gettext("Insufficient privileges for editing source strings."))
477 # Bilingual source translations
478 if (
479 translation.is_source
480 and not translation.is_template
481 and not hasattr(translation.component.file_format_cls, "update_bilingual")
482 ):
483 return Denied(
484 gettext("The file format does not support updating source strings.")
485 )
486 if translation.component.is_glossary:
487 permission = "glossary.upload"
488 return check_can_edit(user, permission, translation) and (
489 # Normal upload
490 check_edit_approved(user, "unit.edit", translation)
491 # Suggestion upload
492 or check_suggestion_add(user, "suggestion.add", translation)
493 # Add upload
494 or check_suggestion_add(user, "unit.add", translation)
495 # Source upload
496 or translation.is_source
497 )
500@register_perm("machinery.view")
501def check_machinery(
502 user: User, permission: str, obj: Translation | Component | Project
503) -> bool | PermissionResult:
504 # No machinery for source without intermediate language
505 if (
506 isinstance(obj, Translation)
507 and obj.is_source
508 and not obj.component.intermediate
509 ):
510 return False
512 # Check the actual machinery.view permission
513 if not check_permission(user, permission, obj):
514 return False
516 # Only show machinery to users allowed to translate or suggest
517 return check_edit_approved(user, "unit.edit", obj) or check_suggestion_add(
518 user, "suggestion.add", obj
519 )
522@register_perm("translation.delete")
523def check_translation_delete(
524 user: User, permission: str, obj: Translation
525) -> bool | PermissionResult:
526 if obj.is_source:
527 return False
528 return check_permission(user, permission, obj)
531@register_perm("reports.view", "change.download")
532def check_possibly_global(
533 user: User, permission: str, obj: Language | Translation | Component | Project
534) -> bool | PermissionResult:
535 if obj is None or isinstance(obj, Language):
536 return user.is_superuser
537 return check_permission(user, permission, obj)
540@register_perm("meta:vcs.status")
541def check_repository_status(
542 user: User, permission: str, obj: Translation | Component | Project
543) -> bool | PermissionResult:
544 return (
545 check_permission(user, "vcs.push", obj)
546 or check_permission(user, "vcs.commit", obj)
547 or check_permission(user, "vcs.reset", obj)
548 or check_permission(user, "vcs.update", obj)
549 )
552@register_perm("meta:team.edit")
553def check_team_edit(user: User, permission: str, obj: Group) -> bool:
554 from weblate.auth.models import Group
556 return (
557 check_global_permission(user, "group.edit")
558 or (
559 isinstance(obj, Group)
560 and obj.defining_project
561 and check_permission(user, "project.permissions", obj.defining_project)
562 )
563 or (
564 isinstance(obj, Project)
565 and check_permission(user, "project.permissions", obj)
566 )
567 )
570@register_perm("meta:team.users")
571def check_team_edit_users(
572 user: User, permission: str, obj: Group
573) -> bool | PermissionResult:
574 return (
575 check_team_edit(user, permission, obj) or obj.pk in user.administered_group_ids
576 )
579@register_perm("billing.view")
580def check_billing_view(
581 user: User, permission: str, obj: Billing | Project
582) -> bool | PermissionResult:
583 # We check Billling by hasttr to avoid importing optional Django app. To make type
584 # checker understand this, there is negative check on Project and cast in the
585 # check_permission call.
586 if hasattr(obj, "all_projects") and not isinstance(obj, Project):
587 if user.has_perm("billing.manage") or obj.owners.filter(pk=user.pk).exists():
588 return True
589 # This is a billing object
590 return any(check_permission(user, permission, prj) for prj in obj.all_projects)
591 return check_permission(user, permission, cast("Project", obj))
594@register_perm("billing:project.permissions")
595def check_billing(user: User, permission: str, obj: Project) -> bool | PermissionResult:
596 if user.is_superuser:
597 return True
599 if (
600 "weblate.billing" in settings.INSTALLED_APPS
601 and not any(billing.plan.change_access_control for billing in obj.billings)
602 and not obj.access_control
603 ):
604 return False
606 return check_permission(user, "project.permissions", obj)
609# This does not exist for real
610@register_perm("meta:announcement.delete")
611def check_announcement_delete(
612 user: User, permission: str, obj: Announcement
613) -> bool | PermissionResult:
614 if user.is_superuser:
615 return True
616 if obj.component:
617 return check_permission(user, "component.edit", obj.component)
618 if obj.project:
619 return check_permission(user, "project.edit", obj.project)
620 return False
623# This does not exist for real
624@register_perm("meta:unit.flag")
625def check_unit_flag(
626 user: User, permission: str, obj: Unit | Translation
627) -> bool | PermissionResult:
628 if isinstance(obj, Unit):
629 obj = obj.translation
630 if not obj.component.is_glossary:
631 return user.has_perm("source.edit", obj)
633 return check_can_edit(user, "glossary.edit", obj)
636@register_perm("memory.edit", "memory.delete")
637def check_memory_perms(
638 user: User, permission: str, memory: Memory | Project
639) -> bool | PermissionResult:
640 from weblate.memory.models import Memory
642 if isinstance(memory, Memory): 642 ↛ 647line 642 didn't jump to line 647 because the condition on line 642 was always true
643 if memory.user_id == user.id: 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true
644 return True
645 project = memory.project
646 else:
647 project = memory
648 if project is None: 648 ↛ 650line 648 didn't jump to line 650 because the condition on line 648 was always true
649 return check_global_permission(user, "memory.manage")
650 return check_permission(user, permission, project)