diff --git a/geotrek/common/views.py b/geotrek/common/views.py index 76824e0b9b..cd47534c96 100644 --- a/geotrek/common/views.py +++ b/geotrek/common/views.py @@ -77,6 +77,11 @@ logger = logging.getLogger(__name__) +ANNOTATION_FORBIDDEN_CHARS = re.compile(r"['`\"\]\[;\s]|--|/\*|\*/") +REPLACEMENT_CHAR = "_" + +def normalize_annotation_column_name(col_name): + return ANNOTATION_FORBIDDEN_CHARS.sub(repl=REPLACEMENT_CHAR, string=col_name) def handler404(request, exception, template_name="404.html"): if "api/v2" in request.get_full_path(): diff --git a/geotrek/maintenance/views.py b/geotrek/maintenance/views.py index 37128df704..659711ab48 100755 --- a/geotrek/maintenance/views.py +++ b/geotrek/maintenance/views.py @@ -12,6 +12,7 @@ from geotrek.authent.decorators import same_structure_required from geotrek.common.mixins.forms import FormsetMixin from geotrek.common.mixins.views import CustomColumnsMixin +from geotrek.common.views import normalize_annotation_column_name from geotrek.common.viewsets import GeotrekMapentityViewSet from geotrek.feedback.models import Report from .filters import InterventionFilterSet, ProjectFilterSet @@ -24,14 +25,6 @@ logger = logging.getLogger(__name__) -ANNOTATION_FORBIDDEN_CHARS = re.compile(r"['`\"\]\[;\s]|--|/\*|\*/") -REPLACEMENT_CHAR = "_" - - -def _normalize_annotation_column_name(col_name): - return ANNOTATION_FORBIDDEN_CHARS.sub(repl=REPLACEMENT_CHAR, string=col_name) - - class InterventionList(CustomColumnsMixin, MapEntityList): queryset = Intervention.objects.existing() mandatory_columns = ['id', 'name'] @@ -50,7 +43,7 @@ class InterventionFormatList(MapEntityFormat, InterventionList): @classmethod def build_cost_column_name(cls, job_name): - return _normalize_annotation_column_name(f"{_('Cost')} {job_name}") + return normalize_annotation_column_name(f"{_('Cost')} {job_name}") def get_queryset(self): """Returns all interventions joined with a new column for each job, to record the total cost of each job in each intervention""" diff --git a/geotrek/settings/base.py b/geotrek/settings/base.py index e83b63d08e..924ead3b94 100644 --- a/geotrek/settings/base.py +++ b/geotrek/settings/base.py @@ -843,6 +843,7 @@ def api_bbox(bbox, buffer): HIDDEN_FORM_FIELDS = {'report': ['assigned_user']} COLUMNS_LISTS = {} ENABLE_JOBS_COSTS_DETAILED_EXPORT = False +ENABLE_EVENTS_PARTICIPANTS_DETAILED_EXPORT = False ACCESSIBILITY_ATTACHMENTS_ENABLED = True diff --git a/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html b/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html index 9ac7920c8d..380fda97da 100644 --- a/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html +++ b/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html @@ -47,7 +47,8 @@ {{ object|verbose:"price" }} - {{ object.price }} + {% if object.price %}{{ object.price }} + {% else %}{% trans "None" %}{% endif %} {{ object|verbose:"meeting_point" }} @@ -105,7 +106,8 @@ {{ object|verbose:"capacity" }} - {{ object.capacity }} + {% if object.capacity %}{{ object.capacity }} + {% else %}{% trans "None" %}{% endif %} {{ object|verbose:"cancelled" }} @@ -113,7 +115,8 @@ {{ object|verbose:"cancellation_reason" }} - {{ object.cancellation_reason|safe }} + {% if object.cancellation_reason %}{{ object.cancellation_reason|safe }} + {% else %}{% trans "None" %}{% endif %} {% trans "Source" %} @@ -174,11 +177,13 @@ {{ object|verbose:"preparation_duration" }} - {{ object.preparation_duration }} + {% if object.preparation_duration %}{{ object.preparation_duration|safe }} + {% else %}{% trans "None" %}{% endif %} {{ object|verbose:"intervention_duration" }} - {{ object.intervention_duration }} + {% if object.intervention_duration %}{{ object.intervention_duration|safe }} + {% else %}{% trans "None" %}{% endif %} {% include "common/publication_info_fragment.html" %} diff --git a/geotrek/tourism/views.py b/geotrek/tourism/views.py index cf349fdc96..8aa8968565 100644 --- a/geotrek/tourism/views.py +++ b/geotrek/tourism/views.py @@ -4,10 +4,11 @@ from django.contrib.auth.decorators import login_required from django.contrib.gis.db.models.functions import Transform from django.core.exceptions import PermissionDenied -from django.db.models import Sum +from django.db.models import Sum, OuterRef, F from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ from django.utils.html import escape from django.views.generic import CreateView from mapentity.views import (MapEntityCreate, MapEntityUpdate, MapEntityList, MapEntityDetail, MapEntityFilter, @@ -17,13 +18,13 @@ from geotrek.authent.decorators import same_structure_required from geotrek.common.mixins.views import CompletenessMixin, CustomColumnsMixin from geotrek.common.models import RecordSource, TargetPortal -from geotrek.common.views import DocumentPublic, DocumentBookletPublic, MarkupPublic +from geotrek.common.views import DocumentPublic, DocumentBookletPublic, MarkupPublic, normalize_annotation_column_name from geotrek.common.viewsets import GeotrekMapentityViewSet from geotrek.trekking.models import Trek from .filters import TouristicContentFilterSet, TouristicEventFilterSet from .forms import TouristicContentForm, TouristicEventForm, TouristicEventOrganizerFormPopup from .models import (TouristicContent, TouristicEvent, TouristicContentCategory, TouristicEventOrganizer, - InformationDesk) + InformationDesk, TouristicEventParticipantCategory, TouristicEventParticipantCount) from .serializers import (TouristicContentSerializer, TouristicEventSerializer, TrekInformationDeskGeojsonSerializer, TouristicContentGeojsonSerializer, TouristicEventGeojsonSerializer) @@ -184,13 +185,56 @@ class TouristicEventFormatList(MapEntityFormat, TouristicEventList): 'date_insert', 'date_update', 'source', 'portal', 'review', 'published', 'publication_date', 'cities', 'districts', 'areas', 'approved', 'uuid', - 'cancelled', 'cancellation_reason', 'total_participants', 'place', + 'cancelled', 'cancellation_reason', 'place', 'preparation_duration', 'intervention_duration', 'price' ] + @classmethod + def build_participants_column_name(cls, category_label): + return normalize_annotation_column_name(f"{_('Participants')} {category_label}") + + @classmethod + def get_mandatory_columns(cls): + mandatory_columns = ['id'] + if settings.ENABLE_EVENTS_PARTICIPANTS_DETAILED_EXPORT: + categories_names = list(TouristicEventParticipantCategory.objects.order_by('order').values_list('label', flat=True)) + # Create column names for each unique category + categories_columns_names = list(map(cls.build_participants_column_name, categories_names)) + # Add these column names to export + mandatory_columns = mandatory_columns + categories_columns_names + ['total_participants'] + else: + mandatory_columns = mandatory_columns + ['total_participants'] + return mandatory_columns + def get_queryset(self): - qs = super().get_queryset().select_related('place', 'cancellation_reason').prefetch_related('participants') - return qs.annotate(total_participants=Sum('participants__count')) + """Returns all events joined with a new column for each participant count""" + + queryset = super().get_queryset().select_related('place', 'cancellation_reason').prefetch_related('participants') + + if settings.ENABLE_EVENTS_PARTICIPANTS_DETAILED_EXPORT: + # Get all participants categories, as unique ids and names + categories = TouristicEventParticipantCategory.objects.order_by('order').values_list('id', 'label') + + # Iter over unique categories + for category_id, label in categories: + + # Create column name for current category + column_name = self.build_participants_column_name(label) + + # Create subquery to retrieve category count (renamed because of ambiguity with 'count' method) + subquery = (TouristicEventParticipantCount.objects.filter( + event=OuterRef('pk'), + category=category_id + ).annotate( + category_count=F('count') + ).values('category_count')) + + # Annotate queryset with this cost query + params = {column_name: subquery} + queryset = queryset.annotate(**params).annotate(total_participants=Sum('participants__count')) + else: + return queryset.annotate(total_participants=Sum('participants__count')) + return queryset class TouristicEventDetail(CompletenessMixin, MapEntityDetail):