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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7from typing import TYPE_CHECKING, cast 

8 

9from django.conf import settings 

10from django.utils.translation import gettext 

11 

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 

23 

24from .results import Allowed, Denied 

25 

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 

28 

29 from django.db.models import Model 

30 

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 ) 

40 

41 from .results import PermissionResult 

42 

43SPECIALS: dict[str, Callable[[User, str, Model], bool | PermissionResult]] = {} 

44 

45 

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 

51 

52 return wrap_perm 

53 

54 

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 

60 

61 

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 

65 

66 

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) 

134 

135 

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) 

143 

144 

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) 

152 

153 

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 

167 

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) 

186 

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 ) 

192 

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 ) 

201 

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.")) 

206 

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 ) 

218 

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.")) 

229 

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.")) 

237 

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 ) 

253 

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.")) 

257 

258 return Allowed() 

259 

260 

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) 

296 

297 

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) 

333 

334 

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() 

351 

352 

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 

374 

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.")) 

378 

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) 

382 

383 

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 

393 

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.")) 

397 

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" 

400 

401 return check_can_edit(user, permission, translation) 

402 

403 

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) 

421 

422 

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) 

435 

436 

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) 

444 

445 

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) 

462 

463 

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. 

470 

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 ) 

498 

499 

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 

511 

512 # Check the actual machinery.view permission 

513 if not check_permission(user, permission, obj): 

514 return False 

515 

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 ) 

520 

521 

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) 

529 

530 

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) 

538 

539 

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 ) 

550 

551 

552@register_perm("meta:team.edit") 

553def check_team_edit(user: User, permission: str, obj: Group) -> bool: 

554 from weblate.auth.models import Group 

555 

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 ) 

568 

569 

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 ) 

577 

578 

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)) 

592 

593 

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 

598 

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 

605 

606 return check_permission(user, "project.permissions", obj) 

607 

608 

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 

621 

622 

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) 

632 

633 return check_can_edit(user, "glossary.edit", obj) 

634 

635 

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 

641 

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)