diff --git a/src/api/urls.py b/src/api/urls.py index fc95fb5b8b303f617498d1b6f7153f53325efe07..011ccc3f9bbc44b8ad2fcc8bcf309f94169a1992 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ path('c/<slug:conference>/assembly/<slug:assembly>/issue_redeem_token/<str:issuing_token>', badges.BadgeTokenListCreate.as_view(), name='badge-token-create-with-token'), path('c/<slug:conference>/assembly/<slug:assembly>/badges/<uuid:pk>', badges.BadgeTokenListCreate.as_view(), name='badge-token-list-create'), + path('c/<slug:conference>/map/poi.json', maps.PoiExportView.as_view(), name='map-poi'), path('c/<slug:conference>/map/assemblies/poi.json', maps.AssembliesPoiExportView.as_view(), name='map-assemblies-poi'), path('c/<slug:conference>/map/assemblies/areas.json', maps.AssembliesAreasExportView.as_view(), name='map-assemblies-areas'), path('c/<slug:conference>/badges/redeem_token', badges.redeem_badge_token, name='badge-redeem'), diff --git a/src/api/views/maps.py b/src/api/views/maps.py index 078157d6717a50683f424f47f1dda667474c61e6..2129db883a89c0a68106e4b2e62cae36132105a2 100644 --- a/src/api/views/maps.py +++ b/src/api/views/maps.py @@ -5,29 +5,72 @@ from django.contrib.gis.gdal import SpatialReference, CoordTransform from rest_framework.views import APIView from core.models.assemblies import Assembly +from core.models.map import MapPOI from core.models.conference import ConferenceExportCache from .mixins import ConferenceSlugMixin +_cts = {} # cache of CoordTransforms (if needed) + + +def get_field_geojson(value, srid: int, ct_cache: dict = None): + if value.srid != srid: + if ct_cache is None: + ct_cache = {} + if value.srid not in ct_cache: + srs = SpatialReference(srid) + ct_cache[value.srid] = CoordTransform(value.srs, srs) + value.transform(ct_cache[value.srid]) + return json.loads(value.geojson) + + +class PoiExportView(ConferenceSlugMixin, APIView): + def get_geojson(self): + srid = 4326 # RFC7946 mandates WGS84 + ct_cache = {} + features = [] + result = { + 'type': 'FeatureCollection', + # 'crs': {'type': 'name', 'properties': {'name': f'EPSG:{srid}'}}, # deprecated, not in RFC7946 + 'features': features, + } + + for poi in MapPOI.objects.filter(conference=self.conference, visible=True).exclude(location_point=None): + feature = { + 'type': 'Feature', + 'id': str(poi.id), + 'geometry': get_field_geojson(poi.location_point, srid=srid, ct_cache=ct_cache), + 'properties': { + 'layer': poi.get_location_layer_index(), + 'type': 'poi', + 'name': poi.name, + 'official': poi.is_official, + }, + } + features.append(feature) + + return result + + def get(self, request, *args, **kwargs): + cache_id = 'geojson-poi' + + return ConferenceExportCache.handle_http_request( + request=request, + conference=self.conference, + type=ConferenceExportCache.Type.MAP, + ident=cache_id, + content_type='application/geo+json', + result_func=lambda: json.dumps(self.get_geojson()), + ) + + class AssembliesExportView(ConferenceSlugMixin, APIView, metaclass=abc.ABCMeta): geometry_field = None - _cts = {} # cache of CoordTransforms (if needed) def get_queryset(self): exportable_states = Assembly.PLACED_STATES + [Assembly.State.HIDDEN] return Assembly.objects.filter(conference=self.conference, state_assembly__in=exportable_states) - @staticmethod - def get_field_geojson(value, srid: int, ct_cache: dict = None): - if value.srid != srid: - if ct_cache is None: - ct_cache = {} - if value.srid not in ct_cache: - srs = SpatialReference(srid) - ct_cache[value.srid] = CoordTransform(value.srs, srs) - value.transform(ct_cache[value.srid]) - return json.loads(value.geojson) - def get_geometry_field(self, obj): return getattr(obj, self.geometry_field) @@ -49,8 +92,10 @@ class AssembliesExportView(ConferenceSlugMixin, APIView, metaclass=abc.ABCMeta): feature = { 'type': 'Feature', 'id': assembly.slug, - 'geometry': self.get_field_geojson(geometry, srid=srid, ct_cache=ct_cache), + 'geometry': get_field_geojson(geometry, srid=srid, ct_cache=ct_cache), 'properties': { + 'layer': assembly.get_location_layer_index(), + 'type': 'assembly', 'name': assembly.name, 'official': assembly.is_official, 'cluster': assembly.is_cluster, diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po index aaca57d4f704d45a971e367a7d1eb3842d23e38b..a3875c1c295e85a1c53a891cf77d6bd9214c52c6 100644 --- a/src/backoffice/locale/de/LC_MESSAGES/django.po +++ b/src/backoffice/locale/de/LC_MESSAGES/django.po @@ -747,6 +747,9 @@ msgstr "Vouchers" msgid "nav_channels" msgstr "Channels-Team" +msgid "nav_map" +msgstr "Karte" + msgid "nav_users" msgstr "Teilnehmer" @@ -819,6 +822,38 @@ msgstr "Noch keinen Account?" msgid "registration_password_reset_link" msgstr "Passwort vergessen?" +# use translation from core +msgid "MapLayer" +msgstr "" + +# use translation from core +msgid "MapLayers" +msgstr "" + +# use translation from core +msgid "MapLayer__index" +msgstr "" + +# use translation from core +msgid "MapLayer__name" +msgstr "" + +# use translation from core +msgid "MapPOI" +msgstr "" + +# use translation from core +msgid "MapPOIs" +msgstr "" + +# use translation from core +msgid "MapPOI__visible" +msgstr "" + +# use translation from core +msgid "MapPOI__name" +msgstr "" + msgid "Project-new" msgstr "Neues Projekt" @@ -1392,6 +1427,9 @@ msgstr "Selforganized Session aktualisiert" msgid "Event--sos-removed%s" msgstr "Selforganized Session %s gelöscht" +msgid "all" +msgstr "alle" + msgid "backoffice:assembly-organisational-data" msgstr "Organisatorisches" diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po index fe780fb909aac8eb81969642e7e7a4c14f418585..2a6575fb016d6f02887dc1978dff01757d639857 100644 --- a/src/backoffice/locale/en/LC_MESSAGES/django.po +++ b/src/backoffice/locale/en/LC_MESSAGES/django.po @@ -746,6 +746,9 @@ msgstr "Vouchers" msgid "nav_channels" msgstr "channels team" +msgid "nav_map" +msgstr "map" + msgid "nav_users" msgstr "users" @@ -818,6 +821,38 @@ msgstr "No account yet?" msgid "registration_password_reset_link" msgstr "Forgot password?" +# use translation from core +msgid "MapLayer" +msgstr "" + +# use translation from core +msgid "MapLayers" +msgstr "" + +# use translation from core +msgid "MapLayer__index" +msgstr "" + +# use translation from core +msgid "MapLayer__name" +msgstr "" + +# use translation from core +msgid "MapPOI" +msgstr "" + +# use translation from core +msgid "MapPOIs" +msgstr "" + +# use translation from core +msgid "MapPOI__visible" +msgstr "" + +# use translation from core +msgid "MapPOI__name" +msgstr "" + msgid "Project-new" msgstr "new project" @@ -1390,6 +1425,9 @@ msgstr "selforganized session updated" msgid "Event--sos-removed%s" msgstr "selforganized session %s deleted" +msgid "all" +msgstr "all" + msgid "backoffice:assembly-organisational-data" msgstr "Organisational Data" diff --git a/src/backoffice/templates/backoffice/base.html b/src/backoffice/templates/backoffice/base.html index 81dd727f1c838157ff72333a4c756d57d2a28226..d078c94d4657f7f4c2b78243b5335563b1f7b8d6 100644 --- a/src/backoffice/templates/backoffice/base.html +++ b/src/backoffice/templates/backoffice/base.html @@ -44,6 +44,11 @@ <a class="nav-link{% if active_page == 'channels' %} active{% endif %}" href="{% url 'backoffice:channels' %}">{% trans "nav_channels" %}{% if active_page == 'channels' %} <span class="visually-hidden">{{ activetab_srmarker }}</span>{% endif %}</a> </li> {% endif %} + {% if has_map %} + <li class="nav-item"> + <a class="nav-link{% if active_page == 'map' %} active{% endif %}" href="{% url 'backoffice:map-poi-list' %}">{% trans "nav_map" %}{% if active_page == 'map' %} <span class="visually-hidden">{{ activetab_srmarker }}</span>{% endif %}</a> + </li> + {% endif %} {% if has_users %} <li class="nav-item"> <a class="nav-link{% if active_page == 'users' %} active{% endif %}" href="{% url 'backoffice:users' %}">{% trans "nav_users" %}{% if active_page == 'users' %} <span class="visually-hidden">{{ activetab_srmarker }}</span>{% endif %}</a> diff --git a/src/backoffice/templates/backoffice/map_layer_form.html b/src/backoffice/templates/backoffice/map_layer_form.html new file mode 100644 index 0000000000000000000000000000000000000000..c84d6aa07f4e1528de2323474bb8e03afa4f1fd6 --- /dev/null +++ b/src/backoffice/templates/backoffice/map_layer_form.html @@ -0,0 +1,41 @@ +{% extends 'backoffice/base.html' %} +{% load django_bootstrap5 %} +{% load i18n %} + +{% block title %} +{{ object.name }} +{% endblock %} + +{% block content %} +<div class="row mb-3"> + <div class="col-md-12"> + <form action="" method="POST" enctype="multipart/form-data">{% csrf_token %} + <div class="card border-default"> + <div class="card-header bg-default"> + {% if object.id %} + <span class="text-muted float-end text-end me-2" style="font-size: 50%;">ID: <strong>{{ object.id }}</strong></span> + {% trans 'MapLayer' %} "{{ object.name }}" + {% else %} + {% trans 'MapLayer' %} <i class="bi bi-plus-circle"></i> + {% endif %} + </div> + <div class="card-body"> + {% bootstrap_field form.index %} + <div class="row"> + <div class="col-md-6"> + {% bootstrap_field form.name_de %} + </div> + <div class="col-md-6"> + {% bootstrap_field form.name_en %} + </div> + </div> + </div> + <div class="card-body"> + <button type="submit" class="btn btn-primary">{% trans "save" %}</button> + </div> + </div> + </form> + </div> +</div> + +{% endblock %} diff --git a/src/backoffice/templates/backoffice/map_layer_list.html b/src/backoffice/templates/backoffice/map_layer_list.html new file mode 100644 index 0000000000000000000000000000000000000000..d7e98e79c4edfe3850286d65914d21de73e09682 --- /dev/null +++ b/src/backoffice/templates/backoffice/map_layer_list.html @@ -0,0 +1,73 @@ +{% extends 'backoffice/base.html' %} +{% load humanize %} +{% load i18n %} +{% load static %} + +{% block htmlhead %} + <link rel="stylesheet" href="{% static 'datatables/datatables.min.css' %}"> +{% endblock %} + +{% block scripts %} + <script src="{% static 'datatables/datatables.min.js' %}"></script> + <script> + $(document).ready(function() { + $('#pois').DataTable({ + pageLength: 100, + language: { + "decimal": "", + "emptyTable": "{% trans 'assembly_info_no_data' %}", + "info": "{% trans 'assembly_info_paginated' %}", + "infoEmpty": "{% trans 'assembly_info_empty' %}", + "infoFiltered": "{% trans 'assembly_info_filtered' %}", + "infoPostFix": "", + "thousands": " ", + "lengthMenu": "{% trans 'assembly_paginate_menu' %}", + "loadingRecords": "LOADING ...", + "processing": "Processing...", + "search": "{% trans 'assembly_search' %}", + "zeroRecords": "{% trans 'assembly_noentries' %}", + "paginate": { + "first": "{% trans 'assembly_paginate_first' %}", + "last": "{% trans 'assembly_paginate_last' %}", + "next": "{% trans 'assembly_paginate_next' %}", + "previous": "{% trans 'assembly_paginate_previous' %}" + }, + "aria": { + "sortAscending": ": activate to sort column ascending", + "sortDescending": ": activate to sort column descending" + } + } + }); + }); + </script> +{% endblock %} + +{% block content %} + +<div class="card"> + <div class="card-header"> + <a href="{% url 'backoffice:map-layer-create' %}" class="float-end btn btn-sm btn-primary"><i class="bi bi-plus-circle"></i> {% trans 'add' %}</a> + {% trans 'MapLayers' %} + </div> + <div class="card-body"> + + <table class="table table-sm" id="pois"> + <thead> + <tr> + <th>{% trans "MapLayer__index" %}</th> + <th>{% trans "MapLayer__name" %}</th> + </tr> + </thead> + <tbody> + {% for layer in object_list %} + <tr> + <td>{{ layer.index }}</td> + <td><a href="{% url 'backoffice:map-layer-edit' pk=layer.pk %}">{{ layer.name }}</a></td> + </tr> + {% endfor %} + </tbody> + </table> + + </div> +</div> +{% endblock %} diff --git a/src/backoffice/templates/backoffice/map_poi_form.html b/src/backoffice/templates/backoffice/map_poi_form.html new file mode 100644 index 0000000000000000000000000000000000000000..4c71ac78da23e4c85105b6da16221dd68253642c --- /dev/null +++ b/src/backoffice/templates/backoffice/map_poi_form.html @@ -0,0 +1,55 @@ +{% extends 'backoffice/base.html' %} +{% load django_bootstrap5 %} +{% load i18n %} + +{% block title %} +{{ object.name }} +{% endblock %} + +{% block content %} +<div class="row mb-3"> + <div class="col-md-12"> + <form action="" method="POST" enctype="multipart/form-data">{% csrf_token %} + <div class="card border-default"> + <div class="card-header bg-default"> + {% if object.id %} + <span class="text-muted float-end text-end me-2" style="font-size: 50%;">ID: <strong>{{ object.id }}</strong></span> + {% trans 'MapPOI' %} "{{ object.name }}" + {% else %} + {% trans 'MapPOI' %} <i class="bi bi-plus-circle"></i> + {% endif %} + </div> + <div class="card-body"> + {% bootstrap_field form.visible %} + {% bootstrap_field form.is_official %} + <div class="row"> + <div class="col-md-6"> + {% bootstrap_field form.name_de %} + </div> + <div class="col-md-6"> + {% bootstrap_field form.name_en %} + </div> + </div> + <div class="row"> + <div class="col-md-6"> + {% bootstrap_field form.description_de %} + </div> + <div class="col-md-6"> + {% bootstrap_field form.description_en %} + </div> + </div> + + {% bootstrap_field form.location_layer %} + {% include "core/map.html" with map_config=conference.map_config.backoffice poi_id="poi" areas_id="areas" %} + <input type="hidden" name="location_point" id="poi" value="{{ poi.get_location_point_as_json }}"> + + </div> + <div class="card-body"> + <button type="submit" class="btn btn-primary">{% trans "save" %}</button> + </div> + </div> + </form> + </div> +</div> + +{% endblock %} diff --git a/src/backoffice/templates/backoffice/map_poi_list.html b/src/backoffice/templates/backoffice/map_poi_list.html new file mode 100644 index 0000000000000000000000000000000000000000..8c2841966577b8c319a7174b081f280ee2a52918 --- /dev/null +++ b/src/backoffice/templates/backoffice/map_poi_list.html @@ -0,0 +1,73 @@ +{% extends 'backoffice/base.html' %} +{% load humanize %} +{% load i18n %} +{% load static %} + +{% block htmlhead %} + <link rel="stylesheet" href="{% static 'datatables/datatables.min.css' %}"> +{% endblock %} + +{% block scripts %} + <script src="{% static 'datatables/datatables.min.js' %}"></script> + <script> + $(document).ready(function() { + $('#pois').DataTable({ + pageLength: 100, + language: { + "decimal": "", + "emptyTable": "{% trans 'assembly_info_no_data' %}", + "info": "{% trans 'assembly_info_paginated' %}", + "infoEmpty": "{% trans 'assembly_info_empty' %}", + "infoFiltered": "{% trans 'assembly_info_filtered' %}", + "infoPostFix": "", + "thousands": " ", + "lengthMenu": "{% trans 'assembly_paginate_menu' %}", + "loadingRecords": "LOADING ...", + "processing": "Processing...", + "search": "{% trans 'assembly_search' %}", + "zeroRecords": "{% trans 'assembly_noentries' %}", + "paginate": { + "first": "{% trans 'assembly_paginate_first' %}", + "last": "{% trans 'assembly_paginate_last' %}", + "next": "{% trans 'assembly_paginate_next' %}", + "previous": "{% trans 'assembly_paginate_previous' %}" + }, + "aria": { + "sortAscending": ": activate to sort column ascending", + "sortDescending": ": activate to sort column descending" + } + } + }); + }); + </script> +{% endblock %} + +{% block content %} + +<div class="card"> + <div class="card-header"> + <a href="{% url 'backoffice:map-poi-create' %}" class="float-end btn btn-sm btn-primary"><i class="bi bi-plus-circle"></i> {% trans 'add' %}</a> + {% trans 'MapPOIs' %} + </div> + <div class="card-body"> + + <table class="table table-sm" id="pois"> + <thead> + <tr> + <th>{% trans "MapPOI__visible" %}</th> + <th>{% trans "MapPOI__name" %}</th> + </tr> + </thead> + <tbody> + {% for poi in object_list %} + <tr class="{% if not poi.visible %}text-muted{% endif %}"> + <td>{{ poi.visible|yesno }}</td> + <td><a href="{% url 'backoffice:map-poi-edit' pk=poi.pk %}">{{ poi.name }}</a></td> + </tr> + {% endfor %} + </tbody> + </table> + + </div> +</div> +{% endblock %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index ce2b6ef2c4e4560c71fbb3348df5a980a6f35c39..6ec50ab99444ba33c337d5c4387917f8173ab337 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -8,6 +8,7 @@ from .views import \ auth, \ channelteam, \ events, \ + map, \ misc, \ pages, \ profile, \ @@ -97,6 +98,13 @@ urlpatterns = [ path('assembly/<uuid:assembly>/r/<uuid:room>/remove_link', assemblies.RemoveRoomLinkView.as_view(), name='roomlink-remove'), path('assembly/<uuid:assembly>/r/<uuid:room>/remove', assemblies.RemoveRoomView.as_view(), name='assembly-remove-room'), + path('map/layers', map.LayerListView.as_view(), name='map-layer-list'), + path('map/layer/new', map.LayerCreateView.as_view(), name='map-layer-create'), + path('map/layer/<uuid:pk>', map.LayerUpdateView.as_view(), name='map-layer-edit'), + path('map/pois', map.POIListView.as_view(), name='map-poi-list'), + path('map/poi/new', map.POICreateView.as_view(), name='map-poi-create'), + path('map/poi/<uuid:pk>', map.POIUpdateView.as_view(), name='map-poi-edit'), + path('schedule/', schedules.SchedulesIndexView.as_view(), name='schedules'), path('schedule/sources', schedules.ScheduleSourcesListView.as_view(), name='schedulesource-list'), path('schedule/source/add', schedules.ScheduleSourcesCreateView.as_view(), name='schedulesource-add'), diff --git a/src/backoffice/views/map.py b/src/backoffice/views/map.py new file mode 100644 index 0000000000000000000000000000000000000000..85026ee725b2b4c953b78d9072534c59f910f5de --- /dev/null +++ b/src/backoffice/views/map.py @@ -0,0 +1,137 @@ +import logging + +from django.contrib import messages +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.views.generic import ListView +from django.views.generic.detail import SingleObjectTemplateResponseMixin +from django.views.generic.edit import UpdateView, CreateView + +from core.models.map import MapPOI, MapLayer +from .mixins import ConferenceMixin, guess_active_sidebar_item + +logger = logging.getLogger(__name__) + + +class MapAdminMixin(ConferenceMixin): + require_conference = True + permission_required = ['core.map_edit'] + + def get_context_data(self, *args, **kwargs): + ctx = super().get_context_data(*args, **kwargs) + ctx['active_page'] = 'map' + + layers = [{ + 'caption': f'{layer["name"]} ({layer["index"]})', + 'link': reverse('backoffice:map-layer-edit', kwargs={'pk': layer["pk"]}), + } for layer in MapLayer.objects.filter(conference=self.conference).order_by('index').values('pk', 'name', 'index')] + + pois = [] + poi_count = 0 + ctx['sidebar'] = { + 'title': _('nav_map'), + # 'title_link': reverse(self.base_view_name), + 'items': [ + { + 'caption': _('MapLayers'), + 'children': layers, + 'count': len(layers), + 'add_link': reverse('backoffice:map-layer-create'), + }, + { + 'caption': _('MapPOIs'), + 'children': pois, + 'add_link': reverse('backoffice:map-poi-create'), + }, + ], + } + + for poi in MapPOI.objects.filter(conference=self.conference).values('id', 'visible', 'name').iterator(): + poi_count += 1 + pois.append({ + 'caption': poi['name'] if poi['visible'] else format_html('<s>{}</s>', poi['name']), + 'link': reverse('backoffice:map-poi-edit', kwargs={'pk': poi['id']}), + }) + pois.insert(0, { + 'caption': format_html('<i>({all})</i>', all=_('all')), + 'link': reverse('backoffice:map-poi-list'), + 'count': poi_count, + }) + + # try to guess 'active' sidebar item + guess_active_sidebar_item(self.request, ctx['sidebar']['items'], with_query_string=False) + + return ctx + + +class LayerListView(MapAdminMixin, ListView): + template_name = 'backoffice/map_layer_list.html' + + model = MapLayer + + def get_queryset(self, *args, **kwargs): + return MapLayer.objects.filter(conference=self.conference).order_by('index') + + +class LayerFormMixin(MapAdminMixin, SingleObjectTemplateResponseMixin): + fields = ['name_de', 'name_en', 'index'] + template_name = 'backoffice/map_layer_form.html' + model = MapLayer + + def get_queryset(self, *args, **kwargs): + return MapLayer.objects.filter(conference=self.conference) + + def form_valid(self, form): + messages.success(self.request, _('updated')) + # TODO: ConferenceExportCache.signal_poi_modification(self.conference) + return super().form_valid(form) + + def get_success_url(self): + return reverse('backoffice:map-poi-edit', kwargs={'pk': self.object.id}) + + +class LayerCreateView(LayerFormMixin, CreateView): + def form_valid(self, form): + form.instance.conference = self.conference + return super().form_valid(form) + + +class LayerUpdateView(LayerFormMixin, UpdateView): + pass + + +class POIListView(MapAdminMixin, ListView): + template_name = 'backoffice/map_poi_list.html' + + model = MapPOI + + def get_queryset(self, *args, **kwargs): + return MapPOI.objects.filter(conference=self.conference).order_by('name') + + +class POIFormMixin(MapAdminMixin, SingleObjectTemplateResponseMixin): + model = MapPOI + fields = ['visible', 'is_official', 'name_de', 'name_en', 'description_de', 'description_en', 'location_layer', 'location_point'] + template_name = 'backoffice/map_poi_form.html' + + def get_queryset(self, *args, **kwargs): + return MapPOI.objects.filter(conference=self.conference) + + def form_valid(self, form): + messages.success(self.request, _('updated')) + # TODO: ConferenceExportCache.signal_poi_modification(self.conference) + return super().form_valid(form) + + def get_success_url(self): + return reverse('backoffice:map-poi-edit', kwargs={'pk': self.object.id}) + + +class POICreateView(POIFormMixin, CreateView): + def form_valid(self, form): + form.instance.conference = self.conference + return super().form_valid(form) + + +class POIUpdateView(POIFormMixin, UpdateView): + pass diff --git a/src/backoffice/views/mixins.py b/src/backoffice/views/mixins.py index a1f95d32da8e39c35cf43c28b745b5cf978502fe..fed9d0c48cf34196513875ab816d925b1558200a 100644 --- a/src/backoffice/views/mixins.py +++ b/src/backoffice/views/mixins.py @@ -100,6 +100,7 @@ class ConferenceMixin(LoginRequiredMixin, PermissionRequiredMixin): 'has_assemblies': self.is_assembly_team, 'has_channel': self.is_channel_team, 'has_pages': self.request.user.has_conference_staffpermission(self.conference, 'core.static_pages'), + 'has_map': self.request.user.has_conference_staffpermission(self.conference, 'core.map_edit'), 'has_users': self.request.user.has_conference_staffpermission(self.conference, 'core.platformusers'), 'has_schedules': self.request.user.has_conference_staffpermission(self.conference, 'core.scheduleadmin'), 'has_workadventure': @@ -110,6 +111,7 @@ class ConferenceMixin(LoginRequiredMixin, PermissionRequiredMixin): 'has_assemblies': False, 'has_channel': False, 'has_pages': False, + 'has_map': False, 'has_users': False, 'has_schedules': False, 'has_workadventure': False, diff --git a/src/core/admin.py b/src/core/admin.py index 1ae94c86bda2afcbc444f9b7f8bb5846b03ceb9b..faf5d55b5523d3a9bf697e8fb33d406dc5a39b8c 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -13,6 +13,7 @@ from .models import \ PlatformUser, \ Room, RoomLink, \ Assembly, AssemblyLink, AssemblyMember, AssemblyLogEntry, \ + MapLayer, MapPOI, \ Badge, BadgeToken, BadgeTokenTimeConstraint, \ ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping, \ StaticPage, StaticPageRevision, \ @@ -302,6 +303,47 @@ class AssemblyLogEntryAdmin(admin.ModelAdmin): ] +class MapLayerAdmin(admin.ModelAdmin): + list_display = ['conference', 'index', 'name'] + list_display_links = ['name'] + list_filter = ['conference'] + readonly_fields = ['id', 'conference'] + + def get_readonly_fields(self, request, obj=None, **kwargs): + result = list(super().get_readonly_fields(request, obj, **kwargs)) + if obj is None: + result.remove('conference') + return result + + +class MapPOIAdmin(GISModelAdmin): + list_display = ['conference', 'visible', 'name', 'is_official'] + list_display_links = ['name'] + list_filter = ['conference', 'visible', 'is_official'] + readonly_fields = ['id', 'conference'] + search_fields = ['name', 'description'] + + fieldsets = ( + ('Organisation', { + 'fields': ['id', 'conference'], + }), + ('Data', { + 'fields': ['visible', 'is_official', + ('name_de', 'name_en'), + ('description_de', 'description_en')], + }), + ('Location', { + 'fields': ['location_layer', 'location_point'], + }), + ) + + def get_readonly_fields(self, request, obj=None, **kwargs): + result = list(super().get_readonly_fields(request, obj, **kwargs)) + if obj is None: + result.remove('conference') + return result + + class BadgeTokenTimeConstraintInline(admin.TabularInline): model = BadgeTokenTimeConstraint fields = ['date_time_range'] @@ -645,6 +687,8 @@ admin.site.register(Assembly, AssemblyAdmin) admin.site.register(AssemblyLogEntry, AssemblyLogEntryAdmin) admin.site.register(BadgeToken, BadgeTokenAdmin) admin.site.register(Event, EventAdmin) +admin.site.register(MapLayer, MapLayerAdmin) +admin.site.register(MapPOI, MapPOIAdmin) admin.site.register(Room, RoomAdmin) admin.site.register(ScheduleSource, ScheduleSourceAdmin) admin.site.register(ScheduleSourceImport, ScheduleSourceImportAdmin) diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po index 7cf8882a13c15c946df320dfe0782cccdb5efe14..869b48aa342329ec1d7e71ab559dc2f056afbb09 100644 --- a/src/core/locale/de/LC_MESSAGES/django.po +++ b/src/core/locale/de/LC_MESSAGES/django.po @@ -243,6 +243,12 @@ msgstr "öffentlich sichtbare E-Mail für Kontakt" msgid "Assembly__public_contact" msgstr "öffentlicher Kontakt" +msgid "Assembly__location_layer__help" +msgstr "Ebene, auf der sich die Assembly befindet" + +msgid "Assembly__location_layer" +msgstr "Ebene" + msgid "Assembly__location_point__help" msgstr "Position der Assembly für z. B. die Such-Funktion, hier sollte also der \"Haupteingang\" gewählt werden" @@ -511,6 +517,9 @@ msgstr "Channel-Team: alle Assemblies verwaltbar, auch noch nicht fertig angeleg msgid "ConferenceMember__permission-static_pages" msgstr "Statische Seiten: Verwaltung von Info-Seiten" +msgid "ConferenceMember__permission-map_edit" +msgstr "Bearbeitung der Karte (POIs)" + msgid "ConferenceMember__permission-block_platformuser" msgstr "Nutzer blockieren" @@ -943,6 +952,69 @@ msgstr "persönlicher Kommentar" msgid "EventParticipant__must_be_conference_member" msgstr "Dieser Nutzer ist kein Teilnehmer der Konferenz." +msgid "MapLayer" +msgstr "Ebene" + +msgid "MapLayers" +msgstr "Ebenen" + +msgid "MapLayer__index__help" +msgstr "numerischer Index des Layer, wird u.a. beim Tile-Zugriff benutzt" + +msgid "MapLayer__index" +msgstr "index" + +msgid "MapLayer__name__help" +msgstr "sprechender Name" + +msgid "MapLayer__name" +msgstr "Name" + +msgid "MapPOI" +msgstr "POI" + +msgid "MapPOIs" +msgstr "POIs" + +msgid "MapPOI__visible__help" +msgstr "wird dieser POI auf der Karte dargestellt?" + +msgid "MapPOI__visible" +msgstr "sichtbar" + +msgid "MapPOI__name__help" +msgstr "sprechender Name des Point Of Interest" + +msgid "MapPOI__name" +msgstr "Name" + +msgid "MapPOI__description__help" +msgstr "öffentliche Information" + +msgid "MapPOI__description" +msgstr "Beschreibung" + +msgid "MapPOI__is_official__help" +msgstr "Dies ist ein für die Veranstaltung relevanter Point-Of-Interest (z. B. Kasse, Info-Desk)." + +msgid "MapPOI__is_official" +msgstr "offiziell" + +msgid "MapPOI__location_layer__help" +msgstr "auf welcher Ebene befindet sich der POI" + +msgid "MapPOI__location_layer" +msgstr "Ebene" + +msgid "MapPOI__location_point__help" +msgstr "tatsächliche Position des POI auf der Karte" + +msgid "MapPOI__location_point" +msgstr "Position" + +msgid "MapPOI__need-both-layer-and-point" +msgstr "Es werden sowohl Ebene als auch Position benötigt, eines alleine ist nicht aussagekräftig." + msgid "DirectMessage__sender__help" msgstr "Absender der Nachricht (leer = System)" diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po index 5ff8090eda68f186b284c6f474747121f2317855..76badc5e69a0be0c10338451e2e09929543b0640 100644 --- a/src/core/locale/en/LC_MESSAGES/django.po +++ b/src/core/locale/en/LC_MESSAGES/django.po @@ -243,6 +243,12 @@ msgstr "publicly visible email for contact" msgid "Assembly__public_contact" msgstr "public contact" +msgid "Assembly__location_layer__help" +msgstr "layer, on which this assembly is placed" + +msgid "Assembly__location_layer" +msgstr "layer" + msgid "Assembly__location_point__help" msgstr "point on the map where this location (assembly) can be found, consider choosing its \"main entrance\" here" @@ -511,6 +517,9 @@ msgstr "channel team: manage all assemblies, see also incomplete and rejected re msgid "ConferenceMember__permission-static_pages" msgstr "static pages: manage information pages" +msgid "ConferenceMember__permission-map_edit" +msgstr "edit the map (POIs)" + msgid "ConferenceMember__permission-block_platformuser" msgstr "block conference members" @@ -943,6 +952,69 @@ msgstr "personal comment" msgid "EventParticipant__must_be_conference_member" msgstr "The participant must be a member of the conference." +msgid "MapLayer" +msgstr "layer" + +msgid "MapLayers" +msgstr "layers" + +msgid "MapLayer__index__help" +msgstr "numerical ordering (or: identifier) of this map layer" + +msgid "MapLayer__index" +msgstr "index" + +msgid "MapLayer__name__help" +msgstr "descriptive name of this layer" + +msgid "MapLayer__name" +msgstr "name" + +msgid "MapPOI" +msgstr "POI" + +msgid "MapPOIs" +msgstr "POIs" + +msgid "MapPOI__visible__help" +msgstr "Will this POI be shown on the map?" + +msgid "MapPOI__visible" +msgstr "visible" + +msgid "MapPOI__name__help" +msgstr "descriptive name of this POI" + +msgid "MapPOI__name" +msgstr "name" + +msgid "MapPOI__description__help" +msgstr "public information" + +msgid "MapPOI__description" +msgstr "description" + +msgid "MapPOI__is_official__help" +msgstr "This POI is relevant for the conference (e.g. cash desk, info desk)." + +msgid "MapPOI__is_official" +msgstr "official" + +msgid "MapPOI__location_layer__help" +msgstr "The layer this POI is on." + +msgid "MapPOI__location_layer" +msgstr "layer" + +msgid "MapPOI__location_point__help" +msgstr "point on the map where this POI is located" + +msgid "MapPOI__location_point" +msgstr "location" + +msgid "MapPOI__need-both-layer-and-point" +msgstr "Need both layer and point, one is not enough." + msgid "DirectMessage__sender__help" msgstr "user which sent this message (empty = system)" diff --git a/src/core/migrations/0118_map.py b/src/core/migrations/0118_map.py new file mode 100644 index 0000000000000000000000000000000000000000..550b405fee8152c2c23027ca76d3e4a86d972abd --- /dev/null +++ b/src/core/migrations/0118_map.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.6 on 2023-11-04 14:01 + +import core.fields +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def create_default_layer(apps, schema_editor): + """ create a default layer per conference. """ + Conference = apps.get_model('core', 'Conference') + MapLayer = apps.get_model('core', 'MapLayer') + for c in Conference.objects.all(): + layer = MapLayer(conference=c, index=0, name='Default') + layer.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0117_conference_support_channels'), + ] + + operations = [ + migrations.CreateModel( + name='MapLayer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('index', models.SmallIntegerField(help_text='MapLayer__index__help', verbose_name='MapLayer__index')), + ('name', models.CharField(help_text='MapLayer__name__help', max_length=200, verbose_name='MapLayer__name')), + ('name_de', models.CharField(help_text='MapLayer__name__help', max_length=200, null=True, verbose_name='MapLayer__name')), + ('name_en', models.CharField(help_text='MapLayer__name__help', max_length=200, null=True, verbose_name='MapLayer__name')), + ('conference', core.fields.ConferenceReference(help_text='Conference__reference_help', on_delete=django.db.models.deletion.CASCADE, related_name='map_layers', to='core.conference', verbose_name='Conference__reference')), + ], + options={ + 'verbose_name': 'MapLayer', + 'verbose_name_plural': 'MapLayers', + }, + ), + migrations.AlterModelOptions( + name='conferencemember', + options={'permissions': [('assembly_team', 'ConferenceMember__permission-assembly_team'), ('channel_team', 'ConferenceMember__permission-channel_team'), ('static_pages', 'ConferenceMember__permission-static_pages'), ('map_edit', 'ConferenceMember__permission-map_edit'), ('platformusers', 'Orga: Users List'), ('rename_platformuser', 'Orga: Rename User'), ('block_platformuser', 'ConferenceMember__permission-block_platformuser'), ('change_conferencemember__active_angel', 'ConferenceMember__permission-change_conferencemember__active_angel'), ('view_platformuser__guardian', 'ConferenceMember__permission-view_platformuser__guardian'), ('voucher_admin', 'ConferenceMember__permission-voucher_admin'), ('scheduleadmin', 'ConferenceMember__permission-scheduleadmin'), ('workadventure_admin', 'ConferenceMember__permission-workadventure_admin')]}, + ), + migrations.CreateModel( + name='MapPOI', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('visible', models.BooleanField(default=True, help_text='MapPOI__visible__help', verbose_name='MapPOI__visible')), + ('name', models.CharField(help_text='MapPOI__name__help', max_length=200, verbose_name='MapPOI__name')), + ('name_de', models.CharField(help_text='MapPOI__name__help', max_length=200, null=True, verbose_name='MapPOI__name')), + ('name_en', models.CharField(help_text='MapPOI__name__help', max_length=200, null=True, verbose_name='MapPOI__name')), + ('description', models.TextField(blank=True, help_text='MapPOI__description__help', null=True, verbose_name='MapPOI__description')), + ('description_de', models.TextField(blank=True, help_text='MapPOI__description__help', null=True, verbose_name='MapPOI__description')), + ('description_en', models.TextField(blank=True, help_text='MapPOI__description__help', null=True, verbose_name='MapPOI__description')), + ('description_html', models.TextField(blank=True)), + ('description_html_de', models.TextField(blank=True, null=True)), + ('description_html_en', models.TextField(blank=True, null=True)), + ('is_official', models.BooleanField(default=False, help_text='MapPOI__is_official__help', verbose_name='MapPOI__is_official')), + ('location_point', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='MapPOI__location_point__help', null=True, srid=4326, verbose_name='MapPOI__location_point')), + ('conference', core.fields.ConferenceReference(help_text='Conference__reference_help', on_delete=django.db.models.deletion.CASCADE, related_name='pois', to='core.conference', verbose_name='Conference__reference')), + ('location_layer', models.ForeignKey(blank=True, help_text='MapPOI__location_layer__help', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pois', to='core.maplayer', verbose_name='MapPOI__location_layer')), + ], + options={ + 'verbose_name': 'MapPOI', + 'verbose_name_plural': 'MapPOIs', + }, + ), + migrations.AddField( + model_name='assembly', + name='location_layer', + field=models.ForeignKey(blank=True, help_text='Assembly__location_layer__help', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assemblies', to='core.maplayer', verbose_name='Assembly__location_layer'), + ), + ] diff --git a/src/core/models/__init__.py b/src/core/models/__init__.py index a8aead8c1e25aeb7fadec36564122773ea065e4e..8c514fa8ba47dc4190ee99d7db667a7f819ff6b0 100644 --- a/src/core/models/__init__.py +++ b/src/core/models/__init__.py @@ -6,6 +6,7 @@ from .board import BulletinBoardEntry from .events import Event, EventAttachment, EventLikeCount, EventParticipant from .pages import StaticPage, StaticPageRevision from .markdown import MarkdownMeta +from .map import MapLayer, MapPOI from .messages import DirectMessage from .rooms import Room, RoomLink from .schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping @@ -27,6 +28,7 @@ __all__ = [ 'ConferenceTag', 'ConferenceTrack', 'DereferrerStats', 'DirectMessage', 'Event', 'EventAttachment', 'EventLikeCount', 'EventParticipant', + 'MapLayer', 'MapPOI', 'PlatformUser', 'Room', 'RoomLink', 'ScheduleSource', 'ScheduleSourceImport', 'ScheduleSourceMapping', diff --git a/src/core/models/assemblies.py b/src/core/models/assemblies.py index 6b76ce22cfdd4796c7017af3fd48c3d7568c7e94..6aa98f711250c11d05b907fb6c295c2fceefa008 100644 --- a/src/core/models/assemblies.py +++ b/src/core/models/assemblies.py @@ -22,6 +22,7 @@ from ..fields import ConferenceReference from ..markdown import compile_translated_markdown_fields, render_markdown, store_relationships from ..utils import render_markdown_as_text from .conference import Conference, ConferenceMember +from .map import MapLayer from .tags import TaggedItemMixin from .users import PlatformUser @@ -246,6 +247,14 @@ class Assembly(TaggedItemMixin, models.Model): verbose_name=_('Assembly__public_contact'), ) + location_layer = models.ForeignKey( + MapLayer, + blank=True, null=True, + related_name='assemblies', on_delete=models.PROTECT, + help_text=_('Assembly__location_layer__help'), + verbose_name=_('Assembly__location_layer'), + ) + location_point = gis_models.PointField( blank=True, null=True, help_text=_('Assembly__location_point__help'), @@ -519,6 +528,9 @@ class Assembly(TaggedItemMixin, models.Model): has_for_all_assemblies = Voucher.objects.available_for_conference(self.conference).exists() return 0 if has_for_all_assemblies else None + def get_location_layer_index(self): + return self.location_layer.index if self.location_layer else None + def get_location_point_as_json(self): if not self.location_point or not self.location_point.valid: return '' diff --git a/src/core/models/conference.py b/src/core/models/conference.py index c562663f010141ae2ef2845605666f48a56642c1..45c906dc20f1ea81d436a2d0c9ce2c9e0fc70b39 100644 --- a/src/core/models/conference.py +++ b/src/core/models/conference.py @@ -64,6 +64,9 @@ class ConferenceMember(models.Model): ('static_pages', _('ConferenceMember__permission-static_pages')), # Access to static pages, can be further limited by configuring static_page_groups. + ('map_edit', _('ConferenceMember__permission-map_edit')), + # modification of the map, i.e. POIs + ('platformusers', 'Orga: Users List'), ('rename_platformuser', 'Orga: Rename User'), diff --git a/src/core/models/map.py b/src/core/models/map.py new file mode 100644 index 0000000000000000000000000000000000000000..56864f99ee2a8e8da69cf710bf62731398cee88b --- /dev/null +++ b/src/core/models/map.py @@ -0,0 +1,93 @@ +import json +import logging +from uuid import uuid4 + +from django.contrib.gis.db import models as gis_models +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ..fields import ConferenceReference +from ..markdown import compile_translated_markdown_fields, store_relationships + + +logger = logging.getLogger(__name__) + + +class MapLayer(models.Model): + class Meta: + verbose_name = _('MapLayer') + verbose_name_plural = _('MapLayers') + + id = models.UUIDField(default=uuid4, primary_key=True, editable=False, serialize=False) + conference = ConferenceReference(related_name='map_layers') + + index = models.SmallIntegerField(help_text=_('MapLayer__index__help'), verbose_name=_('MapLayer__index')) + name = models.CharField( + max_length=200, + help_text=_('MapLayer__name__help'), + verbose_name=_('MapLayer__name')) + + +class MapPOI(models.Model): + class Meta: + verbose_name = _('MapPOI') + verbose_name_plural = _('MapPOIs') + + id = models.UUIDField(default=uuid4, primary_key=True, editable=False, serialize=False) + conference = ConferenceReference(related_name='pois') + + visible = models.BooleanField( + default=True, + help_text=_('MapPOI__visible__help'), + verbose_name=_('MapPOI__visible')) + + name = models.CharField( + max_length=200, + help_text=_('MapPOI__name__help'), + verbose_name=_('MapPOI__name')) + description = models.TextField( + blank=True, null=True, + help_text=_('MapPOI__description__help'), + verbose_name=_('MapPOI__description')) + description_html = models.TextField(blank=True) + + is_official = models.BooleanField( + default=False, + help_text=_('MapPOI__is_official__help'), + verbose_name=_('MapPOI__is_official')) + + location_layer = models.ForeignKey( + MapLayer, + blank=True, null=True, + related_name='pois', on_delete=models.PROTECT, + help_text=_('MapPOI__location_layer__help'), + verbose_name=_('MapPOI__location_layer'), + ) + + location_point = gis_models.PointField( + blank=True, null=True, + help_text=_('MapPOI__location_point__help'), + verbose_name=_('MapPOI__location_point'), + ) + + def clean(self): + if self.location_point or self.location_layer: + if not self.location_point or not self.location_layer: + raise ValidationError(_('MapPOI__need-both-layer-and-point')) + + def save(self, *args, update_fields=None, **kwargs): + if update_fields is None or 'description' in update_fields: + render_results = compile_translated_markdown_fields(self, self.conference, 'description') + store_relationships(self.conference, self, render_results) + + return super().save(*args, update_fields=update_fields, **kwargs) + + def get_location_layer_index(self): + return self.location_layer.index if self.location_layer else None + + def get_location_point_as_json(self): + if not self.location_point or not self.location_point.valid: + return '' + + return json.dumps([self.location_point.x, self.location_point.y]) diff --git a/src/core/translation.py b/src/core/translation.py index 2c08c1e040e47b001001fc1531ea842941a0924a..f10c55b4f60b7ed5db3fbe45c7e70aaf59c87eab 100644 --- a/src/core/translation.py +++ b/src/core/translation.py @@ -6,6 +6,7 @@ from .models import \ ConferenceMember, \ ConferenceNavigationItem, \ Event, \ + MapLayer, MapPOI, \ Room @@ -37,3 +38,13 @@ class ConferenceMemberTranslationOptions(TranslationOptions): @register(Room) class RoomTranslationOptions(TranslationOptions): fields = ('description', 'description_html', ) + + +@register(MapLayer) +class MapLayerTranslationOptions(TranslationOptions): + fields = ('name', ) + + +@register(MapPOI) +class MapPOITranslationOptions(TranslationOptions): + fields = ('name', 'description', 'description_html', )