diff --git a/docs/changelog.rst b/docs/changelog.rst
index 1b4979962c..0eed24a9f8 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,12 +4,16 @@ CHANGELOG
2.102.1+dev (XXXX-XX-XX)
------------------------
-- Add popup button to add organizer in touristic event form
**New features**
- Add `include_externals` filter to Cirkwi trek exports, to allow excluding treks with an external id (eid) (#3947)
+**Improvments**
+
+- Add popup button to add organizer in touristic event form
+- Change the `organizer` field of `TouristicEvent` model to a many to many field named `organizers` (#3587)
+
2.102.1 (2024-02-20)
--------------------
diff --git a/docs/install/advanced-configuration.rst b/docs/install/advanced-configuration.rst
index 27fef76484..687f4ade97 100644
--- a/docs/install/advanced-configuration.rst
+++ b/docs/install/advanced-configuration.rst
@@ -1389,7 +1389,7 @@ A (nearly?) exhaustive list of attributes available for display and export as co
"email",
"website",
"end_date",
- "organizer",
+ "organizers",
"speaker",
"type",
"accessibility",
@@ -1929,7 +1929,7 @@ A (nearly?) exhaustive list of attributes available for display and export as co
"contact",
"email",
"website",
- "organizer",
+ "organizers",
"speaker",
"accessibility",
"capacity",
@@ -2277,7 +2277,7 @@ An exhaustive list of form fields hideable in each module.
'contact',
'email',
'website',
- 'organizer',
+ 'organizers',
'speaker',
'type',
'accessibility',
diff --git a/geotrek/api/mobile/serializers/tourism.py b/geotrek/api/mobile/serializers/tourism.py
index ba4b6ee26f..7c7287d298 100644
--- a/geotrek/api/mobile/serializers/tourism.py
+++ b/geotrek/api/mobile/serializers/tourism.py
@@ -37,6 +37,8 @@ class Meta:
class TouristicEventListSerializer(geo_serializers.GeoFeatureModelSerializer):
geometry = geo_serializers.GeometryField(read_only=True, precision=7, source='geom2d_transformed')
pictures = rest_serializers.SerializerMethodField()
+ organizers = rest_serializers.SerializerMethodField()
+ organizer = rest_serializers.SerializerMethodField()
def get_pictures(self, obj):
if not obj.resized_pictures:
@@ -50,6 +52,13 @@ def get_pictures(self, obj):
'url': os.path.join('/', str(self.context['root_pk']), settings.MEDIA_URL[1:], thdetail_first.name),
}]
+ def get_organizers(self, obj):
+ return ", ".join(
+ map(lambda org: org.label, obj.organizers.all())
+ )
+ # for retrocompatibility of API
+ get_organizer = get_organizers
+
class Meta:
model = tourism_models.TouristicEvent
id_field = 'pk'
@@ -57,7 +66,7 @@ class Meta:
fields = ('id', 'pk', 'name', 'description_teaser', 'description', 'themes', 'pictures',
'begin_date', 'end_date', 'duration', 'meeting_point',
'start_time', 'contact', 'email', 'website',
- 'organizer', 'speaker', 'type', 'accessibility',
+ 'organizers', 'organizer', 'speaker', 'type', 'accessibility',
'capacity', 'booking', 'target_audience',
'practical_info', 'approved', 'geometry')
diff --git a/geotrek/api/tests/test_mobile/test_api_mobile.py b/geotrek/api/tests/test_mobile/test_api_mobile.py
index aca8ea35b1..86cbb046b7 100644
--- a/geotrek/api/tests/test_mobile/test_api_mobile.py
+++ b/geotrek/api/tests/test_mobile/test_api_mobile.py
@@ -49,7 +49,7 @@
TOURISTIC_EVENT_LIST_PROPERTIES_GEOJSON_STRUCTURE = sorted([
'id', 'name', 'description_teaser', 'description', 'themes', 'pictures', 'begin_date', 'end_date', 'duration',
- 'start_time', 'contact', 'email', 'website', 'organizer', 'speaker', 'type', 'accessibility', 'meeting_point',
+ 'start_time', 'contact', 'email', 'website', 'organizer', 'organizers', 'speaker', 'type', 'accessibility', 'meeting_point',
'capacity', 'booking', 'target_audience', 'practical_info', 'approved',
])
diff --git a/geotrek/api/tests/test_v2.py b/geotrek/api/tests/test_v2.py
index bad2747d2b..0845aaa68a 100644
--- a/geotrek/api/tests/test_v2.py
+++ b/geotrek/api/tests/test_v2.py
@@ -228,7 +228,7 @@
TOURISTIC_EVENT_DETAIL_JSON_STRUCTURE = sorted([
'id', 'accessibility', 'approved', 'attachments', 'begin_date', 'bookable', 'booking', 'cities', 'contact', 'create_datetime',
'description', 'description_teaser', 'districts', 'duration', 'email', 'end_date', 'external_id', 'geometry',
- 'meeting_point', 'start_time', 'meeting_time', 'end_time', 'name', 'organizer', 'organizer_id', 'capacity', 'pdf', 'place', 'portal',
+ 'meeting_point', 'start_time', 'meeting_time', 'end_time', 'name', 'organizer', 'organizers', 'organizers_id', 'capacity', 'pdf', 'place', 'portal',
'practical_info', 'provider', 'published', 'source', 'speaker', 'structure', 'target_audience', 'themes',
'type', 'update_datetime', 'url', 'uuid', 'website', 'cancelled', 'cancellation_reason', 'participant_number'
])
@@ -3241,8 +3241,8 @@ def setUpTestData(cls):
capacity=12,
bookable=False,
place=cls.place,
- organizer=cls.organizer
)
+ cls.touristic_event5.organizers.set([cls.organizer])
cls.touristic_content = tourism_factory.TouristicContentFactory(geom=Point(0.77802, 43.047482, srid=4326))
def test_touristic_event_list(self):
diff --git a/geotrek/api/v2/filters.py b/geotrek/api/v2/filters.py
index c4509eb395..0e403a340b 100644
--- a/geotrek/api/v2/filters.py
+++ b/geotrek/api/v2/filters.py
@@ -507,6 +507,7 @@ class TouristicEventFilterSet(filters.FilterSet):
widget=CSVWidget(),
queryset=TouristicEventOrganizer.objects.all(),
help_text=_("Filter by one or more organizer, comma-separated."),
+ field_name="organizers"
)
help_texts = {
diff --git a/geotrek/api/v2/serializers.py b/geotrek/api/v2/serializers.py
index d7205d5222..6003640fc4 100644
--- a/geotrek/api/v2/serializers.py
+++ b/geotrek/api/v2/serializers.py
@@ -232,6 +232,14 @@ class Meta:
)
+class TouristicOrganismSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
+ class Meta:
+ model = tourism_models.TouristicEventOrganizer
+ fields = (
+ 'id',
+ )
+
+
class OrganismSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
name = serializers.CharField(source='organism')
@@ -503,13 +511,9 @@ def get_departure_city(self, obj):
return city.code if city else None
class TouristicEventSerializer(TouristicModelSerializer):
- organizer = serializers.SlugRelatedField(
- read_only=True,
- slug_field='label'
- )
- organizer_id = serializers.PrimaryKeyRelatedField(
- read_only=True
- )
+ organizers = serializers.SerializerMethodField()
+ organizer = serializers.SerializerMethodField()
+ organizers_id = serializers.PrimaryKeyRelatedField(many=True, source='organizers', read_only=True)
attachments = AttachmentSerializer(many=True, source='sorted_attachments')
url = HyperlinkedIdentityField(view_name='apiv2:touristicevent-detail')
begin_date = serializers.DateField()
@@ -545,6 +549,14 @@ def get_participant_number(self, obj):
def get_end_date(self, obj):
return obj.end_date or obj.begin_date
+ def get_organizers(self, obj):
+ return ", ".join(
+ map(lambda org: org.label, obj.organizers.all())
+ )
+
+ # for retrocompatibility of API
+ get_organizer = get_organizers
+
class Meta(TimeStampedSerializer.Meta):
model = tourism_models.TouristicEvent
fields = TimeStampedSerializer.Meta.fields + (
@@ -552,7 +564,7 @@ class Meta(TimeStampedSerializer.Meta):
'booking', 'cancellation_reason', 'cancelled', 'capacity', 'cities',
'contact', 'description', 'description_teaser', 'districts', 'duration',
'email', 'end_date', 'end_time', 'external_id', 'geometry', 'meeting_point',
- 'meeting_time', 'name', 'organizer', 'organizer_id', 'participant_number', 'pdf', 'place',
+ 'meeting_time', 'name', 'organizers', 'organizer', 'organizers_id', 'participant_number', 'pdf', 'place',
'portal', 'practical_info', 'provider', 'published', 'source', 'speaker',
'start_time', 'structure', 'target_audience', 'themes', 'type',
'url', 'uuid', 'website'
diff --git a/geotrek/api/v2/views/tourism.py b/geotrek/api/v2/views/tourism.py
index b70f93db22..0ac4a4bf10 100644
--- a/geotrek/api/v2/views/tourism.py
+++ b/geotrek/api/v2/views/tourism.py
@@ -95,7 +95,7 @@ def get_queryset(self):
activate(self.request.GET.get('language'))
return tourism_models.TouristicEvent.objects.existing()\
.select_related('type') \
- .prefetch_related('themes', 'source', 'portal',
+ .prefetch_related('themes', 'source', 'portal', 'organizers',
Prefetch('attachments',
queryset=Attachment.objects.select_related('license', 'filetype', 'filetype__structure'))
) \
diff --git a/geotrek/tourism/forms.py b/geotrek/tourism/forms.py
index cf5aef6169..9c2c70c8b7 100644
--- a/geotrek/tourism/forms.py
+++ b/geotrek/tourism/forms.py
@@ -95,7 +95,7 @@ class TouristicEventForm(CommonForm):
'contact',
'email',
'website',
- 'organizer',
+ 'organizers',
'speaker',
'accessibility',
'bookable',
@@ -131,7 +131,7 @@ class TouristicEventForm(CommonForm):
class Meta:
fields = ['name', 'place', 'review', 'published', 'description_teaser', 'description',
'themes', 'begin_date', 'end_date', 'duration', 'meeting_point',
- 'start_time', 'end_time', 'contact', 'email', 'website', 'organizer', 'speaker',
+ 'start_time', 'end_time', 'contact', 'email', 'website', 'organizers', 'speaker',
'type', 'accessibility', 'capacity', 'booking', 'target_audience',
'practical_info', 'approved', 'source', 'portal', 'geom', 'eid', 'structure', 'bookable',
'cancelled', 'cancellation_reason', 'preparation_duration', 'intervention_duration']
@@ -145,8 +145,8 @@ def __init__(self, *args, **kwargs):
self.fields['start_time'].widget.attrs['placeholder'] = _('HH:MM')
self.fields['end_time'].widget.attrs['placeholder'] = _('HH:MM')
if self.user.has_perm("tourism.add_touristiceventorganizer"):
- self.fields['organizer'].widget = SelectMultipleWithPop(
- choices=self.fields['organizer'].choices,
+ self.fields['organizers'].widget = SelectMultipleWithPop(
+ choices=self.fields['organizers'].choices,
add_url=TouristicEventOrganizer.get_add_url()
)
# Since we use chosen() in trek_form.html, we don't need the default help text
diff --git a/geotrek/tourism/locale/en/LC_MESSAGES/django.po b/geotrek/tourism/locale/en/LC_MESSAGES/django.po
index 2d7fa78f4a..55ee69bd09 100644
--- a/geotrek/tourism/locale/en/LC_MESSAGES/django.po
+++ b/geotrek/tourism/locale/en/LC_MESSAGES/django.po
@@ -63,7 +63,7 @@ msgid "Start date is after end date"
msgstr ""
msgid "Label"
-msgstr "Label"
+msgstr ""
msgid "Information desk type"
msgstr ""
@@ -280,7 +280,7 @@ msgid "Event places"
msgstr ""
msgid "Organizer"
-msgstr "Organizer"
+msgstr ""
msgid "Organizers"
msgstr ""
diff --git a/geotrek/tourism/migrations/0050_auto_20231220_0952.py b/geotrek/tourism/migrations/0050_auto_20231220_0952.py
new file mode 100644
index 0000000000..14b78a3432
--- /dev/null
+++ b/geotrek/tourism/migrations/0050_auto_20231220_0952.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.23 on 2023-12-20 09:52
+
+from django.db import migrations, models
+
+
+def forward_func(apps, schema_editors):
+ ToursticEvent = apps.get_model("tourism", "TouristicEvent")
+ for event in ToursticEvent.objects.all():
+ if event.organizer:
+ event.organizers.add(event.organizer)
+
+
+def reverse_func(apps, schema_editors):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tourism', '0049_alter_touristiccontentcategory_color'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='touristicevent',
+ name='organizers',
+ field=models.ManyToManyField(blank=True, related_name='touristicevent', to='tourism.TouristicEventOrganizer', verbose_name='Organizers'),
+ ),
+ migrations.RunPython(forward_func, reverse_func),
+ migrations.RemoveField(
+ model_name='touristicevent',
+ name='organizer',
+ ),
+ ]
diff --git a/geotrek/tourism/migrations/0051_merge_20240229_1225.py b/geotrek/tourism/migrations/0051_merge_20240229_1225.py
new file mode 100644
index 0000000000..4427ced73e
--- /dev/null
+++ b/geotrek/tourism/migrations/0051_merge_20240229_1225.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.24 on 2024-02-29 11:25
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tourism', '0050_alter_touristiceventorganizer_label'),
+ ('tourism', '0050_auto_20231220_0952'),
+ ]
+
+ operations = [
+ ]
diff --git a/geotrek/tourism/models.py b/geotrek/tourism/models.py
index 5af120e49a..d8f429671b 100644
--- a/geotrek/tourism/models.py
+++ b/geotrek/tourism/models.py
@@ -430,8 +430,9 @@ class TouristicEvent(ZoningPropertiesMixin, AddPropertyMixin, PublishableMixin,
blank=True, null=True)
website = models.URLField(verbose_name=_("Website"), max_length=256,
blank=True, null=True)
- organizer = models.ForeignKey('tourism.TouristicEventOrganizer', verbose_name=_("Organizer"), blank=True, null=True,
- on_delete=models.PROTECT, related_name="touristicevent")
+
+ organizers = models.ManyToManyField('tourism.TouristicEventOrganizer', verbose_name=_("Organizers"), blank=True,
+ related_name="touristicevent")
speaker = models.CharField(verbose_name=_("Speaker"), max_length=256, blank=True)
type = models.ForeignKey(TouristicEventType, verbose_name=_("Type"), blank=True, null=True, on_delete=models.PROTECT)
accessibility = models.TextField(verbose_name=_("Accessibility"), blank=True)
diff --git a/geotrek/tourism/parsers.py b/geotrek/tourism/parsers.py
index c84e550b7e..857dab45cd 100644
--- a/geotrek/tourism/parsers.py
+++ b/geotrek/tourism/parsers.py
@@ -246,7 +246,6 @@ class TouristicEventApidaeParser(AttachmentApidaeParserMixin, ApidaeParser):
),
'email': 'informations.moyensCommunication',
'website': 'informations.moyensCommunication',
- 'organizer': 'informations.structureGestion.nom.libelleFr',
'type': 'informationsFeteEtManifestation.typesManifestation.0.libelleFr',
'capacity': 'informationsFeteEtManifestation.nbParticipantsAttendu',
'practical_info': (
@@ -284,15 +283,17 @@ class TouristicEventApidaeParser(AttachmentApidaeParserMixin, ApidaeParser):
'illustrations'
]
m2m_fields = {
- 'themes': 'informationsFeteEtManifestation.themes.*.libelleFr'
+ 'themes': 'informationsFeteEtManifestation.themes.*.libelleFr',
+ 'organizers': ('informations.structureGestion.nom.libelleFr',),
}
natural_keys = {
'themes': 'label',
'type': 'type',
- 'organizer': 'label',
+ 'organizers': 'label',
'source': 'name',
'portal': 'name',
}
+ # separator = ","
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -301,7 +302,7 @@ def __init__(self, *args, **kwargs):
self.field_options = self.field_options.copy()
self.field_options['themes'] = {'create': True}
self.field_options['type'] = {'create': True}
- self.field_options['organizer'] = {'create': True}
+ self.field_options['organizers'] = {'create': True}
if self.type is not None:
self.constant_fields['type'] = self.type
if self.themes is not None:
@@ -939,16 +940,17 @@ class LEITouristicEventParser(LEIParser):
'ADRPROD_TEL', 'ADRPROD_TEL2', 'ADRPREST_TEL', 'ADRPREST_TEL2'),
'email': ('ADRPROD_EMAIL', 'ADRPREST_EMAIL', 'ADRPREST_EMAIL2'),
'website': ('ADRPROD_URL', 'ADRPREST_URL'),
- 'organizer': ('RAISONSOC_PERSONNE_EN_CHARGE', 'RAISONSOC_RESPONSABLE'),
'speaker': ('CIVILITE_RESPONSABLE', 'NOM_RESPONSABLE', 'PRENOM_RESPONSABLE'),
'type': 'TYPE_NOM',
'geom': ('LATITUDE', 'LONGITUDE'),
}
- m2m_fields = {}
+ m2m_fields = {
+ 'organizers': ('RAISONSOC_PERSONNE_EN_CHARGE', 'RAISONSOC_RESPONSABLE')
+ }
type = None
natural_keys = {
'category': 'label',
- 'organizer': 'label',
+ 'organizers': 'label',
'geom': {'required': True},
'type': 'type',
}
@@ -958,6 +960,7 @@ class LEITouristicEventParser(LEIParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ self.field_options['organizers'] = {'create': True}
if self.type:
self.constant_fields['type'] = self.type
@@ -966,9 +969,9 @@ def filter_description_teaser(self, src, val):
val = val.replace('\n', '
')
return val
- def filter_organizer(self, src, val):
+ def filter_organizers(self, src, val):
(first, second) = val
- return self.apply_filter('organizer', src, [first if first else second])
+ return self.apply_filter('organizers', src, [first if first else second])
def filter_speaker(self, src, val):
(civilite, nom, prenom) = val
diff --git a/geotrek/tourism/serializers.py b/geotrek/tourism/serializers.py
index 15682805ab..6ff6b59977 100644
--- a/geotrek/tourism/serializers.py
+++ b/geotrek/tourism/serializers.py
@@ -182,6 +182,7 @@ class TouristicEventAPISerializer(PicturesSerializerMixin, PublishableSerializer
source = RecordSourceSerializer(many=True)
portal = TargetPortalSerializer(many=True)
structure = StructureSerializer()
+ organizers = rest_serializers.SerializerMethodField(source="organizers")
# Nearby
touristic_contents = CloseTouristicContentSerializer(many=True, source='published_touristic_contents')
@@ -193,6 +194,11 @@ class TouristicEventAPISerializer(PicturesSerializerMixin, PublishableSerializer
type1 = TouristicEventTypeSerializer(many=True)
category = rest_serializers.SerializerMethodField()
+ def get_organizers(self, obj):
+ return ", ".join(
+ map(lambda org: org.label, obj.organizers.all())
+ )
+
def __init__(self, instance=None, *args, **kwargs):
super().__init__(instance, *args, **kwargs)
if 'geotrek.diving' in settings.INSTALLED_APPS:
@@ -205,7 +211,7 @@ class Meta:
fields = (
'id', 'accessibility', 'approved', 'begin_date', 'booking',
'capacity', 'category', 'contact', 'description', 'description_teaser',
- 'duration', 'email', 'end_date', 'end_time', 'meeting_point', 'organizer',
+ 'duration', 'email', 'end_date', 'end_time', 'meeting_point', 'organizers',
'pois', 'portal', 'practical_info', 'source', 'speaker', 'start_time',
'structure', 'target_audience', 'themes', 'touristic_contents', 'touristic_events',
'treks', 'type', 'type1', 'website'
diff --git a/geotrek/tourism/templates/tourism/sql/post_20_views.sql b/geotrek/tourism/templates/tourism/sql/post_20_views.sql
index 2718db6fb1..6524aa1e83 100644
--- a/geotrek/tourism/templates/tourism/sql/post_20_views.sql
+++ b/geotrek/tourism/templates/tourism/sql/post_20_views.sql
@@ -196,7 +196,7 @@ SELECT a.id,
{% for lang in MODELTRANSLATION_LANGUAGES %}
a.meeting_point_{{ lang }} AS "Meeting point {{ lang }}",
{% endfor %}
- d.label AS "Organizer",
+ o.labels AS "Organizers",
{% for lang in MODELTRANSLATION_LANGUAGES %}
a.accessibility_{{ lang }} AS "Accessibility {{ lang }}",
{% endfor %}
@@ -218,7 +218,6 @@ SELECT a.id,
FROM public.tourism_touristicevent a
LEFT JOIN public.tourism_touristiceventtype b ON a.type_id = b.id
LEFT JOIN public.authent_structure c ON a.structure_id = c.id
-LEFT JOIN public.tourism_touristiceventorganizer d ON a.organizer_id = d.id
LEFT JOIN public.tourism_cancellationreason cr ON a.cancellation_reason_id = cr.id
LEFT JOIN public.tourism_touristiceventplace p ON a.place_id = p.id
LEFT JOIN LATERAL (
@@ -240,6 +239,13 @@ LEFT JOIN
JOIN tourism_touristicevent_themes b ON a.id = b.theme_id
JOIN tourism_touristicevent c ON b.touristicevent_id = c.id
GROUP BY c.id) h ON a.id = h.id
+LEFT JOIN
+ (SELECT c.id,
+ array_to_string(ARRAY_AGG (a.label ORDER BY a.id), ', ', '_') labels
+ FROM tourism_touristiceventorganizer a
+ JOIN tourism_touristicevent_organizers b ON a.id = b.touristiceventorganizer_id
+ JOIN tourism_touristicevent c ON b.touristicevent_id = c.id
+ GROUP BY c.id) o ON a.id = o.id
WHERE deleted IS FALSE
;
diff --git a/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html b/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html
index f23b02b5a7..a0bd44fb64 100644
--- a/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html
+++ b/geotrek/tourism/templates/tourism/touristicevent_detail_attributes.html
@@ -79,8 +79,9 @@