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

461 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 

7import datetime 

8import logging 

9import re 

10from datetime import timedelta 

11from ipaddress import IPv6Network, ip_network 

12from typing import TYPE_CHECKING, Any, ClassVar, Literal 

13from urllib.parse import urlparse 

14 

15from appconf import AppConf 

16from django.conf import settings 

17from django.contrib import admin 

18from django.contrib.auth.signals import user_logged_in 

19from django.core.exceptions import ValidationError 

20from django.core.validators import MaxValueValidator, MinValueValidator 

21from django.db import models 

22from django.db.models import F, Q 

23from django.db.models.functions import Upper 

24from django.db.models.signals import post_save 

25from django.dispatch import receiver 

26from django.utils import timezone 

27from django.utils.functional import cached_property 

28from django.utils.html import format_html 

29from django.utils.timezone import now 

30from django.utils.translation import get_language, gettext, gettext_lazy 

31from django_otp.plugins.otp_static.models import StaticDevice 

32from django_otp.plugins.otp_totp.models import TOTPDevice 

33from django_otp_webauthn.models import WebAuthnCredential 

34from rest_framework.authtoken.models import Token 

35from social_django.models import UserSocialAuth 

36from unidecode import unidecode 

37 

38from weblate.accounts.avatar import get_user_display 

39from weblate.accounts.data import create_default_notifications 

40from weblate.accounts.notifications import ( 

41 NOTIFICATIONS, 

42 NotificationFrequency, 

43 NotificationScope, 

44) 

45from weblate.accounts.tasks import notify_auditlog 

46from weblate.auth.models import User 

47from weblate.lang.models import Language 

48from weblate.trans.defines import EMAIL_LENGTH 

49from weblate.trans.models import Change, ComponentList, Translation 

50from weblate.trans.models.translation import GhostTranslation 

51from weblate.utils import messages 

52from weblate.utils.decorators import disable_for_loaddata 

53from weblate.utils.fields import EmailField 

54from weblate.utils.html import mail_quote_value 

55from weblate.utils.render import validate_editor 

56from weblate.utils.request import get_ip_address, get_user_agent 

57from weblate.utils.stats import ( 

58 CategoryLanguageStats, 

59 GhostCategoryLanguageStats, 

60 GhostProjectLanguageStats, 

61 ProjectLanguageStats, 

62) 

63from weblate.utils.token import get_token 

64from weblate.utils.validators import EMAIL_BLACKLIST, WeblateURLValidator 

65from weblate.wladmin.models import get_support_status 

66 

67from .types import ThemeChoices 

68 

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

70 from collections.abc import Callable, Iterable 

71 

72 from django.http.request import HttpRequest 

73 from django_otp.models import Device 

74 

75 from weblate.accounts.types import DeviceType 

76 from weblate.auth.models import AuthenticatedHttpRequest 

77 from weblate.trans.models import Unit 

78 

79LOGGER = logging.getLogger("weblate.audit") 

80 

81 

82class WeblateAccountsConf(AppConf): 

83 """Accounts settings.""" 

84 

85 # Disable avatars 

86 ENABLE_AVATARS = True 

87 

88 # Avatar URL prefix 

89 AVATAR_URL_PREFIX = "https://www.gravatar.com/" 

90 

91 # Avatar fallback image 

92 # See http://en.gravatar.com/site/implement/images/ for available choices 

93 AVATAR_DEFAULT_IMAGE = "identicon" 

94 

95 # Enable registrations 

96 REGISTRATION_OPEN = True 

97 

98 # Allow registration from certain backends 

99 REGISTRATION_ALLOW_BACKENDS: ClassVar[list[str]] = [] 

100 

101 # Allow rebinding to existing accounts 

102 REGISTRATION_REBIND = False 

103 

104 # Registration email filter 

105 REGISTRATION_EMAIL_MATCH = ".*" 

106 

107 # Captcha for registrations 

108 REGISTRATION_CAPTCHA = True 

109 

110 ALTCHA_MAX_NUMBER = 1_000_000 

111 

112 REGISTRATION_HINTS: ClassVar[dict[str, str]] = {} 

113 

114 # How long to keep auditlog entries 

115 AUDITLOG_EXPIRY = 180 

116 

117 # Disable login support status check for superusers 

118 SUPPORT_STATUS_CHECK = True 

119 

120 # Auto-watch setting for new users 

121 DEFAULT_AUTO_WATCH = True 

122 

123 CONTACT_FORM = "reply-to" 

124 

125 PRIVATE_COMMIT_EMAIL_TEMPLATE = "{username}@users.noreply.{site_domain}" 

126 PRIVATE_COMMIT_EMAIL_OPT_IN = True 

127 

128 # Auth0 provider default image & title on login page 

129 SOCIAL_AUTH_AUTH0_IMAGE = "auth0.svg" 

130 SOCIAL_AUTH_AUTH0_TITLE = "Auth0" 

131 SOCIAL_AUTH_SAML_IMAGE = "saml.svg" 

132 SOCIAL_AUTH_SAML_TITLE = "SAML" 

133 

134 MAXIMAL_PASSWORD_LENGTH = 72 

135 

136 # Login required URLs 

137 LOGIN_REQUIRED_URLS: ClassVar[list[str]] = [] 

138 LOGIN_REQUIRED_URLS_EXCEPTIONS = ( 

139 r"{URL_PREFIX}/accounts/(.*)$", # Required for login 

140 r"{URL_PREFIX}/admin/login/(.*)$", # Required for admin login 

141 r"{URL_PREFIX}/static/(.*)$", # Required for development mode 

142 r"{URL_PREFIX}/widgets/(.*)$", # Allowing public access to widgets 

143 r"{URL_PREFIX}/data/(.*)$", # Allowing public access to data exports 

144 r"{URL_PREFIX}/hooks/(.*)$", # Allowing public access to notification hooks 

145 r"{URL_PREFIX}/healthz/$", # Allowing public access to health check 

146 r"{URL_PREFIX}/api/(.*)$", # Allowing access to API 

147 r"{URL_PREFIX}/js/i18n/$", # JavaScript localization 

148 r"{URL_PREFIX}/contact/$", # Optional for contact form 

149 r"{URL_PREFIX}/legal/(.*)$", # Optional for legal app 

150 r"{URL_PREFIX}/avatar/(.*)$", # Optional for avatars 

151 r"{URL_PREFIX}/site.webmanifest$", # The request for the manifest is made without credentials 

152 ) 

153 

154 # Multi-level rate limiting for email notifications 

155 # Each tuple contains (max_emails, time_window_seconds) 

156 RATELIMIT_NOTIFICATION_LIMITS: ClassVar[list[tuple[int, int]]] = [ 

157 # Prevent burst sends - 3 emails per 2 minutes 

158 (3, 120), 

159 # Equalize to avoid getting blocked for too long - 10 emails per hour 

160 (10, 3600), 

161 # Daily limit: 50 emails per day 

162 (50, 86400), 

163 ] 

164 

165 class Meta: 

166 prefix = "" 

167 

168 

169# This is essentially a part for django.core.validators.EmailValidator 

170DOT_ATOM_RE = re.compile( 

171 r"^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*\Z", re.IGNORECASE 

172) 

173 

174 

175def format_private_email(username: str, user_id: int) -> str: 

176 if not settings.PRIVATE_COMMIT_EMAIL_TEMPLATE: 

177 return "" 

178 if username: 

179 if username.endswith(".") or ".." in username: 

180 # Remove problematic docs 

181 username = username.replace(".", "_") 

182 if not DOT_ATOM_RE.match(username): 

183 # Remove unicode 

184 username = unidecode(username) 

185 if not DOT_ATOM_RE.match(username) or EMAIL_BLACKLIST.match(username): 

186 username = "" 

187 if not username: 

188 username = f"user-{user_id}" 

189 return settings.PRIVATE_COMMIT_EMAIL_TEMPLATE.format( 

190 username=username.lower(), 

191 site_domain=settings.SITE_DOMAIN.rsplit(":", 1)[0], 

192 ) 

193 

194 

195class SubscriptionQuerySet(models.QuerySet["Subscription"]): 

196 def order(self): 

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

198 return self.order_by("user", "scope") 

199 

200 def prefetch(self): 

201 return self.prefetch_related("component", "project") 

202 

203 

204class Subscription(models.Model): 

205 user = models.ForeignKey(User, on_delete=models.deletion.CASCADE) 

206 notification = models.CharField( 

207 choices=[n.get_choice() for n in NOTIFICATIONS], max_length=100 

208 ) 

209 scope = models.IntegerField(choices=NotificationScope.choices) 

210 frequency = models.IntegerField(choices=NotificationFrequency.choices) 

211 project = models.ForeignKey( 

212 "trans.Project", on_delete=models.deletion.CASCADE, null=True 

213 ) 

214 component = models.ForeignKey( 

215 "trans.Component", on_delete=models.deletion.CASCADE, null=True 

216 ) 

217 onetime = models.BooleanField(default=False) 

218 

219 objects = SubscriptionQuerySet.as_manager() 

220 

221 class Meta: 

222 verbose_name = "Notification subscription" 

223 verbose_name_plural = "Notification subscriptions" 

224 constraints = [ # noqa: RUF012 

225 models.UniqueConstraint( 

226 name="accounts_subscription_notification_unique", 

227 fields=("notification", "scope", "project", "component", "user"), 

228 nulls_distinct=False, 

229 ), 

230 ] 

231 

232 def __str__(self) -> str: 

233 return f"{self.user.username}:{self.get_scope_display()},{self.get_notification_display()} ({self.project},{self.component})" 

234 

235 

236ACCOUNT_ACTIVITY = { 

237 # Translators: Audit log entry 

238 "password": gettext_lazy("Password changed."), 

239 # Translators: Audit log entry 

240 "username": gettext_lazy("Username changed from {old} to {new}."), 

241 # Translators: Audit log entry 

242 "email": gettext_lazy("E-mail changed from {old} to {new}."), 

243 # Translators: Audit log entry 

244 "full_name": gettext_lazy("Full name changed from {old} to {new}."), 

245 # Translators: Audit log entry 

246 "reset-request": gettext_lazy("Password reset requested."), 

247 # Translators: Audit log entry 

248 "reset": gettext_lazy("Password reset confirmed, password turned off."), 

249 # Translators: Audit log entry 

250 "auth-connect": gettext_lazy("Configured sign in using {method} ({name})."), 

251 # Translators: Audit log entry 

252 "auth-disconnect": gettext_lazy("Removed sign in using {method} ({name})."), 

253 # Translators: Audit log entry 

254 "login": gettext_lazy("Signed in using {method} ({name})."), 

255 # Translators: Audit log entry 

256 "login-new": gettext_lazy("Signed in using {method} ({name}) from a new device."), 

257 # Translators: Audit log entry 

258 "register": gettext_lazy("Somebody attempted to register with your e-mail."), 

259 # Translators: Audit log entry 

260 "connect": gettext_lazy( 

261 "Somebody attempted to register using your e-mail address." 

262 ), 

263 # Translators: Audit log entry 

264 "failed-auth": gettext_lazy("Could not sign in using {method} ({name})."), 

265 # Translators: Audit log entry 

266 "locked": gettext_lazy("Account locked due to many failed sign in attempts."), 

267 # Translators: Audit log entry 

268 "admin-locked": gettext_lazy("Account locked by the site administrator."), 

269 # Translators: Audit log entry 

270 "removed": gettext_lazy("Account and all private data removed."), 

271 # Translators: Audit log entry 

272 "removal-request": gettext_lazy("Account removal confirmation sent to {email}."), 

273 # Translators: Audit log entry 

274 "tos": gettext_lazy("Agreement with General Terms and Conditions {date}."), 

275 # Translators: Audit log entry 

276 "invited": gettext_lazy("Invited to {site_title} by {username}."), 

277 # Translators: Audit log entry 

278 "accepted": gettext_lazy("Accepted invitation from {username}."), 

279 # Translators: Audit log entry 

280 "trial": gettext_lazy("Started trial period."), 

281 # Translators: Audit log entry 

282 "sent-email": gettext_lazy("Sent confirmation mail to {email}."), 

283 # Translators: Audit log entry 

284 "autocreated": gettext_lazy( 

285 "The system created a user to track authorship of " 

286 "translations uploaded by other user." 

287 ), 

288 # Translators: Audit log entry 

289 "blocked": gettext_lazy("Access to project {project} was blocked."), 

290 # Translators: Audit log entry 

291 "enabled": gettext_lazy("User was enabled by administrator."), 

292 # Translators: Audit log entry 

293 "disabled": gettext_lazy("User was disabled by administrator."), 

294 # Translators: Audit log entry 

295 "disabled-expiry": gettext_lazy( 

296 "User was disabled because the access has expired." 

297 ), 

298 # Translators: Audit log entry 

299 "donate": gettext_lazy("Semiannual support status review was displayed."), 

300 # Translators: Audit log entry 

301 "team-add": gettext_lazy("User was added to the {team} team by {username}."), 

302 # Translators: Audit log entry 

303 "team-remove": gettext_lazy("User was removed from the {team} team by {username}."), 

304 # Translators: Audit log entry 

305 "recovery-generate": gettext_lazy( 

306 "Two-factor authentication recovery codes were generated" 

307 ), 

308 # Translators: Audit log entry 

309 "recovery-show": gettext_lazy( 

310 "Two-factor authentication recovery codes were viewed" 

311 ), 

312 # Translators: Audit log entry 

313 "twofactor-add": gettext_lazy("Two-factor authentication added: {device}"), 

314 # Translators: Audit log entry 

315 "twofactor-remove": gettext_lazy("Two-factor authentication removed: {device}"), 

316 # Translators: Audit log entry 

317 "twofactor-login": gettext_lazy("Two-factor authentication sign in using {device}"), 

318 # Translators: Audit log entry 

319 "twofactor-failed": gettext_lazy( 

320 "Two-factor authentication failed using {device_type}" 

321 ), 

322} 

323AUDIT_WARNING = {"locked", "removed", "failed-auth", "admin-locked", "twofactor-failed"} 

324# Override activity messages based on method 

325ACCOUNT_ACTIVITY_METHOD = { 

326 "password": { 

327 # Translators: Audit log entry 

328 "auth-connect": gettext_lazy("Configured password to sign in."), 

329 # Translators: Audit log entry 

330 "login": gettext_lazy("Signed in using password."), 

331 # Translators: Audit log entry 

332 "login-new": gettext_lazy("Signed in using password from a new device."), 

333 # Translators: Audit log entry 

334 "failed-auth": gettext_lazy("Could not sign in using password."), 

335 }, 

336 "project": { 

337 # Translators: Audit log entry 

338 "invited": gettext_lazy("Invited to {project} by {username}."), 

339 }, 

340 "configured": { 

341 # Translators: Audit log entry 

342 "password": gettext_lazy("Password configured."), 

343 }, 

344} 

345 

346EXTRA_MESSAGES = { 

347 # Translators: Audit log hint 

348 "locked": gettext_lazy( 

349 "To restore access to your account, please reset your password." 

350 ), 

351 # Translators: Audit log hint 

352 "blocked": gettext_lazy( 

353 "Please contact project maintainers if you feel this is inappropriate." 

354 ), 

355 # Translators: Audit log hint 

356 "register": gettext_lazy( 

357 "If it was you, please use a password reset to regain access to your account." 

358 ), 

359 # Translators: Audit log hint 

360 "connect": gettext_lazy( 

361 "If it was you, please use a password reset to regain access to your account." 

362 ), 

363} 

364 

365NOTIFY_ACTIVITY = { 

366 "password", 

367 "reset", 

368 "auth-connect", 

369 "auth-disconnect", 

370 "register", 

371 "connect", 

372 "locked", 

373 "removed", 

374 "login-new", 

375 "email", 

376 "username", 

377 "full_name", 

378 "blocked", 

379 "recovery-generate", 

380 "recovery-show", 

381 "twofactor-add", 

382 "twofactor-remove", 

383 "twofactor-failed", 

384} 

385 

386 

387class AuditLogManager(models.Manager): 

388 def is_new_login(self, user: User, address, user_agent) -> bool: 

389 """ 

390 Check whether this login is coming from a new device. 

391 

392 Currently based purely on the IP address. 

393 """ 

394 logins = self.filter(user=user, activity="login-new") 

395 

396 # First login 

397 if not logins.exists(): 

398 return False 

399 

400 return not logins.filter(Q(address=address) | Q(user_agent=user_agent)).exists() 

401 

402 def create( # type: ignore[override] 

403 self, user: User, request: HttpRequest | None, activity: str, **params 

404 ): 

405 address: str | None = None 

406 user_agent: str = "" 

407 # Log only address for own actions (unauthenticated or when the request user matches audit user) 

408 if request and ( 408 ↛ 414line 408 didn't jump to line 414 because the condition on line 408 was never true

409 not hasattr(request, "user") 

410 or not request.user 

411 or not request.user.is_authenticated 

412 or request.user == user 

413 ): 

414 address = get_ip_address(request) 

415 user_agent = get_user_agent(request) 

416 if activity == "login" and self.is_new_login(user, address, user_agent): 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true

417 activity = "login-new" 

418 return super().create( 

419 user=user, 

420 activity=activity, 

421 address=address, 

422 user_agent=user_agent, 

423 params=params, 

424 ) 

425 

426 

427class AuditLogQuerySet(models.QuerySet["AuditLog"]): 

428 def get_after(self, user: User, after: str, activity: str) -> AuditLogQuerySet: 

429 """ 

430 Get user activities of given type after another activity. 

431 

432 This is mostly used for rate limiting, as it can return the number of failed 

433 authentication attempts since last login. 

434 """ 

435 try: 

436 latest_login = self.filter( 

437 user=user, activity__in={after, "reset"} 

438 ).order()[0] 

439 kwargs = {"timestamp__gte": latest_login.timestamp} 

440 except IndexError: 

441 kwargs = {} 

442 return self.filter(user=user, activity=activity, **kwargs) 

443 

444 def get_past_passwords(self, user: User): 

445 """Get user activities with password change.""" 

446 start = timezone.now() - datetime.timedelta(days=settings.AUTH_PASSWORD_DAYS) 

447 return self.filter( 

448 user=user, activity__in=("reset", "password"), timestamp__gt=start 

449 ) 

450 

451 def order(self): 

452 return self.order_by("-timestamp") 

453 

454 

455class AuditLog(models.Model): 

456 """User audit log storage.""" 

457 

458 user = models.ForeignKey(User, on_delete=models.deletion.CASCADE, null=True) 

459 activity = models.CharField( 

460 max_length=20, 

461 choices=[(a, a) for a in sorted(ACCOUNT_ACTIVITY.keys())], 

462 db_index=True, 

463 ) 

464 params = models.JSONField(default=dict) 

465 address = models.GenericIPAddressField(null=True) 

466 user_agent = models.CharField(max_length=200, default="") 

467 timestamp = models.DateTimeField(auto_now_add=True, db_index=True) 

468 

469 objects = AuditLogManager.from_queryset(AuditLogQuerySet)() 

470 

471 class Meta: 

472 verbose_name = "Audit log entry" 

473 verbose_name_plural = "Audit log entries" 

474 

475 def __str__(self) -> str: 

476 if self.user: 

477 return f"{self.activity} for {self.user.username} from {self.address}" 

478 return f"{self.activity} from {self.address}" 

479 

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

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

482 

483 # User notification 

484 if self.should_notify() and self.user: 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true

485 email = self.user.email 

486 notify_auditlog.delay_on_commit(self.pk, email) 

487 

488 # Log event 

489 LOGGER.log( 

490 logging.WARNING if self.activity in AUDIT_WARNING else logging.INFO, 

491 "audit[%s]: %s from %s", 

492 self.activity, 

493 self.user.username if self.user else "<no-user>", 

494 self.address, 

495 ) 

496 

497 def get_params(self) -> dict[str, Any]: 

498 from weblate.accounts.templatetags.authnames import get_auth_name 

499 

500 result: dict[str, Any] = { 

501 "site_title": settings.SITE_TITLE, 

502 } 

503 for name, value in self.params.items(): 

504 if value is None: 

505 value = format_html("<em>{}</em>", value) 

506 elif name in {"old", "new", "name", "email", "username"}: 

507 value = format_html("<code>{}</code>", mail_quote_value(value)) 

508 elif name == "method": 

509 value = format_html("<strong>{}</strong>", get_auth_name(value)) 

510 elif name in {"device", "project", "site_title"}: 

511 value = format_html("<strong>{}</strong>", mail_quote_value(value)) 

512 

513 result[name] = value 

514 

515 return result 

516 

517 @admin.display(description=gettext_lazy("Account activity")) 

518 def get_message(self): 

519 method = self.params.get("method") 

520 activity = self.activity 

521 if activity in ACCOUNT_ACTIVITY_METHOD.get(method, {}): 

522 message = ACCOUNT_ACTIVITY_METHOD[method][activity] 

523 else: 

524 message = ACCOUNT_ACTIVITY[activity] 

525 return format_html(str(message), **self.get_params()) 

526 

527 def get_extra_message(self) -> str | None: 

528 if self.activity in EXTRA_MESSAGES: 

529 return EXTRA_MESSAGES[self.activity].format(**self.params) 

530 return None 

531 

532 def should_notify(self) -> bool: 

533 return ( 

534 self.user is not None 

535 and not self.user.is_bot 

536 and self.user.is_active 

537 and self.user.email 

538 and self.activity in NOTIFY_ACTIVITY 

539 and not self.params.get("skip_notify") 

540 ) 

541 

542 def check_rate_limit(self, request: AuthenticatedHttpRequest) -> bool: 

543 """Check whether the activity should be rate limited.""" 

544 from weblate.accounts.utils import lock_user 

545 

546 if ( 

547 self.activity == "failed-auth" 

548 and self.user 

549 and self.user.has_usable_password() 

550 ): 

551 failures = AuditLog.objects.get_after(self.user, "login", "failed-auth") 

552 if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: 

553 lock_user(self.user, "locked", request) 

554 return True 

555 

556 elif ( 

557 self.activity == "twofactor-failed" 

558 and self.user 

559 and self.user.has_usable_password() 

560 ): 

561 failures = AuditLog.objects.get_after( 

562 self.user, "twofactor-login", "twofactor-failed" 

563 ) 

564 if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: 

565 lock_user(self.user, "locked", request) 

566 return True 

567 

568 elif self.activity == "reset-request": 

569 failures = AuditLog.objects.filter( 

570 user=self.user, 

571 timestamp__gte=timezone.now() - datetime.timedelta(days=1), 

572 activity="reset-request", 

573 ) 

574 if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: 

575 return True 

576 

577 return False 

578 

579 @property 

580 def shortened_address(self) -> str: 

581 if not self.address: 

582 return "" 

583 network = ip_network(self.address) 

584 prefix_len = 48 if isinstance(network, IPv6Network) else 16 

585 supernet = network.supernet(new_prefix=prefix_len) 

586 return str(supernet.network_address) 

587 

588 

589class VerifiedEmail(models.Model): 

590 """Storage for verified e-mails from auth backends.""" 

591 

592 is_deliverable = models.BooleanField(default=True) 

593 social = models.ForeignKey(UserSocialAuth, on_delete=models.deletion.CASCADE) 

594 email = EmailField() 

595 

596 class Meta: 

597 verbose_name = "Verified e-mail" 

598 verbose_name_plural = "Verified e-mails" 

599 indexes = [ # noqa: RUF012 

600 models.Index( 

601 Upper("email"), 

602 name="accounts_verifiedemail_email", 

603 ), 

604 ] 

605 

606 def __str__(self) -> str: 

607 return f"{self.social.user.username} - {self.email}" 

608 

609 @property 

610 def provider(self): 

611 return self.social.provider 

612 

613 

614class Profile(models.Model): 

615 """User profiles storage.""" 

616 

617 user = models.OneToOneField( 

618 User, unique=True, editable=False, on_delete=models.deletion.CASCADE 

619 ) 

620 language = models.CharField( 

621 verbose_name=gettext_lazy("Interface Language"), 

622 max_length=10, 

623 choices=settings.LANGUAGES, 

624 ) 

625 languages = models.ManyToManyField( 

626 Language, 

627 verbose_name=gettext_lazy("Translated languages"), 

628 blank=True, 

629 help_text=gettext_lazy( 

630 "Choose the languages you can translate to. " 

631 "These will be offered to you on the dashboard " 

632 "for easier access to your chosen translations." 

633 ), 

634 ) 

635 secondary_languages = models.ManyToManyField( 

636 Language, 

637 verbose_name=gettext_lazy("Secondary languages"), 

638 help_text=gettext_lazy( 

639 "Choose languages you can understand, strings in those languages " 

640 "will be shown in addition to the source string." 

641 ), 

642 related_name="secondary_profile_set", 

643 blank=True, 

644 ) 

645 suggested = models.IntegerField(default=0, db_index=True) 

646 translated = models.IntegerField(default=0, db_index=True) 

647 uploaded = models.IntegerField(default=0, db_index=True) 

648 commented = models.IntegerField(default=0, db_index=True) 

649 theme = models.CharField( 

650 max_length=10, 

651 verbose_name=gettext_lazy("Theme"), 

652 default="auto", 

653 choices=ThemeChoices, 

654 ) 

655 hide_completed = models.BooleanField( 

656 verbose_name=gettext_lazy("Hide completed translations on the dashboard"), 

657 default=False, 

658 ) 

659 secondary_in_zen = models.BooleanField( 

660 verbose_name=gettext_lazy("Show secondary translations in the Zen mode"), 

661 default=True, 

662 ) 

663 hide_source_secondary = models.BooleanField( 

664 verbose_name=gettext_lazy("Hide source if a secondary translation exists"), 

665 default=False, 

666 ) 

667 editor_link = models.CharField( 

668 default="", 

669 blank=True, 

670 max_length=200, 

671 verbose_name=gettext_lazy("Editor link"), 

672 help_text=gettext_lazy( 

673 "Enter a custom URL to be used as link to the source code. " 

674 "You can use {{branch}} for branch, " 

675 "{{filename}} and {{line}} as filename and line placeholders." 

676 ), 

677 validators=[validate_editor], 

678 ) 

679 TRANSLATE_FULL = 0 

680 TRANSLATE_ZEN = 1 

681 translate_mode = models.IntegerField( 

682 verbose_name=gettext_lazy("Translation editor mode"), 

683 choices=( 

684 (TRANSLATE_FULL, gettext_lazy("Full editor")), 

685 (TRANSLATE_ZEN, gettext_lazy("Zen mode")), 

686 ), 

687 default=TRANSLATE_FULL, 

688 ) 

689 ZEN_VERTICAL = 0 

690 ZEN_HORIZONTAL = 1 

691 zen_mode = models.IntegerField( 

692 verbose_name=gettext_lazy("Zen editor mode"), 

693 choices=( 

694 (ZEN_VERTICAL, gettext_lazy("Top to bottom")), 

695 (ZEN_HORIZONTAL, gettext_lazy("Side by side")), 

696 ), 

697 default=ZEN_VERTICAL, 

698 ) 

699 special_chars = models.CharField( 

700 default="", 

701 blank=True, 

702 max_length=30, 

703 verbose_name=gettext_lazy("Special characters"), 

704 help_text=gettext_lazy( 

705 "You can specify additional special visual keyboard characters " 

706 "to be shown while translating. It can be useful for " 

707 "characters you use frequently, but are hard to type on your keyboard." 

708 ), 

709 ) 

710 nearby_strings = models.SmallIntegerField( 

711 verbose_name=gettext_lazy("Number of nearby strings"), 

712 default=settings.NEARBY_MESSAGES, 

713 validators=[MinValueValidator(1), MaxValueValidator(50)], 

714 help_text=gettext_lazy( 

715 "Number of nearby strings to show in each direction in the full editor." 

716 ), 

717 ) 

718 auto_watch = models.BooleanField( 

719 verbose_name=gettext_lazy("Automatically watch projects on contribution"), 

720 default=settings.DEFAULT_AUTO_WATCH, 

721 help_text=gettext_lazy( 

722 "Whenever you translate a string in a project, you will start watching it." 

723 ), 

724 ) 

725 contribute_personal_tm = models.BooleanField( 

726 verbose_name=gettext_lazy("Contribute to personal translation memory"), 

727 default=True, 

728 help_text=gettext_lazy( 

729 "Allow your translations to be added to your personal translation memory." 

730 ), 

731 ) 

732 

733 DASHBOARD_WATCHED = 1 

734 DASHBOARD_COMPONENT_LIST = 4 

735 DASHBOARD_SUGGESTIONS = 5 

736 DASHBOARD_COMPONENT_LISTS = 6 

737 DASHBOARD_MANAGED = 7 

738 

739 DASHBOARD_CHOICES = ( 

740 (DASHBOARD_WATCHED, gettext_lazy("Watched translations")), 

741 (DASHBOARD_COMPONENT_LISTS, gettext_lazy("All component lists")), 

742 (DASHBOARD_COMPONENT_LIST, gettext_lazy("Component list")), 

743 (DASHBOARD_SUGGESTIONS, gettext_lazy("Suggested translations")), 

744 (DASHBOARD_MANAGED, gettext_lazy("Managed projects")), 

745 ) 

746 

747 DASHBOARD_SLUGS: ClassVar[dict[int, str]] = { 

748 DASHBOARD_WATCHED: "your-subscriptions", 

749 DASHBOARD_COMPONENT_LIST: "list", 

750 DASHBOARD_SUGGESTIONS: "suggestions", 

751 DASHBOARD_COMPONENT_LISTS: "componentlists", 

752 DASHBOARD_MANAGED: "managed", 

753 } 

754 

755 dashboard_view = models.IntegerField( 

756 choices=DASHBOARD_CHOICES, 

757 verbose_name=gettext_lazy("Default dashboard view"), 

758 default=DASHBOARD_WATCHED, 

759 ) 

760 

761 dashboard_component_list = models.ForeignKey( 

762 "trans.ComponentList", 

763 verbose_name=gettext_lazy("Default component list"), 

764 on_delete=models.deletion.SET_NULL, 

765 blank=True, 

766 null=True, 

767 ) 

768 

769 watched = models.ManyToManyField( 

770 "trans.Project", 

771 verbose_name=gettext_lazy("Watched projects"), 

772 help_text=gettext_lazy( 

773 "You can receive notifications for watched projects and " 

774 "they are shown on the dashboard by default." 

775 ), 

776 blank=True, 

777 ) 

778 

779 # Public profile fields 

780 website = models.URLField( 

781 verbose_name=gettext_lazy("Website URL"), 

782 blank=True, 

783 validators=[WeblateURLValidator()], 

784 ) 

785 contact = models.URLField( 

786 verbose_name=gettext_lazy("Contact URL"), 

787 blank=True, 

788 validators=[WeblateURLValidator()], 

789 help_text=gettext_lazy( 

790 "Link to contact you online using services like Signal, SimpleX or Telegram." 

791 ), 

792 ) 

793 liberapay = models.SlugField( 

794 verbose_name=gettext_lazy("Liberapay username"), 

795 blank=True, 

796 help_text=gettext_lazy( 

797 "Liberapay is a platform to donate money to teams, " 

798 "organizations and individuals." 

799 ), 

800 db_index=False, 

801 ) 

802 fediverse = models.URLField( 

803 verbose_name=gettext_lazy("Fediverse URL"), 

804 blank=True, 

805 help_text=gettext_lazy( 

806 "Link to your Fediverse profile for federated services " 

807 "like Mastodon or diaspora*." 

808 ), 

809 validators=[WeblateURLValidator()], 

810 ) 

811 codesite = models.URLField( 

812 verbose_name=gettext_lazy("Code site URL"), 

813 blank=True, 

814 help_text=gettext_lazy( 

815 "Link to your code profile for services like Codeberg or GitLab." 

816 ), 

817 validators=[WeblateURLValidator()], 

818 ) 

819 github = models.SlugField( 

820 verbose_name=gettext_lazy("GitHub username"), 

821 blank=True, 

822 db_index=False, 

823 ) 

824 twitter = models.SlugField( 

825 verbose_name=gettext_lazy("X username"), 

826 blank=True, 

827 db_index=False, 

828 ) 

829 linkedin = models.SlugField( 

830 verbose_name=gettext_lazy("LinkedIn profile name"), 

831 help_text=gettext_lazy( 

832 "Your LinkedIn profile name from linkedin.com/in/profilename" 

833 ), 

834 blank=True, 

835 db_index=False, 

836 allow_unicode=True, 

837 ) 

838 location = models.CharField( 

839 verbose_name=gettext_lazy("Location"), 

840 max_length=100, 

841 blank=True, 

842 ) 

843 company = models.CharField( 

844 verbose_name=gettext_lazy("Company"), 

845 max_length=100, 

846 blank=True, 

847 ) 

848 public_email = EmailField( 

849 verbose_name=gettext_lazy("Public e-mail"), 

850 blank=True, 

851 max_length=EMAIL_LENGTH, 

852 ) 

853 

854 commit_email = EmailField( 

855 verbose_name=gettext_lazy("Commit e-mail"), 

856 blank=True, 

857 max_length=EMAIL_LENGTH, 

858 ) 

859 

860 last_2fa = models.CharField( 

861 choices=( 

862 ("", "None"), 

863 ("totp", "TOTP"), 

864 ("webauthn", "WebAuthn"), 

865 ), 

866 blank=True, 

867 default="", 

868 max_length=15, 

869 ) 

870 

871 class Meta: 

872 verbose_name = "User profile" 

873 verbose_name_plural = "User profiles" 

874 

875 def __str__(self) -> str: 

876 return self.user.username 

877 

878 def get_absolute_url(self) -> str: 

879 return self.user.get_absolute_url() 

880 

881 def get_user_display(self): 

882 return get_user_display(self.user) 

883 

884 def get_user_display_link(self): 

885 return get_user_display(self.user, True, True) 

886 

887 def get_user_name(self): 

888 return get_user_display(self.user, False) 

889 

890 def get_fediverse_share(self): 

891 if not self.fediverse: 

892 return None 

893 parsed = urlparse(self.fediverse) 

894 if not parsed.hostname: 

895 return None 

896 return parsed._replace(path="/share", query="text=", fragment="").geturl() 

897 

898 def increase_count(self, item: str, increase: int = 1) -> None: 

899 """Update user actions counter.""" 

900 # Update our copy 

901 setattr(self, item, getattr(self, item) + increase) 

902 # Update database 

903 update = {item: F(item) + increase} 

904 Profile.objects.filter(pk=self.pk).update(**update) 

905 

906 @cached_property 

907 def all_languages(self): 

908 return self.languages.all() 

909 

910 @property 

911 def full_name(self): 

912 """Return user's full name.""" 

913 return self.user.full_name 

914 

915 def clean(self) -> None: 

916 """Check if component list is chosen when required.""" 

917 # There is matching logic in ProfileBaseForm.add_error to ignore this 

918 # validation on partial forms 

919 if ( 

920 self.dashboard_view == Profile.DASHBOARD_COMPONENT_LIST 

921 and self.dashboard_component_list is None 

922 ): 

923 message = gettext( 

924 "Please choose which component list you want to display on " 

925 "the dashboard." 

926 ) 

927 raise ValidationError( 

928 {"dashboard_component_list": message, "dashboard_view": message} 

929 ) 

930 if ( 

931 self.dashboard_view != Profile.DASHBOARD_COMPONENT_LIST 

932 and self.dashboard_component_list is not None 

933 ): 

934 message = gettext( 

935 "Selecting component list has no effect when not shown on " 

936 "the dashboard." 

937 ) 

938 raise ValidationError( 

939 {"dashboard_component_list": message, "dashboard_view": message} 

940 ) 

941 

942 def dump_data(self): 

943 def map_attr(attr): 

944 if attr.endswith("_id"): 

945 return attr[:-3] 

946 return attr 

947 

948 def dump_object(obj, *attrs): 

949 return {map_attr(attr): getattr(obj, attr) for attr in attrs} 

950 

951 result = { 

952 "basic": dump_object( 

953 self.user, "username", "full_name", "email", "date_joined" 

954 ), 

955 "profile": dump_object( 

956 self, 

957 "language", 

958 "suggested", 

959 "translated", 

960 "uploaded", 

961 "hide_completed", 

962 "theme", 

963 "secondary_in_zen", 

964 "hide_source_secondary", 

965 "editor_link", 

966 "translate_mode", 

967 "zen_mode", 

968 "special_chars", 

969 "dashboard_view", 

970 "dashboard_component_list_id", 

971 ), 

972 "auditlog": [ 

973 dump_object(log, "address", "user_agent", "timestamp", "activity") 

974 for log in self.user.auditlog_set.iterator() 

975 ], 

976 } 

977 result["profile"]["languages"] = [ 

978 lang.code for lang in self.languages.iterator() 

979 ] 

980 result["profile"]["secondary_languages"] = [ 

981 lang.code for lang in self.secondary_languages.iterator() 

982 ] 

983 result["profile"]["watched"] = [ 

984 project.slug for project in self.watched.iterator() 

985 ] 

986 return result 

987 

988 @cached_property 

989 def primary_language_ids(self) -> set[int]: 

990 return {language.pk for language in self.all_languages} 

991 

992 @cached_property 

993 def allowed_dashboard_component_lists(self): 

994 return ComponentList.objects.filter( 

995 show_dashboard=True, 

996 components__project__in=self.user.allowed_projects, 

997 ).distinct() 

998 

999 @cached_property 

1000 def secondary_language_ids(self) -> set[int]: 

1001 return set(self.secondary_languages.values_list("pk", flat=True)) 

1002 

1003 def get_translation_orderer( 

1004 self, request: AuthenticatedHttpRequest | None 

1005 ) -> Callable[ 

1006 [ 

1007 Unit 

1008 | Translation 

1009 | Language 

1010 | ProjectLanguageStats 

1011 | CategoryLanguageStats 

1012 | GhostProjectLanguageStats 

1013 | GhostCategoryLanguageStats 

1014 | GhostTranslation 

1015 ], 

1016 str, 

1017 ]: 

1018 """Create a function suitable for ordering languages based on user preferences.""" 

1019 

1020 def get_translation_order( 

1021 obj: Unit 

1022 | Translation 

1023 | Language 

1024 | ProjectLanguageStats 

1025 | CategoryLanguageStats 

1026 | GhostProjectLanguageStats 

1027 | GhostCategoryLanguageStats 

1028 | GhostTranslation, 

1029 ) -> str: 

1030 from weblate.trans.models import Unit 

1031 

1032 language: Language 

1033 is_source = False 

1034 if isinstance(obj, Language): 

1035 language = obj 

1036 elif isinstance(obj, Unit): 

1037 translation = obj.translation 

1038 language = translation.language 

1039 is_source = translation.is_source 

1040 elif isinstance( 

1041 obj, 

1042 ( 

1043 Translation, 

1044 ProjectLanguageStats, 

1045 CategoryLanguageStats, 

1046 GhostProjectLanguageStats, 

1047 GhostCategoryLanguageStats, 

1048 GhostTranslation, 

1049 ), 

1050 ): 

1051 language = obj.language 

1052 is_source = obj.is_source 

1053 else: 

1054 message = f"{obj.__class__.__name__} is not supported" 

1055 raise TypeError(message) 

1056 

1057 if language.pk in self.primary_language_ids: 

1058 priority = 0 

1059 elif language.pk in self.secondary_language_ids: 

1060 priority = 1 

1061 elif ( 

1062 not self.primary_language_ids 

1063 and request is not None 

1064 and language == request.accepted_language 

1065 ): 

1066 priority = 2 

1067 elif is_source: 

1068 priority = 3 

1069 else: 

1070 priority = 4 

1071 

1072 return f"{priority}-{language}" 

1073 

1074 return get_translation_order 

1075 

1076 def fixup_profile(self, request: AuthenticatedHttpRequest) -> None: 

1077 fields = set() 

1078 if not self.language: 

1079 self.language = get_language() 

1080 fields.add("language") 

1081 

1082 allowed = {clist.pk for clist in self.allowed_dashboard_component_lists} 

1083 

1084 if not allowed and self.dashboard_view in { 

1085 Profile.DASHBOARD_COMPONENT_LIST, 

1086 Profile.DASHBOARD_COMPONENT_LISTS, 

1087 }: 

1088 self.dashboard_view = Profile.DASHBOARD_WATCHED 

1089 fields.add("dashboard_view") 

1090 

1091 if self.dashboard_component_list_id and ( 

1092 self.dashboard_component_list_id not in allowed 

1093 or self.dashboard_view != Profile.DASHBOARD_COMPONENT_LIST 

1094 ): 

1095 self.dashboard_component_list = None 

1096 self.dashboard_view = Profile.DASHBOARD_WATCHED 

1097 fields.add("dashboard_view") 

1098 fields.add("dashboard_component_list") 

1099 

1100 if ( 

1101 not self.dashboard_component_list_id 

1102 and self.dashboard_view == Profile.DASHBOARD_COMPONENT_LIST 

1103 ): 

1104 self.dashboard_view = Profile.DASHBOARD_WATCHED 

1105 fields.add("dashboard_view") 

1106 

1107 if not self.languages.exists(): 

1108 language = Language.objects.get_request_language(request) 

1109 if language: 

1110 self.languages.add(language) 

1111 messages.info( 

1112 request, 

1113 gettext( 

1114 "Added %(language)s to your translated languages. " 

1115 "You can adjust them in the settings." 

1116 ) 

1117 % {"language": language}, 

1118 ) 

1119 

1120 if fields: 

1121 self.save(update_fields=fields) 

1122 

1123 def get_commit_email(self) -> str: 

1124 email = self.commit_email 

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

1126 not email 

1127 and not settings.PRIVATE_COMMIT_EMAIL_OPT_IN 

1128 and not self.user.is_bot 

1129 ): 

1130 email = self.get_site_commit_email() 

1131 if not email: 1131 ↛ 1133line 1131 didn't jump to line 1133 because the condition on line 1131 was always true

1132 email = self.user.email 

1133 return email 

1134 

1135 def get_site_commit_email(self) -> str: 

1136 return format_private_email(self.user.username, self.user.pk) 

1137 

1138 def _get_second_factors(self) -> Iterable[Device]: 

1139 backend: type[Device] 

1140 for backend in (StaticDevice, TOTPDevice, WebAuthnCredential): 

1141 yield from backend.objects.filter(user=self.user) 

1142 

1143 @cached_property 

1144 def second_factors(self) -> list[Device]: 

1145 return list(self._get_second_factors()) 

1146 

1147 @cached_property 

1148 def second_factor_types(self) -> set[Literal["totp", "webauthn", "recovery"]]: 

1149 from weblate.accounts.utils import get_key_type 

1150 

1151 return {get_key_type(device) for device in self.second_factors} 

1152 

1153 @property 

1154 def has_2fa(self) -> bool: 

1155 return any( 

1156 isinstance(device, (TOTPDevice, WebAuthnCredential)) 

1157 for device in self.second_factors 

1158 ) 

1159 

1160 def log_2fa(self, request: AuthenticatedHttpRequest, device: Device) -> None: 

1161 from weblate.accounts.utils import get_key_name, get_key_type 

1162 

1163 # Audit log entry 

1164 AuditLog.objects.create( 

1165 self.user, request, "twofactor-login", device=get_key_name(device) 

1166 ) 

1167 # Store preferred method (skipping recovery codes) 

1168 device_type = get_key_type(device) 

1169 if device_type not in {self.last_2fa, "recovery"}: 

1170 self.last_2fa = device_type 

1171 self.save(update_fields=["last_2fa"]) 

1172 

1173 def log_2fa_failed( 

1174 self, request: AuthenticatedHttpRequest, device_type: DeviceType 

1175 ) -> None: 

1176 AuditLog.objects.create( 

1177 self.user, request, "twofactor-failed", device_type=device_type 

1178 ) 

1179 

1180 def get_second_factor_type(self) -> Literal["totp", "webauthn"]: 

1181 if self.last_2fa in self.second_factor_types: 

1182 return self.last_2fa # type: ignore[return-value] 

1183 for tested in ("webauthn", "totp"): 

1184 if tested in self.second_factor_types: 

1185 return tested 

1186 msg = "No second factor available!" 

1187 raise ValueError(msg) 

1188 

1189 

1190def set_lang_cookie(response, profile) -> None: 

1191 """Set session language based on user preferences.""" 

1192 if profile.language: 

1193 response.set_cookie( 

1194 settings.LANGUAGE_COOKIE_NAME, 

1195 profile.language, 

1196 max_age=settings.LANGUAGE_COOKIE_AGE, 

1197 path=settings.LANGUAGE_COOKIE_PATH, 

1198 domain=settings.LANGUAGE_COOKIE_DOMAIN, 

1199 secure=settings.LANGUAGE_COOKIE_SECURE, 

1200 httponly=settings.LANGUAGE_COOKIE_HTTPONLY, 

1201 samesite=settings.LANGUAGE_COOKIE_SAMESITE, 

1202 ) 

1203 

1204 

1205@receiver(user_logged_in) 

1206def post_login_handler( 

1207 sender, request: AuthenticatedHttpRequest, user: User, **kwargs 

1208) -> None: 

1209 """ 

1210 Signal handler for post login. 

1211 

1212 It sets user language and migrates profile if needed. 

1213 """ 

1214 backend_name = getattr(user, "backend", "") 

1215 is_email_auth = backend_name.endswith((".EmailAuth", ".WeblateUserBackend")) 

1216 

1217 # Warning about setting password 

1218 if is_email_auth and not user.has_usable_password(): 

1219 request.session["show_set_password"] = True 

1220 

1221 # Redirect superuser to donate page twice a year 

1222 if ( 

1223 settings.SUPPORT_STATUS_CHECK 

1224 and user.is_superuser 

1225 and not get_support_status(request)["has_support"] 

1226 and not user.auditlog_set.filter( 

1227 timestamp__gt=now() - timedelta(days=180), activity="donate" 

1228 ).exists() 

1229 and Change.objects.filter(timestamp__lt=now() - timedelta(days=14)).exists() 

1230 ): 

1231 request.session["redirect_to_donate"] = True 

1232 

1233 # Migrate django-registration based verification to python-social-auth 

1234 # and handle external authentication such as LDAP 

1235 if ( 

1236 is_email_auth 

1237 and user.has_usable_password() 

1238 and user.email 

1239 and not user.social_auth.filter(provider="email").exists() 

1240 ): 

1241 social = user.social_auth.create(provider="email", uid=user.email) 

1242 VerifiedEmail.objects.create(social=social, email=user.email) 

1243 

1244 # Fixup accounts with empty name 

1245 if not user.full_name: 

1246 user.full_name = user.username 

1247 user.save(update_fields=["full_name"]) 

1248 

1249 # Warn about not set e-mail 

1250 if not user.email: 

1251 messages.error( 

1252 request, 

1253 gettext("Please provide an e-mail address for submitting translations."), 

1254 ) 

1255 

1256 # Sanitize profile 

1257 user.profile.fixup_profile(request) 

1258 

1259 

1260@receiver(post_save, sender=User) 

1261@disable_for_loaddata 

1262def create_profile_callback(sender, instance, created=False, **kwargs) -> None: 

1263 """Automatically create token and profile for user.""" 

1264 if created: 

1265 # Create API token 

1266 instance.auth_token = Token.objects.create( 

1267 user=instance, key=get_token("wlp" if instance.is_bot else "wlu") 

1268 ) 

1269 # Create profile 

1270 instance.profile = Profile.objects.create(user=instance) 

1271 # Create subscriptions 

1272 if not instance.is_anonymous and not instance.is_bot: 

1273 create_default_notifications(instance)