Coverage for app/venv/lib/python3.14/site-packages/weblate/auth/models.py: 64%

611 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 

4from __future__ import annotations 

5 

6import re 

7import uuid 

8from collections import defaultdict 

9from collections.abc import ( 

10 Iterable, 

11) 

12from contextvars import ContextVar 

13from functools import cache as functools_cache 

14from itertools import chain 

15from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, cast 

16 

17import sentry_sdk 

18from appconf import AppConf 

19from django.conf import settings 

20from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager 

21from django.contrib.auth.hashers import make_password 

22from django.contrib.auth.models import Group as DjangoGroup 

23from django.core.validators import MinValueValidator 

24from django.db import models 

25from django.db.models import Prefetch, Q, UniqueConstraint 

26from django.db.models.functions import Upper 

27from django.db.models.signals import m2m_changed, post_save 

28from django.dispatch import receiver 

29from django.http import Http404, HttpRequest 

30from django.urls import reverse 

31from django.utils import timezone 

32from django.utils.functional import cached_property 

33from django.utils.translation import gettext, gettext_lazy, pgettext 

34 

35from weblate.auth.data import ( 

36 ACL_GROUPS, 

37 GLOBAL_PERM_NAMES, 

38 PERMISSION_NAMES, 

39 SELECTION_ALL, 

40 SELECTION_ALL_PROTECTED, 

41 SELECTION_ALL_PUBLIC, 

42 SELECTION_COMPONENT_LIST, 

43 SELECTION_MANUAL, 

44) 

45from weblate.auth.permissions import SPECIALS, check_global_permission, check_permission 

46from weblate.auth.utils import ( 

47 create_anonymous, 

48 format_address, 

49 is_django_permission, 

50 migrate_groups, 

51 migrate_permissions, 

52 migrate_roles, 

53) 

54from weblate.lang.models import Language 

55from weblate.trans.defines import FULLNAME_LENGTH, USERNAME_LENGTH 

56from weblate.trans.fields import RegexField 

57from weblate.trans.models import Component, ComponentList, Project 

58from weblate.utils.decorators import disable_for_loaddata 

59from weblate.utils.fields import EmailField, UsernameField 

60from weblate.utils.search import parse_query 

61from weblate.utils.validators import CRUD_RE, validate_fullname, validate_username 

62 

63if TYPE_CHECKING: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 from collections.abc import Iterable, Mapping 

65 

66 from django_otp.models import Device 

67 from social_core.backends.base import BaseAuth 

68 from social_django.models import DjangoStorage 

69 

70 from weblate.accounts.models import Subscription 

71 from weblate.accounts.strategy import WeblateStrategy 

72 from weblate.auth.results import PermissionResult 

73 from weblate.wladmin.models import SupportStatusDict 

74 

75 SimplePermissionList = list[tuple[set[str], set[int] | None]] 

76 

77 # This is SimplePermissionList with additional None instead of permissions 

78 # to indicate user block 

79 PermissionList = list[tuple[set[str] | None, set[int] | None]] 

80 

81 PermissionCacheType = dict[int, PermissionList] 

82 SimplePermissionCacheType = dict[int, SimplePermissionList] 

83 

84 class PermissionsDictType(TypedDict, total=False): 

85 projects: PermissionCacheType 

86 components: SimplePermissionCacheType 

87 

88 

89class Permission(models.Model): 

90 codename = models.CharField(max_length=100, unique=True) 

91 name = models.CharField(max_length=200) 

92 

93 class Meta: 

94 verbose_name = "Permission" 

95 verbose_name_plural = "Permissions" 

96 

97 def __str__(self) -> str: 

98 name = gettext(self.name) 

99 if self.codename in GLOBAL_PERM_NAMES: 

100 return gettext("%s (site-wide permission)") % name 

101 return name 

102 

103 

104class Role(models.Model): 

105 name = models.CharField( 

106 verbose_name=gettext_lazy("Name"), max_length=200, unique=True 

107 ) 

108 permissions = models.ManyToManyField( 

109 Permission, 

110 verbose_name=gettext_lazy("Permissions"), 

111 blank=True, 

112 help_text=gettext_lazy("Choose permissions granted to this role."), 

113 ) 

114 

115 class Meta: 

116 verbose_name = "Role" 

117 verbose_name_plural = "Roles" 

118 

119 def __str__(self) -> str: 

120 return pgettext("Access-control role", self.name) 

121 

122 

123class GroupQuerySet(models.QuerySet["Group"]): 

124 def order(self): 

125 """Ordering in project scope by priority.""" 

126 return self.order_by("defining_project__name", "name") 

127 

128 

129class Group(models.Model): 

130 name = models.CharField(gettext_lazy("Name"), max_length=150) 

131 roles = models.ManyToManyField( 

132 Role, 

133 verbose_name=gettext_lazy("Roles"), 

134 blank=True, 

135 help_text=gettext_lazy("Choose roles granted to this team."), 

136 ) 

137 

138 defining_project = models.ForeignKey( 

139 "trans.Project", 

140 related_name="defined_groups", 

141 on_delete=models.deletion.CASCADE, 

142 null=True, 

143 blank=True, 

144 ) 

145 

146 project_selection = models.IntegerField( 

147 verbose_name=gettext_lazy("Project selection"), 

148 choices=( 

149 (SELECTION_MANUAL, gettext_lazy("As defined")), 

150 (SELECTION_ALL, gettext_lazy("All projects")), 

151 (SELECTION_ALL_PUBLIC, gettext_lazy("All public projects")), 

152 (SELECTION_ALL_PROTECTED, gettext_lazy("All protected projects")), 

153 (SELECTION_COMPONENT_LIST, gettext_lazy("From component list")), 

154 ), 

155 default=SELECTION_MANUAL, 

156 ) 

157 projects = models.ManyToManyField( 

158 "trans.Project", verbose_name=gettext_lazy("Projects"), blank=True 

159 ) 

160 components = models.ManyToManyField( 

161 "trans.Component", 

162 verbose_name=gettext_lazy("Components"), 

163 blank=True, 

164 help_text=gettext_lazy( 

165 "Empty selection grants access to all components in project scope." 

166 ), 

167 ) 

168 componentlists = models.ManyToManyField( 

169 "trans.ComponentList", 

170 verbose_name=gettext_lazy("Component lists"), 

171 blank=True, 

172 ) 

173 

174 language_selection = models.IntegerField( 

175 verbose_name=gettext_lazy("Language selection"), 

176 choices=( 

177 (SELECTION_MANUAL, gettext_lazy("As defined")), 

178 (SELECTION_ALL, gettext_lazy("All languages")), 

179 ), 

180 default=SELECTION_MANUAL, 

181 ) 

182 languages = models.ManyToManyField( 

183 "lang.Language", verbose_name=gettext_lazy("Languages"), blank=True 

184 ) 

185 

186 internal = models.BooleanField( 

187 verbose_name=gettext_lazy("Internal Weblate team"), default=False 

188 ) 

189 

190 admins = models.ManyToManyField( 

191 "weblate_auth.User", 

192 verbose_name=gettext_lazy("Team administrators"), 

193 blank=True, 

194 help_text=gettext_lazy( 

195 "The administrator can add or remove users from a team." 

196 ), 

197 related_name="administered_group_set", 

198 ) 

199 enforced_2fa = models.BooleanField( 

200 verbose_name=gettext_lazy("Enforced two-factor authentication"), 

201 default=False, 

202 help_text=gettext_lazy( 

203 "Requires users to have two-factor authentication configured." 

204 ), 

205 ) 

206 

207 objects = GroupQuerySet.as_manager() 

208 

209 class Meta: 

210 verbose_name = "Group" 

211 verbose_name_plural = "Groups" 

212 

213 def __str__(self) -> str: 

214 if self.defining_project: 

215 return pgettext("Per-project access-control team name", self.name) 

216 return pgettext("Access-control team name", self.name) 

217 

218 def save(self, *args, **kwargs) -> None: 

219 super().save(*args, **kwargs) 

220 if self.language_selection == SELECTION_ALL: 

221 self.languages.clear() 

222 if self.project_selection in { 

223 SELECTION_ALL, 

224 SELECTION_ALL_PUBLIC, 

225 SELECTION_ALL_PROTECTED, 

226 }: 

227 self.projects.clear() 

228 elif self.project_selection == SELECTION_COMPONENT_LIST: 

229 self.projects.set( 

230 Project.objects.filter( 

231 component__componentlist__in=self.componentlists.all() 

232 ), 

233 clear=True, 

234 ) 

235 

236 def get_absolute_url(self) -> str: 

237 return reverse("team", kwargs={"pk": self.pk}) 

238 

239 def long_name(self): 

240 if self.defining_project: 

241 return f"{self.defining_project} / {self}" 

242 return str(self) 

243 

244 

245bot_cache = ContextVar("bot_cache", default=dict) 

246 

247 

248class UserManager(BaseUserManager["User"]): 

249 def _create_user(self, username, email, password, **extra_fields): 

250 """Create and save a User with the given fields.""" 

251 if not username: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 msg = "The given username must be set" 

253 raise ValueError(msg) 

254 email = self.normalize_email(email) 

255 username = self.model.normalize_username(username) 

256 user = self.model(username=username, email=email, **extra_fields) 

257 user.set_password(password) 

258 user.save(using=self._db) 

259 return user 

260 

261 def create_user(self, username, email=None, password=None, **extra_fields): 

262 extra_fields.setdefault("is_superuser", False) 

263 return self._create_user(username, email, password, **extra_fields) 

264 

265 def create_superuser(self, username, email, password, **extra_fields): 

266 extra_fields.setdefault("is_superuser", True) 

267 

268 if extra_fields.get("is_superuser") is not True: 

269 msg = "Superuser must have is_superuser=True." 

270 raise ValueError(msg) 

271 

272 return self._create_user(username, email, password, **extra_fields) 

273 

274 def get_or_create_bot(self, *, scope: str, name: str, verbose: str) -> User: 

275 cached = bot_cache.get({}) 

276 username = f"{scope}:{name}" 

277 try: 

278 return cached[username] 

279 except KeyError: 

280 user = self.get_or_create( 

281 username=username, 

282 defaults={ 

283 "is_bot": True, 

284 "full_name": verbose, 

285 "email": f"noreply-{scope}-{name}@weblate.org", 

286 "is_active": False, 

287 "password": make_password(None), 

288 }, 

289 )[0] 

290 cached[username] = user 

291 return user 

292 

293 

294class UserQuerySet(models.QuerySet["User"]): 

295 def having_perm(self, perm, project): 

296 """ 

297 All users having explicit permission on a project. 

298 

299 Note: This intentionally does not list superusers or site-wide permissions 

300 given using project_selection. 

301 """ 

302 return self.filter( 

303 groups__roles__permissions__codename=perm, groups__projects=project 

304 ).distinct() 

305 

306 def all_admins(self, project): 

307 """All admins in a project.""" 

308 return self.having_perm("project.edit", project) 

309 

310 def all_reviewers(self, project: Project) -> UserQuerySet: 

311 """All reviewers in a project.""" 

312 return self.having_perm("unit.review", project) 

313 

314 def order(self): 

315 return self.order_by("username") 

316 

317 def search( 

318 self, 

319 query: str, 

320 parser: Literal["plain", "user", "superuser"] = "user", 

321 **context, 

322 ): 

323 """High level wrapper for searching.""" 

324 if parser == "plain": 

325 result = self.filter( 

326 Q(username__icontains=query) | Q(full_name__icontains=query) 

327 ) 

328 else: 

329 filters, annotations = parse_query(query, parser=parser, **context) 

330 result = self.annotate(**annotations).filter(filters) 

331 return result.distinct() 

332 

333 def get_author_by_email( 

334 self, 

335 author_name: str | None, 

336 author_email: str | None, 

337 fallback: User | None, 

338 request: AuthenticatedHttpRequest, 

339 ) -> User | None: 

340 from weblate.accounts.models import AuditLog 

341 

342 if author_email and (fallback is None or not fallback.has_email(author_email)): 

343 author, created = User.objects.get_or_create( 

344 email=author_email, 

345 defaults={ 

346 "username": author_email, 

347 "full_name": author_name or author_email, 

348 }, 

349 ) 

350 if created: 

351 AuditLog.objects.create(author, request, "autocreated") 

352 if fallback is None and author.is_anonymous: 

353 return author 

354 if author.is_active and not author.is_bot and not author.is_anonymous: 

355 return author 

356 return fallback 

357 

358 def get_or_create( 

359 self, 

360 defaults: Mapping[str, Any] | None = None, 

361 **kwargs: Any, # noqa: ANN401 

362 ) -> tuple[User, bool]: 

363 filtered: dict[str, Any] | None 

364 extra: dict[str, Any] 

365 if defaults is None: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 filtered = None 

367 extra = {} 

368 else: 

369 filtered = { 

370 name: value 

371 for name, value in defaults.items() 

372 if name not in User.DUMMY_FIELDS 

373 } 

374 extra = { 

375 name: value 

376 for name, value in defaults.items() 

377 if name in User.DUMMY_FIELDS 

378 } 

379 

380 user, created = super().get_or_create(defaults=filtered, **kwargs) 

381 if created: 

382 user.extra_data = extra 

383 user.save() 

384 return user, created 

385 

386 

387@functools_cache 

388def get_anonymous() -> User: 

389 """Return an anonymous user.""" 

390 return User.objects.select_related("profile").get( 

391 username=settings.ANONYMOUS_USER_NAME 

392 ) 

393 

394 

395def convert_groups(objs): 

396 """Convert Django Group objects to Weblate ones.""" 

397 objs = list(objs) 

398 for idx, obj in enumerate(objs): 

399 if isinstance(obj, DjangoGroup): 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true

400 objs[idx] = Group.objects.get_or_create(name=obj.name)[0] 

401 return objs 

402 

403 

404def wrap_group(func): 

405 """Replace Django Group instances by Weblate Group instances.""" 

406 

407 def group_wrapper(self, *objs, **kwargs): 

408 objs = convert_groups(objs) 

409 return func(self, *objs, **kwargs) 

410 

411 return group_wrapper 

412 

413 

414def wrap_group_list(func): 

415 """Replace Django Group instances by Weblate Group instances.""" 

416 

417 def group_list_wrapper(self, objs, **kwargs): 

418 objs = convert_groups(objs) 

419 return func(self, objs, **kwargs) 

420 

421 return group_list_wrapper 

422 

423 

424class GroupManyToManyField(models.ManyToManyField): 

425 """Customized field to accept Django Groups objects as well.""" 

426 

427 def contribute_to_class( 

428 self, cls: type[models.Model], name: str, private_only: bool = False, **kwargs 

429 ) -> None: 

430 super().contribute_to_class(cls, name, private_only=private_only, **kwargs) 

431 

432 # Get related descriptor 

433 descriptor = getattr(cls, self.name) 

434 

435 # We care only on forward relation 

436 if not descriptor.reverse: 436 ↛ exitline 436 didn't return from function 'contribute_to_class' because the condition on line 436 was always true

437 # We are running in a migration 

438 if isinstance(descriptor.rel.model, str): 

439 return 

440 

441 # Get related manager class 

442 related_manager_cls = descriptor.related_manager_cls 

443 

444 # Monkey patch it to accept Django Group instances as well 

445 related_manager_cls.add = wrap_group(related_manager_cls.add) 

446 related_manager_cls.remove = wrap_group(related_manager_cls.remove) 

447 related_manager_cls.set = wrap_group_list(related_manager_cls.set) 

448 

449 

450class User(AbstractBaseUser): 

451 username = UsernameField( 

452 gettext_lazy("Username"), 

453 max_length=USERNAME_LENGTH, 

454 unique=True, 

455 help_text=gettext_lazy( 

456 "Username may only contain letters, " 

457 "numbers or the following characters: @ . + - _" 

458 ), 

459 validators=[validate_username], 

460 error_messages={ 

461 "unique": gettext_lazy("A user with that username already exists.") 

462 }, 

463 ) 

464 full_name = models.CharField( 

465 gettext_lazy("Full name"), 

466 max_length=FULLNAME_LENGTH, 

467 blank=False, 

468 validators=[validate_fullname], 

469 ) 

470 email = EmailField( 

471 gettext_lazy("E-mail"), 

472 blank=False, 

473 null=True, 

474 unique=True, 

475 ) 

476 is_superuser = models.BooleanField( 

477 gettext_lazy("Superuser status"), 

478 default=False, 

479 help_text=gettext_lazy("User has all possible permissions."), 

480 ) 

481 is_active = models.BooleanField( 

482 gettext_lazy("Active"), 

483 default=True, 

484 help_text=gettext_lazy("Mark user as inactive instead of removing."), 

485 ) 

486 is_bot = models.BooleanField( 

487 "Robot user", 

488 default=False, 

489 db_index=True, 

490 ) 

491 date_expires = models.DateTimeField( 

492 gettext_lazy("Expires"), 

493 null=True, 

494 blank=True, 

495 default=None, 

496 validators=[MinValueValidator(timezone.now)], 

497 help_text=gettext_lazy("The account will be disabled after the expiry."), 

498 ) 

499 date_joined = models.DateTimeField( 

500 gettext_lazy("Date joined"), default=timezone.now 

501 ) 

502 groups = GroupManyToManyField( 

503 Group, 

504 verbose_name=gettext_lazy("Teams"), 

505 blank=True, 

506 help_text=gettext_lazy( 

507 "The user is granted all permissions included in membership of these teams." 

508 ), 

509 ) 

510 

511 objects = UserManager.from_queryset(UserQuerySet)() 

512 

513 # social_auth integration 

514 social_auth: DjangoStorage 

515 

516 # django_otp integration (via OTPMiddleware) 

517 otp_device: Device 

518 

519 EMAIL_FIELD = "email" 

520 USERNAME_FIELD = "username" 

521 REQUIRED_FIELDS = ["email", "full_name"] # noqa: RUF012 

522 DUMMY_FIELDS = ("first_name", "last_name", "is_staff") 

523 

524 class Meta: 

525 verbose_name = "User" 

526 verbose_name_plural = "Users" 

527 constraints = [ # noqa: RUF012 

528 UniqueConstraint(Upper("username"), name="weblate_auth_user_username_ci"), 

529 UniqueConstraint(Upper("email"), name="weblate_auth_user_email_ci"), 

530 ] 

531 

532 def __str__(self) -> str: 

533 return self.full_name 

534 

535 def save(self, *args, **kwargs) -> None: 

536 from weblate.accounts.models import AuditLog 

537 

538 original = None 

539 if self.pk: 

540 original = User.objects.get(pk=self.pk) 

541 if self.is_anonymous: 

542 self.is_active = False 

543 # Generate full name from parts 

544 # This is needed with LDAP authentication when the 

545 # server does not contain full name 

546 if "first_name" in self.extra_data and "last_name" in self.extra_data: 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true

547 self.full_name = "{first_name} {last_name}".format(**self.extra_data) 

548 elif "first_name" in self.extra_data: 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 self.full_name = self.extra_data["first_name"] 

550 elif "last_name" in self.extra_data: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true

551 self.full_name = self.extra_data["last_name"] 

552 if not self.email: 

553 self.email = None 

554 if not self.is_active: 

555 self.date_expires = None 

556 super().save(*args, **kwargs) 

557 self.clear_cache() 

558 if ( 558 ↛ 565line 558 didn't jump to line 565 because the condition on line 558 was never true

559 original 

560 and original.is_active != self.is_active 

561 and self.full_name != "Deleted User" 

562 and not self.is_anonymous 

563 ): 

564 activity: str 

565 if original.date_expires and not self.is_active: 

566 activity = "disabled-expiry" 

567 elif self.is_active: 

568 activity = "enabled" 

569 else: 

570 activity = "disabled" 

571 AuditLog.objects.create(user=self, request=None, activity=activity) 

572 

573 def get_absolute_url(self) -> str: 

574 return reverse("user_page", kwargs={"user": self.username}) 

575 

576 def __init__(self, *args, **kwargs) -> None: 

577 self.extra_data: dict[str, str] = {} 

578 self.cla_cache: dict[tuple[int, int], bool] = {} 

579 self._permissions: PermissionsDictType = {} 

580 self.current_subscription: Subscription | None = None 

581 for name in self.DUMMY_FIELDS: 

582 if name in kwargs: 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true

583 self.extra_data[name] = kwargs.pop(name) 

584 super().__init__(*args, **kwargs) 

585 

586 def clear_cache(self) -> None: 

587 self.cla_cache = {} 

588 self._permissions = {} 

589 perm_caches = ( 

590 "project_permissions", 

591 "component_permissions", 

592 "allowed_projects", 

593 "needs_component_restrictions_filter", 

594 "needs_project_filter", 

595 "watched_projects", 

596 "owned_projects", 

597 "managed_projects", 

598 "cached_groups", 

599 ) 

600 for name in perm_caches: 

601 if name in self.__dict__: 

602 del self.__dict__[name] 

603 

604 def has_usable_password(self): 

605 # For some reason Django says that empty string is a valid password 

606 return self.password and super().has_usable_password() 

607 

608 @cached_property 

609 def is_anonymous(self): 

610 return self.username == settings.ANONYMOUS_USER_NAME 

611 

612 def is_verified(self) -> bool: 

613 # django_otp overrides this method in OTPMiddleware 

614 return False 

615 

616 @cached_property 

617 def is_authenticated(self) -> bool: # type: ignore[override] 

618 return not self.is_anonymous 

619 

620 def get_full_name(self): 

621 return self.full_name 

622 

623 def get_short_name(self): 

624 return self.full_name 

625 

626 def __setattr__(self, name, value) -> None: 

627 """Mimic first/last name for third-party auth and ignore is_staff flag.""" 

628 if name in self.DUMMY_FIELDS: 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

629 self.extra_data[name] = value 

630 else: 

631 super().__setattr__(name, value) 

632 

633 def has_module_perms(self, module): 

634 """Compatibility API for admin interface.""" 

635 return self.is_superuser 

636 

637 @property 

638 def is_staff(self): 

639 """Compatibility API for admin interface.""" 

640 return self.is_superuser 

641 

642 @property 

643 def first_name(self) -> str: 

644 """Compatibility API for third-party modules.""" 

645 return "" 

646 

647 @property 

648 def last_name(self): 

649 """Compatibility API for third-party modules.""" 

650 return self.full_name 

651 

652 def has_perms(self, perm_list, obj=None) -> bool: 

653 return all(self.has_perm(perm, obj) for perm in perm_list) 

654 

655 def has_perm(self, perm: str, obj=None) -> PermissionResult | bool: 

656 """Permission check.""" 

657 # Weblate global scope permissions 

658 if perm in GLOBAL_PERM_NAMES: 

659 return check_global_permission(self, perm) 

660 

661 # Compatibility API for admin interface 

662 if is_django_permission(perm): 662 ↛ 663line 662 didn't jump to line 663 because the condition on line 662 was never true

663 if not self.is_superuser: 

664 return False 

665 

666 # Check permissions restrictions 

667 allowed = settings.AUTH_RESTRICT_ADMINS.get(self.username) 

668 return allowed is None or perm in allowed 

669 

670 # Validate perms 

671 if perm not in SPECIALS and perm not in PERMISSION_NAMES: 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true

672 msg = f"Invalid permission: {perm}" 

673 raise ValueError(msg) 

674 

675 # Special permission functions 

676 if perm in SPECIALS: 

677 return SPECIALS[perm](self, perm, obj) 

678 

679 # Generic permission 

680 return check_permission(self, perm, obj) 

681 

682 def can_access_project(self, project): 

683 """Check access to given project.""" 

684 if self.is_superuser: 

685 return True 

686 return self.get_project_permissions(project) != [] 

687 

688 def get_project_permissions(self, project: Project) -> SimplePermissionList: 

689 # Build a fresh list as we need to merge them 

690 result: SimplePermissionList = [] 

691 # This relies on project_permission being defaultdict(list) 

692 result.extend(self.project_permissions[project.pk]) # type: ignore[arg-type] 

693 # Apply blocking 

694 if result == [(None, None)]: 

695 return [] 

696 if project.access_control == Project.ACCESS_PUBLIC: 

697 result.extend( 

698 self.project_permissions[-SELECTION_ALL_PUBLIC] # type: ignore[arg-type] 

699 ) 

700 elif project.access_control == Project.ACCESS_PROTECTED: 

701 result.extend( 

702 self.project_permissions[-SELECTION_ALL_PROTECTED] # type: ignore[arg-type] 

703 ) 

704 result.extend( 

705 self.project_permissions[-SELECTION_ALL] # type: ignore[arg-type] 

706 ) 

707 return result 

708 

709 def check_access(self, project) -> None: 

710 """Raise an error if user is not allowed to access this project.""" 

711 if not self.can_access_project(project): 

712 msg = "Access denied" 

713 raise Http404(msg) 

714 

715 def can_access_component(self, component): 

716 """Check access to given component.""" 

717 if self.is_superuser: 717 ↛ 719line 717 didn't jump to line 719 because the condition on line 717 was always true

718 return True 

719 if not self.can_access_project(component.project): 

720 return False 

721 return not component.restricted or component.pk in self.component_permissions 

722 

723 def check_access_component(self, component) -> None: 

724 """Raise an error if user is not allowed to access this component.""" 

725 if not self.can_access_component(component): 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true

726 msg = "Access denied" 

727 raise Http404(msg) 

728 

729 @cached_property 

730 def allowed_projects(self): 

731 """List of allowed projects.""" 

732 if self.is_superuser: 732 ↛ 735line 732 didn't jump to line 735 because the condition on line 732 was always true

733 return Project.objects.order() 

734 # All public and protected projects are accessible 

735 acls = {Project.ACCESS_PUBLIC, Project.ACCESS_PROTECTED} 

736 if -SELECTION_ALL in self.project_permissions: 

737 acls.add(Project.ACCESS_PRIVATE) 

738 acls.add(Project.ACCESS_CUSTOM) 

739 condition = Q(access_control__in=acls) 

740 

741 # Add project-specific allowance 

742 restricted = {-SELECTION_ALL_PUBLIC, -SELECTION_ALL_PROTECTED, -SELECTION_ALL} 

743 project_ids = {key for key in self.project_permissions if key not in restricted} 

744 if project_ids: 

745 condition |= Q(pk__in=project_ids) 

746 

747 return Project.objects.filter(condition).order() 

748 

749 @cached_property 

750 def needs_component_restrictions_filter(self): 

751 if self.is_superuser: 751 ↛ 753line 751 didn't jump to line 753 because the condition on line 751 was always true

752 return False 

753 return self.allowed_projects.filter(component__restricted=True).exists() 

754 

755 @cached_property 

756 def needs_project_filter(self): 

757 if self.is_superuser: 757 ↛ 759line 757 didn't jump to line 759 because the condition on line 757 was always true

758 return False 

759 return self.allowed_projects.count() != Project.objects.all().count() 

760 

761 @cached_property 

762 def watched_projects(self): 

763 """ 

764 List of watched projects. 

765 

766 Ensure ACL filtering applies (the user could have been removed 

767 from the project meanwhile) 

768 """ 

769 return (self.profile.watched.all() & self.allowed_projects).order() 

770 

771 @cached_property 

772 def owned_projects(self): 

773 return self.projects_with_perm("project.edit", explicit=True) 

774 

775 @cached_property 

776 def managed_projects(self): 

777 return self.projects_with_perm("project.edit") 

778 

779 @cached_property 

780 def administered_group_ids(self): 

781 return set(self.administered_group_set.values_list("id", flat=True)) 

782 

783 @cached_property 

784 def cached_groups(self) -> Iterable[Group]: 

785 return self.groups.prefetch_related( 

786 "roles__permissions", 

787 Prefetch( 

788 "componentlists__components", 

789 queryset=Component.objects.only("id", "project_id"), 

790 ), 

791 # The name and slug are used when rendering the groups 

792 Prefetch( 

793 "components", 

794 queryset=Component.objects.all().only( 

795 "id", "project_id", "name", "slug" 

796 ), 

797 ), 

798 # The name and slug are used when rendering the groups 

799 Prefetch( 

800 "projects", 

801 queryset=Project.objects.only("id", "name", "slug"), 

802 ), 

803 # The name and code are used when rendering the groups 

804 Prefetch("languages", queryset=Language.objects.only("id", "name", "code")), 

805 ) 

806 

807 def group_enforces_2fa(self) -> bool: 

808 return any(group.enforced_2fa for group in self.cached_groups) 

809 

810 def _fetch_permissions(self) -> None: 

811 """Fetch all user permissions into a dictionary.""" 

812 projects: PermissionCacheType = defaultdict(list) 

813 components: SimplePermissionCacheType = defaultdict(list) 

814 with sentry_sdk.start_span(op="auth.permissions", name=self.username): 

815 for group in self.cached_groups: 

816 # Skip permissions for not verified users 

817 if group.enforced_2fa and not self.profile.has_2fa: 817 ↛ 818line 817 didn't jump to line 818 because the condition on line 817 was never true

818 continue 

819 if group.language_selection == SELECTION_ALL: 819 ↛ 822line 819 didn't jump to line 822 because the condition on line 819 was always true

820 languages = None 

821 else: 

822 languages = {language.id for language in group.languages.all()} 

823 permissions = { 

824 permission.codename 

825 for permission in chain.from_iterable( 

826 role.permissions.all() for role in group.roles.all() 

827 ) 

828 } 

829 

830 # Component list specific permissions 

831 componentlist_values = { 

832 (component.id, component.project_id) 

833 for component in chain.from_iterable( 

834 clist.components.all() for clist in group.componentlists.all() 

835 ) 

836 } 

837 if group.componentlists.exists(): 837 ↛ 838line 837 didn't jump to line 838 because the condition on line 837 was never true

838 for component, project in componentlist_values: 

839 components[component].append((permissions, languages)) 

840 # Grant access to the project 

841 projects[project].append((set(), languages)) 

842 continue 

843 

844 # Component specific permissions 

845 component_values = { 

846 (component.id, component.project_id) 

847 for component in group.components.all() 

848 } 

849 if component_values: 849 ↛ 850line 849 didn't jump to line 850 because the condition on line 849 was never true

850 for component, project in component_values: 

851 components[component].append((permissions, languages)) 

852 # Grant access to the project 

853 projects[project].append((set(), languages)) 

854 continue 

855 

856 # Handle project selection 

857 if group.project_selection in { 857 ↛ 865line 857 didn't jump to line 865 because the condition on line 857 was always true

858 SELECTION_ALL_PUBLIC, 

859 SELECTION_ALL_PROTECTED, 

860 SELECTION_ALL, 

861 }: 

862 projects[-group.project_selection].append((permissions, languages)) 

863 else: 

864 # Project specific permissions 

865 for project_obj in group.projects.all(): 

866 projects[project_obj.id].append((permissions, languages)) 

867 # Apply blocking 

868 now = timezone.now() 

869 for block in self.userblock_set.all(): 869 ↛ 870line 869 didn't jump to line 870 because the loop on line 869 never started

870 if block.expiry is not None and block.expiry <= now: 

871 # Delete expired blocks 

872 block.delete() 

873 else: 

874 # Remove all permissions for blocked user 

875 projects[block.project_id] = [(None, None)] 

876 

877 self._permissions = {"projects": projects, "components": components} 

878 

879 @cached_property 

880 def project_permissions(self) -> PermissionCacheType: 

881 """List all project permissions.""" 

882 if not self._permissions: 882 ↛ 884line 882 didn't jump to line 884 because the condition on line 882 was always true

883 self._fetch_permissions() 

884 return self._permissions["projects"] 

885 

886 @cached_property 

887 def component_permissions(self) -> SimplePermissionCacheType: 

888 """List all project permissions.""" 

889 if not self._permissions: 

890 self._fetch_permissions() 

891 return self._permissions["components"] 

892 

893 @cached_property 

894 def global_permissions(self) -> set[str]: 

895 return set( 

896 Permission.objects.filter( 

897 role__group__user=self, codename__in=GLOBAL_PERM_NAMES 

898 ).values_list("codename", flat=True) 

899 ) 

900 

901 def projects_with_perm(self, perm: str, explicit: bool = False): 

902 if not explicit and self.is_superuser: 

903 return Project.objects.all().order() 

904 # Explicit permissions 

905 condition = Q(group__user=self) & Q(group__roles__permissions__codename=perm) 

906 

907 # Site-wide permissions 

908 if not explicit: 908 ↛ 922line 908 didn't jump to line 922 because the condition on line 908 was always true

909 for access, selection in ( 

910 (Project.ACCESS_PUBLIC, -SELECTION_ALL_PUBLIC), 

911 (Project.ACCESS_PROTECTED, -SELECTION_ALL_PROTECTED), 

912 (None, -SELECTION_ALL), 

913 ): 

914 if any( 914 ↛ 918line 914 didn't jump to line 918 because the condition on line 914 was never true

915 perm in cast("set[str]", permissions) 

916 for permissions, _langs in self.project_permissions[selection] 

917 ): 

918 if access is None: 

919 condition = Q() 

920 break 

921 condition |= Q(access_control=access) 

922 return Project.objects.filter(condition).distinct().order() 

923 

924 def get_visible_name(self) -> str: 

925 """Get full name from database or username.""" 

926 if not self.full_name or CRUD_RE.match(self.full_name): 926 ↛ 927line 926 didn't jump to line 927 because the condition on line 926 was never true

927 return self.username 

928 return self.full_name 

929 

930 def get_author_name(self, address: str | None = None) -> str: 

931 """Return formatted author name with e-mail.""" 

932 return format_address( 

933 self.get_visible_name(), address or self.profile.get_commit_email() 

934 ) 

935 

936 def add_team( 

937 self, 

938 request: AuthenticatedHttpRequest | None, 

939 team: Group, 

940 *, 

941 user: User | None = None, 

942 ) -> None: 

943 from weblate.accounts.models import AuditLog 

944 

945 self.groups.add(team) 

946 

947 username: str | None 

948 if user is not None: 948 ↛ 949line 948 didn't jump to line 949 because the condition on line 948 was never true

949 username = user.username 

950 elif request is not None: 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true

951 username = request.user.username 

952 else: 

953 username = None 

954 

955 AuditLog.objects.create( 

956 user=self, 

957 request=request if request is not None and request.user == self else None, 

958 activity="team-add", 

959 username=username, 

960 team=team.name, 

961 ) 

962 

963 def remove_team( 

964 self, request: AuthenticatedHttpRequest | None, team: Group 

965 ) -> None: 

966 from weblate.accounts.models import AuditLog 

967 

968 self.groups.remove(team) 

969 AuditLog.objects.create( 

970 user=self, 

971 request=request if request is not None and request.user == self else None, 

972 activity="team-remove", 

973 username=request.user.username 

974 if request is not None and request.user 

975 else None, 

976 team=team.name, 

977 ) 

978 

979 def has_email(self, email: str) -> bool: 

980 return ( 

981 email == self.email 

982 or User.objects.filter( 

983 pk=self.pk, social_auth__verifiedemail__email=email 

984 ).exists() 

985 ) 

986 

987 

988class AutoGroup(models.Model): 

989 match = RegexField( 

990 verbose_name=gettext_lazy("Regular expression for e-mail address"), 

991 max_length=200, 

992 default="^$", 

993 help_text=gettext_lazy( 

994 "Users with e-mail addresses found to match will be added to this team." 

995 ), 

996 ) 

997 group = models.ForeignKey( 

998 Group, 

999 verbose_name=gettext_lazy("Team to assign"), 

1000 on_delete=models.deletion.CASCADE, 

1001 ) 

1002 

1003 class Meta: 

1004 verbose_name = "Automatic team assignment" 

1005 verbose_name_plural = "Automatic team assignments" 

1006 

1007 def __str__(self) -> str: 

1008 return f"Automatic rule for {self.group}" 

1009 

1010 

1011class UserBlock(models.Model): 

1012 user = models.ForeignKey( 

1013 User, 

1014 verbose_name=gettext_lazy("User to block"), 

1015 on_delete=models.deletion.CASCADE, 

1016 db_index=False, 

1017 ) 

1018 project = models.ForeignKey( 

1019 Project, verbose_name=gettext_lazy("Project"), on_delete=models.deletion.CASCADE 

1020 ) 

1021 expiry = models.DateTimeField(gettext_lazy("Block expiry"), null=True) 

1022 

1023 class Meta: 

1024 verbose_name = "Blocked user" 

1025 verbose_name_plural = "Blocked users" 

1026 unique_together = [ # noqa: RUF012 

1027 ("user", "project"), 

1028 ] 

1029 

1030 def __str__(self) -> str: 

1031 return f"{self.user} blocked for {self.project}" 

1032 

1033 

1034def create_groups(update) -> None: 

1035 """Create standard groups and gives them permissions.""" 

1036 # Create permissions and roles 

1037 migrate_permissions(Permission) 

1038 new_roles = migrate_roles(Role, Permission) 

1039 builtin_groups = migrate_groups(Group, Role, update) 

1040 

1041 # Create anonymous user 

1042 create_anonymous(User, Group, update) 

1043 

1044 # Automatic assignment to the users group 

1045 group = builtin_groups["Users"] 

1046 if not AutoGroup.objects.filter(group=group).exists(): 1046 ↛ 1048line 1046 didn't jump to line 1048 because the condition on line 1046 was always true

1047 AutoGroup.objects.create(group=group, match="^.*$") 

1048 group = builtin_groups["Viewers"] 

1049 if not AutoGroup.objects.filter(group=group).exists(): 1049 ↛ 1053line 1049 didn't jump to line 1053 because the condition on line 1049 was always true

1050 AutoGroup.objects.create(group=group, match="^.*$") 

1051 

1052 # Create new per project groups 

1053 if new_roles: 1053 ↛ exitline 1053 didn't return from function 'create_groups' because the condition on line 1053 was always true

1054 for project in Project.objects.iterator(): 1054 ↛ 1055line 1054 didn't jump to line 1055 because the loop on line 1054 never started

1055 setup_project_groups(Project, project, new_roles=new_roles) 

1056 

1057 

1058def sync_create_groups(sender, **kwargs) -> None: 

1059 """Create default groups.""" 

1060 create_groups(False) 

1061 

1062 

1063def auto_assign_group(user: User) -> None: 

1064 """Automatic group assignment based on user e-mail address.""" 

1065 if user.username == settings.ANONYMOUS_USER_NAME: 

1066 return 

1067 # Add user to automatic groups 

1068 for auto in AutoGroup.objects.prefetch_related("group"): 

1069 if re.match(auto.match, user.email or ""): 1069 ↛ 1068line 1069 didn't jump to line 1068 because the condition on line 1069 was always true

1070 user.add_team(None, auto.group) 

1071 

1072 

1073@receiver(m2m_changed, sender=ComponentList.components.through) 

1074@disable_for_loaddata 

1075def change_componentlist(sender, instance, action, **kwargs) -> None: 

1076 if not action.startswith("post_"): 

1077 return 

1078 groups = Group.objects.filter( 

1079 componentlists=instance, project_selection=SELECTION_COMPONENT_LIST 

1080 ) 

1081 for group in groups: 

1082 group.projects.set( 

1083 Project.objects.filter(component__componentlist=instance), clear=True 

1084 ) 

1085 

1086 

1087@receiver(m2m_changed, sender=User.groups.through) 

1088def remove_group_admin(sender, instance, action, pk_set, reverse, **kwargs) -> None: 

1089 if action != "post_remove": 1089 ↛ 1091line 1089 didn't jump to line 1091 because the condition on line 1089 was always true

1090 return 

1091 for pk in pk_set: 

1092 if reverse: 

1093 group = instance 

1094 user = User.objects.get(pk=pk) 

1095 else: 

1096 group = Group.objects.get(pk=pk) 

1097 user = instance 

1098 group.admins.remove(user) 

1099 

1100 

1101@receiver(post_save, sender=User) 

1102@disable_for_loaddata 

1103def auto_group_upon_save(sender, instance, created=False, **kwargs) -> None: 

1104 """Apply automatic group assignment rules.""" 

1105 if created: 

1106 auto_assign_group(instance) 

1107 

1108 

1109@receiver(post_save, sender=Project) 

1110@disable_for_loaddata 

1111def setup_project_groups( 

1112 sender, 

1113 instance, 

1114 created: bool = False, 

1115 new_roles: set[str] | None = None, 

1116 **kwargs, 

1117) -> None: 

1118 """Set up group objects upon saving project.""" 

1119 old_access_control = instance.old_access_control 

1120 instance.old_access_control = instance.access_control 

1121 

1122 changed_review = ( 

1123 instance.old_translation_review != instance.translation_review 

1124 or instance.old_source_review != instance.source_review 

1125 ) 

1126 # Handle no groups as newly created project 

1127 if not created and not instance.defined_groups.exists(): 1127 ↛ 1128line 1127 didn't jump to line 1128 because the condition on line 1127 was never true

1128 created = True 

1129 

1130 # No changes needed 

1131 if ( 

1132 old_access_control == instance.access_control 

1133 and not changed_review 

1134 and not created 

1135 and not new_roles 

1136 ): 

1137 return 

1138 

1139 # Do not perform anything with custom ACL 

1140 if instance.access_control == Project.ACCESS_CUSTOM: 1140 ↛ 1141line 1140 didn't jump to line 1141 because the condition on line 1140 was never true

1141 return 

1142 

1143 # Choose groups to configure 

1144 if instance.access_control == Project.ACCESS_PUBLIC: 1144 ↛ 1147line 1144 didn't jump to line 1147 because the condition on line 1144 was always true

1145 groups = {"Administration", "Review"} 

1146 else: 

1147 groups = set(ACL_GROUPS.keys()) 

1148 

1149 # Remove review group if review is not enabled 

1150 if not instance.source_review and not instance.translation_review: 

1151 groups.remove("Review") 

1152 

1153 # Remove billing if billing is not installed 

1154 if "weblate.billing" not in settings.INSTALLED_APPS: 1154 ↛ 1158line 1154 didn't jump to line 1158 because the condition on line 1154 was always true

1155 groups.discard("Billing") 

1156 

1157 # Filter only newly introduced groups 

1158 if new_roles: 1158 ↛ 1159line 1158 didn't jump to line 1159 because the condition on line 1158 was never true

1159 groups = {group for group in groups if ACL_GROUPS[group] in new_roles} 

1160 

1161 # Access control changed 

1162 elif ( 1162 ↛ 1173line 1162 didn't jump to line 1173 because the condition on line 1162 was never true

1163 not created 

1164 and ( 

1165 instance.access_control == Project.ACCESS_PUBLIC 

1166 or old_access_control in {Project.ACCESS_PROTECTED, Project.ACCESS_PRIVATE} 

1167 ) 

1168 and not changed_review 

1169 ): 

1170 # Avoid changing groups on some access control changes: 

1171 # - Public groups are always present, so skip change on changing to public 

1172 # - Change between protected/private means no change in groups 

1173 return 

1174 

1175 # Create role specific groups 

1176 for group_name in groups: 

1177 group, created = instance.defined_groups.get_or_create( 

1178 internal=True, 

1179 name=group_name, 

1180 project_selection=SELECTION_MANUAL, 

1181 defining_project=instance, 

1182 language_selection=SELECTION_ALL, 

1183 ) 

1184 if not created: 

1185 continue 

1186 group.projects.add(instance) 

1187 group.roles.add(Role.objects.get(name=ACL_GROUPS[group_name])) 

1188 

1189 

1190class Invitation(models.Model): 

1191 """ 

1192 User invitation store. 

1193 

1194 Either user or e-mail attribute is set, this is to invite current and new users. 

1195 """ 

1196 

1197 uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 

1198 timestamp = models.DateTimeField(auto_now_add=True) 

1199 author = models.ForeignKey( 

1200 User, on_delete=models.deletion.CASCADE, related_name="created_invitation_set" 

1201 ) 

1202 user = models.ForeignKey( 

1203 User, 

1204 on_delete=models.deletion.CASCADE, 

1205 null=True, 

1206 verbose_name=gettext_lazy("User to add"), 

1207 help_text=gettext_lazy( 

1208 "Please type in an existing Weblate account name or e-mail address." 

1209 ), 

1210 ) 

1211 username = UsernameField( 

1212 gettext_lazy("Username"), 

1213 max_length=USERNAME_LENGTH, 

1214 blank=True, 

1215 help_text=gettext_lazy( 

1216 "Suggest username for the user. It can be changed later." 

1217 ), 

1218 validators=[validate_username], 

1219 ) 

1220 full_name = models.CharField( 

1221 gettext_lazy("Full name"), 

1222 max_length=FULLNAME_LENGTH, 

1223 blank=True, 

1224 help_text=gettext_lazy( 

1225 "Suggest full name for the user. It can be changed later." 

1226 ), 

1227 validators=[validate_fullname], 

1228 ) 

1229 group = models.ForeignKey( 

1230 Group, 

1231 verbose_name=gettext_lazy("Team"), 

1232 help_text=gettext_lazy( 

1233 "The user is granted all permissions included in membership of these teams." 

1234 ), 

1235 on_delete=models.deletion.CASCADE, 

1236 ) 

1237 email = EmailField( 

1238 gettext_lazy("E-mail"), 

1239 blank=True, 

1240 ) 

1241 is_superuser = models.BooleanField( 

1242 gettext_lazy("Superuser status"), 

1243 default=False, 

1244 help_text=gettext_lazy("User has all possible permissions."), 

1245 ) 

1246 

1247 def __str__(self) -> str: 

1248 return f"invitation {self.uuid} for {self.user or self.email} to {self.group}" 

1249 

1250 def get_absolute_url(self) -> str: 

1251 return reverse("invitation", kwargs={"pk": self.uuid}) 

1252 

1253 def send_email(self) -> None: 

1254 from weblate.accounts.notifications import send_notification_email 

1255 

1256 email: str 

1257 if self.email: 

1258 email = self.email 

1259 elif self.user is not None: 

1260 email = self.user.email 

1261 else: 

1262 msg = "Intiviation without an e-mail!" 

1263 raise ValueError(msg) 

1264 

1265 send_notification_email( 

1266 None, 

1267 [email], 

1268 "invite", 

1269 info=f"{self}", 

1270 context={"invitation": self, "validity": settings.AUTH_TOKEN_VALID // 3600}, 

1271 ) 

1272 

1273 def accept(self, request: AuthenticatedHttpRequest, user: User) -> None: 

1274 from weblate.accounts.models import AuditLog 

1275 

1276 if self.user and self.user != user: 

1277 msg = "User mismatch on accept!" 

1278 raise ValueError(msg) 

1279 

1280 if self.is_superuser: 

1281 user.is_superuser = True 

1282 user.save(update_fields=["is_superuser"]) 

1283 

1284 AuditLog.objects.create( 

1285 user=user, 

1286 request=request, 

1287 activity="accepted", 

1288 username=self.author.username, 

1289 ) 

1290 

1291 user.add_team(request, self.group, user=self.author) 

1292 

1293 self.delete() 

1294 

1295 

1296class WeblateAuthConf(AppConf): 

1297 """Authentication settings.""" 

1298 

1299 AUTH_RESTRICT_ADMINS: ClassVar[dict] = {} 

1300 

1301 # Anonymous user name 

1302 ANONYMOUS_USER_NAME = "anonymous" 

1303 

1304 SESSION_COOKIE_AGE_AUTHENTICATED = 1209600 

1305 SESSION_COOKIE_AGE_2FA = 180 

1306 

1307 class Meta: 

1308 prefix = "" 

1309 

1310 

1311class AuthenticatedHttpRequest(HttpRequest): 

1312 user: User 

1313 # Added by weblate.accounts.AuthenticationMiddleware 

1314 accepted_language: Language 

1315 

1316 # type hint for social_auth 

1317 social_strategy: WeblateStrategy 

1318 

1319 # type hint for auth 

1320 backend: BaseAuth | None 

1321 

1322 # type hint for accounts middleware 

1323 weblate_cached_user: User 

1324 

1325 # type hint for wladmin 

1326 weblate_support_status: SupportStatusDict 

1327 

1328 # type hint for configuration module 

1329 weblate_custom_css: str 

1330 

1331 # Overrides django.http.request URL generating 

1332 _current_scheme_host: str