diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po index 750a6417b354d2be5a65f13c32b07b266e130182..3c2f31a824dc320d3d2e529b36b8629ab0ed1f59 100644 --- a/src/backoffice/locale/en/LC_MESSAGES/django.po +++ b/src/backoffice/locale/en/LC_MESSAGES/django.po @@ -1589,7 +1589,7 @@ msgid "TeamMember__actions" msgstr "Actions" msgid "TeamMember__demote_button" -msgstr "Remote management rights" +msgstr "Remove management rights" msgid "TeamMember__promote_button" msgstr "Add management rights" diff --git a/src/backoffice/templates/backoffice/schedule_source_import-detail.html b/src/backoffice/templates/backoffice/schedule_source_import-detail.html index ec3be03022ad3a310bf8c4ac15812b7502b0a77a..72d72c70c46e5ffbce09898d389c6ac3e48e665e 100644 --- a/src/backoffice/templates/backoffice/schedule_source_import-detail.html +++ b/src/backoffice/templates/backoffice/schedule_source_import-detail.html @@ -3,13 +3,18 @@ {% load i18n %} {% block content %} + <div class="float-end"> + <form action="{% url 'backoffice:schedulesource-import' pk=object.schedule_source.id %}" + method="post"> + {% csrf_token %} + <button type="submit" class="btn btn-outline-primary">re-run import</button> + </form> - {% if object.data %} - <div class="float-end"> + {% if object.data %} <a href="{% url 'backoffice:schedulesourceimport-detail-data' pk=object.pk %}" class="btn btn-outline-primary">data as JSON</a> - </div> - {% endif %} + {% endif %} + </div> <h1> ScheduleSourceImport for "<span title="{{ object.schedule_source.assembly.name }}">{{ object.schedule_source.assembly.slug }}</span>" @@ -28,9 +33,7 @@ State: <strong class="{{ object.text_color_class }}">{{ object.state }}</strong> </p> <p> - Summary: - <br /> - <strong>{{ object.summary|linebreaks }}</strong> + Summary: <strong>{{ object.summary|linebreaksbr }}</strong> </p> <p> Start: <strong title="{{ object.start }}">{{ object.start|naturaltime }}</strong> diff --git a/src/backoffice/views/schedules.py b/src/backoffice/views/schedules.py index b4ffa0b2311124f5867551824970ba9f418c9986..b69c8792f9e4a24eac7fef4d0fb9dabf22c89f91 100644 --- a/src/backoffice/views/schedules.py +++ b/src/backoffice/views/schedules.py @@ -128,7 +128,7 @@ class ScheduleSourceImportDetailView(ScheduleAdminMixin, DetailView): if data := ctx['object'].data: ctx['activity'] = sorted( data.get('_activity', []), - key=lambda item: (action2sort.get(item['action'], 99), type2sort.get(item['type'], 99), item['source_id']), + key=lambda item: (action2sort.get(item['action'], 99), type2sort.get(item['type'], 99), str(item['source_id'])), ) else: ctx['activity'] = None diff --git a/src/core/models/events.py b/src/core/models/events.py index 9908758a1ed23a87fc590d33507e0eac0284204f..12e855e0e3cf13154b95f72f14fa03623edce845 100644 --- a/src/core/models/events.py +++ b/src/core/models/events.py @@ -483,8 +483,9 @@ class Event(TaggedItemMixin, BackendMixin, ActivityLogMixin, models.Model): sos = self.kind == self.Kind.SELF_ORGANIZED event_type = _('SoS') if sos else _('event') - if Event.objects.exclude(id=self.id).filter(conference=self.conference, assembly=self.assembly, name=self.name).exists(): - errors['name'] = ValidationError(_('Event__name__already_exists %(event_type)s') % {'event_type': event_type}, code='duplicate') + if settings.BLOCK_DUPLICATE_EVENT_NAMES: + if Event.objects.exclude(id=self.id).filter(conference=self.conference, assembly=self.assembly, name=self.name).exists(): + errors['name'] = ValidationError(_('Event__name__already_exists %(event_type)s') % {'event_type': event_type}, code='duplicate') # SoS assembly setting on conference must be set if self.assembly and self.conference and sos: diff --git a/src/core/models/schedules.py b/src/core/models/schedules.py index 6ef85f09bb2e1221cc50dba818a075998acc892d..d39c6087e179e5e1e983e23188b5a5f3585d6d22 100644 --- a/src/core/models/schedules.py +++ b/src/core/models/schedules.py @@ -165,9 +165,9 @@ class ScheduleSource(models.Model): raise ValueError('Multiple candidate speakers found: ' + '; '.join(str(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 + # 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].user, False # the expected case: nothing found, create a new one name_split = name.split(' ') @@ -206,51 +206,56 @@ class ScheduleSource(models.Model): return mapping, False except ObjectDoesNotExist: - lo = None - if create_local_object: - if mapping_type == ScheduleSourceMapping.MappingType.ROOM: - try: - if source_uuid: - lo = Room.objects.get(conference=self.conference, pk=source_uuid) - elif self.assembly: - lo = Room.objects.get(conference=self.conference, assembly=self.assembly, name__iexact=source_id) - else: - lo = Room.objects.get(conference=self.conference, name__iexact=source_id) - except Room.MultipleObjectsReturned: - raise ValueError('Room name is not unique, please provide room guid') - except Room.DoesNotExist: - if self.assembly: - lo = Room(conference=self.conference, pk=source_uuid, assembly=self.assembly) - else: - raise ValueError('Cannot create room for wildcard schedule source.') - - elif mapping_type == ScheduleSourceMapping.MappingType.EVENT: - assembly = self.assembly - if assembly is None: - # wildcard schedulesource: lookup assembly based on referenced room - if hints and 'room_lookup' in hints and 'room_name' in hints: - r = hints['room_lookup'](hints['room_name']) - assembly = r.assembly - + try: + # we could not find the mapping, but we have a source_uuid -> try to find the local object by that + mapping = self.mappings.get(mapping_type=mapping_type, local_id=source_uuid) + return mapping, False + except ObjectDoesNotExist: + lo = None + if create_local_object: + if mapping_type == ScheduleSourceMapping.MappingType.ROOM: + try: + if source_uuid: + lo = Room.objects.get(conference=self.conference, pk=source_uuid) + elif self.assembly: + lo = Room.objects.get(conference=self.conference, assembly=self.assembly, name__iexact=source_id) + else: + lo = Room.objects.get(conference=self.conference, name__iexact=source_id) + except Room.MultipleObjectsReturned: + raise ValueError('Room name is not unique, please provide room guid') + except Room.DoesNotExist: + if self.assembly: + lo = Room(conference=self.conference, pk=source_uuid, assembly=self.assembly) + else: + raise ValueError('Cannot create room for wildcard schedule source.') + + elif mapping_type == ScheduleSourceMapping.MappingType.EVENT: + assembly = self.assembly if assembly is None: - raise NotImplementedError('Event on wildcard schedulesource could not be matched to an 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_type=mapping_type, - source_id=source_id, - local_id=lo.pk, - local_object=lo, - ) - return mapping, True + # wildcard schedulesource: lookup assembly based on referenced room + if hints and 'room_lookup' in hints and 'room_name' in hints: + r = hints['room_lookup'](hints['room_name']) + assembly = r.assembly + + if assembly is None: + raise NotImplementedError('Event on wildcard schedulesource could not be matched to an 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_type=mapping_type, + source_id=source_id, + local_id=lo.pk, + local_object=lo, + ) + return mapping, True def _load_dataitem(self, activity: list, item: dict, item_source_id: str, item_type: str, expected_items: list, items: dict, from_dict_args: dict) -> str: if item_source_id is None: @@ -549,36 +554,41 @@ class ScheduleSource(models.Model): 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(): - try: - if replace_conference_slug_prefix and e.get('slug', '').startswith(replace_conference_slug_prefix): - e['slug'] = self.conference.slug + e['slug'][len(replace_conference_slug_prefix) :] + for e_id, entries in data['events'].items(): + has_multiple_slots = isinstance(entries, list) + # `entries` can either by an single event of a group of events with the same id + for e in entries if has_multiple_slots else [entries]: + source_id = e.get('guid') if has_multiple_slots else e_id + try: + if replace_conference_slug_prefix and e.get('slug', '').startswith(replace_conference_slug_prefix): + e['slug'] = self.conference.slug + e['slug'][len(replace_conference_slug_prefix) :] + + self._load_dataitem( + activity=activity, + item=e, + item_source_id=source_id, + item_type='event', + expected_items=expected_events, + items=events, + from_dict_args={ + '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': rooms.get, + 'speaker_lookup': speaker_lookup, + }, + ) - self._load_dataitem( - activity=activity, - item=e, - item_source_id=e_id, - item_type='event', - expected_items=expected_events, - items=events, - from_dict_args={ - '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': rooms.get, - 'speaker_lookup': speaker_lookup, - }, - ) - except Exception as err: - activity.append( - { - 'action': 'error', - 'type': 'event', - 'source_id': e_id, - 'local_id': e.get('uuid', None), - 'message': str(err), - } - ) - logging.exception('Import on ScheduleSource %s encountered exception on loading event "%s".', self.pk, e_id) + except Exception as err: + activity.append( + { + 'action': 'error', + 'type': 'event', + 'source_id': source_id, + 'local_id': e.get('guid', None), + '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: diff --git a/src/core/schedules/schedulejson.py b/src/core/schedules/schedulejson.py index f6c92eaab1bd7d66db490e0ba30d121b2c68ce73..cb13339cc48f083859464b668ed48c4f46ca969b 100644 --- a/src/core/schedules/schedulejson.py +++ b/src/core/schedules/schedulejson.py @@ -69,31 +69,45 @@ class ScheduleJSONSupport(BaseScheduleSupport): return f'{host}{uri}' return uri + def format_event(e): + """ + transforms an raw ScheduleJSON event into a HUB event dict, which can ne used in Event.from_dict() + + :param e: event dict from ScheduleJSON class + :return: dict with data for Event.from_dict() + """ + return { + 'guid': e.get('guid'), + 'slug': e.get('slug').split(f"{e.get('id')}-")[1][0:150].strip('-') or e.get('slug')[0:150].strip('-'), + 'name': e.get('title'), + 'language': e.get('language'), + 'abstract': e.get('abstract') or '', + 'description': e.get('description') or '', + 'description_en': e.get('description') or '', + 'description_de': schedule_de.event(e.get('guid')).get('description') or '', + 'track': e.get('track'), + 'room': e.get('room'), + 'schedule_start': e.get('date'), + 'schedule_duration': str(schedule_time_to_timedelta(e.get('duration'))), + 'is_public': True, + 'kind': kind, + 'speakers': e.get('persons', []), + 'banner_image_url': ensure_full_url(e.get('logo')), + 'additional_data': filter_additional_data(e, self.computed_data(e), other_fields_to_drop), + } + + def format_group(events): + # if the submission has only one slot, we return a single event - to be backwards compatible + if len(events) == 1: + return format_event(events[0]) + + # if we have a submisson with multiple slots, we return a list of events + return [format_event(e) for e in events] + return { 'version': schedule.version(), 'rooms': {r['name']: r for r in schedule.rooms()}, - 'events': { - e.get('id'): { - 'guid': e.get('guid'), - 'slug': e.get('slug').split(f"{e.get('id')}-")[1][0:150].strip('-') or e.get('slug')[0:150].strip('-'), - 'name': e.get('title'), - 'language': e.get('language'), - 'abstract': e.get('abstract') or '', - 'description': e.get('description') or '', - 'description_en': e.get('description') or '', - 'description_de': schedule_de.event(e.get('guid')).get('description') or '', - 'track': e.get('track'), - 'room': e.get('room'), - 'schedule_start': e.get('date'), - 'schedule_duration': str(schedule_time_to_timedelta(e.get('duration'))), - 'is_public': True, - 'kind': kind, - 'speakers': e.get('persons', []), - 'banner_image_url': ensure_full_url(e.get('logo')), - 'additional_data': filter_additional_data(e, self.computed_data(e), other_fields_to_drop), - } - for e in schedule.events() - }, + 'events': {k: format_group(e) for k, e in schedule.events_grouped(by='id').items()}, } def computed_data(self, event: dict): @@ -171,6 +185,14 @@ class ScheduleJSON: for room in day.get('rooms'): yield from day.get('rooms')[room] + def events_grouped(self, by='id'): + groups = {} + for event in self.events(): + if event[by] not in groups: + groups[event[by]] = [] + groups[event[by]].append(event) + return groups + def event(self, guid): if guid in self._events: return self._events[guid] diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py index ddfc524ffb7179449d24ed8bfc128177a73b91cd..64a6e524415d1484deee53071c071d1b6491d577 100644 --- a/src/hub/settings/base.py +++ b/src/hub/settings/base.py @@ -118,6 +118,7 @@ env = environ.FileAwareEnv( CSP_BASE_URI=(list, ["'self'"]), CSP_INCLUDE_NONCE_IN=(list, ['script-src']), ADDITIONAL_LINK_PROTOCOLS=(dict, {}), + BLOCK_DUPLICATE_EVENT_NAMES=(bool, False), ) @@ -546,6 +547,12 @@ METRICS_SERVER_IPS = env('METRICS_SERVER_IPS') ADDITIONAL_LINK_PROTOCOLS = env.dict('ADDITIONAL_LINK_PROTOCOLS', cast={'value': str}) +# ---------------------------------- +# Internal Schedule Support +# ---------------------------------- + +BLOCK_DUPLICATE_EVENT_NAMES = env.bool('BLOCK_DUPLICATE_EVENT_NAMES') + # ---------------------------------- # External Schedule Support # ----------------------------------