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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
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
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
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
67from .types import ThemeChoices
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
72 from django.http.request import HttpRequest
73 from django_otp.models import Device
75 from weblate.accounts.types import DeviceType
76 from weblate.auth.models import AuthenticatedHttpRequest
77 from weblate.trans.models import Unit
79LOGGER = logging.getLogger("weblate.audit")
82class WeblateAccountsConf(AppConf):
83 """Accounts settings."""
85 # Disable avatars
86 ENABLE_AVATARS = True
88 # Avatar URL prefix
89 AVATAR_URL_PREFIX = "https://www.gravatar.com/"
91 # Avatar fallback image
92 # See http://en.gravatar.com/site/implement/images/ for available choices
93 AVATAR_DEFAULT_IMAGE = "identicon"
95 # Enable registrations
96 REGISTRATION_OPEN = True
98 # Allow registration from certain backends
99 REGISTRATION_ALLOW_BACKENDS: ClassVar[list[str]] = []
101 # Allow rebinding to existing accounts
102 REGISTRATION_REBIND = False
104 # Registration email filter
105 REGISTRATION_EMAIL_MATCH = ".*"
107 # Captcha for registrations
108 REGISTRATION_CAPTCHA = True
110 ALTCHA_MAX_NUMBER = 1_000_000
112 REGISTRATION_HINTS: ClassVar[dict[str, str]] = {}
114 # How long to keep auditlog entries
115 AUDITLOG_EXPIRY = 180
117 # Disable login support status check for superusers
118 SUPPORT_STATUS_CHECK = True
120 # Auto-watch setting for new users
121 DEFAULT_AUTO_WATCH = True
123 CONTACT_FORM = "reply-to"
125 PRIVATE_COMMIT_EMAIL_TEMPLATE = "{username}@users.noreply.{site_domain}"
126 PRIVATE_COMMIT_EMAIL_OPT_IN = True
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"
134 MAXIMAL_PASSWORD_LENGTH = 72
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 )
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 ]
165 class Meta:
166 prefix = ""
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)
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 )
195class SubscriptionQuerySet(models.QuerySet["Subscription"]):
196 def order(self):
197 """Ordering in project scope by priority."""
198 return self.order_by("user", "scope")
200 def prefetch(self):
201 return self.prefetch_related("component", "project")
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)
219 objects = SubscriptionQuerySet.as_manager()
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 ]
232 def __str__(self) -> str:
233 return f"{self.user.username}:{self.get_scope_display()},{self.get_notification_display()} ({self.project},{self.component})"
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}
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}
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}
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.
392 Currently based purely on the IP address.
393 """
394 logins = self.filter(user=user, activity="login-new")
396 # First login
397 if not logins.exists():
398 return False
400 return not logins.filter(Q(address=address) | Q(user_agent=user_agent)).exists()
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 )
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.
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)
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 )
451 def order(self):
452 return self.order_by("-timestamp")
455class AuditLog(models.Model):
456 """User audit log storage."""
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)
469 objects = AuditLogManager.from_queryset(AuditLogQuerySet)()
471 class Meta:
472 verbose_name = "Audit log entry"
473 verbose_name_plural = "Audit log entries"
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}"
480 def save(self, *args, **kwargs) -> None:
481 super().save(*args, **kwargs)
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)
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 )
497 def get_params(self) -> dict[str, Any]:
498 from weblate.accounts.templatetags.authnames import get_auth_name
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))
513 result[name] = value
515 return result
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())
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
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 )
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
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
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
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
577 return False
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)
589class VerifiedEmail(models.Model):
590 """Storage for verified e-mails from auth backends."""
592 is_deliverable = models.BooleanField(default=True)
593 social = models.ForeignKey(UserSocialAuth, on_delete=models.deletion.CASCADE)
594 email = EmailField()
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 ]
606 def __str__(self) -> str:
607 return f"{self.social.user.username} - {self.email}"
609 @property
610 def provider(self):
611 return self.social.provider
614class Profile(models.Model):
615 """User profiles storage."""
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 )
733 DASHBOARD_WATCHED = 1
734 DASHBOARD_COMPONENT_LIST = 4
735 DASHBOARD_SUGGESTIONS = 5
736 DASHBOARD_COMPONENT_LISTS = 6
737 DASHBOARD_MANAGED = 7
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 )
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 }
755 dashboard_view = models.IntegerField(
756 choices=DASHBOARD_CHOICES,
757 verbose_name=gettext_lazy("Default dashboard view"),
758 default=DASHBOARD_WATCHED,
759 )
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 )
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 )
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 )
854 commit_email = EmailField(
855 verbose_name=gettext_lazy("Commit e-mail"),
856 blank=True,
857 max_length=EMAIL_LENGTH,
858 )
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 )
871 class Meta:
872 verbose_name = "User profile"
873 verbose_name_plural = "User profiles"
875 def __str__(self) -> str:
876 return self.user.username
878 def get_absolute_url(self) -> str:
879 return self.user.get_absolute_url()
881 def get_user_display(self):
882 return get_user_display(self.user)
884 def get_user_display_link(self):
885 return get_user_display(self.user, True, True)
887 def get_user_name(self):
888 return get_user_display(self.user, False)
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()
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)
906 @cached_property
907 def all_languages(self):
908 return self.languages.all()
910 @property
911 def full_name(self):
912 """Return user's full name."""
913 return self.user.full_name
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 )
942 def dump_data(self):
943 def map_attr(attr):
944 if attr.endswith("_id"):
945 return attr[:-3]
946 return attr
948 def dump_object(obj, *attrs):
949 return {map_attr(attr): getattr(obj, attr) for attr in attrs}
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
988 @cached_property
989 def primary_language_ids(self) -> set[int]:
990 return {language.pk for language in self.all_languages}
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()
999 @cached_property
1000 def secondary_language_ids(self) -> set[int]:
1001 return set(self.secondary_languages.values_list("pk", flat=True))
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."""
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
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)
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
1072 return f"{priority}-{language}"
1074 return get_translation_order
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")
1082 allowed = {clist.pk for clist in self.allowed_dashboard_component_lists}
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")
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")
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")
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 )
1120 if fields:
1121 self.save(update_fields=fields)
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
1135 def get_site_commit_email(self) -> str:
1136 return format_private_email(self.user.username, self.user.pk)
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)
1143 @cached_property
1144 def second_factors(self) -> list[Device]:
1145 return list(self._get_second_factors())
1147 @cached_property
1148 def second_factor_types(self) -> set[Literal["totp", "webauthn", "recovery"]]:
1149 from weblate.accounts.utils import get_key_type
1151 return {get_key_type(device) for device in self.second_factors}
1153 @property
1154 def has_2fa(self) -> bool:
1155 return any(
1156 isinstance(device, (TOTPDevice, WebAuthnCredential))
1157 for device in self.second_factors
1158 )
1160 def log_2fa(self, request: AuthenticatedHttpRequest, device: Device) -> None:
1161 from weblate.accounts.utils import get_key_name, get_key_type
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"])
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 )
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)
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 )
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.
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"))
1217 # Warning about setting password
1218 if is_email_auth and not user.has_usable_password():
1219 request.session["show_set_password"] = True
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
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)
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"])
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 )
1256 # Sanitize profile
1257 user.profile.fixup_profile(request)
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)