diff --git a/src/api/serializers.py b/src/api/serializers.py index e0a344ae4a481f727e1d5d525ca9f6a29684a07f..bbad60a25ca8a057c2da409cb7360f97f328f9af 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -11,6 +11,7 @@ from core.models.assemblies import Assembly from core.models.badges import Badge, BadgeToken, BadgeTokenTimeConstraint from core.models.conference import Conference, ConferenceMember, ConferenceTrack from core.models.events import Event +from core.models.links import Link from core.models.messages import DirectMessage from core.models.metanavi import MetaNavItem from core.models.rooms import Room @@ -50,6 +51,56 @@ class ParameterisedHyperlinkedIdentityField(HyperlinkedIdentityField): return reverse(view_name, kwargs=kwargs, request=request, format=format) +class HubHyperlinkedIdentityField(HyperlinkedIdentityField): + """ + Represents the instance, or a property on the instance, using hyperlinking. + + lookup_fields is a tuple of tuples of the form: + ('model_field', 'url_parameter') + + taken from https://github.com/encode/django-rest-framework/issues/1024 + """ + + lookup_fields = (('pk', 'pk'),) + + def __init__(self, *args, **kwargs): + self.lookup_fields = kwargs.pop('lookup_fields', self.lookup_fields) + super().__init__(*args, **kwargs) + + def get_url(self, obj, view_name, request, format): # pylint: disable=redefined-builtin + """ + Given an object, return the URL that hyperlinks to the object. + + May raise a `NoReverseMatch` if the `view_name` and `lookup_field` + attributes are not configured to correctly match the URL conf. + """ + kwargs = {} + for model_field, url_param in self.lookup_fields: + attr = obj + for field in model_field.split('.'): + attr = getattr(attr, field) + kwargs[url_param] = attr + + from core.templatetags.hub_absolute import hub_absolute # pylint: disable=import-outside-toplevel + + # TODO: add request=request, format=format, + return hub_absolute(view_name, **kwargs, i18n=False) + + +class LinkRelatedField(serializers.RelatedField): + """ + A read only field that represents its targets using their + plain string representation. + """ + + def __init__(self, **kwargs): + kwargs['read_only'] = True + super().__init__(**kwargs) + + def to_representation(self, value: Link): + return value.to_dict() + + class ValidatingModelSerializer(serializers.ModelSerializer): def validate(self, data): instance = self.Meta.model(**{field: value for field, value in data.items() if field in self.Meta.model._meta.fields}) @@ -217,17 +268,20 @@ class BadgeTokenUpdateSerializer(BadgeTokenSerializer): class RoomSerializer(HubModelSerializer): assembly = serializers.SlugRelatedField(read_only=True, slug_field='slug') - links = serializers.StringRelatedField( + links = LinkRelatedField( many=True, read_only=True, ) + public_url = HubHyperlinkedIdentityField(view_name='plainui:room', lookup_fields=(('slug', 'slug'),)) + class Meta: model = Room read_only_fields = ['id'] fields = [ 'id', 'name', + 'slug', 'blocked', 'room_type', 'capacity', @@ -235,6 +289,7 @@ class RoomSerializer(HubModelSerializer): 'assembly', 'links', 'backend_link', + 'public_url', ] staff_only_fields = ['blocked', 'backend_link'] diff --git a/src/api/views/rooms.py b/src/api/views/rooms.py index 716e8795e55ec091a9b6322e0da51deab81d6445..82c422a2d1478d65289a8e2f472bf33ad063005e 100644 --- a/src/api/views/rooms.py +++ b/src/api/views/rooms.py @@ -14,7 +14,7 @@ class ConferenceRoomList(ConferenceSlugMixin, generics.ListAPIView): serializer_class = RoomSerializer def get_queryset(self, **kwargs): - return Room.objects.conference_accessible(conference=self.conference).order_by('name') + return Room.objects.conference_accessible(conference=self.conference).order_by('official_room_order', 'name') class ConferenceRoomDetail(ConferenceSlugMixin, generics.RetrieveAPIView): diff --git a/src/backoffice/templates/backoffice/schedule_source-detail.html b/src/backoffice/templates/backoffice/schedule_source-detail.html index 2122ae2de9557aecdc34277179b406c8cf5f687d..ac55db261e5d5ae1c85e163e5d9af5eb09982be3 100644 --- a/src/backoffice/templates/backoffice/schedule_source-detail.html +++ b/src/backoffice/templates/backoffice/schedule_source-detail.html @@ -20,7 +20,7 @@ </div> <div class="card-body"> <p> - Assembly: <strong>{{ object.assembly|default:"<em>WILDCARD</em>" }}</strong> + Assembly: <strong><a href="{% url 'backoffice:assembly-edit' pk=object.assembly.id %}">{{ object.assembly|default:"<em>WILDCARD</em>" }}</a></strong> </p> <p> Type: <strong>{{ object.import_type }}</strong> @@ -108,8 +108,10 @@ {% for mapping in object.mappings.all %} <tr> <td>{{ mapping.get_mapping_type_display }}</td> - <td>{{ mapping.source_id|default:"-" }}</td> - <td>{{ mapping.local_id|default:"-" }}</td> + <td>{{ mapping.source_id|default:'-' }}</td> + <td> + <a href="{{ mapping.local_url }}">{{ mapping.local_id|default:'-' }}</a> + </td> <td>{{ mapping.skip|yesno }}</td> <td> </td> </tr> diff --git a/src/backoffice/templates/backoffice/schedule_source_import-detail.html b/src/backoffice/templates/backoffice/schedule_source_import-detail.html index 8b7e2c2597d29b28450036f892667917660171ba..ec3be03022ad3a310bf8c4ac15812b7502b0a77a 100644 --- a/src/backoffice/templates/backoffice/schedule_source_import-detail.html +++ b/src/backoffice/templates/backoffice/schedule_source_import-detail.html @@ -95,7 +95,7 @@ {{ item.source_id|default:"-/-" }} </td> <td class="{% if item.action == "seen" %}text-muted{% elif item.action == "error" %}text-danger{% elif item.action == "missing" or item.action == "removed" %}text-warning{% elif item.action == "added" %}text-success{% endif %}"> - {{ item.local_id|default:"-/-" }} + <a href="{{ item.local_url }}">{{ item.local_id|default:"-/-" }}</a> </td> </tr> {% endfor %} diff --git a/src/core/admin.py b/src/core/admin.py index 8158cdcd2d8298de242c0e11d39fd529859fa8aa..d9ee6d63023dc4ceef23e7dcd652ed3192127f44 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -750,11 +750,11 @@ class RoomShareAdmin(admin.ModelAdmin): class RoomAdmin(admin.ModelAdmin): - list_display = ['conference', 'assembly', 'name', 'room_type', 'blocked'] + list_display = ['conference', 'assembly', 'name', 'room_type', 'blocked', 'official_room_order', 'id'] list_display_links = ['name'] list_filter = ['conference', 'room_type', 'backend_status', 'blocked', 'is_official', 'is_public_fahrplan'] save_as = True - search_fields = ['assembly__name', 'name', 'slug'] + search_fields = ['assembly__name', 'name', 'slug', 'id'] inlines = [RoomLinkInline, RoomShareInline, TagsInline] readonly_fields = ['id', 'occupants', 'reserve_capacity'] ordering = ('-conference__id', F('assembly__is_official').desc(nulls_last=True), 'assembly__name', F('capacity').desc(nulls_last=True), 'name') diff --git a/src/core/models/links.py b/src/core/models/links.py index 86764e7e409e67adef65819949d2b20d5600766f..65ded766dd94077ea2e2c23e69fe18d70a3f5a38 100644 --- a/src/core/models/links.py +++ b/src/core/models/links.py @@ -5,7 +5,7 @@ from django.core.validators import URLValidator from django.db import models from django.utils.translation import gettext_lazy as _ -from core.utils import resolve_link +from core.utils import resolve_internal_url, resolve_link class Link(models.Model): @@ -62,6 +62,14 @@ class Link(models.Model): except ValidationError: raise ValidationError({'link': _('Link__link__must_be_url')}) + def to_dict(self): + return { + 'type': self.link_type, + 'name': self.name, + 'uri': self.link, + 'url': resolve_internal_url(self.link), + } + def __str__(self) -> str: return self.name diff --git a/src/core/models/rooms.py b/src/core/models/rooms.py index b54819c259eb740fe27d5810a9ede9ef16a73cac..cef75f46a77b2b89f46a675ac1a6e2c22217d303 100644 --- a/src/core/models/rooms.py +++ b/src/core/models/rooms.py @@ -273,6 +273,11 @@ class Room(BackendMixin, models.Model): return link.link return None + def get_absolute_url(self): + from core.templatetags.hub_absolute import hub_absolute # pylint: disable=import-outside-toplevel + + return hub_absolute('plainui:room', slug=self.slug, i18n=settings.ARCHIVE_MODE) + def __create_slug(self, extension='', max_length: int = 50): """ recursive function to generate a free room slug based on the room name @@ -501,5 +506,13 @@ class RoomLink(models.Model): if not resolve_internal_url(self.link, fallback_as_is=False): raise ValidationError({'link': _('RoomLink__link__must_be_url')}) + def to_dict(self): + return { + 'type': self.link_type, + 'name': self.name, + 'uri': self.link, + 'url': resolve_internal_url(self.link), + } + def __str__(self): return self.name diff --git a/src/core/models/schedules.py b/src/core/models/schedules.py index aed616a497c2659faa6e4b1bf292f6dd723c06bc..6ef85f09bb2e1221cc50dba818a075998acc892d 100644 --- a/src/core/models/schedules.py +++ b/src/core/models/schedules.py @@ -348,6 +348,9 @@ class ScheduleSource(models.Model): ) 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 if it was created + if new_mapping: + mapping.delete() return 'error' else: @@ -427,7 +430,7 @@ class ScheduleSource(models.Model): allow_track = cfg.get('import_tracks') or False # note down all existing rooms, events and speakers so that we can call out the missing ones - if self.assembly: + if self.assembly and cfg.get('missing_rooms') != 'ignore': expected_rooms = list(self.assembly.rooms.values_list('id', flat=True)) else: expected_rooms = list( @@ -690,6 +693,18 @@ class ScheduleSourceMapping(models.Model): # we don't know about that mapping type, bail out raise LocalObjectAccessViolation('Unknown mapping.') + @property + def local_url(self): + if self.mapping_type == self.MappingType.ROOM: + return Room.admin_url(self.local_id) + + if self.mapping_type == self.MappingType.EVENT: + return Event.admin_url(self.local_id) + if self.mapping_type == self.MappingType.SPEAKER: + return '' + + return '' + @property def local_object(self): if self._local_object is None: @@ -824,18 +839,14 @@ class ScheduleSourceImport(models.Model): # create list of unique errors for summary msgs = list({x['message'].split('\n')[0] for x in errors}) - stats = ( - ', '.join( - (t + '=' + str(sum(1 for x in activity if x['action'] == t))) - for t in ['added', 'changed', 'seen', 'deleted', 'missing', 'error', 'skipped'] - ) - + ' \n' - + ' \n'.join(msgs) - ) - self.summary = f"{data.get('version') or ''}\nDONE: {stats}"[:200] + stats = {t: sum(1 for x in activity if x['action'] == t) for t in ['added', 'changed', 'seen', 'deleted', 'missing', 'error', 'skipped']} + stats_str = ', '.join([f'{v}={k}' for (k, v) in stats.items() if v]) + ' \n' + ' \n'.join(msgs) + self.summary = f"{data.get('version') or ''}\nDONE: {stats_str}"[:200] - if len(errors) > len(activity) / 2: - raise Exception('Too many errors, aborting import: ' + stats) + # add debug option to disable abort feature, as it might hide the actual error messages + if len(errors) > len(activity) / 2 and self.schedule_source.import_configuration.get('debug') is not True: + self.save(update_fields=['summary']) + raise Exception('Too many errors, aborting import: ' + stats_str, errors) self.save(update_fields=['data', 'summary']) diff --git a/src/core/models/shared.py b/src/core/models/shared.py index 77d69925df55fca9553719454a82412b1a4070de..967c37ee60402d618d187cd832c8a2f91aefce67 100644 --- a/src/core/models/shared.py +++ b/src/core/models/shared.py @@ -3,6 +3,12 @@ from django.utils.translation import gettext_lazy as _ class BackendMixin(models.Model): + @classmethod + def admin_url(cls, pk): + from django.urls import reverse + + return reverse(f'admin:{cls._meta.app_label}_{cls._meta.model_name}_change', args=[pk]) + class Meta: abstract = True