pontoon/base/models/user.py (394 lines of code) (raw):
from datetime import timedelta
from hashlib import md5
from urllib.parse import quote, urlencode
from dateutil.relativedelta import relativedelta
from guardian.shortcuts import get_objects_for_user
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from pontoon.actionlog.models import ActionLog
@property
def user_profile_url(self):
return reverse(
"pontoon.contributors.contributor.username", kwargs={"username": self.username}
)
def user_gravatar_url(self, size):
email = md5(self.email.lower().encode("utf-8")).hexdigest()
data = {
"s": str(size),
"d": "https://ui-avatars.com/api/{name}/{size}/{background}/{color}".format(
name=quote(self.display_name),
size=size,
background="333941",
color="FFFFFF",
),
}
return "//www.gravatar.com/avatar/{email}?{data}".format(
email=email, data=urlencode(data)
)
@property
def user_gravatar_url_small(self):
return user_gravatar_url(self, 88)
@property
def user_name_or_email(self):
return self.first_name or self.email
@property
def user_contact_email(self):
return self.profile.contact_email or self.email
@property
def user_display_name(self):
return self.first_name or self.email.split("@")[0]
@property
def user_display_name_and_email(self):
name = self.display_name
return f"{name} <{self.email}>"
@classmethod
def user_display_name_or_blank(cls, user):
"""Shorcut function that displays user info if user isn't none."""
return user.name_or_email if user else ""
@property
def user_translator_for_locales(self):
"""A list of locales, in which the user is assigned Translator permissions.
Only includes explicitly assigned locales for superusers.
"""
locales = []
for group in self.groups.all():
locale = group.translated_locales.first()
if locale:
locales.append(locale)
return locales
@property
def user_manager_for_locales(self):
"""A list of locales, in which the user is assigned Manager permissions.
Only includes explicitly assigned locales for superusers.
"""
locales = []
for group in self.groups.all():
locale = group.managed_locales.first()
if locale:
locales.append(locale)
return locales
@property
def user_can_translate_locales(self):
"""A list of locale codes the user has permission to translate.
Includes all locales for superusers.
"""
return get_objects_for_user(
self, "base.can_translate_locale", accept_global_perms=False
)
@property
def user_can_manage_locales(self):
"""A list of locale codes the user has permission to manage.
Includes all locales for superusers.
"""
return get_objects_for_user(
self, "base.can_manage_locale", accept_global_perms=False
)
@property
def user_translated_projects(self):
"""
Returns a map of permission for every user
:param self:
:return:
"""
from pontoon.base.models.project_locale import ProjectLocale
user_project_locales = (
get_objects_for_user(
self, "base.can_translate_project_locale", accept_global_perms=False
)
).values_list("pk", flat=True)
project_locales = ProjectLocale.objects.filter(
has_custom_translators=True
).values_list("pk", "locale__code", "project__slug")
permission_map = {
f"{locale}-{project}": (pk in user_project_locales)
for pk, locale, project in project_locales
}
return permission_map
def user_role(self, managers=None, translators=None):
"""
Prefetched managers and translators dicts help reduce the number of queries
on pages that contain a lot of users, like the Top Contributors page.
"""
if self.is_superuser:
return "Admin"
if self.pk is None or self.profile.system_user:
return "System User"
if managers is not None:
if self in managers:
return "Manager for " + ", ".join(managers[self])
else:
manager_for_locales = self.can_manage_locales.values_list("code", flat=True)
if manager_for_locales:
return "Manager for " + ", ".join(manager_for_locales)
if translators is not None:
if self in translators:
return "Translator for " + ", ".join(translators[self])
else:
translator_for_locales = self.can_translate_locales.values_list(
"code", flat=True
)
if translator_for_locales:
return "Translator for " + ", ".join(translator_for_locales)
return "Contributor"
def user_locale_role(self, locale):
if self in locale.managers_group.user_set.all():
return "Manager"
if self in locale.translators_group.user_set.all():
return "Translator"
if self.is_superuser:
return "Admin"
if self.pk is None or self.profile.system_user:
return "System User"
else:
return "Contributor"
def user_banner(self, locale, project_contact):
if self.pk is None or self.profile.system_user:
return ("", "")
if self in locale.managers_group.user_set.all():
return ("MNGR", "Team Manager")
if self in locale.translators_group.user_set.all():
return ("TRNSL", "Translator")
if project_contact and self.pk == project_contact.pk:
return ("PM", "Project Manager")
if self.is_superuser:
return ("ADMIN", "Admin")
if self.date_joined >= timezone.now() - relativedelta(months=3):
return ("NEW", "New User")
return ("", "")
@property
def contributed_translations(self):
"""Contributions provided by user."""
return self.translation_set.all()
@property
def has_approved_translations(self):
"""Return True if the user has approved translations."""
return self.translation_set.filter(approved=True).exists()
@property
def badges_translation_count(self):
"""Contributions provided by user that count towards their badges."""
return self.actions.filter(
action_type="translation:created",
created_at__gte=settings.BADGES_START_DATE,
).count()
@property
def badges_review_count(self):
"""Translation reviews provided by user that count towards their badges."""
return self.actions.filter(
Q(action_type="translation:approved") | Q(action_type="translation:rejected"),
created_at__gte=settings.BADGES_START_DATE,
is_implicit_action=False,
).count()
@property
def badges_promotion_count(self):
"""Role promotions performed by user that count towards their badges"""
added_entries = self.changed_permissions_log.filter(
action_type="added",
created_at__gte=settings.BADGES_START_DATE,
)
# Check if user was demoted from Manager to Translator.
# In this case, it doesn't count as a promotion.
#
# TODO:
# This code is the only consumer of the PermissionChangelog model, so we should
# refactor to simplify how promotions are retrieved. (see #2195)
return (
added_entries.exclude(
Exists(
self.changed_permissions_log.filter(
performed_by=OuterRef("performed_by"),
performed_on=OuterRef("performed_on"),
action_type="removed",
created_at__gt=OuterRef("created_at"),
created_at__lte=OuterRef("created_at") + timedelta(milliseconds=10),
)
)
)
.order_by("performed_on", "group")
# Only count promotion of each user to the same group once
.distinct("performed_on", "group")
.count()
)
@property
def badges_translation_level(self):
thresholds = settings.BADGES_TRANSLATION_THRESHOLDS
for level in range(len(thresholds) - 1):
if thresholds[level] <= self.badges_translation_count < thresholds[level + 1]:
return level + 1
return 0
@property
def badges_review_level(self):
thresholds = settings.BADGES_REVIEW_THRESHOLDS
for level in range(len(thresholds) - 1):
if thresholds[level] <= self.badges_review_count < thresholds[level + 1]:
return level + 1
return 0
@property
def top_contributed_locale(self):
"""Locale the user has made the most contributions to."""
try:
return (
self.translation_set.values("locale__code")
.annotate(total=Count("locale__code"))
.distinct()
.order_by("-total")
.first()["locale__code"]
)
except TypeError:
# This error is raised if `top_contribution` is null. That happens if the user
# has never contributed to any locales.
return None
def can_translate(self, locale, project):
"""Check if user has suitable permissions to translate in given locale or project/locale."""
from pontoon.base.models.project_locale import ProjectLocale
# Locale managers can translate all projects
if self.has_perm("base.can_manage_locale", locale):
return True
project_locale = ProjectLocale.objects.get(project=project, locale=locale)
if project_locale.has_custom_translators:
return self.has_perm("base.can_translate_project_locale", project_locale)
return self.has_perm("base.can_translate_locale", locale)
def has_one_contribution(self, locale):
"""Return True if the user has made just 1 contribution to the locale."""
return (
self.translation_set.filter(locale=locale)
.exclude(entity__resource__project__system_project=True)
.count()
== 1
)
@property
def notification_list(self):
"""A list of notifications to display in the notifications menu."""
notifications = self.notifications.prefetch_related(
"actor", "target", "action_object"
)
# In order to prefetch Resource and Project data for Entities, we need to split the
# QuerySet into two parts: one for comment notifications, which store Entity objects
# into the Notification.target field, and one for other notifications.
comment_query = {
"target_content_type": ContentType.objects.get(app_label="base", model="entity")
}
comment_notifications = notifications.filter(**comment_query).prefetch_related(
"target__resource__project"
)
other_notifications = notifications.exclude(**comment_query)
notifications = list(comment_notifications) + list(other_notifications)
notifications.sort(key=lambda x: x.timestamp, reverse=True)
return notifications
def menu_notifications(self, unread_count):
"""A list of notifications to display in the notifications menu."""
count = settings.NOTIFICATIONS_MAX_COUNT
if unread_count > count:
count = unread_count
return self.notifications.prefetch_related("actor", "target", "action_object")[
:count
]
def unread_notifications_display(self, unread_count):
"""Textual representation of the unread notifications count."""
if unread_count > 9:
return "9+"
return unread_count
@property
def serialized_notifications(self):
"""Serialized list of notifications to display in the notifications menu."""
unread_count = self.notifications.unread().count()
count = settings.NOTIFICATIONS_MAX_COUNT
notifications = []
if unread_count > count:
count = unread_count
for notification in self.notifications.prefetch_related(
"actor", "target", "action_object"
)[:count]:
actor = None
is_comment = False
if hasattr(notification.actor, "slug"):
if "new string" in notification.verb:
actor = {
"anchor": notification.actor.name,
"url": reverse(
"pontoon.translate.locale.agnostic",
kwargs={
"slug": notification.actor.slug,
"part": "all-resources",
},
)
+ "?status=missing,pretranslated",
}
else:
actor = {
"anchor": notification.actor.name,
"url": reverse(
"pontoon.projects.project",
kwargs={"slug": notification.actor.slug},
),
}
elif hasattr(notification.actor, "email"):
actor = {
"anchor": notification.actor.name_or_email,
"url": reverse(
"pontoon.contributors.contributor.username",
kwargs={"username": notification.actor.username},
),
}
target = None
if notification.target:
t = notification.target
# New string or Manual notification
if hasattr(t, "slug"):
target = {
"anchor": t.name,
"url": reverse(
"pontoon.projects.project",
kwargs={"slug": t.slug},
),
}
# Comment notifications
elif hasattr(t, "resource"):
is_comment = True
target = {
"anchor": t.resource.project.name,
"url": reverse(
"pontoon.translate",
kwargs={
"locale": notification.action_object.code,
"project": t.resource.project.slug,
"resource": t.resource.path,
},
)
+ f"?string={t.pk}",
}
notifications.append(
{
"id": notification.id,
"level": notification.level,
"unread": notification.unread,
"description": {
"content": notification.description,
"is_comment": is_comment,
},
"verb": notification.verb,
"date": notification.timestamp.strftime("%b %d, %Y %H:%M"),
"date_iso": notification.timestamp.isoformat(),
"actor": actor,
"target": target,
}
)
return {
"has_unread": unread_count > 0,
"notifications": notifications,
"unread_count": str(self.unread_notifications_display(unread_count)),
}
def is_subscribed_to_notification(self, notification):
"""
Determines if the user has email subscription to the given notification.
"""
profile = self.profile
category = notification.data.get("category") if notification.data else None
CATEGORY_TO_FIELD = {
"new_string": profile.new_string_notifications_email,
"project_deadline": profile.project_deadline_notifications_email,
"comment": profile.comment_notifications_email,
"unreviewed_suggestion": profile.unreviewed_suggestion_notifications_email,
"review": profile.review_notifications_email,
"new_contributor": profile.new_contributor_notifications_email,
}
return CATEGORY_TO_FIELD.get(category, False)
def user_serialize(self):
"""Serialize Project contact"""
return {
"avatar": self.gravatar_url_small,
"name": self.name_or_email,
"url": self.profile_url,
}
@property
def latest_action(self):
"""
Return the date of the latest user activity (translation submission or review).
"""
try:
return ActionLog.objects.filter(
performed_by=self,
action_type__startswith="translation:",
).latest("created_at")
except ActionLog.DoesNotExist:
return None
User.add_to_class("profile_url", user_profile_url)
User.add_to_class("gravatar_url", user_gravatar_url)
User.add_to_class("gravatar_url_small", user_gravatar_url_small)
User.add_to_class("name_or_email", user_name_or_email)
User.add_to_class("contact_email", user_contact_email)
User.add_to_class("display_name", user_display_name)
User.add_to_class("display_name_and_email", user_display_name_and_email)
User.add_to_class("display_name_or_blank", user_display_name_or_blank)
User.add_to_class("translator_for_locales", user_translator_for_locales)
User.add_to_class("manager_for_locales", user_manager_for_locales)
User.add_to_class("can_translate_locales", user_can_translate_locales)
User.add_to_class("can_manage_locales", user_can_manage_locales)
User.add_to_class("translated_projects", user_translated_projects)
User.add_to_class("role", user_role)
User.add_to_class("locale_role", user_locale_role)
User.add_to_class("banner", user_banner)
User.add_to_class("contributed_translations", contributed_translations)
User.add_to_class("badges_translation_count", badges_translation_count)
User.add_to_class("badges_review_count", badges_review_count)
User.add_to_class("badges_promotion_count", badges_promotion_count)
User.add_to_class("badges_translation_level", badges_translation_level)
User.add_to_class("badges_review_level", badges_review_level)
User.add_to_class("has_approved_translations", has_approved_translations)
User.add_to_class("top_contributed_locale", top_contributed_locale)
User.add_to_class("can_translate", can_translate)
User.add_to_class("has_one_contribution", has_one_contribution)
User.add_to_class("notification_list", notification_list)
User.add_to_class("menu_notifications", menu_notifications)
User.add_to_class("unread_notifications_display", unread_notifications_display)
User.add_to_class("serialized_notifications", serialized_notifications)
User.add_to_class("is_subscribed_to_notification", is_subscribed_to_notification)
User.add_to_class("serialize", user_serialize)
User.add_to_class("latest_action", latest_action)