diff --git a/src/core/admin.py b/src/core/admin.py index 48eeed926c9026fb2120cc19f9a37c22121081de..f60d9b884264e263cde4f31ce924e3eba9ffdfe3 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -155,11 +155,12 @@ class PlatformUserAdmin(UserAdmin): list_display = ['username', 'user_type'] list_filter = ['user_type', 'is_active', 'is_staff'] + search_fields = ['username', 'display_name', 'communication_channels__address'] fieldsets = ( (None, {'fields': ('username', 'slug', 'password', 'user_type', 'timezone')}), - ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), - ('Self Portrayal', {'fields': (('pronouns', 'show_name'), ('status', 'status_public'), ('avatar_url', 'avatar_config'))}), + ('Personal info', {'fields': ('first_name', 'last_name', 'display_name', 'email')}), + ('Self Portrayal', {'fields': (('pronouns', 'show_name'), ('status', 'status_public'), ('avatar', 'avatar_url', 'avatar_config'))}), ('Accessibility', {'fields': ('theme', 'no_animations', 'colorblind', 'high_contrast', 'tag_ignorelist')}), ('Disturbance Settings', {'fields': ('receive_dms', 'receive_dm_images', 'receive_audio', 'receive_video', 'autoaccept_contacts')}), ('Permissions', {'fields': ('is_active', 'shadow_banned', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po index dcf44fd942b408370ad2fb9a86a21bb534ab4299..983a986aba2ce99f648bffe1b91ebecbbe5a83d5 100644 --- a/src/core/locale/de/LC_MESSAGES/django.po +++ b/src/core/locale/de/LC_MESSAGES/django.po @@ -1923,9 +1923,16 @@ msgstr "Der Nutzer hat bereits ein Ticket genutzt, 'doppelt hält besser' ist hi msgid "ConferenceMemberTicket__token_already_used" msgstr "Dieses Ticket wurde bereits genutzt." +#, python-format +msgid "PlatformUser__avatar__help %(min_width)d, %(min_height)d, %(max_width)d, %(max_height)d" +msgstr "Das Avatar-Bild muss quadratisch sein, min %(min_width)dpx/%(min_height)dpx und max %(max_width)dpx/%(max_height)dpx." + msgid "PlatformUser__type-human" msgstr "Mensch" +msgid "PlatformUser__type-speaker" +msgstr "Vortragender (autom. importiert)" + msgid "PlatformUser__type-service" msgstr "Dienst (Service)" @@ -1947,12 +1954,21 @@ msgstr "Art des Benutzers" msgid "PlatformUser__type" msgstr "Typ" +msgid "PlatformUser__display_name__help" +msgstr "Wie soll der Nutzer angezeigt werden?" + +msgid "PlatformUser__display_name" +msgstr "Anzeigename" + msgid "PlatformUser__show_name__help" msgstr "soll der Nutzername überhaupt angezeigt werden" msgid "PlatformUser__show_name" msgstr "Namen anzeigen" +msgid "PlatformUser__avatar" +msgstr "Avatar" + msgid "PlatformUser__avatar_url__help" msgstr "URL zum Avatar-Bild" diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po index 7334f860cd9ab7cb71e5d22e63d2eae45948d595..8cde7d22c0cf1e1635943ca7c4446a9128cd8d24 100644 --- a/src/core/locale/en/LC_MESSAGES/django.po +++ b/src/core/locale/en/LC_MESSAGES/django.po @@ -1921,9 +1921,16 @@ msgstr "User has used a ticket already." msgid "ConferenceMemberTicket__token_already_used" msgstr "This ticket has already been used." +#, python-format +msgid "PlatformUser__avatar__help %(min_width)d, %(min_height)d, %(max_width)d, %(max_height)d" +msgstr "The avatar image must be square, min %(min_width)dpx/%(min_height)dpx and max %(max_width)dpx/%(max_height)dpx." + msgid "PlatformUser__type-human" msgstr "human" +msgid "PlatformUser__type-speaker" +msgstr "speaker (autom. imported)" + msgid "PlatformUser__type-service" msgstr "service" @@ -1945,12 +1952,21 @@ msgstr "type of the user" msgid "PlatformUser__type" msgstr "type" +msgid "PlatformUser__display_name__help" +msgstr "how shall the user be displayed in the frontend(s)?" + +msgid "PlatformUser__display_name" +msgstr "display name" + msgid "PlatformUser__show_name__help" msgstr "select whether a name shall be shown at all" msgid "PlatformUser__show_name" msgstr "show name" +msgid "PlatformUser__avatar" +msgstr "avatar" + msgid "PlatformUser__avatar_url__help" msgstr "URL to the avatar's image" diff --git a/src/core/migrations/0146_platformuser_speaker_changes.py b/src/core/migrations/0146_platformuser_speaker_changes.py new file mode 100644 index 0000000000000000000000000000000000000000..505eda713d16d802aac07840827daaceeb2e90b3 --- /dev/null +++ b/src/core/migrations/0146_platformuser_speaker_changes.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.6 on 2023-12-21 23:18 +from django.db.models import F + +import core.models.users +import core.validators +from django.db import migrations, models + + +def set_display_names(apps, schema_editor): + PlatformUser = apps.get_model("core", "PlatformUser") + PlatformUser.objects.all().update(display_name=F('username')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0145_alter_eventlikecount_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='platformuser', + name='accepted_speakersagreement', + ), + migrations.AddField( + model_name='platformuser', + name='avatar', + field=models.ImageField(blank=True, help_text=core.models.users.get_user_avatar_help_text, null=True, upload_to=core.models.users.get_user_avatar_filename, validators=[core.validators.ImageDimensionValidator(max_size=(1024, 1024), min_size=(64, 64), square=True)], verbose_name='PlatformUser__avatar'), + ), + migrations.AddField( + model_name='platformuser', + name='display_name', + field=models.CharField(blank=True, help_text='PlatformUser__display_name__help', max_length=100, verbose_name='PlatformUser__display_name'), + ), + migrations.AlterField( + model_name='platformuser', + name='user_type', + field=models.CharField(choices=[('human', 'PlatformUser__type-human'), ('speaker', 'PlatformUser__type-speaker'), ('service', 'PlatformUser__type-service'), ('assembly', 'PlatformUser__type-assembly'), ('bot', 'PlatformUser__type-bot')], default='human', help_text='PlatformUser__type__help', max_length=10, verbose_name='PlatformUser__type'), + ), + migrations.RunPython(set_display_names, migrations.RunPython.noop), + ] diff --git a/src/core/models/users.py b/src/core/models/users.py index ad0d8f576bd48458d367ac67c3a90e94bafa46e7..1939e84ed6337a66f23add6623466be4e372aa6e 100644 --- a/src/core/models/users.py +++ b/src/core/models/users.py @@ -1,5 +1,6 @@ import logging import re +from pathlib import Path from typing import TYPE_CHECKING, Any from uuid import uuid4 @@ -24,14 +25,36 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from ..fields import ConferenceReference +from ..utils import download_from_url +from ..validators import ImageDimensionValidator if TYPE_CHECKING: from core.models import ConferenceMember +logger = logging.getLogger(__name__) + + +def get_user_avatar_filename(instance: 'PlatformUser', filename: Path | str): + ext = str(filename).rsplit('.', maxsplit=1)[-1] + return Path('avatars').joinpath(f'{instance.id}.{ext}') + + +def get_user_avatar_help_text() -> str: + return ( + _('PlatformUser__avatar__help %(min_width)d, %(min_height)d, %(max_width)d, %(max_height)d') + % { + 'min_width': settings.USER_AVATAR_WIDTH_MINIMUM, + 'min_height': settings.USER_AVATAR_HEIGHT_MINIMUM, + 'max_width': settings.USER_AVATAR_WIDTH_MAXIMUM, + 'max_height': settings.USER_AVATAR_HEIGHT_MAXIMUM, + }, + ) + class PlatformUser(AbstractUser): class Type(models.TextChoices): HUMAN = ('human', _('PlatformUser__type-human')) + SPEAKER = ('speaker', _('PlatformUser__type-speaker')) SERVICE = ('service', _('PlatformUser__type-service')) ASSEMBLY = ('assembly', _('PlatformUser__type-assembly')) BOT = ('bot', _('PlatformUser__type-bot')) @@ -40,6 +63,9 @@ class PlatformUser(AbstractUser): DARK = ('dark', _('PlatformUser__theme-dark')) LIGHT = ('light', _('PlatformUser__theme-light')) + PERSON_TYPES = [Type.HUMAN, Type.SPEAKER] + """User types which can be shown as persons on e.g. the frontend.""" + INTERACTIVE_TYPES = [Type.HUMAN, Type.BOT] """User types which can be interactive (and can thus have an avatar and/or modified by e.g. the Engelsystem).""" @@ -49,9 +75,30 @@ class PlatformUser(AbstractUser): uuid = models.UUIDField(default=uuid4, unique=True) slug = models.SlugField(blank=False, unique=True) + display_name = models.CharField(max_length=100, blank=True, help_text=_('PlatformUser__display_name__help'), verbose_name=_('PlatformUser__display_name')) # self portrayal show_name = models.BooleanField(null=True, help_text=_('PlatformUser__show_name__help'), verbose_name=_('PlatformUser__show_name')) + avatar = models.ImageField( + blank=True, + null=True, + help_text=get_user_avatar_help_text, + upload_to=get_user_avatar_filename, + verbose_name=_('PlatformUser__avatar'), + validators=[ + ImageDimensionValidator( + min_size=( + settings.USER_AVATAR_WIDTH_MINIMUM, + settings.USER_AVATAR_HEIGHT_MINIMUM, + ), + max_size=( + settings.USER_AVATAR_WIDTH_MAXIMUM, + settings.USER_AVATAR_HEIGHT_MAXIMUM, + ), + square=True, + ), + ], + ) avatar_url = models.URLField(blank=True, null=True, help_text=_('PlatformUser__avatar_url__help'), verbose_name=_('PlatformUser__avatar_url')) avatar_config = models.JSONField(blank=True, null=True, help_text=_('PlatformUser__avatar_config__help'), verbose_name=_('PlatformUser__avatar_config')) pronouns = models.CharField(max_length=50, blank=True, default='', help_text=_('PlatformUser__pronouns__help'), verbose_name=_('PlatformUser__pronouns')) @@ -264,10 +311,14 @@ class PlatformUser(AbstractUser): return conference.users.filter(user=self, is_staff=True).exists() @property - def display_name(self): + def is_person(self): + return self.user_type in self.PERSON_TYPES + + def get_display_name(self): + result = self.username if self.user_type != self.Type.SPEAKER else (self.first_name + ' ' + self.last_name).strip() if self.pronouns: - return f'{self.username} ({self.pronouns})' - return self.username + result += f' ({self.pronouns})' + return result @property def guardians(self): @@ -301,6 +352,9 @@ class PlatformUser(AbstractUser): if not self.slug: self.generate_slug() + # update the display name + self.display_name = self.get_display_name() + return super().save(*args, update_fields=update_fields, **kwargs) def has_conference_staffpermission(self, conference, *perms, need_all=False): @@ -365,6 +419,18 @@ class PlatformUser(AbstractUser): .first() ) + def load_avatar_from_url(self, url: str, save: bool = True): + logger.debug('Loading avatar for user %s (%s) from URL: %s', self.username, self.pk, url) + + filename, data = download_from_url(url) + target_filename = get_user_avatar_filename(self, filename) + + logger.debug('Storing avatar for user %s (%s): %s', self.username, self.pk, target_filename) + self.avatar.save(target_filename, data) + if save: + self.save(update_fields=['avatar']) + logger.debug('Updated avatar for user %s (%s) as "%s" from URL: %s', self.username, self.pk, target_filename, url) + class UserContact(models.Model): user = models.ForeignKey(PlatformUser, related_name='contacts', on_delete=models.CASCADE) diff --git a/src/core/utils.py b/src/core/utils.py index 8332304b94ac9ac29c4c68009ea67e151b540b36..f7a3ca8cbd381d482afd5ffdb055597c2bf38656 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -5,11 +5,14 @@ import shutil import subprocess import tempfile from datetime import UTC, datetime, timedelta +from io import BytesIO from pathlib import Path from string import ascii_letters, digits -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Tuple, Union from urllib.parse import parse_qs, urlparse, urlunparse +import requests + from django.urls import NoReverseMatch from django.utils.functional import cached_property from django.utils.html import strip_tags @@ -191,6 +194,35 @@ def resolve_internal_url(url: str, fallback_as_is: bool = True) -> Optional[str] return url if fallback_as_is else None +def download_from_url(url: str) -> Tuple[str, bytes]: + # let requests library fetch the URL + r = requests.get(url) + + # bail out if response is not 200 + r.raise_for_status() + + # try parsing the filename from the Content-Disposition header + filename = None + if hdr := r.headers.get('Content-Disposition'): + # it's surprising that parsing via e.g. EmailMessage works with Wikipedia + # but fails in unexpected/unrecognizable ways for frab ... + hdr_split = hdr.split(';', maxsplit=1) + if len(hdr_split) > 1 and hdr_split[1].startswith('filename='): + filename = hdr_split[1][len('filename=') :].rsplit('/', maxsplit=1)[-1] + + # if the above fails, try guessing the filename from the URL's path + if not filename: + url_parsed = urlparse(url) + filename = url_parsed.path.rsplit('/')[-1] + + # read the binary content + r.raw.decode_content = True + data = BytesIO(r.content) + + # return the result + return filename, data + + class GitCloneError(Exception): pass diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py index 0c445f35199a2a2d84dca768afdfae1859e1a23e..262abdcd744d3120331e723228ed5968122f6eeb 100644 --- a/src/hub/settings/base.py +++ b/src/hub/settings/base.py @@ -102,6 +102,10 @@ env = environ.FileAwareEnv( BADGE_IMAGE_HEIGHT_MINIMUM=(int, 256), BADGE_IMAGE_WIDTH_MAXIMUM=(int, 512), BADGE_IMAGE_HEIGHT_MAXIMUM=(int, 512), + USER_AVATAR_WIDTH_MINIMUM=(int, 64), + USER_AVATAR_HEIGHT_MINIMUM=(int, 64), + USER_AVATAR_WIDTH_MAXIMUM=(int, 1024), + USER_AVATAR_HEIGHT_MAXIMUM=(int, 1024), API_USERS=(list, []), DISABLE_REQUEST_LOGGING=(bool, False), MOLLY_GUARD=(bool, True), @@ -614,3 +618,8 @@ BADGE_IMAGE_WIDTH_MINIMUM = env('BADGE_IMAGE_WIDTH_MINIMUM') BADGE_IMAGE_HEIGHT_MINIMUM = env('BADGE_IMAGE_HEIGHT_MINIMUM') BADGE_IMAGE_WIDTH_MAXIMUM = env('BADGE_IMAGE_WIDTH_MAXIMUM') BADGE_IMAGE_HEIGHT_MAXIMUM = env('BADGE_IMAGE_HEIGHT_MAXIMUM') + +USER_AVATAR_WIDTH_MINIMUM = env('USER_AVATAR_WIDTH_MINIMUM') +USER_AVATAR_HEIGHT_MINIMUM = env('USER_AVATAR_HEIGHT_MINIMUM') +USER_AVATAR_WIDTH_MAXIMUM = env('USER_AVATAR_WIDTH_MAXIMUM') +USER_AVATAR_HEIGHT_MAXIMUM = env('USER_AVATAR_HEIGHT_MAXIMUM')