diff --git a/src/api/schedule.py b/src/api/schedule.py index d518eee8c54efc101eaa39ba1f4274b8a383b45a..991605607819aaa35d887c07f5639b8a8667dc78 100644 --- a/src/api/schedule.py +++ b/src/api/schedule.py @@ -110,29 +110,30 @@ class ScheduleEncoder(json.JSONEncoder): if isinstance(p, PlatformUser): member: ConferenceMember = p.conferences.first() # TODO search for correct conference - name = p.username # TODO use p.participant.get_display_name() + name = p.get_display_name() + return { 'guid': p.uuid, 'name': name, 'public_name': name, - 'avatar_url': p.avatar_url, + 'avatar': p.avatar_url, 'biography': member.description if member else None, - # 'links': person.links, # TODO - # 'url': p.get_absolute_url(), + # 'links': p.links, # TODO + 'url': p.get_absolute_url(), } if isinstance(p, EventParticipant): member: ConferenceMember = p.event.conference.users.filter(pk=p.participant.id).first() - name = p.participant.username # TODO use p.participant.get_display_name() + name = p.participant.get_display_name() return { 'guid': p.participant.uuid, 'name': name, 'public_name': name, - 'avatar_url': p.participant.avatar_url, + 'avatar': p.participant.avatar_url, 'biography': member.description if member else '', - # 'links': person.participant.links, # TODO - # 'url': p.participant.get_absolute_url(), + # 'links': p.participant.links, # TODO + 'url': p.participant.get_absolute_url(), } # we assume it is a dict, but we normally should never get here @@ -140,7 +141,7 @@ class ScheduleEncoder(json.JSONEncoder): 'guid': p.get('guid', None), 'id': p.get('id', None), 'name': p.get('name', p.get('public_name')), - 'avatar_url': p.get('avatar', None), + 'avatar': p.get('avatar', None), 'biography': p.get('biography') or p.get('description', ''), 'links': p.get('links', []), } diff --git a/src/api/tests/schedule.py b/src/api/tests/schedule.py index 8cd756bf377bce55330b9d79358aa22f5d1a1fee..3fa2c6d7e32b77ac512618798f806b370f897b64 100644 --- a/src/api/tests/schedule.py +++ b/src/api/tests/schedule.py @@ -42,8 +42,8 @@ class ScheduleTest(TestCase): self.token.save() def _test_schedule_export(self, url_suffix: str, expected_content_type: str, content_parser): - url = reverse('api:conference-schedule') + url_suffix - resp = self.client.get(url) + schedule_url = reverse('api:conference-schedule') + url_suffix + resp = self.client.get(schedule_url) self.assertEqual(200, resp.status_code) self.assertEqual(resp['Content-Type'], expected_content_type) content_str = resp.content.decode('utf-8') @@ -68,14 +68,22 @@ class ScheduleTest(TestCase): schedule_duration=timedelta(minutes=42), ) ev1.save() + event_url = reverse('api:event-schedule', kwargs={'pk': ev1.pk}) # check that event is in the output - resp = self.client.get(url) + resp = self.client.get(schedule_url) self.assertEqual(200, resp.status_code) content_str = resp.content.decode('utf-8') content_parser(content_str) self.assertIn(ev1.name, content_str) + if url_suffix == '.json': + resp = self.client.get(event_url) + self.assertEqual(200, resp.status_code) + content_str = resp.content.decode('utf-8') + content_parser(content_str) + self.assertIn(ev1.name, content_str) + # create too-short and non-public events ev2a = Event( conference=self.conf, @@ -99,7 +107,7 @@ class ScheduleTest(TestCase): ev2b.save() # check that none of the new events are in the output - resp = self.client.get(url) + resp = self.client.get(schedule_url) self.assertEqual(200, resp.status_code) content_str = resp.content.decode('utf-8') content_parser(content_str) @@ -107,6 +115,40 @@ class ScheduleTest(TestCase): self.assertNotIn(ev2a.name, content_str) self.assertNotIn(ev2b.name, content_str) + # Add addintional data to event 1 to append the persons + ev1.additional_data = { + 'id': 12334, + 'url': 'https://fahrplan.events.ccc.de/congress/2023/fahrplan/events/12334.html', + 'slug': 'Vogc-12334-eroffnung', + 'type': 'lecture', + 'track': 'CCC', + 'persons': [ + { + 'id': 987654, + 'guid': 'aaaaaaaa-0000-000d-d00d-1d1d1d1d1d1d', + 'name': 'arthur', + 'links': [], + 'biography': 'Test speaker', + 'public_name': 'arthur', + } + ], + } + ev1.save() + + # check that event is in the output + resp = self.client.get(schedule_url) + self.assertEqual(200, resp.status_code) + content_str = resp.content.decode('utf-8') + content_parser(content_str) + self.assertIn(ev1.name, content_str) + + if url_suffix == '.json': + resp = self.client.get(event_url) + self.assertEqual(200, resp.status_code) + content_str = resp.content.decode('utf-8') + content_parser(content_str) + self.assertIn(ev1.name, content_str) + def test_export_json(self): self._test_schedule_export('.json', 'application/json', json.loads) diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po index 8f393e510154b53d2ef8f3da70945d9ced02f105..c237b885c5dc97eddd5b0cf9a3036971dc8ce12d 100644 --- a/src/backoffice/locale/de/LC_MESSAGES/django.po +++ b/src/backoffice/locale/de/LC_MESSAGES/django.po @@ -960,6 +960,12 @@ msgstr "" msgid "Assembly__is_remote" msgstr "" +msgid "moderation__public_link" +msgstr "öffentliche Ansicht" + +msgid "moderation__notpublic" +msgstr "nicht veröffentlicht" + msgid "moderation__assembly__cannotunhide" msgstr "Eine versteckte Assembly muss vom Assembly-Team wieder in den passenden Status zurückversetzt werden (multiple Optionen)." @@ -1073,6 +1079,14 @@ msgstr "Projekt Banner" msgid "PlatformUser__type" msgstr "Typ" +msgid "username" +msgstr "Benutzername" + +# use translation from core +msgid "PlatformUser__display_name" +msgstr "" + +# use translation from core/Django msgid "date joined" msgstr "" @@ -1086,15 +1100,20 @@ msgstr "" msgid "BulletinBoardEntrys" msgstr "Boardeinträge" +# use translation from core +msgid "EventParticipant__is_accepted" +msgstr "" + +# use translation from core +msgid "EventParticipant__is_public" +msgstr "" + msgid "not active" msgstr "nicht aktiv" msgid "PlatformUser__registration" msgstr "Registrierung" -msgid "username" -msgstr "Benutzername" - msgid "StaticPage__title" msgstr "Überschrift" @@ -1267,6 +1286,46 @@ msgstr "Anlage derzeit nicht möglich." msgid "Room-new-other__create" msgstr "sonstigen Raum anlegen" +msgid "backoffice_schedules_tabularview" +msgstr "tabellarische Ansicht" + +# use translation from core +msgid "ScheduleSource__import_url" +msgstr "" + +# use translation from core +msgid "ScheduleSource__import_type" +msgstr "" + +# use translation from core +msgid "ScheduleSource__last_import" +msgstr "" + +# use translation from core +msgid "ScheduleSource__import_frequency" +msgstr "" + +msgid "ScheduleSource__has_running_import" +msgstr "laufender Import" + +msgid "ScheduleSource__is_due" +msgstr "ist fällig" + +msgid "ScheduleSource__beyond_deadline" +msgstr "über Deadline" + +msgid "ScheduleSource__latest_import" +msgstr "aktuellster Import" + +msgid "start" +msgstr "" + +msgid "end" +msgstr "" + +msgid "state" +msgstr "" + msgid "Self-organized content" msgstr "Self-organized Inhalte" diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po index 964da351cdfc5905c33b3e1cd0e7dd86fca4b004..9faebeab3c712d14ab7e47e3056693802d3e35b4 100644 --- a/src/backoffice/locale/en/LC_MESSAGES/django.po +++ b/src/backoffice/locale/en/LC_MESSAGES/django.po @@ -963,6 +963,12 @@ msgstr "" msgid "Assembly__is_remote" msgstr "" +msgid "moderation__public_link" +msgstr "public link" + +msgid "moderation__notpublic" +msgstr "not public" + msgid "moderation__assembly__cannotunhide" msgstr "A hidden assembly can only be reversed by the assembly team as there are multiple valid states/types." @@ -1077,6 +1083,13 @@ msgstr "project banner" msgid "PlatformUser__type" msgstr "" +msgid "username" +msgstr "" + +# use translation from core +msgid "PlatformUser__display_name" +msgstr "" + # use translation from core msgid "date joined" msgstr "" @@ -1091,15 +1104,20 @@ msgstr "" msgid "BulletinBoardEntrys" msgstr "board entries" +# use translation from core +msgid "EventParticipant__is_accepted" +msgstr "" + +# use translation from core +msgid "EventParticipant__is_public" +msgstr "" + msgid "not active" msgstr "" msgid "PlatformUser__registration" msgstr "registration" -msgid "username" -msgstr "" - # use translation from core msgid "StaticPage__title" msgstr "" @@ -1275,6 +1293,46 @@ msgstr "not available" msgid "Room-new-other__create" msgstr "create other room" +msgid "backoffice_schedules_tabularview" +msgstr "tabular view" + +# use translation from core +msgid "ScheduleSource__import_url" +msgstr "" + +# use translation from core +msgid "ScheduleSource__import_type" +msgstr "" + +# use translation from core +msgid "ScheduleSource__last_import" +msgstr "" + +# use translation from core +msgid "ScheduleSource__import_frequency" +msgstr "" + +msgid "ScheduleSource__has_running_import" +msgstr "import running" + +msgid "ScheduleSource__is_due" +msgstr "is due" + +msgid "ScheduleSource__beyond_deadline" +msgstr "beyond deadline" + +msgid "ScheduleSource__latest_import" +msgstr "latest import" + +msgid "start" +msgstr "" + +msgid "end" +msgstr "" + +msgid "state" +msgstr "" + msgid "Self-organized content" msgstr "Self-organized content" diff --git a/src/backoffice/templates/backoffice/moderation_assembly-detail.html b/src/backoffice/templates/backoffice/moderation_assembly-detail.html index 750c917236c2c54a47ce2d1f9d5bbee2d79b2d70..1b27538235c4cebd4f25e7b79dd7d2efe2b43e7f 100644 --- a/src/backoffice/templates/backoffice/moderation_assembly-detail.html +++ b/src/backoffice/templates/backoffice/moderation_assembly-detail.html @@ -2,6 +2,7 @@ {% load humanize %} {% load i18n %} {% load static %} +{% load hub_absolute %} {% block content %} @@ -66,6 +67,11 @@ </div> <div class="col-md-3"> + <a class="d-block btn btn-outline-{% if event.is_public %}success{% else %}warning{% endif %} mb-3" href="{% hub_absolute 'plainui:assembly' assembly_slug=assembly.slug %}"> + {% trans "moderation__public_link" %} + {% if not assembly.is_public %}<br><small class="text-muted fst-italic">{% trans "moderation__notpublic" %}</small>{% endif %} + </a> + <div class="card border-primary-subtle"> <div class="card-header bg-primary-subtle text-bg-primary">{% trans "nav_moderation" %}</div> <div class="card-body" id="moderation"> diff --git a/src/backoffice/templates/backoffice/moderation_event-detail.html b/src/backoffice/templates/backoffice/moderation_event-detail.html index 10a3c1c89175e793bf83111c53c8e641354d0362..35d7db8786f6379500fb88a1bec9de8c1e2e314f 100644 --- a/src/backoffice/templates/backoffice/moderation_event-detail.html +++ b/src/backoffice/templates/backoffice/moderation_event-detail.html @@ -2,10 +2,11 @@ {% load humanize %} {% load i18n %} {% load c3assemblies %} +{% load hub_absolute %} {% block content %} - <h1>Event "{{ object.slug }}": {{ object.name }}</h1> + <h1>{% trans "Event" %} "{{ object.slug }}": {{ object.name }}</h1> <div class="row"> <div class="col-md-9"> @@ -51,7 +52,7 @@ <dt class="col-sm-3">{% trans "Event__owner" %}:</dt> <dd class="col-sm-9">{% if object.owner %}<a href="{% url "backoffice:moderation-user-detail" pk=object.owner.id %}">{{ object.owner.username }}</a>{% else %}-/-{% endif %}</dd> <dt class="col-sm-3">{% trans "Event__public_speakers" %}:</dt> - <dd class="col-sm-9">{% for speaker in object.public_speaker %}- <a href="{% url "backoffice:moderation-user-detail" pk=speaker.participant.id %}">{{ speaker.participant.username }}</a><br>{% empty %}-/-{% endfor %}</dd> + <dd class="col-sm-9">{% for speaker in object.public_speakers %}- <a href="{% url "backoffice:moderation-user-detail" pk=speaker.participant.id %}" title="{{ speaker.participant.username }}">{{ speaker.participant.get_display_name }}</a><br>{% empty %}-/-{% endfor %}</dd> </dl> </div> </div> @@ -79,6 +80,11 @@ </div> <div class="col-md-3"> + <a class="d-block btn btn-outline-{% if event.is_public %}success{% else %}warning{% endif %} mb-3" href="{% hub_absolute 'plainui:event' event_slug=event.slug %}"> + {% trans "moderation__public_link" %} + {% if not event.is_public %}<br><small class="text-muted fst-italic">{% trans "moderation__notpublic" %}</small>{% endif %} + </a> + <div class="card border-primary-subtle"> <div class="card-header bg-primary-subtle text-bg-primary">{% trans "nav_moderation" %}</div> <div class="card-body" id="moderation"> diff --git a/src/backoffice/templates/backoffice/moderation_user-detail.html b/src/backoffice/templates/backoffice/moderation_user-detail.html index 4491508a195fc7783527030ff5c628cb0fb72a5f..77bc599934d957b6a7dfd7097e252379b3e353c8 100644 --- a/src/backoffice/templates/backoffice/moderation_user-detail.html +++ b/src/backoffice/templates/backoffice/moderation_user-detail.html @@ -20,6 +20,10 @@ <dd class="col-sm-9">{{ object.id }}</dd> <dt class="col-sm-3">UUID:</dt> <dd class="col-sm-9">{{ object.uuid }}</dd> + <dt class="col-sm-3">{% trans "username" %}:</dt> + <dd class="col-sm-9">{{ object.username|default:"-/-" }}</dd> + <dt class="col-sm-3">{% trans "PlatformUser__display_name" %}:</dt> + <dd class="col-sm-9">{{ object.display_name|default:"-/-" }}</dd> <dt class="col-sm-3">{% trans "date joined" %}:</dt> <dd class="col-sm-9">{{ object.date_joined }} ({{ object.date_joined|naturaltime }})</dd> <dt class="col-sm-3">{% trans "active" %}:</dt> @@ -51,7 +55,7 @@ {% endfor %}</ul> </div> </div> - + <div class="row"> <div class="col-md-6"> @@ -86,7 +90,32 @@ </div> </div> </div> - + + <div class="row"> + <div class="col-md-12"> + + <div class="card mb-3"> + <div class="card-header">{% trans "Events" %} (beteiligt)</div> + <div class="card-body"> + <ul>{% for e in object.events.all %} + <li> + <strong>{{ e.get_role_display }}</strong> + {% if e.is_accepted %}<span class="badge text-bg-success">{% trans "EventParticipant__is_accepted" %}</span> + {% else %}<span class="badge text-bg-warning text-decoration-line-through">{% trans "EventParticipant__is_accepted" %}</span> + {% endif %} + {% if e.is_public %}<span class="badge text-bg-success">{% trans "EventParticipant__is_public" %}</span> + {% else %}<span class="badge text-bg-warning text-decoration-line-through">{% trans "EventParticipant__is_public" %}</span> + {% endif %} + <a href="{% url "backoffice:moderation-event-detail" pk=e.event.pk %}">{{ e.event.name }}</a><br> + <small>{{ e.event.schedule_start }} .. {{ e.event.schedule_end }} ({{ e.event.schedule_duration|naturaltimespan }})</small> + </li> + {% empty %} + <span class="text-muted">-/-</span> + {% endfor %}</ul> + </div> + </div> + </div> + </div> </div> <div class="col-md-3"> <div class="card border-primary-subtle mb-3"> @@ -106,7 +135,7 @@ <a class="mb-3 w-100 btn btn-outline-primary" href="{% url "backoffice:moderation-user-rename" pk=object.pk %}"><i class="bi bi-mask"></i> rename</a> </div> </div> - + <div class="card mb-3"> <div class="card-header">DMs</div> <div class="card-body"> diff --git a/src/backoffice/templates/backoffice/moderation_user-item.html b/src/backoffice/templates/backoffice/moderation_user-item.html index 2a755f0e4d73dd1a688a246ef2dbfb9b2e3ca2d0..05dc57661d06e04afc8bec341faec782430f1331 100644 --- a/src/backoffice/templates/backoffice/moderation_user-item.html +++ b/src/backoffice/templates/backoffice/moderation_user-item.html @@ -2,7 +2,7 @@ {% load humanize %} <div class="card mb-1"> <div class="card-body"> - User "<a href="{% url 'backoffice:moderation-user-detail' pk=user.pk %}" class="fw-bold">{{ user.username }}</a>" + User <a href="{% url 'backoffice:moderation-user-detail' pk=user.pk %}" class="fw-bold"><code>{{ user.username }}</code> "{{ user.display_name }}"</a> <br> {% if user.is_active %}<span class="text-success">{% trans "active" %}</span>{% else %}<span class="text-danger">{% trans "not active" %}</span>{% endif %} diff --git a/src/backoffice/templates/backoffice/schedules_index.html b/src/backoffice/templates/backoffice/schedules_index.html index 02b783c9cf055461a07c8140848a434d11b7c8a0..d3f899751982df2d412472643c01844b90544932 100644 --- a/src/backoffice/templates/backoffice/schedules_index.html +++ b/src/backoffice/templates/backoffice/schedules_index.html @@ -1,13 +1,72 @@ {% extends 'backoffice/base.html' %} {% load i18n %} +{% load humanize %} {% block content %} -<div class="card"> - <div class="card-body"> - Sources: <a href="{% url 'backoffice:schedulesource-list' %}">{{ sources_count }}</a> + <div class="mb-3 align-content-end"> + <a class="btn btn-primary" href="{% url 'backoffice:schedulesource-list' %}">{% trans "backoffice_schedules_tabularview" %}</a> </div> -</div> -<div class="alert alert-warning my-3">TODO</div> -{% endblock %} \ No newline at end of file + <div class="row row-cols-1 row-cols-md-3 g-4"> + + {% for source in sources %} + {% with source.latest_import as latest %} + <div class="col"> + <div class="card{% if source.has_running_import %} border-primary{% elif source.is_due %} border-warning{% endif %}"> + <div class="card-header{% if source.has_running_import %} text-bg-primary{% elif source.is_due %} text-bg-warning{% endif %}"> + <a href="{% url 'backoffice:schedulesource-detail' pk=source.pk %}" class="float-end ms-3">Details</a> + {% if source.assembly %} + <abbr title="{% trans "Assembly" %}: {{ source.assembly.name }}">{{ source.assembly.slug }}</abbr> + {% else %} + <span title="Wildcard">*</span> + {% endif %} + </div> + <div class="card-body"> + <p> + <span class="text-muted small">{% trans "ScheduleSource__import_url" %} (<span title="{% trans "ScheduleSource__import_type" %}">{{ source.import_type }}</span>):</span><br> + {{ source.import_url_masked }} + </p> + <p> + <span class="text-muted small">{% trans "ScheduleSource__last_import" %}:</span><br> + {{ source.last_import|naturaltime|default:"-/-" }} + </p> + <p> + <span class="text-muted small">{% trans "ScheduleSource__import_frequency" %}:</span><br> + {% if source.frequency %}{{ source.frequency|naturaltimespan }} + {% else %}-/-{% endif %} + </p> + {% if source.has_running_import %} + <p class="text-primary"> + <i class="bi bi-house-gear"></i> {% trans "ScheduleSource__has_running_import" %} + </p> + {% elif source.is_due %} + <p class="text-warning"> + <i class="bi bi-clock-history"></i> {% trans "ScheduleSource__is_due" %} + </p> + {% endif %} + {% if latest.is_beyond_deadline %} + <p class="text-danger"> + <i class="bi bi-hourglass-bottom"></i> {% trans "ScheduleSource__beyond_deadline" %} + </p> + {% endif %} + </div> + <div class="card-footer"> + <p> + <span class="text-muted small fw-bold">{% trans "ScheduleSource__latest_import" %}</span><br> + <span class="text-muted small">{% trans "start" %}:</span> + <abbr title="{{ latest.start }}">{{ latest.start|naturaltime|default:"-/-" }}</abbr><br> + <span class="text-muted small">{% trans "end" %}:</span> + <abbr title="{{ latest.end }}">{{ latest.end|naturaltime|default:"-/-" }}</abbr><br> + <span class="text-muted small">{% trans "state" %}:</span> + {{ latest.get_state_display }}<br> + </p> + </div> + </div> + </div> + {% endwith %} + {% endfor %} + + </div> + +{% endblock %} diff --git a/src/backoffice/views/moderation.py b/src/backoffice/views/moderation.py index e8bc456dbb5de94c082f07bd67bc712548568119..01c233b86ec4e19c2202a7fe2d858db05514e762 100644 --- a/src/backoffice/views/moderation.py +++ b/src/backoffice/views/moderation.py @@ -102,11 +102,13 @@ def lookup_moderation_items(conference: Conference, query: str): q_slug = Q() q_name = Q() q_username = Q() + q_display_name = Q() for word in words: q_slug = q_slug & Q(slug__icontains=word) q_name = q_name & Q(name__icontains=word) q_username = q_username & Q(username__icontains=word) + q_display_name = q_display_name & Q(display_name__icontains=word) for a in conference.assemblies.filter(q_slug).values('pk'): candidates.append(('assembly', a['pk'])) @@ -114,7 +116,7 @@ def lookup_moderation_items(conference: Conference, query: str): candidates.append(('badge', b['pk'])) for e in conference.events.filter(q_slug).values('pk'): candidates.append(('event', e['pk'])) - for u in PlatformUser.objects.filter(conferences__conference=conference).filter(q_username | q_slug).values('pk'): + for u in PlatformUser.objects.filter(conferences__conference=conference).filter(q_username | q_display_name | q_slug).values('pk'): candidates.append(('user', u['pk'])) for p in conference.pages.filter(q_slug).values('pk'): candidates.append(('wiki', p['pk'])) diff --git a/src/backoffice/views/schedules.py b/src/backoffice/views/schedules.py index d7f0c454477c90c03012246d031a1832b558c8e9..34d7c5755ac60f95d6d267cdec7173b5fdaa375c 100644 --- a/src/backoffice/views/schedules.py +++ b/src/backoffice/views/schedules.py @@ -26,7 +26,7 @@ class SchedulesIndexView(ScheduleAdminMixin, TemplateView): def get_context_data(self, *args, **kwargs): ctx = super().get_context_data(*args, **kwargs) - ctx['sources_count'] = ScheduleSource.objects.count() + ctx['sources'] = self.conference.schedule_sources.select_related('assembly').all() return ctx diff --git a/src/core/admin.py b/src/core/admin.py index 5ef0c587cad881478bbc05493bb979e7d99b0894..3b6d86532ecc2cfa0854e1020eaa41b6dc2e1d1a 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.contrib import admin from django.contrib.admin import FieldListFilter from django.contrib.auth.admin import UserAdmin @@ -155,12 +156,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')}), - ('Legal stuff', {'fields': ('accepted_speakersagreement',)}), - ('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')}), @@ -1067,10 +1068,10 @@ class ScheduleSourceImportAdmin(admin.ModelAdmin): list_filter = ['schedule_source', 'state'] def has_add_permission(self, *args, **kwargs): - return False + return settings.DEBUG def has_change_permission(self, *args, **kwargs): - return False + return settings.DEBUG class ScheduleSourceMappingAdmin(admin.ModelAdmin): diff --git a/src/core/forms.py b/src/core/forms.py index 7049ffc0aa3b516ad7cfa1a9f1f86aa5acb97993..5e4f619ec4710069f8c62e6d42c82a1a0615ed45 100644 --- a/src/core/forms.py +++ b/src/core/forms.py @@ -130,6 +130,8 @@ class RegistrationForm(UserCreationForm): def clean(self): if self.request and self.request.limited: raise ValidationError(_('Registration__rate-limited')) + if (username := self.cleaned_data.get('username')) and username.startswith('_'): + raise ValidationError({'username': _('Registration__username__nounderscore')}) return super().clean() def send_mail( diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po index 4a88a6e248afdc2d6827a5a2590c652b01b7cb32..d4368df364e92fd7dd717f13d7199b25aa356c3a 100644 --- a/src/core/locale/de/LC_MESSAGES/django.po +++ b/src/core/locale/de/LC_MESSAGES/django.po @@ -131,6 +131,9 @@ msgstr "Deine Kontakt Email für diese Veranstaltung (nicht öffentlich)" msgid "Registration__rate-limited" msgstr "Zu viele Request (Rate-Limited), bitte einen Moment warten!" +msgid "Registration__username__nounderscore" +msgstr "Der Benutzername darf nicht mit einem Unterstrich beginnen." + msgid "Tags" msgstr "" @@ -1923,9 +1926,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,11 +1957,11 @@ msgstr "Art des Benutzers" msgid "PlatformUser__type" msgstr "Typ" -msgid "PlatformUser__accepted_speakersagreement__help" -msgstr "Nutzer hat das Speaker's Agreement unterzeichnet" +msgid "PlatformUser__display_name__help" +msgstr "Wie soll der Nutzer angezeigt werden?" -msgid "PlatformUser__accepted_speakersagreement" -msgstr "Speaker's Agreement" +msgid "PlatformUser__display_name" +msgstr "Anzeigename" msgid "PlatformUser__show_name__help" msgstr "soll der Nutzername überhaupt angezeigt werden" @@ -1959,6 +1969,9 @@ 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 874aa908c35fba43c8a6f1669a5b8d8d9ce5eee1..4f843e94e27fa13e192adc3818208b0f9d0328a9 100644 --- a/src/core/locale/en/LC_MESSAGES/django.po +++ b/src/core/locale/en/LC_MESSAGES/django.po @@ -131,6 +131,9 @@ msgstr "Your contact email for this event (not public)" msgid "Registration__rate-limited" msgstr "Too many requests (Rate-Limited), please wait a moment!" +msgid "Registration__username__nounderscore" +msgstr "The username must not begin with an underscore." + msgid "Tags" msgstr "" @@ -1089,16 +1092,16 @@ msgid "EventAttachment__visibility-public" msgstr "public" msgid "EventParticipant__type-speaker" -msgstr "speaker" +msgstr "Speaker" msgid "EventParticipant__type-angel" -msgstr "angel" +msgstr "Angel" msgid "EventParticipant__type-regular" -msgstr "regular" +msgstr "Regular" msgid "EventParticipant__type-prospect" -msgstr "prospect" +msgstr "Prospect" msgid "EventParticipant__role__help" msgstr "role of this participant" @@ -1921,9 +1924,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,11 +1955,11 @@ msgstr "type of the user" msgid "PlatformUser__type" msgstr "type" -msgid "PlatformUser__accepted_speakersagreement__help" -msgstr "the user has accepted the speaker's agreement" +msgid "PlatformUser__display_name__help" +msgstr "how shall the user be displayed in the frontend(s)?" -msgid "PlatformUser__accepted_speakersagreement" -msgstr "speaker's agreement" +msgid "PlatformUser__display_name" +msgstr "display name" msgid "PlatformUser__show_name__help" msgstr "select whether a name shall be shown at all" @@ -1957,6 +1967,9 @@ 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/management/commands/test_schedule_import.py b/src/core/management/commands/test_schedule_import.py new file mode 100644 index 0000000000000000000000000000000000000000..839f672d1bbb29069bbbb275b4e91ffc815e1486 --- /dev/null +++ b/src/core/management/commands/test_schedule_import.py @@ -0,0 +1,30 @@ +import argparse +import json + +from django.conf import settings +from django.core.exceptions import SuspiciousOperation +from django.core.management.base import BaseCommand +from django.db import transaction + +from ...models.conference import Conference + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--data-file', '-f', type=argparse.FileType('r'), help='the data file to load') + + def handle(self, *args, **options): + datafile = options.get('data_file') + data = json.load(datafile) + + conf = Conference.objects.get(pk=settings.SELECTED_CONFERENCE_ID) + try: + with transaction.atomic(): + assembly = conf.assemblies.create(slug='import') + source = conf.schedule_sources.create(assembly=assembly) + activity = source.load_data(data) + raise SuspiciousOperation('fail deliberately to rollback any possible changes') + except SuspiciousOperation: + pass + + print(json.dumps(activity, indent=2)) 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/events.py b/src/core/models/events.py index c95b67a066bb3ba94f3a343172566c33133d14fc..c080fcb2c140ec7799137343c3618a1de9dc1d87 100644 --- a/src/core/models/events.py +++ b/src/core/models/events.py @@ -34,6 +34,8 @@ from .users import PlatformUser SIMPLE_TIME_RE = compile(r'(.*\s)?(\d+:\d+)$') +logger = logging.getLogger(__name__) + class EventDurationFormField(DurationField): def to_python(self, value): @@ -225,10 +227,9 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): @property def public_speakers(self) -> list: """Returns a list of all public speakers of this event.""" - persons = self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER).values_list('participant', flat=True) + persons = [participant.participant for participant in self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER)] - # If there is no speaker for an SoS, the owner is automatically added. - if self.kind == Event.Kind.SELF_ORGANIZED and not persons and self.owner: + if len(persons) == 0 and self.kind == Event.Kind.SELF_ORGANIZED and self.owner: persons.add(self.owner) return persons @@ -302,16 +303,24 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): names = OrderedSet(self.speakers) except AttributeError: names = OrderedSet() - names |= OrderedSet(self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER).values_list('participant__username', flat=True)) + names |= OrderedSet( + self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER) + .order_by('participant__display_name') + .values_list('participant__display_name', flat=True) + ) - if self.additional_data is not None: - for p in self.additional_data.get('persons', []): - name = p.get('public_name', p.get('name')) - if name: - names.add(name) + # if the "correct" participant list is empty, check if we can derive some speaker names + if len(names) == 0: + # imported events might have a "persons" list in the additional data + if self.additional_data is not None: + for p in self.additional_data.get('persons', []): + name = p.get('public_name', p.get('name')) + if name: + names.add(name) - if self.kind == Event.Kind.SELF_ORGANIZED and self.owner: - names.add(self.owner.username) + # for SoS, use the owner as a speaker + if self.kind == Event.Kind.SELF_ORGANIZED and self.owner: + names.add(self.owner.username) return names @@ -351,6 +360,7 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): allow_kind: bool = False, allow_track: bool = False, room_lookup=None, + speaker_lookup=None, ): """ Loads an Event instance from the given dictionary. @@ -364,6 +374,7 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): :param allow_kind: Flag indicating whether the 'kind' attribute may be set (e.g. to 'official'). :param allow_track: Flag indicating whether the 'track' attribute may be set. :param room_lookup: Lookup function to get the event's room from the data given, without this, a 'room' key will be ignored. + :param speaker_lookup: Lookup function to get a conference user object for the data given, without this, speakers will be ignored. :returns: a new event or the existing one with fields updated from the given data :rtype: Event @@ -440,6 +451,18 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): if pop_used_keys: del data['room'] + if 'speakers' in data: + if speaker_lookup is not None: + obj.save() # TODO: check if we can do this better, but we need the model saved for adding speakers + for item in data['speakers']: + speaker = speaker_lookup(item) + if speaker is not None: + obj.ensure_speaker(speaker) + else: + raise RuntimeWarning('Event.from_dict() got data with "speakers" but no speaker_lookup was provided.') + if pop_used_keys: + del data['speakers'] + if pop_used_keys: obj.additional_data = data elif 'additional_data' in data: @@ -629,6 +652,28 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): # we require a configured BigBlueButton integration return settings.INTEGRATIONS_BBB + def ensure_speaker(self, user: PlatformUser): + entry, created = self.participants.get_or_create(participant=user) + if created: + logger.info( + 'Added participant "%s" (%s) to event "%s" (%s), intended to be a speaker.', + user.username, + user.id, + self.name, + self.pk, + ) + + entry.role = EventParticipant.Role.SPEAKER + entry.is_public = True + entry.is_accepted = True + entry.save() + logger.info( + 'Forced participant #%s (%s) of event %s to accepted+public+speaker.', + entry.id, + user.username, + self.pk, + ) + def _EventAttachment_upload_path(instance, filename): # file will be uploaded to MEDIA_ROOT/user_<id>/<filename> diff --git a/src/core/models/schedules.py b/src/core/models/schedules.py index 80eddbb083d22fda04a817b3bc04d1f10f163abd..b6e321540fdc925887983ce2631eff731e2af01d 100644 --- a/src/core/models/schedules.py +++ b/src/core/models/schedules.py @@ -1,5 +1,7 @@ import logging from datetime import timedelta +from hashlib import sha1 +from typing import TYPE_CHECKING, Dict, List, Optional from uuid import UUID, uuid4 from django.core.exceptions import ObjectDoesNotExist @@ -9,10 +11,14 @@ from django.utils.translation import gettext_lazy as _ from ..fields import ConferenceReference from ..schedules import ScheduleTypeManager -from ..utils import mask_url, str2bool +from ..utils import mail2uuid, mask_url, str2bool from .assemblies import Assembly -from .events import Event, EventAttachment, EventParticipant +from .events import Event, EventAttachment from .rooms import Room +from .users import PlatformUser + +if TYPE_CHECKING: + pass logger = logging.getLogger(__name__) @@ -97,9 +103,14 @@ class ScheduleSource(models.Model): timespan_since_last_import = timezone.now() - self.last_import return timespan_since_last_import >= self.import_frequency + @property + def latest_import(self) -> Optional['ScheduleSourceImport']: + result = self.imports.order_by('-start').first() + return result + @property def has_running_import(self): - latest_import = self.imports.order_by('-start').first() + latest_import = self.latest_import if latest_import is None: # no import at all, therefore no running one return False @@ -109,8 +120,74 @@ class ScheduleSource(models.Model): return False # check that timeout has not passed yet - deadline = latest_import.start + self.import_timeout - return timezone.now() < deadline + return latest_import.is_beyond_deadline + + def _get_or_create_speaker( + self, + name: str, + mail_guid: Optional[str | UUID] = None, + addresses: Optional[List[str]] = None, + ): + if not name: + raise ValueError('You need to provide a name for the speaker.') + + if mail_guid and not isinstance(mail_guid, UUID): + mail_guid = UUID(mail_guid) + + if mail_guid: + speaker_username = '_speaker_' + str(mail_guid) + elif addresses: + speaker_username = '_speaker_' + sha1('\n'.join(sorted(addresses)).encode('utf-8')).hexdigest() + else: + speaker_username = '_speaker_' + sha1(name.encode('utf-8')).hexdigest() + + # try to find by the username + if candidate := PlatformUser.objects.filter(username=speaker_username, user_type=PlatformUser.Type.SPEAKER).first(): + return candidate, False + + # we try to find a PlatformUser who has a verified email matching the given mail_guid or any of the supplied addresses + candidates = [] # type: List[PlatformUser] + for cm in self.conference.users.select_related('user').filter(user__user_type__in=PlatformUser.PERSON_TYPES).iterator(): # type: ConferenceMember + for addr in cm.user.get_verified_mail_addresses(): + if mail_guid and mail_guid == mail2uuid(addr): + candidates.append(cm.user) + break # exit the inner for-loop + if addresses and addr in addresses: + candidates.append(cm.user) + break # exit the inner for-loop + + # the good case: we found something \o/ + if len(candidates) == 1: + return candidates[0], False + + # the very bad case: we found too many + if len(candidates) > 1: + raise ValueError('Multiple candidate speakers found: ' + '; '.join(x.pk for x in candidates)) + + # hail mary attempt: see if we have an imported speaker with the same name + candidates = self.conference.users.select_related('user').filter(user__user_type=PlatformUser.Type.SPEAKER, user__display_name=name).all() + if len(candidates) == 1: + return candidates[0], False + + # the expected case: nothing found, create a new one + name_split = name.split(' ') + user_kwargs = { + 'user_type': PlatformUser.Type.SPEAKER, + 'username': speaker_username, + 'show_name': True, + 'last_name': name_split.pop(), + 'first_name': ' '.join(name_split), + } + + # set an existing UUID, if possible + if mail_guid: + user_kwargs['uuid'] = str(mail_guid) + + # create the PlatformUser + new_user = PlatformUser(**user_kwargs) + new_user.save() + + return new_user, True def get_or_create_mapping(self, mapping_type, source_id, create_local_object=True, source_uuid=None, hints: dict | None = None): """ @@ -160,6 +237,13 @@ class ScheduleSource(models.Model): lo = Event(conference=self.conference, pk=source_uuid, assembly=assembly) + elif mapping_type == ScheduleSourceMapping.MappingType.SPEAKER: + lo, _ = self._get_or_create_speaker( + mail_guid=source_uuid, + name=hints.get('name'), + addresses=hints.get('addresses'), + ) + mapping = self.mappings.create( mapping_type=mapping_type, source_id=source_id, @@ -180,7 +264,7 @@ class ScheduleSource(models.Model): except ValueError: pass try: - candidate = item.get('guid') + candidate = item.get('uuid') or item.get('guid') if item_source_uuid is None and candidate and not isinstance(candidate, int): item_source_uuid = UUID(candidate) except ValueError: @@ -190,10 +274,14 @@ class ScheduleSource(models.Model): if item_type == 'event': hints['room_lookup'] = from_dict_args.get('room_lookup') hints['room_name'] = item.get('room') + hints['speaker_lookup'] = from_dict_args.get('speaker_lookup') + elif item_type == 'speaker': + hints['name'] = item.get('full_public_name') or item.get('public_name') or item.get('name') + hints['addresses'] = item.get('addresses') mapping, new_mapping = self.get_or_create_mapping( mapping_type=item_type, - source_id=item_source_id, + source_id=str(item_source_id), create_local_object=not item_delete, source_uuid=item_source_uuid, hints=hints, @@ -258,6 +346,7 @@ class ScheduleSource(models.Model): 'message': str(err), } ) + logging.exception('Import on ScheduleSource %s encountered exception on creating mapping for %s "%s".', self.pk, item_type, item_source_id) # ... and delete the incomplete (wrong) mapping mapping.delete() @@ -331,6 +420,7 @@ class ScheduleSource(models.Model): activity = [] events = {} rooms = {} + speakers = {} # derive some flags cfg = self.import_configuration or {} @@ -338,7 +428,7 @@ class ScheduleSource(models.Model): replace_conference_slug_prefix = cfg.get('replace_conference_slug_prefix') allow_track = cfg.get('import_tracks') or False - # note down all existing rooms and events so that we can call out the missing ones + # note down all existing rooms, events and speakers so that we can call out the missing ones if self.assembly: expected_rooms = list(self.assembly.rooms.values_list('id', flat=True)) else: @@ -352,6 +442,73 @@ class ScheduleSource(models.Model): mapping_type=ScheduleSourceMapping.MappingType.EVENT, ).values_list('local_id', flat=True) ) + expected_speakers = list( + self.mappings.filter( + mapping_type=ScheduleSourceMapping.MappingType.SPEAKER, + ).values_list('local_id', flat=True) + ) + + def speaker_lookup(speaker_info: Dict[str, str]): + """ + Try to match the given speaker dict to a PlatformUser, if necessary creating a virtual one in the process. + Returns None if the speaker shall be skipped (explicitly, using ScheduleSourceMapping.skip=True). + + Example: + ```json + { + "id": 4711, + "guid": "c25334d0-9539-55e3-92b4-f559c384522b", + "name": "Hub Team", + "links": [ + { + "url": "https://git.cccv.de/hub/hub", + "title": "Quellcode" + } + ], + "biography": "Das Projekt-Team vom Hub, der Daten-Integrationsplattform von Congress & Camp.", + "avatar": "https://www.ccc.de/images/events.png", + "public_name": "Hub Team" + } + ``` + """ + + # for the source id use the provided id, uuid or guid field (in order) + speaker_id = speaker_info.get('id') or speaker_info.get('uuid') or speaker_info.get('guid') + + # sanity check: verify that required attributes are present + if not speaker_info: + raise ValueError('Missing required attribute in speaker_info: id/uuid/guid') + + try: + action = self._load_dataitem( + activity=activity, + item=speaker_info, + item_source_id=speaker_id, + item_type='speaker', + expected_items=expected_speakers, + items=speakers, + from_dict_args={}, + ) + + if action == 'skipped': + # if the speaker has been skipped throw it into the mapping table anyway + spk_mapping = self.mappings.get(mapping_type=ScheduleSourceMapping.MappingType.SPEAKER, source_id=speaker_id) + assert spk_mapping.skip + speakers[speaker_id] = spk_mapping.local_object + + except Exception as err: + activity.append( + { + 'action': 'error', + 'type': 'speaker', + 'source_id': speaker_id, + 'local_id': None, + 'message': str(err), + } + ) + logging.exception('Import on ScheduleSource %s encountered exception on loading speaker "%s".', self.pk, speaker_id) + + return speakers[speaker_id] # first, load the rooms (as they're needed for events) for r_id, r in data['rooms'].items(): @@ -387,6 +544,7 @@ class ScheduleSource(models.Model): 'message': str(err), } ) + logging.exception('Import on ScheduleSource %s encountered exception on loading room "%s".', self.pk, r_id) # then load events for e_id, e in data['events'].items(): @@ -405,6 +563,7 @@ class ScheduleSource(models.Model): 'allow_kind': self.assembly.is_official if self.assembly else False, # TODO: lookup assembly's room if not given 'allow_track': allow_track, # TODO 'room_lookup': lambda r_source_id: rooms.get(r_source_id), + 'speaker_lookup': speaker_lookup, }, ) except Exception as err: @@ -417,6 +576,7 @@ class ScheduleSource(models.Model): 'message': str(err), } ) + logging.exception('Import on ScheduleSource %s encountered exception on loading event "%s".', self.pk, e_id) # flag the non-loaded rooms as 'missing' for room_id in expected_rooms: @@ -523,12 +683,10 @@ class ScheduleSourceMapping(models.Model): return attachment if self.mapping_type == self.MappingType.SPEAKER: - participant = EventParticipant.objects.prefetch_related('event').get(pk=self.local_id) - if self.schedule_source.assembly_id is not None and participant.event.assembly_id != self.schedule_source.assembly_id: - raise LocalObjectAccessViolation('Assembly of EventParticipant does not match.') - if participant.role != EventParticipant.Role.SPEAKER: - raise LocalObjectAccessViolation('Participant selected is not a speaker.') - return participant + speaker = PlatformUser.objects.get(pk=self.local_id) + if not speaker.is_person: + raise LocalObjectAccessViolation("Referenced speaker's PlatformUser is not a person.") + return speaker # we don't know about that mapping type, bail out raise LocalObjectAccessViolation('Unknown mapping.') @@ -592,6 +750,11 @@ class ScheduleSourceImport(models.Model): def is_success(self): return self.state in self.SUCCESS_STATES + @property + def is_beyond_deadline(self): + deadline = self.start + self.schedule_source.import_timeout + return timezone.now() < deadline + @property def text_color_class(self): if self.state == ScheduleSourceImport.State.WARNING: diff --git a/src/core/models/users.py b/src/core/models/users.py index 5501b65eb5be7ab9874b8e050bd4704ac5068d59..178bab1977939c4b95f34854a8f9f73989d71b87 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 + from core.models import Assembly, Conference, 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).""" @@ -48,15 +74,31 @@ class PlatformUser(AbstractUser): ) uuid = models.UUIDField(default=uuid4, unique=True) - slug = models.SlugField(blank=False, unique=True) - # legal stuff - accepted_speakersagreement = models.BooleanField( - null=True, help_text=_('PlatformUser__accepted_speakersagreement__help'), verbose_name=_('PlatformUser__accepted_speakersagreement') - ) + 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')) @@ -180,6 +222,11 @@ class PlatformUser(AbstractUser): except ObjectDoesNotExist: return None + def get_absolute_url(self): + from core.templatetags.hub_absolute import hub_absolute + + return hub_absolute('plainui:user', user_slug=self.slug, i18n=False) + def get_character_layers(self): info = self.avatar_config or {} return info.get('character_layers', []) @@ -269,10 +316,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): @@ -286,6 +337,64 @@ class PlatformUser(AbstractUser): except ObjectDoesNotExist: return None + @classmethod + def from_dict( + cls, + data: dict, + conference: 'Conference', + assembly: 'Assembly' = None, + existing=None, + pop_used_keys: bool = False, + ): + """ + Loads an PlatformUser instance from the given dictionary - used by the Schedule import mechanism. + An existing user can be provided which's data is overwritten (in parts). + However, for sanity reasons, this will only operate on user_type=SPEAKER. + + :param data: a dictionary containing PlatformUser attributes' names as key (and their values) + :param conference: the Conference instance to which this PlatformUser shall be joined + :param assembly: ignored + :param existing: an instance of PlatformUser (or None to get a new one) + :type existing: PlatformUser + :param pop_used_keys: Remove 'used' keys from the provided data. This can be used to detect additional/errornous fields. + + :returns: a new user or the existing one with fields updated from the given data, in both cases joined to the conference + :rtype: PlatformUser + """ + assert isinstance(data, dict), 'Data must be a dictionary.' + if not existing: + raise NotImplementedError('Creating a PlatformUser with .from_dict() is not supported.') + + obj = existing + + # join the new user into the conference + cm, created = conference.users.get_or_create(user=obj) + if existing.user_type == cls.Type.SPEAKER or created: + # only set the description/bio upon creation for real users (imported speakers are updated every time) + cm.description = data.get('biography', '') + cm.save() + + # add all known addresses as (unverified and non-public) communication channels + for addr in data.get('addresses', []): + obj.communication_channels.get_or_create( + channel=UserCommunicationChannel.Channel.MAIL, + address=addr, + defaults={ + 'is_verified': False, + 'show_public': False, + }, + ) + + # load avatar, if an URL is given (and has not changed since last time) + if avatar_url := data.get('avatar'): + if obj.avatar_url != avatar_url: + try: + obj.load_avatar_from_url(avatar_url) + obj.avatar_url = avatar_url + except Exception: + # just log the error, if the speaker has no avatar (yet), we don't care too much + logger.exception('Failed to download avatar for (new) speaker user %s: %s', obj.pk, avatar_url) + def __create_slug(self, extension=''): """ recursive function to get a free slug based on the username and an optional extension string. @@ -306,6 +415,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): @@ -370,6 +482,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/schedules/schedulejson.py b/src/core/schedules/schedulejson.py index ed9e9d90ec0ac0a07ae09d8cab42cf55c22486ec..775e5a23bf37c760025722dc364978805672660b 100644 --- a/src/core/schedules/schedulejson.py +++ b/src/core/schedules/schedulejson.py @@ -46,6 +46,13 @@ class ScheduleJSONSupport(BaseScheduleSupport): kind = self.conf_value('kind') + def ensure_full_url(uri): + if not uri: + return None + if not uri.startswith('http') and not uri.startswith('//'): + return f'{host}{uri}' + return uri + return { 'version': schedule.version(), 'rooms': {r['name']: r for r in schedule.rooms()}, @@ -66,7 +73,7 @@ class ScheduleJSONSupport(BaseScheduleSupport): 'is_public': True, 'kind': kind, 'speakers': e.get('persons', []), - 'banner_image_url': f"{host}{e.get('logo')}" if e.get('logo') else None, + 'banner_image_url': ensure_full_url(e.get('logo')), 'additional_data': filter_additional_data(e, self.computed_data(e)), } for e in schedule.events() diff --git a/src/core/schedules/schedulejsonfrab.py b/src/core/schedules/schedulejsonfrab.py index 314ec2c4d7d5996be3866e4116063d752cae5917..d2285b429d6d2b92bb02358c31fd12e2636e8925 100644 --- a/src/core/schedules/schedulejsonfrab.py +++ b/src/core/schedules/schedulejsonfrab.py @@ -79,8 +79,8 @@ class ScheduleJSONFrabSupport(ScheduleJSONSupport): images_base_url = schedule.get('base_url') def overwrite_avatar_url(p): - if p.get('avatar_url'): - return {**p, 'avatar_url': p['avatar_url'].replace('/original/', '/large/').replace(f'{host}/', images_base_url)} + if p.get('avatar'): + return {**p, 'avatar': p['avatar'].replace('/original/', '/large/').replace(f'{host}/', images_base_url)} return p for e in schedule.events(): @@ -153,7 +153,7 @@ class SpeakerJSON: 'name': s.get('name'), 'public_name': s.get('public_name'), 'biography': SpeakerJSON.build_biogaphy(s), - 'avatar_url': f"{base_url}{s['image']}" if s.get('image') else None, + 'avatar': f"{base_url}{s['image']}" if s.get('image') else None, 'links': s.get('links', []), } ) diff --git a/src/core/tests/schedules.py b/src/core/tests/schedules.py index 94caccff0cb9226bb58ed7ef15d9b65fe1712379..a53bfff0e9c44514d82a0dfc01b219ab957c332f 100644 --- a/src/core/tests/schedules.py +++ b/src/core/tests/schedules.py @@ -310,7 +310,7 @@ class ScheduleTests(TestCase): import_type=ScheduleJSONSupport.identifier, import_url=f"file://{Path(__file__).parent.joinpath('import_data', 'schedule-2020.json')}", ) - self.check_json(src, rooms=1, events=1, room_name='Yellow Room') + self.check_json(src, rooms=1, events=1, speakers=2, room_name='Yellow Room', event_id='12345') @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True) def test_json2021(self): @@ -320,7 +320,7 @@ class ScheduleTests(TestCase): import_type=ScheduleJSONSupport.identifier, import_url=f"file://{Path(__file__).parent.joinpath('import_data', 'schedule-2021.json')}", ) - self.check_json(src, rooms=2, events=1, room_name='Gray Room') + self.check_json(src, rooms=2, events=1, speakers=2, room_name='Gray Room', event_id='12345') @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True) def test_json_frab(self): @@ -340,9 +340,9 @@ class ScheduleTests(TestCase): import_type=ScheduleJSONFrabSupport.identifier, import_url=f"file://{Path(__file__).parent.joinpath('import_data', 'frab.schedule.json')}", ) - self.check_json(src, rooms=3, events=1, room_name='Saal 1', event_id='12338') + self.check_json(src, rooms=3, events=1, speakers=3, room_name='Saal 1', event_id='12338') - def check_json(self, src, rooms, events, room_name='Gray Room', event_id='12345'): + def check_json(self, src: ScheduleSource, rooms: int, events: int, speakers: int, room_name: str, event_id: str): self.assertFalse(src.has_running_import) job = src.imports.create(state=ScheduleSourceImport.State.PREPARED) @@ -371,7 +371,7 @@ class ScheduleTests(TestCase): self.assertIsNotNone(e1) # check that mappings exist - self.assertEqual(rooms + events, src.mappings.count()) # 3 = 2 rooms + 1 event + self.assertEqual(rooms + events + speakers, src.mappings.count()) r1_m = src.mappings.get(mapping_type=ScheduleSourceMapping.MappingType.ROOM, source_id=room_name, local_id=r1.id) e1_m = src.mappings.get(mapping_type=ScheduleSourceMapping.MappingType.EVENT, source_id=event_id, local_id=e1.id) self.assertIsNotNone(r1_m) diff --git a/src/core/tests/utils.py b/src/core/tests/utils.py index 1a7ff7a5353833808dfc718c0e39333343cef4b3..568be24a70ab849ecc8a86440d2bf8478c5bbf60 100644 --- a/src/core/tests/utils.py +++ b/src/core/tests/utils.py @@ -1,8 +1,9 @@ +import uuid from datetime import timedelta from django.test import TestCase -from ..utils import GitRepo, mask_url, scheme_and_netloc_from_url, str2timedelta +from ..utils import GitRepo, mail2uuid, mask_url, scheme_and_netloc_from_url, str2timedelta class UtilsTests(TestCase): @@ -41,6 +42,10 @@ class UtilsTests(TestCase): for check in checks: self.assertEqual(check[1], scheme_and_netloc_from_url(check[0])) + def test_mail2uuid(self): + expected_uuid = uuid.UUID('c25334d0-9539-55e3-92b4-f559c384522b') + self.assertEqual(expected_uuid, mail2uuid('hub@cccv.de')) + class GitRepoOfflineTests(TestCase): def test_invalid_url_local_path(self): diff --git a/src/core/utils.py b/src/core/utils.py index 8332304b94ac9ac29c4c68009ea67e151b540b36..e69123e006a352715a7ed5c3948f03bdb545808d 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -4,12 +4,16 @@ import re import shutil import subprocess import tempfile +import uuid 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 +195,40 @@ 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 + + +def mail2uuid(mail: str, prefix: str = 'acct:', suffix: str = '') -> uuid.UUID: + uri = prefix + mail + suffix + return uuid.uuid5(uuid.NAMESPACE_URL, uri) + + 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') diff --git a/src/plainui/views/events.py b/src/plainui/views/events.py index 2c6ed8fc760095c959bf5cfaa9c8de18267520a9..0b1535e385925ebe14240a34e57923300e1e82a0 100644 --- a/src/plainui/views/events.py +++ b/src/plainui/views/events.py @@ -121,8 +121,8 @@ class ChannelEventsView(ConferenceRequiredMixin, TemplateView): .order_by('room_id', 'schedule_start') .distinct('room_id') ) - speakers = EventParticipant.objects.filter(is_public=True, role=EventParticipant.Role.SPEAKER).order_by('participant__username') - speakers = speakers.annotate(speaker_name=F('participant__username')) + speakers = EventParticipant.objects.filter(is_public=True, role=EventParticipant.Role.SPEAKER).order_by('participant__display_name') + speakers = speakers.annotate(speaker_name=F('participant__display_name')) current_events_qs = current_events_qs.prefetch_related(Prefetch('participants', queryset=speakers, to_attr='speakers')) current_events = {} # type: dict[UUID, Event]