diff --git a/cypress/integration/nav_create_path.js b/cypress/integration/nav_create_path.js index 1dc172e17e..3dd3bfb49d 100644 --- a/cypress/integration/nav_create_path.js +++ b/cypress/integration/nav_create_path.js @@ -5,7 +5,7 @@ describe('Create path', () => { cy.loginByCSRF(username, password) .then((resp) => { - expect(resp.status).to.eq(200) + expect(resp.status).to.eq(200); }); cy.mockTiles(); }); @@ -53,17 +53,17 @@ describe('Create path', () => { it('Path action delete multiple without path', () => { cy.visit('/path/list'); cy.get("button.btn-primary[data-toggle='dropdown']").click(); - cy.get("a[href='#delete']").click(); + cy.get("#btn-delete").click(); cy.url().should('include', '/path/list/'); cy.get("a[title='Path number 1']").should('have.length', 2); cy.get("a[title='Path number 2']").should('have.length', 2); }) it('Path action delete multiple path', () => { cy.visit('/path/list'); - cy.get("input[name='path[]'][value='1']").click(); - cy.get("input[name='path[]'][value='2']").click(); + cy.get('a[data-pk="1"]').closest('tr').find('input.dt-select-checkbox').check(); + cy.get('a[data-pk="2"]').closest('tr').find('input.dt-select-checkbox').check(); cy.get("button.btn-primary[data-toggle='dropdown']").click(); - cy.get("a[href='#delete']").click(); + cy.get("#btn-delete").click(); cy.get("input[type='submit']").click(); cy.url().should('include', '/path/list/'); cy.get("a[title='Path number 1']").should('have.length', 1); @@ -72,8 +72,8 @@ describe('Create path', () => { // Two path it('Path action merge multiple path', () => { cy.visit('/path/list'); - cy.get("input[name='path[]'][value='3']").click(); - cy.get("input[name='path[]'][value='4']").click(); + cy.get('a[data-pk="3"]').closest('tr').find('input.dt-select-checkbox').check(); + cy.get('a[data-pk="4"]').closest('tr').find('input.dt-select-checkbox').check(); cy.get("button.btn-primary[data-toggle='dropdown']").click(); cy.get("a[href='#confirm-merge']").click(); cy.get("button").contains('Merge').click(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 307b1748a0..ecae2b7a81 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -4,7 +4,7 @@ Cypress.Commands.add('loginByCSRF', (username, password) => { .then((body) => { // we can use Cypress.$ to parse the string body // thus enabling us to query into it easily - const $html = Cypress.$(body) + const $html = Cypress.$(body); cy.request({ method: 'POST', url: '/login/?next=/', diff --git a/docs/changelog.rst b/docs/changelog.rst index 69f1f230a9..ba89d98f5b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,9 @@ CHANGELOG * Fix ``label_en`` content on sensitivity module parser +**New features** +* Add bulk deletion/edition on list views (refs #5107). + 2.121.1 (2025-11-17) ---------------------------- diff --git a/geotrek/common/locale/de/LC_MESSAGES/django.po b/geotrek/common/locale/de/LC_MESSAGES/django.po index 0fc414a03e..871eb39fe4 100644 --- a/geotrek/common/locale/de/LC_MESSAGES/django.po +++ b/geotrek/common/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-22 09:48+0000\n" +"POT-Creation-Date: 2025-12-15 08:21+0000\n" "PO-Revision-Date: 2025-10-22 09:01+0000\n" "Last-Translator: Anonymous \n" "Language-Team: German \n" @@ -110,9 +110,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Action" -msgstr "" - msgid "Insertion date" msgstr "" @@ -159,6 +156,9 @@ msgstr "" msgid "External id" msgstr "" +msgid "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." +msgstr "" + msgid "Attachment license" msgstr "" @@ -559,6 +559,9 @@ msgstr "" msgid "User" msgstr "" +msgid "Action" +msgstr "" + msgid "Full history" msgstr "" diff --git a/geotrek/common/locale/en/LC_MESSAGES/django.po b/geotrek/common/locale/en/LC_MESSAGES/django.po index b6a62002f2..04dda4bc7d 100644 --- a/geotrek/common/locale/en/LC_MESSAGES/django.po +++ b/geotrek/common/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-22 09:48+0000\n" +"POT-Creation-Date: 2025-12-15 08:21+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,9 +110,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Action" -msgstr "" - msgid "Insertion date" msgstr "" @@ -159,6 +156,9 @@ msgstr "" msgid "External id" msgstr "" +msgid "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." +msgstr "" + msgid "Attachment license" msgstr "" @@ -559,6 +559,9 @@ msgstr "" msgid "User" msgstr "" +msgid "Action" +msgstr "" + msgid "Full history" msgstr "" diff --git a/geotrek/common/locale/es/LC_MESSAGES/django.po b/geotrek/common/locale/es/LC_MESSAGES/django.po index 07c8fbd49c..818c5d0c92 100644 --- a/geotrek/common/locale/es/LC_MESSAGES/django.po +++ b/geotrek/common/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-22 09:48+0000\n" +"POT-Creation-Date: 2025-12-15 08:21+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Spanish \n" @@ -111,9 +111,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Action" -msgstr "" - msgid "Insertion date" msgstr "Fecha de inserción" @@ -160,6 +157,9 @@ msgstr "" msgid "External id" msgstr "" +msgid "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." +msgstr "" + msgid "Attachment license" msgstr "" @@ -560,6 +560,9 @@ msgstr "" msgid "User" msgstr "" +msgid "Action" +msgstr "" + msgid "Full history" msgstr "" diff --git a/geotrek/common/locale/fr/LC_MESSAGES/django.po b/geotrek/common/locale/fr/LC_MESSAGES/django.po index 0088978b2a..2c0d05355c 100644 --- a/geotrek/common/locale/fr/LC_MESSAGES/django.po +++ b/geotrek/common/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-22 09:48+0000\n" +"POT-Creation-Date: 2025-12-15 08:21+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Emmanuelle Helly \n" "Language-Team: French \n" @@ -110,9 +110,6 @@ msgstr "Le Parc national est un territoire naturel, ouvert à tous, mais soumis msgid "Merge" msgstr "Fusionner" -msgid "Action" -msgstr "Action" - msgid "Insertion date" msgstr "Date d'insertion" @@ -159,6 +156,9 @@ msgstr "Fournisseur" msgid "External id" msgstr "ID externe" +msgid "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." +msgstr "L’accès est restreint car tous les éléments sélectionnés n’appartiennent pas à votre structure. Utilisez le filtre par structure pour sélectionner uniquement les éléments autorisés." + msgid "Attachment license" msgstr "Licence pièce-jointes" @@ -559,6 +559,9 @@ msgstr "Date" msgid "User" msgstr "Utilisateur" +msgid "Action" +msgstr "Action" + msgid "Full history" msgstr "Historique complet" diff --git a/geotrek/common/locale/it/LC_MESSAGES/django.po b/geotrek/common/locale/it/LC_MESSAGES/django.po index 114ba1a8ad..ecce033bca 100644 --- a/geotrek/common/locale/it/LC_MESSAGES/django.po +++ b/geotrek/common/locale/it/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-22 09:48+0000\n" +"POT-Creation-Date: 2025-12-15 08:21+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Italian \n" @@ -110,9 +110,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Action" -msgstr "" - msgid "Insertion date" msgstr "Data di inserimento" @@ -159,6 +156,9 @@ msgstr "" msgid "External id" msgstr "" +msgid "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." +msgstr "" + msgid "Attachment license" msgstr "" @@ -559,6 +559,9 @@ msgstr "" msgid "User" msgstr "" +msgid "Action" +msgstr "" + msgid "Full history" msgstr "" diff --git a/geotrek/common/locale/nl/LC_MESSAGES/django.po b/geotrek/common/locale/nl/LC_MESSAGES/django.po index 118515fc1c..40d6754672 100644 --- a/geotrek/common/locale/nl/LC_MESSAGES/django.po +++ b/geotrek/common/locale/nl/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-22 09:48+0000\n" +"POT-Creation-Date: 2025-12-15 08:21+0000\n" "PO-Revision-Date: 2025-10-22 09:01+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Dutch \n" @@ -110,9 +110,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Action" -msgstr "" - msgid "Insertion date" msgstr "" @@ -159,6 +156,9 @@ msgstr "" msgid "External id" msgstr "" +msgid "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." +msgstr "" + msgid "Attachment license" msgstr "" @@ -559,6 +559,9 @@ msgstr "" msgid "User" msgstr "" +msgid "Action" +msgstr "" + msgid "Full history" msgstr "" diff --git a/geotrek/common/mixins/models.py b/geotrek/common/mixins/models.py index c27e2ffb4e..01c81e0864 100644 --- a/geotrek/common/mixins/models.py +++ b/geotrek/common/mixins/models.py @@ -24,20 +24,6 @@ from geotrek.common.utils import classproperty, logger -class CheckBoxActionMixin: - @property - def checkbox(self): - return f'' - - @classproperty - def checkbox_verbose_name(cls): - return _("Action") - - @property - def checkbox_display(self): - return self.checkbox - - class TimeStampedModelMixin(models.Model): # Computed values (managed at DB-level with triggers) date_insert = models.DateTimeField( diff --git a/geotrek/common/mixins/views.py b/geotrek/common/mixins/views.py index c6f6fb67ae..6ba1973215 100644 --- a/geotrek/common/mixins/views.py +++ b/geotrek/common/mixins/views.py @@ -2,12 +2,14 @@ from io import BytesIO from django.conf import settings -from django.http import HttpResponse, HttpResponseNotFound +from django.contrib import messages +from django.http import HttpResponse, HttpResponseNotFound, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils.functional import classproperty +from django.utils.translation import gettext_lazy as _ from django.views import static from mapentity import views as mapentity_views -from mapentity.helpers import suffix_for +from mapentity.helpers import suffix_for, user_has_perm from pdfimpose.schema.saddle import impose from pymupdf import Document @@ -232,3 +234,66 @@ def get_context_data(self, **kwargs): obj._meta.get_field(field).verbose_name for field in completeness_fields ] return context + + +class BelongStructureMixin: + """ + Check if the selected items are in the same structure than the user, except for super-user + This mixin is for views that handle action on multiple items (ex: MultiDelete, MultiUpdate) + """ + + def get(self, request, *args, **kwargs): + # check pks definition first to avoid get_queryset error + response = super().get(request, *args, **kwargs) + + if isinstance(response, HttpResponseRedirect): + return response + + # check permissions + queryset = self.get_queryset() + user_structure = self.request.user.profile.structure + superuser = self.request.user.is_superuser + + filtered_queryset = queryset.filter(structure__exact=user_structure) + + if not superuser and filtered_queryset.count() != queryset.count(): + messages.warning( + self.request, + _( + "Access is restricted because not all selected items belong to your structure. Use the structure filter to select only authorized items." + ), + ) + return HttpResponseRedirect(self.get_redirect_url()) + + return response + + def get_editable_fields(self): + superuser = self.request.user.is_superuser + editable_fields = super().get_editable_fields() + + if not superuser: + editable_fields.remove("structure") + + return editable_fields + + +class PublishedFieldMixin: + """ + Check if the user can modify "published" field and remove it from the multi update form + This mixin is for MultiUpdate views with a "published" field + """ + + def get_editable_fields(self): + publish_permission = ( + f"{self.model._meta.app_label}.publish_{self.model._meta.model_name}" + ) + editable_fields = super().get_editable_fields() + + if not user_has_perm(self.request.user, publish_permission): + editable_fields = [ + field + for field in list(editable_fields) + if not field.startswith("published") + ] + + return editable_fields diff --git a/geotrek/common/tests/__init__.py b/geotrek/common/tests/__init__.py index 0d27fa301e..990b45a352 100644 --- a/geotrek/common/tests/__init__.py +++ b/geotrek/common/tests/__init__.py @@ -1,5 +1,6 @@ import os from unittest import mock +from unittest.mock import MagicMock from django.conf import settings from django.contrib import messages @@ -507,3 +508,128 @@ def test_map_image_not_serve(self, mock_requests): response = self.client.get(obj.map_image_url) self.assertEqual(response.status_code, 200) self.assertTrue(default_storage.exists(image_path)) + + +class CommonMultiActionViewsMixin: + model = None + modelFactory = None + expected_fields = [] + + def setUp(self): + self.superuser = User.objects.create_superuser( + "admin", "email@corp.com", "test" + ) + self.user = User.objects.create_user("toto", "email@corp.com", "test") + + structure = self.user.profile.structure + self.create_items(structure) + + def create_items(self, struct): + if hasattr(self.model, "structure"): + self.item1 = self.modelFactory.create(structure=struct) + self.item2 = self.modelFactory.create(structure=StructureFactory.create()) + else: + self.item1 = self.modelFactory.create() + self.item2 = self.modelFactory.create() + + def login(self, user): + self.client.logout() + self.client.login(username=user.username, password="test") + return user + + @mock.patch.object(User, "has_perm", return_value=True) + def test_delete_view(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_delete_url() + f"?pks={self.item1.pk}" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_update_view(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + f"?pks={self.item1.pk}" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_delete_view_without_selected_items(self, mock): + self.login(self.user) + response = self.client.get(self.model.get_multi_delete_url() + "?pks=") + self.assertEqual(response.status_code, 302) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_update_view_without_selected_items(self, mock): + self.login(self.user) + response = self.client.get(self.model.get_multi_update_url() + "?pks=") + self.assertEqual(response.status_code, 302) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_editable_fields(self, mock): + self.login(self.superuser) + response = self.client.get( + self.model.get_multi_update_url() + f"?pks={self.item1.pk}" + ) + + for field in self.expected_fields: + self.assertContains(response, field) + + +class CommonMultiActionViewsStructureMixin: + @mock.patch.object(User, "has_perm", return_value=True) + def test_delete_selected_items_with_different_structure_than_user(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_delete_url() + + f"?pks={self.item1.pk}%2C{self.item2.pk}" + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.model.get_list_url()) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_update_selected_items_with_different_structure_than_user(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + + f"?pks={self.item1.pk}%2C{self.item2.pk}" + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.model.get_list_url()) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_editable_fields_with_not_superuser(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + f"?pks={self.item1.pk}" + ) + + self.assertNotContains(response, "Related structure") + + +class CommonMultiActionsViewsPublishedMixin: + def setUp(self): + super().setUp() + self.user_no_publish_perm = User.objects.create_user( + "titi", "email@corp.com", "test" + ) + self.user_no_publish_perm.has_perm = MagicMock(return_value=self.user_perm) + + def user_perm(self, perm): + if ( + perm + == f"{self.model._meta.app_label}.publish_{self.model._meta.model_name}" + ): + return False + return True + + @mock.patch.object(User, "has_perm") + def test_editable_fields_without_publish_perms(self, mock): + mock.side_effect = self.user_perm + + self.login(self.user_no_publish_perm) + response = self.client.get( + self.model.get_multi_update_url() + f"?pks={self.item1.pk}" + ) + + self.assertNotContains(response, "Published") diff --git a/geotrek/core/locale/de/LC_MESSAGES/django.po b/geotrek/core/locale/de/LC_MESSAGES/django.po index 015ca41757..b49478e705 100644 --- a/geotrek/core/locale/de/LC_MESSAGES/django.po +++ b/geotrek/core/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 11:11+0200\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: German \n" @@ -267,21 +267,6 @@ msgstr "" msgid "Add a new trail" msgstr "" -msgid "Actions" -msgstr "" - -msgid "Merge" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Merge paths" -msgstr "" - -msgid "Cancel" -msgstr "" - #, python-format msgid "%(obj)s has changed state to draft. " msgstr "" @@ -327,6 +312,15 @@ msgstr "" msgid "Are you sure you want to merge these paths ?" msgstr "" +msgid "Merge" +msgstr "" + +msgid "Merge paths" +msgstr "" + +msgid "Cancel" +msgstr "" + msgid "Warning" msgstr "" @@ -351,9 +345,6 @@ msgstr "" msgid "Access to the requested resource is restricted. You have been redirected." msgstr "" -msgid "Access to the requested resource is restricted by structure. You have been redirected." -msgstr "" - msgid "path" msgstr "" diff --git a/geotrek/core/locale/en/LC_MESSAGES/django.po b/geotrek/core/locale/en/LC_MESSAGES/django.po index 2b76429524..84b937b230 100644 --- a/geotrek/core/locale/en/LC_MESSAGES/django.po +++ b/geotrek/core/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 11:11+0200\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -267,21 +267,6 @@ msgstr "" msgid "Add a new trail" msgstr "" -msgid "Actions" -msgstr "" - -msgid "Merge" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Merge paths" -msgstr "" - -msgid "Cancel" -msgstr "" - #, python-format msgid "%(obj)s has changed state to draft. " msgstr "" @@ -327,6 +312,15 @@ msgstr "" msgid "Are you sure you want to merge these paths ?" msgstr "" +msgid "Merge" +msgstr "" + +msgid "Merge paths" +msgstr "" + +msgid "Cancel" +msgstr "" + msgid "Warning" msgstr "" @@ -351,9 +345,6 @@ msgstr "" msgid "Access to the requested resource is restricted. You have been redirected." msgstr "" -msgid "Access to the requested resource is restricted by structure. You have been redirected." -msgstr "" - msgid "path" msgstr "" diff --git a/geotrek/core/locale/es/LC_MESSAGES/django.po b/geotrek/core/locale/es/LC_MESSAGES/django.po index 1c560271cd..ad9e46290e 100644 --- a/geotrek/core/locale/es/LC_MESSAGES/django.po +++ b/geotrek/core/locale/es/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 11:11+0200\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Spanish \n" @@ -267,21 +267,6 @@ msgstr "" msgid "Add a new trail" msgstr "" -msgid "Actions" -msgstr "" - -msgid "Merge" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Merge paths" -msgstr "" - -msgid "Cancel" -msgstr "" - #, python-format msgid "%(obj)s has changed state to draft. " msgstr "" @@ -327,6 +312,15 @@ msgstr "" msgid "Are you sure you want to merge these paths ?" msgstr "" +msgid "Merge" +msgstr "" + +msgid "Merge paths" +msgstr "" + +msgid "Cancel" +msgstr "" + msgid "Warning" msgstr "" @@ -351,9 +345,6 @@ msgstr "" msgid "Access to the requested resource is restricted. You have been redirected." msgstr "" -msgid "Access to the requested resource is restricted by structure. You have been redirected." -msgstr "" - msgid "path" msgstr "" diff --git a/geotrek/core/locale/fr/LC_MESSAGES/django.po b/geotrek/core/locale/fr/LC_MESSAGES/django.po index d8301d8f17..09ca667407 100644 --- a/geotrek/core/locale/fr/LC_MESSAGES/django.po +++ b/geotrek/core/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-23 12:05+0200\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-10 18:37+0000\n" "Last-Translator: J-E Castagnede \n" "Language-Team: French \n" @@ -267,21 +267,6 @@ msgstr "Pas d'information" msgid "Add a new trail" msgstr "Ajouter un sentier" -msgid "Actions" -msgstr "Actions" - -msgid "Merge" -msgstr "Fusionner" - -msgid "Delete" -msgstr "Supprimer" - -msgid "Merge paths" -msgstr "Fusionner les tronçons" - -msgid "Cancel" -msgstr "Annuler" - #, python-format msgid "%(obj)s has changed state to draft. " msgstr "%(obj)s a été mis en brouillon. " @@ -327,6 +312,15 @@ msgstr "Sélectionner deux tronçons pour les fusionner" msgid "Are you sure you want to merge these paths ?" msgstr "Etes-vous certain de vouloir fusionner ces tronçons ?" +msgid "Merge" +msgstr "Fusionner" + +msgid "Merge paths" +msgstr "Fusionner les tronçons" + +msgid "Cancel" +msgstr "Annuler" + msgid "Warning" msgstr "Attention" @@ -351,9 +345,6 @@ msgstr "Ce champ est requis." msgid "Access to the requested resource is restricted. You have been redirected." msgstr "L'accès à cette ressource est restreint. Vous avez été redirigé." -msgid "Access to the requested resource is restricted by structure. You have been redirected." -msgstr "L'accès à cette ressource est restreint par structure. Vous avez été redirigé." - msgid "path" msgstr "tronçon" diff --git a/geotrek/core/locale/it/LC_MESSAGES/django.po b/geotrek/core/locale/it/LC_MESSAGES/django.po index db4a006f78..0a9a149664 100644 --- a/geotrek/core/locale/it/LC_MESSAGES/django.po +++ b/geotrek/core/locale/it/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 11:11+0200\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Italian \n" @@ -267,21 +267,6 @@ msgstr "" msgid "Add a new trail" msgstr "" -msgid "Actions" -msgstr "" - -msgid "Merge" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Merge paths" -msgstr "" - -msgid "Cancel" -msgstr "" - #, python-format msgid "%(obj)s has changed state to draft. " msgstr "" @@ -327,6 +312,15 @@ msgstr "" msgid "Are you sure you want to merge these paths ?" msgstr "" +msgid "Merge" +msgstr "" + +msgid "Merge paths" +msgstr "" + +msgid "Cancel" +msgstr "" + msgid "Warning" msgstr "" @@ -351,9 +345,6 @@ msgstr "" msgid "Access to the requested resource is restricted. You have been redirected." msgstr "" -msgid "Access to the requested resource is restricted by structure. You have been redirected." -msgstr "" - msgid "path" msgstr "" diff --git a/geotrek/core/locale/nl/LC_MESSAGES/django.po b/geotrek/core/locale/nl/LC_MESSAGES/django.po index b871289b38..86cb03183a 100644 --- a/geotrek/core/locale/nl/LC_MESSAGES/django.po +++ b/geotrek/core/locale/nl/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 11:11+0200\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Dutch \n" @@ -267,21 +267,6 @@ msgstr "" msgid "Add a new trail" msgstr "" -msgid "Actions" -msgstr "" - -msgid "Merge" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Merge paths" -msgstr "" - -msgid "Cancel" -msgstr "" - #, python-format msgid "%(obj)s has changed state to draft. " msgstr "" @@ -327,6 +312,15 @@ msgstr "" msgid "Are you sure you want to merge these paths ?" msgstr "" +msgid "Merge" +msgstr "" + +msgid "Merge paths" +msgstr "" + +msgid "Cancel" +msgstr "" + msgid "Warning" msgstr "" @@ -351,9 +345,6 @@ msgstr "" msgid "Access to the requested resource is restricted. You have been redirected." msgstr "" -msgid "Access to the requested resource is restricted by structure. You have been redirected." -msgstr "" - msgid "path" msgstr "" diff --git a/geotrek/core/migrations/0046_alter_topology_geom_need_update.py b/geotrek/core/migrations/0046_alter_topology_geom_need_update.py new file mode 100644 index 0000000000..f02af14ebf --- /dev/null +++ b/geotrek/core/migrations/0046_alter_topology_geom_need_update.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-15 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0045_topology_length_2d"), + ] + + operations = [ + migrations.AlterField( + model_name="topology", + name="geom_need_update", + field=models.BooleanField(default=False, editable=False), + ), + ] diff --git a/geotrek/core/models.py b/geotrek/core/models.py index d7006110f8..6246035514 100644 --- a/geotrek/core/models.py +++ b/geotrek/core/models.py @@ -26,7 +26,6 @@ from geotrek.common.functions import IsSimple, LengthSpheroid from geotrek.common.mixins.models import ( AddPropertyMixin, - CheckBoxActionMixin, ExternalSourceMixin, GeotrekMapEntityMixin, NoDeleteMixin, @@ -47,7 +46,6 @@ class Path( - CheckBoxActionMixin, ZoningPropertiesMixin, AddPropertyMixin, GeotrekMapEntityMixin, @@ -491,7 +489,7 @@ class Topology( ) offset = models.FloatField(default=0.0, verbose_name=_("Offset")) # in SRID units kind = models.CharField(editable=False, verbose_name=_("Kind"), max_length=32) - geom_need_update = models.BooleanField(default=False) + geom_need_update = models.BooleanField(default=False, editable=False) geom = models.GeometryField( editable=(not settings.TREKKING_TOPOLOGY_ENABLED), srid=settings.SRID, diff --git a/geotrek/core/serializers.py b/geotrek/core/serializers.py index e11821e2e8..8c5c28d25d 100644 --- a/geotrek/core/serializers.py +++ b/geotrek/core/serializers.py @@ -6,7 +6,6 @@ class PathSerializer(DynamicFieldsMixin, serializers.ModelSerializer): - checkbox = serializers.CharField(source="checkbox_display") length_2d = serializers.FloatField(source="length_2d_display") length = serializers.FloatField(source="length_display") name = serializers.CharField(source="name_display") diff --git a/geotrek/core/templates/core/core_listactions_fragment.html b/geotrek/core/templates/core/core_listactions_fragment.html deleted file mode 100644 index 3f9391e873..0000000000 --- a/geotrek/core/templates/core/core_listactions_fragment.html +++ /dev/null @@ -1,44 +0,0 @@ -{% load i18n %} - -{% if modelname == 'path' %} - {% if perms.core.change_path %} - - - {% endif %} - - - -{% endif %} \ No newline at end of file diff --git a/geotrek/core/templates/core/path_list.html b/geotrek/core/templates/core/path_list.html index 2c99e66d0c..d487cdef9f 100644 --- a/geotrek/core/templates/core/path_list.html +++ b/geotrek/core/templates/core/path_list.html @@ -22,7 +22,8 @@ $(function () { $('#btn-merge').click(function () { - if ($('input[type="checkbox"][name="path\\[\\]"]:checked').length != 2) { + const table = $('#objects-list').DataTable(); + if (table.rows( { selected: true } )[0].length != 2) { $('#btn-confirm').hide(); $('#confirm-merge .modal-body h4').html("{% trans 'Select two paths to merge them' %}"); } else { @@ -31,25 +32,18 @@ } }); - var pathDelete = "{% url 'core:multiple_path_delete' "0,0" %}"; - var pathsToDelete = []; - $('#btn-delete').click(function () { - for (const value of Object.values($('#objects-list input:checkbox:checked'))) { - pathsToDelete.push(value.value); - } - ; - if (pathsToDelete.filter(Boolean).length != 0) { - window.location = pathDelete.replace('0,0', pathsToDelete.filter(Boolean).join(',')); - } - }); - $('#btn-confirm').click(function () { $('#confirm-merge .modal-body h4').html($('#wait_lightbox').html()); $('#btn-confirm').hide(); - $.post("{% url 'core:path-drf-merge-path' %}", - $('input:checkbox:checked').serialize() + '&' + $('input[name=csrfmiddlewaretoken]').serialize(), + const table = $('#objects-list').DataTable(); + const params = { + path: table.rows( { selected: true } ).data().pluck('id').toArray(), + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + } + $.post("{% url 'core:path-drf-merge-path' %}", + params, function (response) { $('#btn-confirm').show(); @@ -67,4 +61,43 @@ -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block actions %} + {% if perms.core.change_path %} + + {% trans "Merge" %} + + {% endif %} +{% endblock %} + +{% block container %} + {{ block.super }} + + + +{% endblock %} \ No newline at end of file diff --git a/geotrek/core/tests/test_permissions.py b/geotrek/core/tests/test_permissions.py index 7a5f3dfa6d..b47dab71c0 100644 --- a/geotrek/core/tests/test_permissions.py +++ b/geotrek/core/tests/test_permissions.py @@ -4,7 +4,6 @@ from django.contrib.auth.models import Permission from django.contrib.gis.geos import LineString from django.test import TestCase -from django.urls import reverse from mapentity.tests.factories import UserFactory from geotrek.core.models import Path @@ -262,40 +261,6 @@ def test_permission_view_delete_path_with_2_permissions(self): self.assertEqual(Path.objects.count(), 0) - def test_delete_multiple_path_draft_withtout_perm(self): - user = UserFactory.create() - self.client.force_login(user=user) - path = PathFactory.create(name="path_1", geom=LineString((0, 0), (4, 0))) - draft_path = PathFactory.create( - name="path_2", geom=LineString((2, 2), (2, -2)), draft=True - ) - - response = self.client.post( - reverse("core:multiple_path_delete", args=[f"{path.pk},{draft_path.pk}"]) - ) - self.assertEqual(response.status_code, 302) - - self.assertEqual(Path.objects.count(), 2) - - user.user_permissions.add(Permission.objects.get(codename="delete_path")) - - response = self.client.post( - reverse("core:multiple_path_delete", args=[f"{path.pk},{draft_path.pk}"]) - ) - self.assertEqual(response.status_code, 302) - - self.assertEqual(Path.objects.count(), 2) - - user.user_permissions.add(Permission.objects.get(codename="delete_draft_path")) - self.client.force_login(user=user) - - response = self.client.post( - reverse("core:multiple_path_delete", args=[f"{path.pk},{draft_path.pk}"]) - ) - self.assertEqual(response.status_code, 302) - - self.assertEqual(Path.objects.count(), 0) - def test_save_path_with_only_add_draft_path(self): """Check save path without permission add_path save with draft=True""" user = UserFactory.create() diff --git a/geotrek/core/tests/test_views.py b/geotrek/core/tests/test_views.py index 6e1ce0f017..311d80ebb6 100644 --- a/geotrek/core/tests/test_views.py +++ b/geotrek/core/tests/test_views.py @@ -4,7 +4,7 @@ from bs4 import BeautifulSoup from django.conf import settings -from django.contrib.auth.models import Permission +from django.contrib.auth.models import Permission, User from django.contrib.gis.geos import LineString, MultiPolygon, Point, Polygon from django.core.cache import caches from django.core.files.storage import default_storage @@ -15,7 +15,11 @@ from geotrek.authent.tests.base import AuthentFixturesTest from geotrek.authent.tests.factories import PathManagerFactory, StructureFactory -from geotrek.common.tests import CommonTest +from geotrek.common.tests import ( + CommonMultiActionViewsMixin, + CommonMultiActionViewsStructureMixin, + CommonTest, +) from geotrek.core.models import Path, PathSource, Trail from geotrek.core.tests.factories import ( ComfortFactory, @@ -51,68 +55,6 @@ def login(self): def logout(self): self.client.logout() - def test_show_delete_multiple_path_in_list(self): - path_1 = PathFactory.create(name="path_1", geom=LineString((0, 0), (4, 0))) - PathFactory.create(name="path_2", geom=LineString((2, 2), (2, -2))) - POIFactory.create(paths=[(path_1, 0, 0)]) - response = self.client.get(reverse("core:path_list")) - self.assertContains( - response, - '', - ) - - def test_delete_view_multiple_path(self): - path_1 = PathFactory.create(name="path_1", geom=LineString((0, 0), (4, 0))) - path_2 = PathFactory.create(name="path_2", geom=LineString((2, 2), (2, -2))) - response = self.client.get( - reverse("core:multiple_path_delete", args=[f"{path_1.pk},{path_2.pk}"]) - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Do you really wish to delete") - - def test_delete_view_multiple_path_one_wrong_structure(self): - other_structure = StructureFactory(name="Other") - path_1 = PathFactory.create(name="path_1", geom=LineString((0, 0), (4, 0))) - path_2 = PathFactory.create( - name="path_2", geom=LineString((2, 2), (2, -2)), structure=other_structure - ) - POIFactory.create(paths=[(path_1, 0, 0)]) - response = self.client.get( - reverse("core:multiple_path_delete", args=[f"{path_1.pk},{path_2.pk}"]) - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, reverse("core:path_list")) - self.assertIn( - response.content, - b"Access to the requested resource is restricted by structure.", - ) - self.assertEqual(Path.objects.count(), 4) - - def test_delete_multiple_path(self): - path_1 = PathFactory.create(name="path_1", geom=LineString((0, 0), (4, 0))) - path_2 = PathFactory.create(name="path_2", geom=LineString((2, 2), (2, -2))) - POIFactory.create(paths=[(path_1, 0, 0)], name="POI_1") - InfrastructureFactory.create(paths=[(path_1, 0, 1)], name="INFRA_1") - signage = SignageFactory.create(paths=[(path_1, 0, 1)], name="SIGNA_1") - TrailFactory.create(paths=[(path_2, 0, 1)], name="TRAIL_1") - ServiceFactory.create(paths=[(path_2, 0, 1)]) - InterventionFactory.create(target=signage, name="INTER_1") - response = self.client.get( - reverse("core:multiple_path_delete", args=[f"{path_1.pk},{path_2.pk}"]) - ) - self.assertContains(response, "POI_1") - self.assertContains(response, "INFRA_1") - self.assertContains(response, "SIGNA_1") - self.assertContains(response, "TRAIL_1") - self.assertContains(response, "Service type") - self.assertContains(response, "INTER_1") - response = self.client.post( - reverse("core:multiple_path_delete", args=[f"{path_1.pk},{path_2.pk}"]) - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(Path.objects.count(), 2) - self.assertEqual(Path.objects.filter(pk__in=[path_1.pk, path_2.pk]).count(), 0) - def get_route_exception_mock(arg1, arg2): msg = "This is an error message" @@ -132,7 +74,6 @@ class PathViewsTest(CommonTest): extra_column_list = ["length_2d", "eid"] expected_column_list_extra = [ "id", - "checkbox", "name", "length", "length_2d", @@ -148,7 +89,6 @@ def get_expected_geojson_attrs(self): def get_expected_datatables_attrs(self): return { - "checkbox": self.obj.checkbox_display, "id": self.obj.pk, "length": 141.6, "length_2d": 141.6, @@ -2426,3 +2366,125 @@ def test_remove_poi(self): self.assertEqual(poi.deleted, False) self.assertAlmostEqual(1.5, poi.offset) + + +class PathMultiActionsViewTest( + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Path + modelFactory = PathFactory + expected_fields = [ + "Provider", + "Related structure", + "Validity", + "Visible", + "Comfort", + "Source", + "Maintenance stake", + "Draft", + ] + + def create_items(self, struct): + self.item1 = self.modelFactory.create(structure=struct, draft=False) + self.item2 = self.modelFactory.create( + structure=StructureFactory.create(), draft=False + ) + self.draft = self.modelFactory.create(structure=struct, draft=True) + + def user_perm_path(self, perm): + if perm in [ + "core.delete_path", + "core.delete_draft_path", + "core.change_path", + "core.change_draft_path", + ]: + return False + return True + + @mock.patch.object(User, "has_perm") + def test_delete_draft_path_without_permission(self, mock): + mock.side_effect = self.user_perm_path + self.login(self.user) + response = self.client.get( + self.model.get_multi_delete_url() + f"?pks={self.draft.pk}" + ) + self.assertEqual(response.status_code, 302) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_delete_draft_path_with_permission(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_delete_url() + f"?pks={self.draft.pk}" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch.object(User, "has_perm") + def test_delete_path_without_permission(self, mock): + mock.side_effect = self.user_perm_path + self.login(self.user) + response = self.client.get( + self.model.get_multi_delete_url() + + f"?pks={self.item1.pk}%2C{self.draft.pk}" + ) + self.assertEqual(response.status_code, 302) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_delete_path_with_permission(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_delete_url() + + f"?pks={self.item1.pk}%2C{self.draft.pk}" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch.object(User, "has_perm") + def test_change_draft_path_without_permission(self, mock): + mock.side_effect = self.user_perm_path + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + f"?pks={self.draft.pk}" + ) + self.assertEqual(response.status_code, 302) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_change_draft_path_with_permission(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + f"?pks={self.draft.pk}" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch.object(User, "has_perm") + def test_change_path_without_permission(self, mock): + mock.side_effect = self.user_perm_path + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + + f"?pks={self.item1.pk}%2C{self.draft.pk}" + ) + self.assertEqual(response.status_code, 302) + + @mock.patch.object(User, "has_perm", return_value=True) + def test_change_path_with_permission(self, mock): + self.login(self.user) + response = self.client.get( + self.model.get_multi_update_url() + + f"?pks={self.item1.pk}%2C{self.draft.pk}" + ) + self.assertEqual(response.status_code, 200) + + +class TrailMultiActionsViewTest( + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Trail + modelFactory = TrailFactory + expected_fields = [ + "Provider", + "Related structure", + "Category", + ] diff --git a/geotrek/core/urls.py b/geotrek/core/urls.py index 8f0d05d5fd..82881c6874 100644 --- a/geotrek/core/urls.py +++ b/geotrek/core/urls.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.urls import path, re_path, register_converter +from django.urls import path, register_converter from mapentity.registry import registry from geotrek.altimetry.urls import AltimetryEntityOptions @@ -7,7 +7,6 @@ from .models import Path, Trail from .views import ( - MultiplePathDelete, PathGPXDetail, PathKMLDetail, TrailGPXDetail, @@ -19,11 +18,6 @@ app_name = "core" urlpatterns = [ - re_path( - r"^path/delete/(?P\d+(,\d+)+)/", - MultiplePathDelete.as_view(), - name="multiple_path_delete", - ), path( "api//paths//path_.gpx", PathGPXDetail.as_view(), diff --git a/geotrek/core/views.py b/geotrek/core/views.py index 25ed1ab633..5fae626d65 100644 --- a/geotrek/core/views.py +++ b/geotrek/core/views.py @@ -6,13 +6,10 @@ from django.contrib.auth.decorators import permission_required from django.contrib.gis.db.models.functions import Transform from django.db.models import Prefetch, Sum -from django.http import HttpResponseRedirect -from django.http.response import HttpResponse +from django.http.response import HttpResponse, HttpResponseRedirect from django.shortcuts import redirect -from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ -from django.views.generic import TemplateView from django.views.generic.detail import BaseDetailView from mapentity.helpers import user_has_perm from mapentity.serializers import GPXSerializer @@ -25,6 +22,8 @@ MapEntityFilter, MapEntityFormat, MapEntityList, + MapEntityMultiDelete, + MapEntityMultiUpdate, MapEntityUpdate, ) from rest_framework import permissions @@ -35,7 +34,7 @@ from geotrek.authent.decorators import same_structure_required from geotrek.common.functions import Length from geotrek.common.mixins.forms import FormsetMixin -from geotrek.common.mixins.views import CustomColumnsMixin +from geotrek.common.mixins.views import BelongStructureMixin, CustomColumnsMixin from geotrek.common.permissions import PublicOrReadPermMixin from geotrek.common.viewsets import GeotrekMapentityViewSet @@ -74,9 +73,8 @@ def get_initial(self): class PathList(CustomColumnsMixin, MapEntityList): queryset = Path.objects.all() - mandatory_columns = ["id", "checkbox", "name", "length"] + mandatory_columns = ["id", "name", "length"] default_extra_columns = ["length_2d"] - unorderable_columns = ["checkbox"] searchable_columns = ["id", "name"] def get_context_data(self, **kwargs): @@ -84,6 +82,12 @@ def get_context_data(self, **kwargs): context["can_add"] = user_has_perm( self.request.user, "core.add_path" ) or user_has_perm(self.request.user, "core.add_draft_path") + context["can_edit"] = user_has_perm( + self.request.user, "core.change_path" + ) or user_has_perm(self.request.user, "core.change_draft_path") + context["can_delete"] = user_has_perm( + self.request.user, "core.delete_path" + ) or user_has_perm(self.request.user, "core.delete_draft_path") return context @@ -212,62 +216,6 @@ def get_form_kwargs(self): return kwargs -class MultiplePathDelete(TemplateView): - template_name = "core/multiplepath_confirm_delete.html" - model = Path - success_url = "core:path_list" - - def dispatch(self, *args, **kwargs): - self.paths_pk = self.kwargs["pk"].split(",") - self.paths = [] - for pk in self.paths_pk: - path = Path.objects.get(pk=pk) - self.paths.append(path) - if path.draft and not self.request.user.has_perm("core.delete_draft_path"): - messages.warning( - self.request, - _( - "Access to the requested resource is restricted. You have been redirected." - ), - ) - return redirect("core:path_list") - if not path.draft and not self.request.user.has_perm("core.delete_path"): - messages.warning( - self.request, - _( - "Access to the requested resource is restricted. You have been redirected." - ), - ) - return redirect("core:path_list") - if not path.same_structure(self.request.user): - messages.warning( - self.request, - _( - "Access to the requested resource is restricted by structure. " - "You have been redirected." - ), - ) - return redirect("core:path_list") - return super().dispatch(*args, **kwargs) - - # Add support for browsers which only accept GET and POST for now. - def post(self, request, *args, **kwargs): - return self.delete(request, *args, **kwargs) - - def delete(self, request, *args, **kwargs): - for path in self.paths: - path.delete() - return HttpResponseRedirect(reverse(self.success_url)) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - topologies_by_model = defaultdict(list) - for path in self.paths: - path.topologies_by_path(topologies_by_model) - context["topologies_by_model"] = dict(topologies_by_model) - return context - - class PathDelete(MapEntityDelete): model = Path @@ -458,6 +406,72 @@ def route_geometry(self, request, *args, **kwargs): return Response(response, status) +class PathMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Path + + def get(self, request, *args, **kwargs): + # check pks definition first to avoid get_queryset error + response = super().get(request, *args, **kwargs) + + if isinstance(response, HttpResponseRedirect): + return response + + # check permissions + qs = self.get_queryset() + + has_drafts = qs.filter(draft=True).exists() + has_non_drafts = qs.filter(draft=False).exists() + + if ( + has_drafts + and not user_has_perm(self.request.user, "core.delete_draft_path") + ) or ( + has_non_drafts and not user_has_perm(self.request.user, "core.delete_path") + ): + messages.warning( + self.request, + _( + "Access to the requested resource is restricted. You have been redirected." + ), + ) + return redirect("core:path_list") + + return response + + +class PathMultiUpdate(BelongStructureMixin, MapEntityMultiUpdate): + model = Path + + def get(self, request, *args, **kwargs): + # check pks definition first to avoid get_queryset error + response = super().get(request, *args, **kwargs) + + if isinstance(response, HttpResponseRedirect): + return response + + # check permissions + qs = self.get_queryset() + + has_drafts = qs.filter(draft__exact=True).exists() + has_non_drafts = qs.filter(draft__exact=False).exists() + + if ( + has_drafts + and not user_has_perm(self.request.user, "core.change_draft_path") + ) or ( + has_non_drafts and not user_has_perm(self.request.user, "core.change_path") + ): + messages.warning( + self.request, + _( + "Access to the requested resource is restricted. You have been redirected." + ), + ) + return redirect("core:path_list") + + return response + + class CertificationTrailMixin(FormsetMixin): context_name = "certificationtrail_formset" formset_class = CertificationTrailFormSet @@ -588,3 +602,11 @@ def get_queryset(self): else: qs = qs.defer("geom", "geom_3d") return qs + + +class TrailMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Trail + + +class TrailMultiUpdate(BelongStructureMixin, MapEntityMultiUpdate): + model = Trail diff --git a/geotrek/diving/tests/test_views.py b/geotrek/diving/tests/test_views.py index 197937f582..6bb91e8b35 100644 --- a/geotrek/diving/tests/test_views.py +++ b/geotrek/diving/tests/test_views.py @@ -1,8 +1,15 @@ +from django.test import TestCase from django.utils.translation import gettext_lazy as _ from mapentity.tests.factories import SuperUserFactory from geotrek.authent.tests.factories import StructureFactory -from geotrek.common.tests import CommonLiveTest, CommonTest +from geotrek.common.tests import ( + CommonLiveTest, + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsMixin, + CommonMultiActionViewsStructureMixin, + CommonTest, +) from ..models import Dive from .factories import ( @@ -70,3 +77,21 @@ class DiveViewsLiveTests(CommonLiveTest): model = Dive modelfactory = DiveFactory userfactory = SuperUserFactory + + +class DiveMultiActionsViewTest( + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Dive + modelFactory = DiveFactory + expected_fields = [ + "Published [fr]", + "Published [en]", + "Waiting for publication", + "Related structure", + "Practice", + "Difficulty level", + ] diff --git a/geotrek/diving/views.py b/geotrek/diving/views.py index 105f85d17b..1b4ac9ead5 100644 --- a/geotrek/diving/views.py +++ b/geotrek/diving/views.py @@ -9,11 +9,18 @@ MapEntityFormat, MapEntityList, MapEntityMapImage, + MapEntityMultiDelete, + MapEntityMultiUpdate, MapEntityUpdate, ) from geotrek.authent.decorators import same_structure_required -from geotrek.common.mixins.views import CompletenessMixin, CustomColumnsMixin +from geotrek.common.mixins.views import ( + BelongStructureMixin, + CompletenessMixin, + CustomColumnsMixin, + PublishedFieldMixin, +) from geotrek.common.views import DocumentBookletPublic, DocumentPublic, MarkupPublic from geotrek.common.viewsets import GeotrekMapentityViewSet from geotrek.trekking.views import FlattenPicturesMixin @@ -140,3 +147,11 @@ def get_queryset(self): qs = qs.only("id", "name", "published") return qs + + +class DiveMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Dive + + +class DiveMultiUpdate(PublishedFieldMixin, BelongStructureMixin, MapEntityMultiUpdate): + model = Dive diff --git a/geotrek/feedback/locale/de/LC_MESSAGES/django.po b/geotrek/feedback/locale/de/LC_MESSAGES/django.po index dc1eca9df3..af61278626 100644 --- a/geotrek/feedback/locale/de/LC_MESSAGES/django.po +++ b/geotrek/feedback/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-24 11:42+0100\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: German \n" @@ -335,5 +335,8 @@ msgstr "" msgid "Map" msgstr "" +msgid "Add" +msgstr "" + msgid "Data to import from Suricate" msgstr "" diff --git a/geotrek/feedback/locale/en/LC_MESSAGES/django.po b/geotrek/feedback/locale/en/LC_MESSAGES/django.po index 2c01ae1c8e..ee2bdeb591 100644 --- a/geotrek/feedback/locale/en/LC_MESSAGES/django.po +++ b/geotrek/feedback/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-24 11:43+0100\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -335,5 +335,8 @@ msgstr "" msgid "Map" msgstr "" +msgid "Add" +msgstr "" + msgid "Data to import from Suricate" msgstr "" diff --git a/geotrek/feedback/locale/es/LC_MESSAGES/django.po b/geotrek/feedback/locale/es/LC_MESSAGES/django.po index 00cb39dac3..af3b1bd1ab 100644 --- a/geotrek/feedback/locale/es/LC_MESSAGES/django.po +++ b/geotrek/feedback/locale/es/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-24 11:41+0100\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Spanish \n" @@ -335,5 +335,8 @@ msgstr "" msgid "Map" msgstr "" +msgid "Add" +msgstr "" + msgid "Data to import from Suricate" msgstr "" diff --git a/geotrek/feedback/locale/fr/LC_MESSAGES/django.po b/geotrek/feedback/locale/fr/LC_MESSAGES/django.po index 4af02ccd46..7ed55626a3 100644 --- a/geotrek/feedback/locale/fr/LC_MESSAGES/django.po +++ b/geotrek/feedback/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-24 11:42+0100\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: J-E Castagnede \n" "Language-Team: French \n" @@ -338,5 +338,8 @@ msgstr "Non" msgid "Map" msgstr "Carte" +msgid "Add" +msgstr "" + msgid "Data to import from Suricate" msgstr "Données à importer de Suricate" diff --git a/geotrek/feedback/locale/it/LC_MESSAGES/django.po b/geotrek/feedback/locale/it/LC_MESSAGES/django.po index 05712777cd..8d6f825bb0 100644 --- a/geotrek/feedback/locale/it/LC_MESSAGES/django.po +++ b/geotrek/feedback/locale/it/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-24 11:42+0100\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Italian \n" @@ -335,5 +335,8 @@ msgstr "" msgid "Map" msgstr "" +msgid "Add" +msgstr "" + msgid "Data to import from Suricate" msgstr "" diff --git a/geotrek/feedback/locale/nl/LC_MESSAGES/django.po b/geotrek/feedback/locale/nl/LC_MESSAGES/django.po index ed2c9e760c..9569c24f99 100644 --- a/geotrek/feedback/locale/nl/LC_MESSAGES/django.po +++ b/geotrek/feedback/locale/nl/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-24 11:44+0100\n" +"POT-Creation-Date: 2025-12-12 08:55+0000\n" "PO-Revision-Date: 2025-10-22 09:00+0000\n" "Last-Translator: Anonymous \n" "Language-Team: Dutch \n" @@ -335,5 +335,8 @@ msgstr "" msgid "Map" msgstr "" +msgid "Add" +msgstr "" + msgid "Data to import from Suricate" msgstr "" diff --git a/geotrek/feedback/templates/feedback/report_list.html b/geotrek/feedback/templates/feedback/report_list.html new file mode 100644 index 0000000000..f7cd881ab8 --- /dev/null +++ b/geotrek/feedback/templates/feedback/report_list.html @@ -0,0 +1,10 @@ +{% extends "mapentity/mapentity_list.html" %} +{% load i18n mapentity_tags %} + +{% block mainactions %} + {% if can_add %} + + {% trans "Add" %} + + {% endif %} +{% endblock mainactions %} \ No newline at end of file diff --git a/geotrek/infrastructure/tests/test_views.py b/geotrek/infrastructure/tests/test_views.py index 5705907048..4ed075cf25 100755 --- a/geotrek/infrastructure/tests/test_views.py +++ b/geotrek/infrastructure/tests/test_views.py @@ -1,7 +1,13 @@ from django.conf import settings +from django.test import TestCase from geotrek.authent.tests.factories import PathManagerFactory -from geotrek.common.tests import CommonTest +from geotrek.common.tests import ( + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsMixin, + CommonMultiActionViewsStructureMixin, + CommonTest, +) from geotrek.core.tests.factories import PathFactory from geotrek.infrastructure.models import Infrastructure, InfrastructureTypeChoices from geotrek.infrastructure.tests.factories import ( @@ -124,3 +130,23 @@ def get_expected_popup_content(self): f' \n' f"" ) + + +class InfrastructureMultiActionsViewTest( + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Infrastructure + modelFactory = InfrastructureFactory + expected_fields = [ + "Published [fr]", + "Published [en]", + "Provider", + "Related structure", + "Access mean", + "Type", + "Maintenance difficulty", + "Usage difficulty", + ] diff --git a/geotrek/infrastructure/views.py b/geotrek/infrastructure/views.py index 66a200d60b..90bd0971eb 100755 --- a/geotrek/infrastructure/views.py +++ b/geotrek/infrastructure/views.py @@ -9,11 +9,17 @@ MapEntityFilter, MapEntityFormat, MapEntityList, + MapEntityMultiDelete, + MapEntityMultiUpdate, MapEntityUpdate, ) from geotrek.authent.decorators import same_structure_required -from geotrek.common.mixins.views import CustomColumnsMixin +from geotrek.common.mixins.views import ( + BelongStructureMixin, + CustomColumnsMixin, + PublishedFieldMixin, +) from geotrek.common.viewsets import GeotrekMapentityViewSet from geotrek.core.models import AltimetryMixin from geotrek.core.views import CreateFromTopologyMixin @@ -123,3 +129,13 @@ def get_queryset(self): ), ) return qs + + +class InfrastructureMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Infrastructure + + +class InfrastructureMultiUpdate( + PublishedFieldMixin, BelongStructureMixin, MapEntityMultiUpdate +): + model = Infrastructure diff --git a/geotrek/land/tests/test_views.py b/geotrek/land/tests/test_views.py index 1d340b9fac..650d7dd095 100644 --- a/geotrek/land/tests/test_views.py +++ b/geotrek/land/tests/test_views.py @@ -4,7 +4,7 @@ from django.test import TestCase from geotrek.authent.tests.factories import PathManagerFactory -from geotrek.common.tests import CommonTest +from geotrek.common.tests import CommonMultiActionViewsMixin, CommonTest from geotrek.common.tests.factories import OrganismFactory from geotrek.core.tests.factories import PathFactory from geotrek.land.models import ( @@ -361,3 +361,71 @@ def get_expected_popup_content(self): f' \n' f"" ) + + +class LandEdgeMultiActionsViewTest( + CommonMultiActionViewsMixin, + TestCase, +): + model = LandEdge + modelFactory = LandEdgeFactory + expected_fields = [ + "Land type", + "Agreement", + ] + + +class PhysicalEdgeMultiActionsViewTest( + CommonMultiActionViewsMixin, + TestCase, +): + model = PhysicalEdge + modelFactory = PhysicalEdgeFactory + expected_fields = [ + "Physical type", + ] + + +class CirculationEdgeMultiActionsViewTest( + CommonMultiActionViewsMixin, + TestCase, +): + model = CirculationEdge + modelFactory = CirculationEdgeFactory + expected_fields = [ + "Circulation type", + "Authorization type", + ] + + +class CompetenceEdgeMultiActionsViewTest( + CommonMultiActionViewsMixin, + TestCase, +): + model = CompetenceEdge + modelFactory = CompetenceEdgeFactory + expected_fields = [ + "Organism", + ] + + +class WorkManagementEdgeMultiActionsViewTest( + CommonMultiActionViewsMixin, + TestCase, +): + model = WorkManagementEdge + modelFactory = WorkManagementEdgeFactory + expected_fields = [ + "Organism", + ] + + +class SignageManagementEdgeMultiActionsViewTest( + CommonMultiActionViewsMixin, + TestCase, +): + model = SignageManagementEdge + modelFactory = SignageManagementEdgeFactory + expected_fields = [ + "Organism", + ] diff --git a/geotrek/maintenance/tests/test_views.py b/geotrek/maintenance/tests/test_views.py index 53d7b60bd3..0ce42473b7 100644 --- a/geotrek/maintenance/tests/test_views.py +++ b/geotrek/maintenance/tests/test_views.py @@ -17,7 +17,11 @@ from mapentity.tests.factories import SuperUserFactory from geotrek.authent.tests.factories import PathManagerFactory, StructureFactory -from geotrek.common.tests import CommonTest +from geotrek.common.tests import ( + CommonMultiActionViewsMixin, + CommonMultiActionViewsStructureMixin, + CommonTest, +) from geotrek.common.tests.factories import AccessMeanFactory, OrganismFactory from geotrek.core.models import PathAggregation from geotrek.core.tests.factories import PathFactory, StakeFactory, TopologyFactory @@ -970,3 +974,37 @@ def test_csv_target_content(self): ) for row in reader: self.assertEqual(row["On"], f"Path: {self.path.name} ({self.path.pk})") + + +class InterventionMultiActionsViewTest( + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Intervention + modelFactory = InterventionFactory + expected_fields = [ + "Related structure", + "Subcontracting", + "Stake", + "Status", + "Type", + "Project", + "Access mean", + ] + + +class ProjectMultiActionsViewTest( + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Project + modelFactory = ProjectFactory + expected_fields = [ + "Related structure", + "Type", + "Domain", + "Project owner", + "Project manager", + ] diff --git a/geotrek/maintenance/views.py b/geotrek/maintenance/views.py index f37b6ea465..92de33a84c 100755 --- a/geotrek/maintenance/views.py +++ b/geotrek/maintenance/views.py @@ -13,13 +13,15 @@ MapEntityFilter, MapEntityFormat, MapEntityList, + MapEntityMultiDelete, + MapEntityMultiUpdate, MapEntityUpdate, ) from geotrek.altimetry.models import AltimetryMixin 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.mixins.views import BelongStructureMixin, CustomColumnsMixin from geotrek.common.viewsets import GeotrekMapentityViewSet from geotrek.feedback.models import Report @@ -237,6 +239,14 @@ def get_queryset(self): return qs +class InterventionMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Intervention + + +class InterventionMultiUpdate(BelongStructureMixin, MapEntityMultiUpdate): + model = Intervention + + class ProjectList(CustomColumnsMixin, MapEntityList): queryset = Project.objects.existing() mandatory_columns = ["id", "name"] @@ -338,3 +348,11 @@ def get_queryset(self): qs = qs.filter(pk__in=non_empty_qs) qs = qs.only("id", "name") return qs + + +class ProjectMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Project + + +class ProjectMultiUpdate(BelongStructureMixin, MapEntityMultiUpdate): + model = Project diff --git a/geotrek/outdoor/tests/test_views.py b/geotrek/outdoor/tests/test_views.py index 1f87250e65..e71c24b005 100644 --- a/geotrek/outdoor/tests/test_views.py +++ b/geotrek/outdoor/tests/test_views.py @@ -5,8 +5,13 @@ from django.urls import reverse from mapentity.tests.factories import SuperUserFactory +from geotrek.common.tests import ( + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsMixin, + CommonMultiActionViewsStructureMixin, +) from geotrek.outdoor import views as course_views -from geotrek.outdoor.models import Site +from geotrek.outdoor.models import Course, Site from geotrek.outdoor.tests.factories import CourseFactory, SiteFactory from geotrek.tourism.tests.test_views import PNG_BLACK_PIXEL from geotrek.trekking.tests.factories import POIFactory @@ -119,3 +124,41 @@ def test_delete_site(self): self.assertEqual(response.status_code, 302) self.assertEqual(Site.objects.count(), 1) self.assertEqual(Site.objects.filter(pk=site_1.pk).exists(), True) + + +class SiteMultiActionsViewTest( + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Site + modelFactory = SiteFactory + expected_fields = [ + "Published [fr]", + "Published [en]", + "Waiting for publication", + "Provider", + "Related structure", + "Parent", + "Practice", + "Type", + ] + + +class CourseMultiActionsViewTest( + CommonMultiActionsViewsPublishedMixin, + CommonMultiActionViewsStructureMixin, + CommonMultiActionViewsMixin, + TestCase, +): + model = Course + modelFactory = CourseFactory + expected_fields = [ + "Published [fr]", + "Published [en]", + "Waiting for publication", + "Provider", + "Related structure", + "Type", + ] diff --git a/geotrek/outdoor/views.py b/geotrek/outdoor/views.py index 27d0b00eff..74aa330cac 100644 --- a/geotrek/outdoor/views.py +++ b/geotrek/outdoor/views.py @@ -10,11 +10,18 @@ MapEntityFilter, MapEntityFormat, MapEntityList, + MapEntityMultiDelete, + MapEntityMultiUpdate, MapEntityUpdate, ) from geotrek.authent.decorators import same_structure_required -from geotrek.common.mixins.views import CompletenessMixin, CustomColumnsMixin +from geotrek.common.mixins.views import ( + BelongStructureMixin, + CompletenessMixin, + CustomColumnsMixin, + PublishedFieldMixin, +) from geotrek.common.models import HDViewPoint from geotrek.common.views import DocumentBookletPublic, DocumentPublic, MarkupPublic from geotrek.common.viewsets import GeotrekMapentityViewSet @@ -166,6 +173,14 @@ def get_queryset(self): return qs +class SiteMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Site + + +class SiteMultiUpdate(PublishedFieldMixin, BelongStructureMixin, MapEntityMultiUpdate): + model = Site + + class CourseList(CustomColumnsMixin, MapEntityList): queryset = ( Course.objects.select_related("type").prefetch_related("parent_sites").all() @@ -286,3 +301,13 @@ def get_queryset(self): else: qs = qs.prefetch_related("parent_sites") return qs + + +class CourseMultiDelete(BelongStructureMixin, MapEntityMultiDelete): + model = Course + + +class CourseMultiUpdate( + PublishedFieldMixin, BelongStructureMixin, MapEntityMultiUpdate +): + model = Course diff --git a/geotrek/sensitivity/templates/sensitivity/sensitivearea_list.html b/geotrek/sensitivity/templates/sensitivity/sensitivearea_list.html index 6a9daf98af..45196ef69b 100644 --- a/geotrek/sensitivity/templates/sensitivity/sensitivearea_list.html +++ b/geotrek/sensitivity/templates/sensitivity/sensitivearea_list.html @@ -5,7 +5,7 @@ {% block mainactions %} {% if can_add %}