Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 299-delete-planed-assemblies
  • 302-habitat-info
  • 445-schedule-redirects
  • 511-schedule-foo-fixed
  • 607-schedule-versions
  • 623-wiki-im-baustellenmodus-sollte-mal-als-wiki-admin-trotzdem-seiten-anlegen-bearbeiten-konnen
  • 720-schedule_source
  • andi/develop
  • andi/schedule-api
  • andi/speaker_import
  • badge-redeem-404
  • camp23-prod
  • chore/event-views
  • cyroxx/add_edit_links
  • cyroxx/bulletin_description
  • deploy/curl-verbose
  • develop
  • editMail
  • feat/dynamic-link-forms
  • feat/unit-integration-tests
  • feature/568-habitatmanagement
  • feature/RegisterSpeaker
  • feature/audit_log
  • feature/bg-eyecandy
  • feature/conference-query-set
  • feature/mqtt
  • feature/parallax-css-testpage
  • feature/pypy
  • feature/scheduleimport_skippedrooms
  • fix/index
  • fix/public-badge-access-rights
  • fix/registration_mail_subject
  • ical-export
  • production
  • room-docu
  • camp23-prod
  • camp23-prod-archive
  • prod-2024-10-14_22-49
  • prod-2024-10-20_23-27
  • prod-2024-10-29_09-45
  • prod-2024-10-31_13-17
  • prod-2024-11-01_11-14
  • prod-2024-11-02_21-16
  • prod-2024-11-03_01-42
  • prod-2024-11-03_12-10
  • prod-2024-11-16_03-41
  • prod-2024-12-04_00-57
  • prod-2024-12-05_00-48
  • prod-2024-12-05_10-09
  • prod-2024-12-10_00-17
  • prod-2024-12-10_07-23
  • prod-2024-12-10_23-04
  • prod-2024-12-14_03-24
  • prod-2024-12-16_02-27
  • prod-2024-12-17_15-05
  • prod-2024-12-19_02-32
  • prod-2024-12-20_12-25
  • prod-2024-12-21_10-44
  • prod-2024-12-21_13-42
  • prod-2024-12-22_00-55
  • prod-2024-12-22_01-34
  • prod-2024-12-22_17-25
  • prod-2024-12-22_21-12
  • prod-2024-12-23_23-39
  • prod-2024-12-24_14-48
  • prod-2024-12-25_01-29
  • prod-2024-12-25_15-54
  • prod-2024-12-25_21-04
  • prod-2024-12-26_00-21
  • prod-2024-12-26_13-12
  • prod-2024-12-26_21-45
  • prod-2024-12-27_00-34
  • prod-2024-12-27_13-29
  • prod-2024-12-27_16-01
  • prod-2024-12-27_16-37
  • prod-2024-12-27_20-15
76 results

Target

Select target project
  • hub/hub
  • cyroxx/hub
  • myigel/hub
  • thomasdotwtf/hub
4 results
Select Git revision
  • 299-delete-planed-assemblies
  • 302-habitat-info
  • 607-schedule-versions
  • 720-schedule_source
  • add-django-ninja
  • andi/develop
  • andi/schedule-api
  • andi/speaker_import
  • camp23-prod
  • chore/backoffice-list
  • chore/conference-singleton
  • chore/event-views
  • chore/update-rooms
  • cyroxx/add_edit_links
  • cyroxx/bulletin_description
  • develop
  • editMail
  • feat/dynamic-link-forms
  • feat/unit-integration-tests
  • feature/RegisterSpeaker
  • feature/audit_log
  • feature/bg-eyecandy
  • feature/conference-query-set
  • feature/event_import_slugs_of_serial_event
  • feature/mqtt
  • feature/parallax-css-testpage
  • feature/pypy
  • feature/scheduleimport_skippedrooms
  • feature/show_vods
  • production
  • room-docu
  • stable-38c3
  • camp23-prod
  • camp23-prod-archive
  • prod-2024-10-14_22-49
  • prod-2024-10-20_23-27
  • prod-2024-10-29_09-45
  • prod-2024-10-31_13-17
  • prod-2024-11-01_11-14
  • prod-2024-11-02_21-16
  • prod-2024-11-03_01-42
  • prod-2024-11-03_12-10
  • prod-2024-11-16_03-41
  • prod-2024-12-04_00-57
  • prod-2024-12-05_00-48
  • prod-2024-12-05_10-09
  • prod-2024-12-10_00-17
  • prod-2024-12-10_07-23
  • prod-2024-12-10_23-04
  • prod-2024-12-14_03-24
  • prod-2024-12-16_02-27
  • prod-2024-12-17_15-05
  • prod-2024-12-19_02-32
  • prod-2024-12-20_12-25
  • prod-2024-12-21_10-44
  • prod-2024-12-21_13-42
  • prod-2024-12-22_00-55
  • prod-2024-12-22_01-34
  • prod-2024-12-22_17-25
  • prod-2024-12-22_21-12
  • prod-2024-12-23_23-39
  • prod-2024-12-24_14-48
  • prod-2024-12-25_01-29
  • prod-2024-12-25_15-54
  • prod-2024-12-25_21-04
  • prod-2024-12-26_00-21
  • prod-2024-12-26_13-12
  • prod-2024-12-26_21-45
  • prod-2024-12-27_00-34
  • prod-2024-12-27_13-29
  • prod-2024-12-27_16-01
  • prod-2024-12-27_16-37
  • prod-2024-12-27_20-15
  • prod-2024-12-27_21-15
  • prod-2024-12-28_02-32
  • prod-2024-12-28_12-24
  • prod-2024-12-28_18-32
  • prod-2024-12-29_02-25
  • prod-2024-12-29_02-55
  • prod-2024-12-29_03-20
  • prod-2024-12-29_03-32
  • prod-2024-12-29_20-35
  • prod-2024-12-30_03-16
  • prod-2024-12-30_12-40
  • prod-2024-12-31_09-54
  • prod-2025-01-07_13-15
  • prod-2025-01-20_00-20
  • prod-2025-01-21_22-00
  • prod-2025-01-21_22-46
  • prod-2025-04-18_22-42
90 results
Show changes

Commits on Source 19

Showing
with 691 additions and 94 deletions
...@@ -14,7 +14,7 @@ from django.conf import settings ...@@ -14,7 +14,7 @@ from django.conf import settings
from django.db.models import Prefetch from django.db.models import Prefetch
from core.models.assemblies import Assembly from core.models.assemblies import Assembly
from core.models.conference import Conference from core.models.conference import Conference, ConferenceMember
from core.models.events import Event, EventParticipant from core.models.events import Event, EventParticipant
from core.models.rooms import Room from core.models.rooms import Room
from core.models.users import PlatformUser from core.models.users import PlatformUser
...@@ -41,6 +41,7 @@ event_template = { ...@@ -41,6 +41,7 @@ event_template = {
'type': 'other', 'type': 'other',
'abstract': None, 'abstract': None,
'description': None, 'description': None,
'logo': None,
'persons': [], 'persons': [],
'links': [], 'links': [],
} }
...@@ -103,53 +104,60 @@ class ScheduleEncoder(json.JSONEncoder): ...@@ -103,53 +104,60 @@ class ScheduleEncoder(json.JSONEncoder):
return f'{days}:{hours:02d}:{minutes:02d}' return f'{days}:{hours:02d}:{minutes:02d}'
return f'{hours:02d}:{minutes:02d}' return f'{hours:02d}:{minutes:02d}'
def encode_person(self, person: PlatformUser | str | dict): def encode_person(self, p: EventParticipant | PlatformUser | str | dict):
if isinstance(person, str): if isinstance(p, str):
return {'id': None, 'name': person, 'public_name': person} return {'id': None, 'name': p, 'public_name': p}
if isinstance(person, PlatformUser): if isinstance(p, PlatformUser):
name = p.get_display_name()
return { return {
'guid': person.uuid, 'guid': p.uuid,
'name': person.username, # TODO username or first_name + last_name? 'name': name,
'public_name': person.username, # TODO username or first_name + last_name? 'public_name': name,
'avatar_url': person.avatar_url, 'avatar': (settings.PLAINUI_BASE_URL + p.avatar.url) if p.avatar else None,
'biography': person.biography, 'biography': p.description,
# 'links': person.links, # TODO # 'links': person.links, # TODO
'url': p.get_absolute_url(),
} }
if isinstance(person, EventParticipant): if isinstance(p, EventParticipant):
member: ConferenceMember = p.event.conference.users.filter(uuid=p.participant.uuid).first()
name = p.participant.get_display_name()
return { return {
'guid': person.participant.uuid, 'guid': p.participant.uuid,
'name': person.participant.username, # TODO username or first_name + last_name? 'name': name,
'public_name': person.participant.username, # TODO username or first_name + last_name? 'public_name': name,
'avatar_url': person.participant.avatar_url, 'avatar': (settings.PLAINUI_BASE_URL + p.participant.avatar.url) if p.participant.avatar else None,
'biography': person.participant.member.description if person.participant.member else None, 'biography': member.description if member else '',
# 'links': person.participant.links, # TODO # 'links': person.participant.links, # TODO
'url': p.participant.get_absolute_url(),
} }
# we assume it is a dict, but we normally should never get here # we assume it is a dict, but we normally should never get here
return { return {
'guid': person.get('guid', None), 'guid': p.get('guid', None),
'id': person.get('id', None), 'id': p.get('id', None),
'name': person.get('name', person.get('public_name')), 'name': p.get('name', p.get('public_name')),
'avatar_url': person.get('avatar_url', None), 'avatar': p.get('avatar', None),
'biography': person.get('biography') or person.get('description', None), 'biography': p.get('biography') or p.get('description', ''),
'links': person.get('links', []), 'links': p.get('links', []),
} }
def collect_persons(self, event): def collect_persons(self, event):
persons = [] persons = []
# TODO remove this workaround once imported speakers are stored as participants
# and have links and biography=description
if event.additional_data and 'persons' in event.additional_data and len(event.additional_data['persons']) > 0:
persons = event.additional_data['persons']
else:
if hasattr(event, 'speakers'): if hasattr(event, 'speakers'):
# event is a QuerySet and already fetched speakers # event is a QuerySet and already fetched speakers
persons = event.speakers persons = list(event.speakers)
else: else:
# direct event lookup -> fetch persons via public_speakers # direct event lookup -> fetch persons via public_speakers
persons = event.public_speakers persons = list(event.public_speakers)
# TODO remove this workaround once imported speakers are stored as participants
if event.additional_data and 'persons' in event.additional_data and len(event.additional_data['persons']) > 0:
persons += event.additional_data['persons']
return [self.encode_person(person) for person in persons] return [self.encode_person(person) for person in persons]
...@@ -309,6 +317,7 @@ class Schedule: ...@@ -309,6 +317,7 @@ class Schedule:
def add_events(self, events: 'QuerySet[Event]'): def add_events(self, events: 'QuerySet[Event]'):
events = events.select_related('track', 'room', 'assembly', 'conference').prefetch_related( events = events.select_related('track', 'room', 'assembly', 'conference').prefetch_related(
Prefetch( Prefetch(
# TODO: we have to prefetch the conference members here, as we need the description for the speakers
'participants', 'participants',
queryset=EventParticipant.objects.filter(is_public=True, role=EventParticipant.Role.SPEAKER).select_related('participant'), queryset=EventParticipant.objects.filter(is_public=True, role=EventParticipant.Role.SPEAKER).select_related('participant'),
to_attr='speakers', to_attr='speakers',
......
...@@ -920,6 +920,12 @@ msgstr "" ...@@ -920,6 +920,12 @@ msgstr ""
msgid "Assembly__is_remote" msgid "Assembly__is_remote"
msgstr "" msgstr ""
msgid "moderation__public_link"
msgstr "öffentliche Ansicht"
msgid "moderation__notpublic"
msgstr "nicht veröffentlicht"
msgid "moderation__assembly__cannotunhide" msgid "moderation__assembly__cannotunhide"
msgstr "Eine versteckte Assembly muss vom Assembly-Team wieder in den passenden Status zurückversetzt werden (multiple Optionen)." msgstr "Eine versteckte Assembly muss vom Assembly-Team wieder in den passenden Status zurückversetzt werden (multiple Optionen)."
...@@ -1037,6 +1043,14 @@ msgstr "suchen" ...@@ -1037,6 +1043,14 @@ msgstr "suchen"
msgid "PlatformUser__type" msgid "PlatformUser__type"
msgstr "Typ" msgstr "Typ"
msgid "username"
msgstr "Benutzername"
# use translation from core
msgid "PlatformUser__display_name"
msgstr ""
# use translation from core/Django
msgid "date joined" msgid "date joined"
msgstr "" msgstr ""
...@@ -1050,15 +1064,20 @@ msgstr "" ...@@ -1050,15 +1064,20 @@ msgstr ""
msgid "BulletinBoardEntrys" msgid "BulletinBoardEntrys"
msgstr "Boardeinträge" msgstr "Boardeinträge"
# use translation from core
msgid "EventParticipant__is_accepted"
msgstr ""
# use translation from core
msgid "EventParticipant__is_public"
msgstr ""
msgid "not active" msgid "not active"
msgstr "nicht aktiv" msgstr "nicht aktiv"
msgid "PlatformUser__registration" msgid "PlatformUser__registration"
msgstr "Registrierung" msgstr "Registrierung"
msgid "username"
msgstr "Benutzername"
msgid "StaticPage__title" msgid "StaticPage__title"
msgstr "Überschrift" msgstr "Überschrift"
...@@ -1170,6 +1189,29 @@ msgstr "Anlage derzeit nicht möglich." ...@@ -1170,6 +1189,29 @@ msgstr "Anlage derzeit nicht möglich."
msgid "Room-new-other__create" msgid "Room-new-other__create"
msgstr "sonstigen Raum anlegen" 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__is_due"
msgstr ""
# use translation from core
msgid "ScheduleSource__has_running_import"
msgstr ""
msgid "Self-organized sessions" msgid "Self-organized sessions"
msgstr "Self-organized Sessions" msgstr "Self-organized Sessions"
......
...@@ -919,6 +919,12 @@ msgstr "" ...@@ -919,6 +919,12 @@ msgstr ""
msgid "Assembly__is_remote" msgid "Assembly__is_remote"
msgstr "" msgstr ""
msgid "moderation__public_link"
msgstr "public link"
msgid "moderation__notpublic"
msgstr "not public"
msgid "moderation__assembly__cannotunhide" msgid "moderation__assembly__cannotunhide"
msgstr "A hidden assembly can only be reversed by the assembly team as there are multiple valid states/types." msgstr "A hidden assembly can only be reversed by the assembly team as there are multiple valid states/types."
...@@ -1041,6 +1047,13 @@ msgstr "search" ...@@ -1041,6 +1047,13 @@ msgstr "search"
msgid "PlatformUser__type" msgid "PlatformUser__type"
msgstr "" msgstr ""
msgid "username"
msgstr ""
# use translation from core
msgid "PlatformUser__display_name"
msgstr ""
# use translation from core # use translation from core
msgid "date joined" msgid "date joined"
msgstr "" msgstr ""
...@@ -1055,15 +1068,20 @@ msgstr "" ...@@ -1055,15 +1068,20 @@ msgstr ""
msgid "BulletinBoardEntrys" msgid "BulletinBoardEntrys"
msgstr "board entries" msgstr "board entries"
# use translation from core
msgid "EventParticipant__is_accepted"
msgstr ""
# use translation from core
msgid "EventParticipant__is_public"
msgstr ""
msgid "not active" msgid "not active"
msgstr "" msgstr ""
msgid "PlatformUser__registration" msgid "PlatformUser__registration"
msgstr "registration" msgstr "registration"
msgid "username"
msgstr ""
# use translation from core # use translation from core
msgid "StaticPage__title" msgid "StaticPage__title"
msgstr "" msgstr ""
...@@ -1178,6 +1196,29 @@ msgstr "not available" ...@@ -1178,6 +1196,29 @@ msgstr "not available"
msgid "Room-new-other__create" msgid "Room-new-other__create"
msgstr "create other room" 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__is_due"
msgstr ""
# use translation from core
msgid "ScheduleSource__has_running_import"
msgstr ""
msgid "Self-organized sessions" msgid "Self-organized sessions"
msgstr "Self-organized sessions" msgstr "Self-organized sessions"
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
{% load humanize %} {% load humanize %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load hub_absolute %}
{% block content %} {% block content %}
...@@ -66,6 +67,11 @@ ...@@ -66,6 +67,11 @@
</div> </div>
<div class="col-md-3"> <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 border-primary-subtle">
<div class="card-header bg-primary-subtle text-bg-primary">{% trans "nav_moderation" %}</div> <div class="card-header bg-primary-subtle text-bg-primary">{% trans "nav_moderation" %}</div>
<div class="card-body" id="moderation"> <div class="card-body" id="moderation">
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
{% load humanize %} {% load humanize %}
{% load i18n %} {% load i18n %}
{% load c3assemblies %} {% load c3assemblies %}
{% load hub_absolute %}
{% block content %} {% block content %}
<h1>Event "{{ object.slug }}": {{ object.name }}</h1> <h1>{% trans "Event" %} "{{ object.slug }}": {{ object.name }}</h1>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
...@@ -51,7 +52,7 @@ ...@@ -51,7 +52,7 @@
<dt class="col-sm-3">{% trans "Event__owner" %}:</dt> <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> <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> <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> </dl>
</div> </div>
</div> </div>
...@@ -79,6 +80,11 @@ ...@@ -79,6 +80,11 @@
</div> </div>
<div class="col-md-3"> <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 border-primary-subtle">
<div class="card-header bg-primary-subtle text-bg-primary">{% trans "nav_moderation" %}</div> <div class="card-header bg-primary-subtle text-bg-primary">{% trans "nav_moderation" %}</div>
<div class="card-body" id="moderation"> <div class="card-body" id="moderation">
......
...@@ -20,6 +20,10 @@ ...@@ -20,6 +20,10 @@
<dd class="col-sm-9">{{ object.id }}</dd> <dd class="col-sm-9">{{ object.id }}</dd>
<dt class="col-sm-3">UUID:</dt> <dt class="col-sm-3">UUID:</dt>
<dd class="col-sm-9">{{ object.uuid }}</dd> <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> <dt class="col-sm-3">{% trans "date joined" %}:</dt>
<dd class="col-sm-9">{{ object.date_joined }} ({{ object.date_joined|naturaltime }})</dd> <dd class="col-sm-9">{{ object.date_joined }} ({{ object.date_joined|naturaltime }})</dd>
<dt class="col-sm-3">{% trans "active" %}:</dt> <dt class="col-sm-3">{% trans "active" %}:</dt>
...@@ -87,6 +91,31 @@ ...@@ -87,6 +91,31 @@
</div> </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>
<div class="col-md-3"> <div class="col-md-3">
<div class="card border-primary-subtle mb-3"> <div class="card border-primary-subtle mb-3">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
{% load humanize %} {% load humanize %}
<div class="card mb-1"> <div class="card mb-1">
<div class="card-body"> <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> <br>
{% if user.is_active %}<span class="text-success">{% trans "active" %}</span>{% else %}<span class="text-danger">{% trans "not active" %}</span>{% endif %} {% if user.is_active %}<span class="text-success">{% trans "active" %}</span>{% else %}<span class="text-danger">{% trans "not active" %}</span>{% endif %}
......
{% extends 'backoffice/base.html' %} {% extends 'backoffice/base.html' %}
{% load i18n %} {% load i18n %}
{% load humanize %}
{% block content %} {% block content %}
<div class="card"> <div class="mb-3 align-content-end">
<a class="btn btn-primary" href="{% url 'backoffice:schedulesource-list' %}">{% trans "backoffice_schedules_tabularview" %}</a>
</div>
<div class="row row-cols-1 row-cols-md-3 g-4">
{% for source in sources %}
{% with source.imports.first 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"> <div class="card-body">
Sources: <a href="{% url 'backoffice:schedulesource-list' %}">{{ sources_count }}</a> <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>
{% if source.is_due %}
<p class="text-warning">
<i class="bi bi-clock-history"></i> {% trans "ScheduleSource__is_due" %}
</p>
{% endif %}
{% if source.has_running_import %}
<p class="text-primary">
<i class="bi bi-house-gear"></i> {% trans "ScheduleSource__has_running_import" %}
</p>
{% endif %}
</div> </div>
</div> </div>
</div>
{% endwith %}
{% endfor %}
</div>
<div class="alert alert-warning my-3">TODO</div>
{% endblock %} {% endblock %}
...@@ -93,11 +93,13 @@ def lookup_moderation_items(conference: Conference, query: str): ...@@ -93,11 +93,13 @@ def lookup_moderation_items(conference: Conference, query: str):
q_slug = Q() q_slug = Q()
q_name = Q() q_name = Q()
q_username = Q() q_username = Q()
q_display_name = Q()
for word in words: for word in words:
q_slug = q_slug & Q(slug__icontains=word) q_slug = q_slug & Q(slug__icontains=word)
q_name = q_name & Q(name__icontains=word) q_name = q_name & Q(name__icontains=word)
q_username = q_username & Q(username__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'): for a in conference.assemblies.filter(q_slug).values('pk'):
candidates.append(('assembly', a['pk'])) candidates.append(('assembly', a['pk']))
...@@ -105,7 +107,7 @@ def lookup_moderation_items(conference: Conference, query: str): ...@@ -105,7 +107,7 @@ def lookup_moderation_items(conference: Conference, query: str):
candidates.append(('badge', b['pk'])) candidates.append(('badge', b['pk']))
for e in conference.events.filter(q_slug).values('pk'): for e in conference.events.filter(q_slug).values('pk'):
candidates.append(('event', e['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'])) candidates.append(('user', u['pk']))
for p in conference.pages.filter(q_slug).values('pk'): for p in conference.pages.filter(q_slug).values('pk'):
candidates.append(('wiki', p['pk'])) candidates.append(('wiki', p['pk']))
......
...@@ -26,7 +26,7 @@ class SchedulesIndexView(ScheduleAdminMixin, TemplateView): ...@@ -26,7 +26,7 @@ class SchedulesIndexView(ScheduleAdminMixin, TemplateView):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*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 return ctx
......
...@@ -161,8 +161,7 @@ class PlatformUserAdmin(UserAdmin): ...@@ -161,8 +161,7 @@ class PlatformUserAdmin(UserAdmin):
fieldsets = ( fieldsets = (
(None, {'fields': ('username', 'slug', 'password', 'user_type', 'timezone')}), (None, {'fields': ('username', 'slug', 'password', 'user_type', 'timezone')}),
('Personal info', {'fields': ('first_name', 'last_name', 'email')}), ('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
('Legal stuff', {'fields': ('accepted_speakersagreement',)}), ('Self Portrayal', {'fields': (('pronouns', 'show_name'), ('status', 'status_public'), ('avatar', 'avatar_url', 'avatar_config'))}),
('Self Portrayal', {'fields': (('pronouns', 'show_name'), ('status', 'status_public'), ('avatar_url', 'avatar_config'))}),
('Accessibility', {'fields': ('theme', 'no_animations', 'colorblind', 'high_contrast', 'tag_ignorelist')}), ('Accessibility', {'fields': ('theme', 'no_animations', 'colorblind', 'high_contrast', 'tag_ignorelist')}),
('Disturbance Settings', {'fields': ('receive_dms', 'receive_dm_images', 'receive_audio', 'receive_video', 'autoaccept_contacts')}), ('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')}), ('Permissions', {'fields': ('is_active', 'shadow_banned', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
......
...@@ -126,6 +126,8 @@ class RegistrationForm(UserCreationForm): ...@@ -126,6 +126,8 @@ class RegistrationForm(UserCreationForm):
def clean(self): def clean(self):
if self.request and self.request.limited: if self.request and self.request.limited:
raise ValidationError(_('Registration__rate-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() return super().clean()
def send_mail( def send_mail(
......
...@@ -128,6 +128,9 @@ msgstr "Deine Kontakt Email für diese Veranstaltung (nicht öffentlich)" ...@@ -128,6 +128,9 @@ msgstr "Deine Kontakt Email für diese Veranstaltung (nicht öffentlich)"
msgid "Registration__rate-limited" msgid "Registration__rate-limited"
msgstr "Zu viele Request (Rate-Limited), bitte einen Moment warten!" msgstr "Zu viele Request (Rate-Limited), bitte einen Moment warten!"
msgid "Registration__username__nounderscore"
msgstr "Der Benutzername darf nicht mit einem Unterstrich beginnen."
msgid "Request failed" msgid "Request failed"
msgstr "Anfrage fehlgeschlagen" msgstr "Anfrage fehlgeschlagen"
...@@ -1793,9 +1796,16 @@ msgstr "Der Nutzer hat bereits ein Ticket genutzt, 'doppelt hält besser' ist hi ...@@ -1793,9 +1796,16 @@ msgstr "Der Nutzer hat bereits ein Ticket genutzt, 'doppelt hält besser' ist hi
msgid "ConferenceMemberTicket__token_already_used" msgid "ConferenceMemberTicket__token_already_used"
msgstr "Dieses Ticket wurde bereits genutzt." 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" msgid "PlatformUser__type-human"
msgstr "Mensch" msgstr "Mensch"
msgid "PlatformUser__type-speaker"
msgstr "Vortragender (autom. importiert)"
msgid "PlatformUser__type-service" msgid "PlatformUser__type-service"
msgstr "Dienst (Service)" msgstr "Dienst (Service)"
...@@ -1817,11 +1827,11 @@ msgstr "Art des Benutzers" ...@@ -1817,11 +1827,11 @@ msgstr "Art des Benutzers"
msgid "PlatformUser__type" msgid "PlatformUser__type"
msgstr "Typ" msgstr "Typ"
msgid "PlatformUser__accepted_speakersagreement__help" msgid "PlatformUser__display_name__help"
msgstr "Nutzer hat das Speaker's Agreement unterzeichnet" msgstr "Wie soll der Nutzer angezeigt werden?"
msgid "PlatformUser__accepted_speakersagreement" msgid "PlatformUser__display_name"
msgstr "Speaker's Agreement" msgstr "Anzeigename"
msgid "PlatformUser__show_name__help" msgid "PlatformUser__show_name__help"
msgstr "soll der Nutzername überhaupt angezeigt werden" msgstr "soll der Nutzername überhaupt angezeigt werden"
...@@ -1829,6 +1839,9 @@ msgstr "soll der Nutzername überhaupt angezeigt werden" ...@@ -1829,6 +1839,9 @@ msgstr "soll der Nutzername überhaupt angezeigt werden"
msgid "PlatformUser__show_name" msgid "PlatformUser__show_name"
msgstr "Namen anzeigen" msgstr "Namen anzeigen"
msgid "PlatformUser__avatar"
msgstr "Avatar"
msgid "PlatformUser__avatar_url__help" msgid "PlatformUser__avatar_url__help"
msgstr "URL zum Avatar-Bild" msgstr "URL zum Avatar-Bild"
......
...@@ -128,6 +128,9 @@ msgstr "Your contact email for this event (not public)" ...@@ -128,6 +128,9 @@ msgstr "Your contact email for this event (not public)"
msgid "Registration__rate-limited" msgid "Registration__rate-limited"
msgstr "Too many requests (Rate-Limited), please wait a moment!" msgstr "Too many requests (Rate-Limited), please wait a moment!"
msgid "Registration__username__nounderscore"
msgstr "The username must not begin with an underscore."
msgid "Request failed" msgid "Request failed"
msgstr "Request failed" msgstr "Request failed"
...@@ -1044,16 +1047,16 @@ msgid "EventAttachment__visibility-public" ...@@ -1044,16 +1047,16 @@ msgid "EventAttachment__visibility-public"
msgstr "public" msgstr "public"
msgid "EventParticipant__type-speaker" msgid "EventParticipant__type-speaker"
msgstr "speaker" msgstr "Speaker"
msgid "EventParticipant__type-angel" msgid "EventParticipant__type-angel"
msgstr "angel" msgstr "Angel"
msgid "EventParticipant__type-regular" msgid "EventParticipant__type-regular"
msgstr "regular" msgstr "Regular"
msgid "EventParticipant__type-prospect" msgid "EventParticipant__type-prospect"
msgstr "prospect" msgstr "Prospect"
msgid "EventParticipant__role__help" msgid "EventParticipant__role__help"
msgstr "role of this participant" msgstr "role of this participant"
...@@ -1793,9 +1796,16 @@ msgstr "User has used a ticket already." ...@@ -1793,9 +1796,16 @@ msgstr "User has used a ticket already."
msgid "ConferenceMemberTicket__token_already_used" msgid "ConferenceMemberTicket__token_already_used"
msgstr "This ticket has already been 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" msgid "PlatformUser__type-human"
msgstr "human" msgstr "human"
msgid "PlatformUser__type-speaker"
msgstr "speaker (autom. imported)"
msgid "PlatformUser__type-service" msgid "PlatformUser__type-service"
msgstr "service" msgstr "service"
...@@ -1817,11 +1827,11 @@ msgstr "type of the user" ...@@ -1817,11 +1827,11 @@ msgstr "type of the user"
msgid "PlatformUser__type" msgid "PlatformUser__type"
msgstr "type" msgstr "type"
msgid "PlatformUser__accepted_speakersagreement__help" msgid "PlatformUser__display_name__help"
msgstr "the user has accepted the speaker's agreement" msgstr "how shall the user be displayed in the frontend(s)?"
msgid "PlatformUser__accepted_speakersagreement" msgid "PlatformUser__display_name"
msgstr "speaker's agreement" msgstr "display name"
msgid "PlatformUser__show_name__help" msgid "PlatformUser__show_name__help"
msgstr "select whether a name shall be shown at all" msgstr "select whether a name shall be shown at all"
...@@ -1829,6 +1839,9 @@ msgstr "select whether a name shall be shown at all" ...@@ -1829,6 +1839,9 @@ msgstr "select whether a name shall be shown at all"
msgid "PlatformUser__show_name" msgid "PlatformUser__show_name"
msgstr "show name" msgstr "show name"
msgid "PlatformUser__avatar"
msgstr "avatar"
msgid "PlatformUser__avatar_url__help" msgid "PlatformUser__avatar_url__help"
msgstr "URL to the avatar's image" msgstr "URL to the avatar's image"
......
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))
# 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', '0138_badge_symbol'),
]
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),
]
...@@ -34,6 +34,8 @@ from .users import PlatformUser ...@@ -34,6 +34,8 @@ from .users import PlatformUser
SIMPLE_TIME_RE = compile(r'(.*\s)?(\d+:\d+)$') SIMPLE_TIME_RE = compile(r'(.*\s)?(\d+:\d+)$')
logger = logging.getLogger(__name__)
class EventDurationFormField(DurationField): class EventDurationFormField(DurationField):
def to_python(self, value): def to_python(self, value):
...@@ -207,9 +209,11 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): ...@@ -207,9 +209,11 @@ class Event(TaggedItemMixin, BackendMixin, models.Model):
@property @property
def public_speakers(self): def public_speakers(self):
"""Returns a list of all public speakers of this event.""" """Returns a list of all public speakers of this event."""
persons = self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER).select_related('participant') persons = (
self.participants.filter(is_public=True, role=EventParticipant.Role.SPEAKER).select_related('participant').order_by('participant__display_name')
)
if self.kind == Event.Kind.SELF_ORGANIZED and self.owner: if len(persons) == 0 and self.kind == Event.Kind.SELF_ORGANIZED and self.owner:
persons.add(self.owner) persons.add(self.owner)
return persons return persons
...@@ -241,14 +245,22 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): ...@@ -241,14 +245,22 @@ class Event(TaggedItemMixin, BackendMixin, models.Model):
names = OrderedSet(self.speakers) names = OrderedSet(self.speakers)
except AttributeError: except AttributeError:
names = OrderedSet() 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 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: if self.additional_data is not None:
for p in self.additional_data.get('persons', []): for p in self.additional_data.get('persons', []):
name = p.get('public_name', p.get('name')) name = p.get('public_name', p.get('name'))
if name: if name:
names.add(name) names.add(name)
# for SoS, use the owner as a speaker
if self.kind == Event.Kind.SELF_ORGANIZED and self.owner: if self.kind == Event.Kind.SELF_ORGANIZED and self.owner:
names.add(self.owner.username) names.add(self.owner.username)
...@@ -290,6 +302,7 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): ...@@ -290,6 +302,7 @@ class Event(TaggedItemMixin, BackendMixin, models.Model):
allow_kind: bool = False, allow_kind: bool = False,
allow_track: bool = False, allow_track: bool = False,
room_lookup=None, room_lookup=None,
speaker_lookup=None,
): ):
""" """
Loads an Event instance from the given dictionary. Loads an Event instance from the given dictionary.
...@@ -303,6 +316,7 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): ...@@ -303,6 +316,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_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 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 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 :returns: a new event or the existing one with fields updated from the given data
:rtype: Event :rtype: Event
...@@ -379,6 +393,18 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): ...@@ -379,6 +393,18 @@ class Event(TaggedItemMixin, BackendMixin, models.Model):
if pop_used_keys: if pop_used_keys:
del data['room'] 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: if pop_used_keys:
obj.additional_data = data obj.additional_data = data
elif 'additional_data' in data: elif 'additional_data' in data:
...@@ -565,6 +591,28 @@ class Event(TaggedItemMixin, BackendMixin, models.Model): ...@@ -565,6 +591,28 @@ class Event(TaggedItemMixin, BackendMixin, models.Model):
# we require a configured BigBlueButton integration # we require a configured BigBlueButton integration
return settings.INTEGRATIONS_BBB 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): def _EventAttachment_upload_path(instance, filename):
# file will be uploaded to MEDIA_ROOT/user_<id>/<filename> # file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
......
import logging import logging
from datetime import timedelta from datetime import timedelta
from hashlib import sha1
from typing import TYPE_CHECKING, Dict, List, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
...@@ -9,10 +11,14 @@ from django.utils.translation import gettext_lazy as _ ...@@ -9,10 +11,14 @@ from django.utils.translation import gettext_lazy as _
from ..fields import ConferenceReference from ..fields import ConferenceReference
from ..schedules import ScheduleTypeManager from ..schedules import ScheduleTypeManager
from ..utils import mask_url, str2bool from ..utils import mail2uuid, mask_url, str2bool
from .assemblies import Assembly from .assemblies import Assembly
from .events import Event, EventAttachment, EventParticipant from .events import Event, EventAttachment
from .rooms import Room from .rooms import Room
from .users import PlatformUser
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -112,6 +118,61 @@ class ScheduleSource(models.Model): ...@@ -112,6 +118,61 @@ class ScheduleSource(models.Model):
deadline = latest_import.start + self.import_timeout deadline = latest_import.start + self.import_timeout
return timezone.now() < deadline return timezone.now() < 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 is None and addresses is None:
raise ValueError('You need to provide at least mail_guid or addresses (better: both).')
if mail_guid and not isinstance(mail_guid, UUID):
mail_guid = UUID(mail_guid)
speaker_username = '_speaker_' + str(mail_guid or sha1('\n'.join(sorted(addresses))))
# try to find by the username
if candidate := PlatformUser.objects.filter(username=speaker_username, user_type=PlatformUser.Type.SPEAKER).first():
return candidate, False
# basically, this is a hail-mary-type attempt: 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))
# 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),
}
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): def get_or_create_mapping(self, mapping_type, source_id, create_local_object=True, source_uuid=None, hints: dict | None = None):
""" """
Fetches the local object mapped by the given type and id. Fetches the local object mapped by the given type and id.
...@@ -160,6 +221,13 @@ class ScheduleSource(models.Model): ...@@ -160,6 +221,13 @@ class ScheduleSource(models.Model):
lo = Event(conference=self.conference, pk=source_uuid, assembly=assembly) 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 = self.mappings.create(
mapping_type=mapping_type, mapping_type=mapping_type,
source_id=source_id, source_id=source_id,
...@@ -190,6 +258,10 @@ class ScheduleSource(models.Model): ...@@ -190,6 +258,10 @@ class ScheduleSource(models.Model):
if item_type == 'event': if item_type == 'event':
hints['room_lookup'] = from_dict_args.get('room_lookup') hints['room_lookup'] = from_dict_args.get('room_lookup')
hints['room_name'] = item.get('room') hints['room_name'] = item.get('room')
hints['speaker_lookup'] = from_dict_args.get('speaker_lookup')
elif item_type == 'speaker':
hints['name'] = item.get('public_name') or item.get('name')
hints['addresses'] = item.get('addresses')
mapping, new_mapping = self.get_or_create_mapping( mapping, new_mapping = self.get_or_create_mapping(
mapping_type=item_type, mapping_type=item_type,
...@@ -258,6 +330,7 @@ class ScheduleSource(models.Model): ...@@ -258,6 +330,7 @@ class ScheduleSource(models.Model):
'message': str(err), '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 # ... and delete the incomplete (wrong) mapping
mapping.delete() mapping.delete()
...@@ -331,6 +404,7 @@ class ScheduleSource(models.Model): ...@@ -331,6 +404,7 @@ class ScheduleSource(models.Model):
activity = [] activity = []
events = {} events = {}
rooms = {} rooms = {}
speakers = {}
# derive some flags # derive some flags
cfg = self.import_configuration or {} cfg = self.import_configuration or {}
...@@ -338,7 +412,7 @@ class ScheduleSource(models.Model): ...@@ -338,7 +412,7 @@ class ScheduleSource(models.Model):
replace_conference_slug_prefix = cfg.get('replace_conference_slug_prefix') replace_conference_slug_prefix = cfg.get('replace_conference_slug_prefix')
allow_track = cfg.get('import_tracks') or False 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: if self.assembly:
expected_rooms = list(self.assembly.rooms.values_list('id', flat=True)) expected_rooms = list(self.assembly.rooms.values_list('id', flat=True))
else: else:
...@@ -352,6 +426,71 @@ class ScheduleSource(models.Model): ...@@ -352,6 +426,71 @@ class ScheduleSource(models.Model):
mapping_type=ScheduleSourceMapping.MappingType.EVENT, mapping_type=ScheduleSourceMapping.MappingType.EVENT,
).values_list('local_id', flat=True) ).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"
}
```
"""
# sanity check: verify that required attributes are present
if any(x not in speaker_info for x in ['id', 'public_name']):
raise ValueError('Missing required attribute in speaker_info.')
speaker_id = speaker_info.get('id')
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': speaker_info.get('guid', 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) # first, load the rooms (as they're needed for events)
for r_id, r in data['rooms'].items(): for r_id, r in data['rooms'].items():
...@@ -387,6 +526,7 @@ class ScheduleSource(models.Model): ...@@ -387,6 +526,7 @@ class ScheduleSource(models.Model):
'message': str(err), 'message': str(err),
} }
) )
logging.exception('Import on ScheduleSource %s encountered exception on loading room "%s".', self.pk, r_id)
# then load events # then load events
for e_id, e in data['events'].items(): for e_id, e in data['events'].items():
...@@ -405,6 +545,7 @@ class ScheduleSource(models.Model): ...@@ -405,6 +545,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_kind': self.assembly.is_official if self.assembly else False, # TODO: lookup assembly's room if not given
'allow_track': allow_track, # TODO 'allow_track': allow_track, # TODO
'room_lookup': lambda r_source_id: rooms.get(r_source_id), 'room_lookup': lambda r_source_id: rooms.get(r_source_id),
'speaker_lookup': speaker_lookup,
}, },
) )
except Exception as err: except Exception as err:
...@@ -417,6 +558,7 @@ class ScheduleSource(models.Model): ...@@ -417,6 +558,7 @@ class ScheduleSource(models.Model):
'message': str(err), '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' # flag the non-loaded rooms as 'missing'
for room_id in expected_rooms: for room_id in expected_rooms:
...@@ -519,12 +661,10 @@ class ScheduleSourceMapping(models.Model): ...@@ -519,12 +661,10 @@ class ScheduleSourceMapping(models.Model):
return attachment return attachment
if self.mapping_type == self.MappingType.SPEAKER: if self.mapping_type == self.MappingType.SPEAKER:
participant = EventParticipant.objects.prefetch_related('event').get(pk=self.local_id) speaker = PlatformUser.objects.get(pk=self.local_id)
if self.schedule_source.assembly_id is not None and participant.event.assembly_id != self.schedule_source.assembly_id: if not speaker.is_person:
raise LocalObjectAccessViolation('Assembly of EventParticipant does not match.') raise LocalObjectAccessViolation("Referenced speaker's PlatformUser is not a person.")
if participant.role != EventParticipant.Role.SPEAKER: return speaker
raise LocalObjectAccessViolation('Participant selected is not a speaker.')
return participant
# we don't know about that mapping type, bail out # we don't know about that mapping type, bail out
raise LocalObjectAccessViolation('Unknown mapping.') raise LocalObjectAccessViolation('Unknown mapping.')
......
import logging import logging
import re import re
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import uuid4 from uuid import UUID, uuid4
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
...@@ -24,14 +25,36 @@ from django.utils.timezone import now ...@@ -24,14 +25,36 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ..fields import ConferenceReference from ..fields import ConferenceReference
from ..utils import download_from_url
from ..validators import ImageDimensionValidator
if TYPE_CHECKING: 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 PlatformUser(AbstractUser):
class Type(models.TextChoices): class Type(models.TextChoices):
HUMAN = ('human', _('PlatformUser__type-human')) HUMAN = ('human', _('PlatformUser__type-human'))
SPEAKER = ('speaker', _('PlatformUser__type-speaker'))
SERVICE = ('service', _('PlatformUser__type-service')) SERVICE = ('service', _('PlatformUser__type-service'))
ASSEMBLY = ('assembly', _('PlatformUser__type-assembly')) ASSEMBLY = ('assembly', _('PlatformUser__type-assembly'))
BOT = ('bot', _('PlatformUser__type-bot')) BOT = ('bot', _('PlatformUser__type-bot'))
...@@ -40,6 +63,9 @@ class PlatformUser(AbstractUser): ...@@ -40,6 +63,9 @@ class PlatformUser(AbstractUser):
DARK = ('dark', _('PlatformUser__theme-dark')) DARK = ('dark', _('PlatformUser__theme-dark'))
LIGHT = ('light', _('PlatformUser__theme-light')) 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] 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).""" """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): ...@@ -48,15 +74,31 @@ class PlatformUser(AbstractUser):
) )
uuid = models.UUIDField(default=uuid4, unique=True) uuid = models.UUIDField(default=uuid4, unique=True)
slug = models.SlugField(blank=False, unique=True) slug = models.SlugField(blank=False, unique=True)
# legal stuff display_name = models.CharField(max_length=100, blank=True, help_text=_('PlatformUser__display_name__help'), verbose_name=_('PlatformUser__display_name'))
accepted_speakersagreement = models.BooleanField(
null=True, help_text=_('PlatformUser__accepted_speakersagreement__help'), verbose_name=_('PlatformUser__accepted_speakersagreement')
)
# self portrayal # self portrayal
show_name = models.BooleanField(null=True, help_text=_('PlatformUser__show_name__help'), verbose_name=_('PlatformUser__show_name')) 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_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')) 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')) 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): ...@@ -180,6 +222,11 @@ class PlatformUser(AbstractUser):
except ObjectDoesNotExist: except ObjectDoesNotExist:
return None 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): def get_character_layers(self):
info = self.avatar_config or {} info = self.avatar_config or {}
return info.get('character_layers', []) return info.get('character_layers', [])
...@@ -269,10 +316,14 @@ class PlatformUser(AbstractUser): ...@@ -269,10 +316,14 @@ class PlatformUser(AbstractUser):
return conference.users.filter(user=self, is_staff=True).exists() return conference.users.filter(user=self, is_staff=True).exists()
@property @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: if self.pronouns:
return f'{self.username} ({self.pronouns})' result += f' ({self.pronouns})'
return self.username return result
@property @property
def guardians(self): def guardians(self):
...@@ -286,6 +337,70 @@ class PlatformUser(AbstractUser): ...@@ -286,6 +337,70 @@ class PlatformUser(AbstractUser):
except ObjectDoesNotExist: except ObjectDoesNotExist:
return None 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.')
if existing.user_type != PlatformUser.Type.SPEAKER:
raise NotImplementedError('Updating a PlatformUser which is not a SPEAKER is not supported (yet).')
given_uuid = UUID(data.pop('guid')) if 'guid' in data else None
if existing:
obj = existing
else:
obj = cls()
if given_uuid is not None:
obj.pk = given_uuid
# join the new user into the conference
cm, _ = conference.users.get_or_create(user=obj)
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=''): def __create_slug(self, extension=''):
""" """
recursive function to get a free slug based on the username and an optional extension string. recursive function to get a free slug based on the username and an optional extension string.
...@@ -306,6 +421,9 @@ class PlatformUser(AbstractUser): ...@@ -306,6 +421,9 @@ class PlatformUser(AbstractUser):
if not self.slug: if not self.slug:
self.generate_slug() self.generate_slug()
# update the display name
self.display_name = self.get_display_name()
return super().save(*args, update_fields=update_fields, **kwargs) return super().save(*args, update_fields=update_fields, **kwargs)
def has_conference_staffpermission(self, conference, *perms, need_all=False): def has_conference_staffpermission(self, conference, *perms, need_all=False):
...@@ -359,6 +477,18 @@ class PlatformUser(AbstractUser): ...@@ -359,6 +477,18 @@ class PlatformUser(AbstractUser):
).values_list('address', flat=True) ).values_list('address', flat=True)
) )
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): class UserContact(models.Model):
user = models.ForeignKey(PlatformUser, related_name='contacts', on_delete=models.CASCADE) user = models.ForeignKey(PlatformUser, related_name='contacts', on_delete=models.CASCADE)
......
...@@ -46,6 +46,13 @@ class ScheduleJSONSupport(BaseScheduleSupport): ...@@ -46,6 +46,13 @@ class ScheduleJSONSupport(BaseScheduleSupport):
kind = self.conf_value('kind') 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 { return {
'version': schedule.version(), 'version': schedule.version(),
'rooms': {r['name']: r for r in schedule.rooms()}, 'rooms': {r['name']: r for r in schedule.rooms()},
...@@ -66,7 +73,7 @@ class ScheduleJSONSupport(BaseScheduleSupport): ...@@ -66,7 +73,7 @@ class ScheduleJSONSupport(BaseScheduleSupport):
'is_public': True, 'is_public': True,
'kind': kind, 'kind': kind,
'speakers': e.get('persons', []), '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)), 'additional_data': filter_additional_data(e, self.computed_data(e)),
} }
for e in schedule.events() for e in schedule.events()
......