From a8c021cdbabb4cbd668a7e282a449116d6d6b516 Mon Sep 17 00:00:00 2001
From: Andreas Hubel <andi@saerdnaer.de>
Date: Thu, 26 Dec 2024 01:59:28 +0100
Subject: [PATCH] feat(schedulejson): add support for event series aka
 submissions with multiple timeslots

---
 src/backoffice/views/schedules.py  |   2 +-
 src/core/models/schedules.py       | 156 +++++++++++++++--------------
 src/core/schedules/schedulejson.py |  66 ++++++++----
 3 files changed, 128 insertions(+), 96 deletions(-)

diff --git a/src/backoffice/views/schedules.py b/src/backoffice/views/schedules.py
index b4ffa0b23..b69c8792f 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/schedules.py b/src/core/models/schedules.py
index ad3ef19f8..d39c6087e 100644
--- a/src/core/models/schedules.py
+++ b/src/core/models/schedules.py
@@ -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 f6c92eaab..cb13339cc 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]
-- 
GitLab