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]