From 9319ce24edceaf113017d9609fd6090a788f5969 Mon Sep 17 00:00:00 2001 From: Jean-Etienne Castagnede Date: Wed, 15 May 2024 16:24:35 +0200 Subject: [PATCH] Merge Develop (#250) * Add load river command and use triggers (#226) * Contributions custom (#248) * feat: add annex uri in API for Station objects. Allow user to display station name in admin for customcontributions --------- Co-authored-by: babastienne --- .coveragerc | 6 +- .github/workflows/ci.yml | 46 +- Dockerfile | 2 +- Makefile | 2 + dev-requirements.in | 2 +- dev-requirements.txt | 65 +- docker-compose.yml | 1 - docs/changelog.rst | 5 + docs/usage/import_data.rst | 70 +- georiviere/altimetry.py | 38 +- georiviere/contribution/admin.py | 142 ++- georiviere/contribution/forms.py | 98 +- .../locale/en/LC_MESSAGES/django.po | 145 ++- .../locale/fr/LC_MESSAGES/django.po | 140 ++- georiviere/contribution/managers.py | 7 - ...ibutiontype_customcontributiontypefield.py | 62 ++ .../migrations/0011_auto_20240411_0859.py | 23 + .../migrations/0012_auto_20240411_0921.py | 20 + ...ustomcontributiontype_linked_to_station.py | 18 + .../migrations/0014_auto_20240412_1505.py | 91 ++ .../migrations/0015_auto_20240412_2038.py | 25 + .../migrations/0016_auto_20240423_1452.py | 22 + .../migrations/0017_auto_20240423_1542.py | 19 + .../migrations/0018_auto_20240425_1328.py | 23 + .../migrations/0019_auto_20240426_1532.py | 27 + .../0020_customcontributiontype_password.py | 18 + georiviere/contribution/models.py | 504 ---------- georiviere/contribution/models/__init__.py | 922 ++++++++++++++++++ georiviere/contribution/models/managers.py | 30 + georiviere/contribution/schema.py | 596 ++++++----- georiviere/contribution/tests/factories.py | 93 +- georiviere/contribution/tests/test_admin.py | 89 +- georiviere/contribution/tests/test_forms.py | 122 ++- .../contribution/tests/test_managers.py | 80 ++ georiviere/contribution/tests/test_models.py | 327 ++++++- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../description/sql/post_10_triggers.sql | 15 + .../description/sql/post_20_defaults.sql | 3 + .../description_detail_fragment.html | 10 +- georiviere/description/tests/test_views.py | 8 +- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 7 +- georiviere/flatpages/forms.py | 2 +- .../flatpages/locale/en/LC_MESSAGES/django.po | 2 +- .../flatpages/locale/fr/LC_MESSAGES/django.po | 2 +- georiviere/flatpages/static/js/tinymce.js | 4 +- .../flatpages/clearable_file_input.html | 2 +- .../knowledge/locale/en/LC_MESSAGES/django.po | 2 +- .../knowledge/locale/fr/LC_MESSAGES/django.po | 2 +- georiviere/knowledge/tests/test_views.py | 2 +- .../main/locale/en/LC_MESSAGES/django.po | 2 +- .../main/locale/fr/LC_MESSAGES/django.po | 2 +- .../main/management/commands/migrate.py | 8 +- georiviere/main/signals.py | 2 +- georiviere/main/sql/post_10_functions.sql | 16 + georiviere/main/sql/pre_10_cleanup.sql | 1 + georiviere/main/views.py | 29 +- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../migrations/0024_auto_20240514_0849.py | 17 + georiviere/observations/models.py | 1 + georiviere/observations/tests/test_views.py | 2 +- .../portal/locale/en/LC_MESSAGES/django.po | 28 +- .../portal/locale/fr/LC_MESSAGES/django.po | 34 +- .../migrations/0007_auto_20240423_1647.py | 30 + .../migrations/0008_auto_20240429_1233.py | 30 + .../migrations/0009_auto_20240430_1309.py | 17 + georiviere/portal/models.py | 2 +- georiviere/portal/serializers/contribution.py | 239 ++++- georiviere/portal/serializers/map.py | 10 +- georiviere/portal/serializers/mixins.py | 8 + georiviere/portal/serializers/observations.py | 53 + georiviere/portal/serializers/river.py | 62 +- georiviere/portal/serializers/valorization.py | 69 +- georiviere/portal/signals.py | 69 +- .../test_serializers/test_contribution.py | 99 +- .../tests/test_serializers/test_portal.py | 4 +- georiviere/portal/tests/test_signals.py | 8 +- .../tests/test_views/test_contribution.py | 72 +- .../tests/test_views/test_observations.py | 88 ++ .../portal/tests/test_views/test_river.py | 91 +- .../tests/test_views/test_valorization.py | 11 +- georiviere/portal/urls.py | 75 +- georiviere/portal/views/contribution.py | 293 +++++- georiviere/portal/views/mixins.py | 29 + georiviere/portal/views/observations.py | 41 + georiviere/portal/views/river.py | 55 +- georiviere/portal/views/valorization.py | 56 +- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../proceeding/sql/post_10_triggers.sql | 3 + .../river/locale/en/LC_MESSAGES/django.po | 2 +- .../river/locale/fr/LC_MESSAGES/django.po | 2 +- georiviere/river/management/__init__.py | 0 .../river/management/commands/__init__.py | 0 .../river/management/commands/load_rivers.py | 57 ++ georiviere/river/managers.py | 6 + georiviere/river/models.py | 32 +- georiviere/river/signals.py | 17 +- georiviere/river/sql/post_10_triggers.sql | 62 ++ georiviere/river/sql/pre_10_cleanup.sql | 3 + georiviere/river/tests/test_models.py | 8 +- georiviere/river/tests/test_signals.py | 17 +- georiviere/river/tests/test_views.py | 10 +- georiviere/settings/__init__.py | 17 +- georiviere/settings/tests.py | 28 + georiviere/static/images/favicon.png | Bin 0 -> 9773 bytes .../studies/locale/en/LC_MESSAGES/django.po | 2 +- .../studies/locale/fr/LC_MESSAGES/django.po | 2 +- georiviere/tests/__init__.py | 35 +- georiviere/tests/factories.py | 3 + georiviere/urls.py | 1 + georiviere/utils/__init__.py | 0 georiviere/utils/mixins/managers.py | 7 + georiviere/valorization/forms.py | 3 +- .../locale/en/LC_MESSAGES/django.po | 5 +- .../locale/fr/LC_MESSAGES/django.po | 7 +- .../migrations/0004_poi_external_uri.py | 18 + georiviere/valorization/models.py | 1 + .../valorization/poi_detail_attributes.html | 4 + georiviere/valorization/tests/test_views.py | 1 + .../watershed/locale/en/LC_MESSAGES/django.po | 2 +- .../watershed/locale/fr/LC_MESSAGES/django.po | 2 +- requirements.in | 1 + requirements.txt | 7 +- 128 files changed, 4481 insertions(+), 1496 deletions(-) create mode 100644 Makefile delete mode 100644 georiviere/contribution/managers.py create mode 100644 georiviere/contribution/migrations/0010_customcontribution_customcontributiontype_customcontributiontypefield.py create mode 100644 georiviere/contribution/migrations/0011_auto_20240411_0859.py create mode 100644 georiviere/contribution/migrations/0012_auto_20240411_0921.py create mode 100644 georiviere/contribution/migrations/0013_customcontributiontype_linked_to_station.py create mode 100644 georiviere/contribution/migrations/0014_auto_20240412_1505.py create mode 100644 georiviere/contribution/migrations/0015_auto_20240412_2038.py create mode 100644 georiviere/contribution/migrations/0016_auto_20240423_1452.py create mode 100644 georiviere/contribution/migrations/0017_auto_20240423_1542.py create mode 100644 georiviere/contribution/migrations/0018_auto_20240425_1328.py create mode 100644 georiviere/contribution/migrations/0019_auto_20240426_1532.py create mode 100644 georiviere/contribution/migrations/0020_customcontributiontype_password.py delete mode 100644 georiviere/contribution/models.py create mode 100644 georiviere/contribution/models/__init__.py create mode 100644 georiviere/contribution/models/managers.py create mode 100644 georiviere/contribution/tests/test_managers.py create mode 100644 georiviere/description/sql/post_10_triggers.sql create mode 100644 georiviere/description/sql/post_20_defaults.sql create mode 100644 georiviere/main/sql/post_10_functions.sql create mode 100644 georiviere/main/sql/pre_10_cleanup.sql create mode 100644 georiviere/observations/migrations/0024_auto_20240514_0849.py create mode 100644 georiviere/portal/migrations/0007_auto_20240423_1647.py create mode 100644 georiviere/portal/migrations/0008_auto_20240429_1233.py create mode 100644 georiviere/portal/migrations/0009_auto_20240430_1309.py create mode 100644 georiviere/portal/serializers/mixins.py create mode 100644 georiviere/portal/serializers/observations.py create mode 100644 georiviere/portal/tests/test_views/test_observations.py create mode 100644 georiviere/portal/views/mixins.py create mode 100644 georiviere/portal/views/observations.py create mode 100644 georiviere/proceeding/sql/post_10_triggers.sql create mode 100644 georiviere/river/management/__init__.py create mode 100644 georiviere/river/management/commands/__init__.py create mode 100644 georiviere/river/management/commands/load_rivers.py create mode 100644 georiviere/river/managers.py create mode 100644 georiviere/river/sql/post_10_triggers.sql create mode 100644 georiviere/river/sql/pre_10_cleanup.sql create mode 100644 georiviere/settings/tests.py create mode 100644 georiviere/static/images/favicon.png create mode 100644 georiviere/utils/__init__.py create mode 100644 georiviere/utils/mixins/managers.py create mode 100644 georiviere/valorization/migrations/0004_poi_external_uri.py diff --git a/.coveragerc b/.coveragerc index 812f4867..61f8bf36 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,8 @@ [run] omit = - */tests* - */migrations* - */settings* + */tests/* + */migrations/* + */settings/* */venv* manage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db461ff8..a998fbaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: push: branches: - master + - develop + release: types: - created @@ -27,11 +29,12 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.9 + - name: Install dependencies run: | echo "${{ github.event_name }}! ${{ github.event.action }}" - python -m pip install --upgrade pip - python -m pip install flake8 + python -m pip install flake8 -c dev-requirements.txt + - name: Lint with flake8 run: | flake8 georiviere @@ -90,49 +93,16 @@ jobs: verbose: true fail_ci_if_error: true # optional (default = false) - build-and-push-dev-image: - runs-on: ubuntu-latest - permissions: - packages: write # required to publish docker image - env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - if: ${{(github.base_ref == 'develop')}} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - build-and-push-image: runs-on: ubuntu-latest - needs: [flake8, doc_build, unittests] + #needs: [flake8, doc_build, unittests] + needs: [flake8, doc_build] permissions: packages: write # required to publish docker image env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - if: ${{ (github.event_name == 'release' && github.event.action == 'created') || (github.ref == 'refs/heads/master' && github.event_name != 'pull_request')}} + if: ${{ (github.event_name == 'release' && github.event.action == 'created') || github.event_name != 'pull_request'}} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/Dockerfile b/Dockerfile index 1650b8a8..92da6a5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,7 +76,7 @@ RUN apt-get update -qq && apt-get install -y -qq \ USER django RUN python3.9 -m venv /opt/venv -RUN /opt/venv/bin/pip install --no-cache-dir pip setuptools wheel -U +RUN /opt/venv/bin/pip install --no-cache-dir pip setuptools wheel -U # geotrek setup fix : it required django before being installed... TODO: fix it in geotrek setup.py RUN /opt/venv/bin/pip install --no-cache-dir django==2.2.* diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..00beb2b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +deps: + docker compose run --rm web bash -c "pip-compile -q && pip-compile -q dev-requirements.in" diff --git a/dev-requirements.in b/dev-requirements.in index 3266d7d6..b6b9985c 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -10,7 +10,7 @@ coverage django-debug-toolbar django-extensions factory-boy -flake8 # WARNING : CI always use last flake8 published version +flake8 # doc sphinx diff --git a/dev-requirements.txt b/dev-requirements.txt index 58b97518..5b2b772e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,17 +1,19 @@ # -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: # # pip-compile dev-requirements.in # -alabaster==0.7.12 +alabaster==0.7.16 # via sphinx asgiref==3.3.1 # via # -c requirements.txt # django -babel==2.9.1 +babel==2.14.0 # via sphinx +build==1.2.1 + # via pip-tools certifi==2020.12.5 # via # -c requirements.txt @@ -54,7 +56,7 @@ faker==9.7.1 # via # -c requirements.txt # factory-boy -flake8==4.0.1 +flake8==7.0.0 # via -r dev-requirements.in freezegun==1.1.0 # via @@ -64,8 +66,12 @@ idna==2.10 # via # -c requirements.txt # requests -imagesize==1.3.0 +imagesize==1.4.1 # via sphinx +importlib-metadata==7.1.0 + # via + # build + # sphinx jinja2==2.11.3 # via # -c requirements.txt @@ -74,28 +80,29 @@ markupsafe==1.1.1 # via # -c requirements.txt # jinja2 -mccabe==0.6.1 +mccabe==0.7.0 # via flake8 packaging==20.9 # via # -c requirements.txt + # build # sphinx -pep517==0.12.0 - # via pip-tools -pip-tools==6.5.1 +pip-tools==6.10.0 # via -r dev-requirements.in -pycodestyle==2.8.0 +pycodestyle==2.11.1 # via flake8 -pyflakes==2.4.0 +pyflakes==3.2.0 # via flake8 -pygments==2.15.0 +pygments==2.17.2 # via sphinx -pygraphviz==1.9 +pygraphviz==1.11 # via -r dev-requirements.in pyparsing==2.4.7 # via # -c requirements.txt # packaging +pyproject-hooks==1.0.0 + # via build python-dateutil==2.8.1 # via # -c requirements.txt @@ -104,7 +111,6 @@ python-dateutil==2.8.1 pytz==2021.1 # via # -c requirements.txt - # babel # django requests==2.26.0 # via @@ -116,43 +122,50 @@ six==1.15.0 # python-dateutil snowballstemmer==2.2.0 # via sphinx -sphinx==4.4.0 +sphinx==5.1.1 # via # -r dev-requirements.in # sphinx-rtd-theme -sphinx-rtd-theme==1.0.0 + # sphinxcontrib-jquery +sphinx-rtd-theme==2.0.0 # via -r dev-requirements.in -sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-applehelp==1.0.8 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==1.0.6 # via sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.5 # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==1.0.7 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx sqlparse==0.4.1 # via # -c requirements.txt # django # django-debug-toolbar -tblib==1.7.0 +tblib==3.0.0 # via -r dev-requirements.in text-unidecode==1.3 # via # -c requirements.txt # faker -tomli==2.0.0 - # via pep517 +tomli==2.0.1 + # via + # build + # pyproject-hooks urllib3==1.26.3 # via # -c requirements.txt # requests -wheel==0.38.1 +wheel==0.43.0 # via pip-tools +zipp==3.18.1 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/docker-compose.yml b/docker-compose.yml index 64b778d6..f519fdc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: web: image: georiviere:latest - user: $UID:$GID build: context: . target: dev diff --git a/docs/changelog.rst b/docs/changelog.rst index e0019e6e..516f478f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,11 @@ CHANGELOG 1.3.0+dev (XXXX-XX-XX) --------------------- +**New features** + +- Add load_rivers command +- Create custom contribution types from the admin with specific field schema + **Bug fix** - Force translation defined in API url /api/portal/ (fix #222) diff --git a/docs/usage/import_data.rst b/docs/usage/import_data.rst index 4adddbaf..5ee28b5b 100644 --- a/docs/usage/import_data.rst +++ b/docs/usage/import_data.rst @@ -3,67 +3,41 @@ Import data To import data, you have to run these commands from the server where GeoRiviere-admin is hosted. -Import data +Import altimetry file --------------------- -Put your data file named CoursEau_FXX.shp in ``var/`` folder, and run command +Altimetry should be imported first in GeoRiviere, in order for other imported objects to use DEM to compute altitude. -Custom your import file named import_bdtopage.py et put this file in ``georiviere/`` +Put your altimetry file in ``var/`` folder, and run command .. code-block :: bash - #!/usr/bin/env python - import os - import django - import logging - from django.contrib.gis.gdal import DataSource - - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'georiviere.settings') - django.setup() - from geotrek.authent.models import Structure - from django.contrib.gis.geos.collections import MultiLineString - from georiviere.river.models import Stream - - # get first structure, adapt with correct structure - structure = Structure.objects.first() - - ds = DataSource('var/CoursEau_FXX.shp') - - for feat in ds[0][4:]: - try: - # get geos object - geom = feat.geom.geos - # force 2D - geom.dim = 2 - name = feat.get('name') or 'No name' - flow = feat.get('flow') - if flow == 1: - flow = 1 - elif flow == 2: - flow = 2 - else: - flow = 0 - stream = Stream.objects.create(structure=structure, - geom=feat.geom.geos, - name=name, - flow=flow) - - except Exception as exc: - logging.warn(exc, feat.geom.geos.ewkt) - -And run command : `docker compose run --rm web ./import_bdtopage.py` + docker-compose run --rm web ./manage.py loaddem +where ```` is ``/opt/georiviere-admin/var/my_dem_file.tif`` -Import altimetry file ---------------------- +If you want to replace an existing DEM, you can add the argument ``--replace`` to the command. -Put your altimetry file in ``var/`` folder, and run command + +Import rivers / stream +---------------------- + +Put your data file (in .shp or .gpkg format) in ``var/`` folder, and run command .. code-block :: bash - docker-compose run --rm web ./manage.py loaddem + docker-compose run --rm web ./manage.py load_rivers + +where ```` is ``/opt/georiviere-admin/var/my_stream_file.tif`` + +Several optional arguments can be used with this command : + +.. code-block :: bash -where ```` is ``/opt/georiviere-admin/var/my_dem_file.tiff`` + --flush : to delete all existing rivers in the database before import + --name-attribute : allow to change the column name used to find the name attribute of the river (default is 'nom') + --default-name-attribute : when there is no content in the designated column, this value will be used for the name of the object (default is 'River') + --batch-size : the rivers are imported by batch, this size can be changed if needed (default is 50) Import stations from Hub'Eau diff --git a/georiviere/altimetry.py b/georiviere/altimetry.py index 64091013..cd7ee6b9 100644 --- a/georiviere/altimetry.py +++ b/georiviere/altimetry.py @@ -1,33 +1,19 @@ from geotrek.altimetry.models import AltimetryMixin as BaseAltimetryMixin - -from georiviere.functions import ElevationInfos, Length3D +from geotrek.common.mixins import TimeStampedModelMixin class AltimetryMixin(BaseAltimetryMixin): - class Meta: - abstract = True + def refresh(self): + # Update object's computed values (reload from database) + if self.pk: + fromdb = self.__class__.objects.get(pk=self.pk) + BaseAltimetryMixin.reload(self, fromdb) + TimeStampedModelMixin.reload(self, fromdb) + return self def save(self, *args, **kwargs): super().save(*args, **kwargs) - elevation_infos = self._meta.model.objects.filter(pk=self.pk) \ - .annotate(infos=ElevationInfos('geom')).first().infos - draped_geom = elevation_infos.get('draped') - self.geom_3d = draped_geom - self.slope = elevation_infos.get('slope') - self.min_elevation = elevation_infos.get('min_elevation') - self.max_elevation = elevation_infos.get('max_elevation') - self.ascent = elevation_infos.get('positive_gain') - self.descent = elevation_infos.get('negative_gain') - compute_results = self._meta.model.objects.filter(pk=self.pk) \ - .annotate(length_3d=Length3D(draped_geom)).first() - self.length = compute_results.length_3d - super().save(force_insert=False, - update_fields=[ - 'geom_3d', - 'slope', - 'min_elevation', - 'max_elevation', - 'ascent', - 'descent', - 'length' - ]) + self.refresh() + + class Meta: + abstract = True diff --git a/georiviere/contribution/admin.py b/georiviere/contribution/admin.py index a1b3d9ca..eaaf2ccb 100644 --- a/georiviere/contribution/admin.py +++ b/georiviere/contribution/admin.py @@ -1,19 +1,127 @@ +from admin_ordering.admin import OrderableAdmin from django.contrib import admin +from django.contrib.admin.widgets import AdminFileWidget +from django.contrib.contenttypes.admin import GenericTabularInline +from django.db.models import FileField +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from leaflet.admin import LeafletGeoAdmin -from georiviere.contribution.models import ( - SeverityType, LandingType, JamType, DiseaseType, DeadSpecies, InvasiveSpecies, HeritageSpecies, HeritageObservation, - FishSpecies, NaturePollution, TypePollution, ContributionStatus -) - -admin.site.register(ContributionStatus, admin.ModelAdmin) -admin.site.register(SeverityType, admin.ModelAdmin) -admin.site.register(LandingType, admin.ModelAdmin) -admin.site.register(JamType, admin.ModelAdmin) -admin.site.register(DiseaseType, admin.ModelAdmin) -admin.site.register(DeadSpecies, admin.ModelAdmin) -admin.site.register(InvasiveSpecies, admin.ModelAdmin) -admin.site.register(HeritageSpecies, admin.ModelAdmin) -admin.site.register(HeritageObservation, admin.ModelAdmin) -admin.site.register(FishSpecies, admin.ModelAdmin) -admin.site.register(NaturePollution, admin.ModelAdmin) -admin.site.register(TypePollution, admin.ModelAdmin) +from . import models, forms +from ..main.models import Attachment + +admin.site.register(models.ContributionStatus, admin.ModelAdmin) +admin.site.register(models.SeverityType, admin.ModelAdmin) +admin.site.register(models.LandingType, admin.ModelAdmin) +admin.site.register(models.JamType, admin.ModelAdmin) +admin.site.register(models.DiseaseType, admin.ModelAdmin) +admin.site.register(models.DeadSpecies, admin.ModelAdmin) +admin.site.register(models.InvasiveSpecies, admin.ModelAdmin) +admin.site.register(models.HeritageSpecies, admin.ModelAdmin) +admin.site.register(models.HeritageObservation, admin.ModelAdmin) +admin.site.register(models.FishSpecies, admin.ModelAdmin) +admin.site.register(models.NaturePollution, admin.ModelAdmin) +admin.site.register(models.TypePollution, admin.ModelAdmin) + + +class CustomFieldInline(OrderableAdmin, admin.TabularInline): + verbose_name = _("Field") + verbose_name_plural = _("Fields") + model = models.CustomContributionTypeField + ordering_field = "order" + ordering = ("order", "label") + form = forms.CustomContributionFieldInlineForm + fields = ( + "label", + "internal_identifier", + "value_type", + "required", + "help_text", + "order", + ) + extra = 0 + show_change_link = True + popup_link = "change" + + +@admin.register(models.CustomContributionType) +class CustomContributionTypeAdmin(admin.ModelAdmin): + list_display = ("label",) + search_fields = ("label",) + filter_horizontal = ("stations",) + inlines = [ + CustomFieldInline, + ] + + +@admin.register(models.CustomContributionTypeField) +class CustomContributionTypeFieldAdmin(admin.ModelAdmin): + list_display = ("label", "key", "value_type", "required", "custom_type") + list_filter = ("custom_type", "value_type", "required") + search_fields = ("label", "key", "custom_type__label") + form = forms.CustomContributionFieldForm + readonly_fields = ["custom_type", "key", "options"] + fieldsets = ( + ( + None, + { + "fields": ( + "custom_type", + "label", + "internal_identifier", + "key", + "value_type", + "required", + "help_text", + ) + }, + ), + ( + _("Customization"), + { + "fields": ("customization", "options"), + }, + ), + ) + + def has_add_permission(self, request): + """Disable addition in list view""" + return False + + def has_delete_permission(self, request, obj=None): + """Disable deletion in list view""" + return False + + +class AdminImageWidget(AdminFileWidget): + def render(self, name, value, attrs=None, renderer=None): + output = [] + if value and getattr(value, "url", None): + image_url = value.url + file_name = str(value) + output.append( + ' %s %s ' + % (image_url, image_url, file_name, _("")) + ) + output.append(super().render(name, value, attrs)) + return mark_safe("".join(output)) + + +class CustomContribAttachmentInline(GenericTabularInline): + model = Attachment + extra = 0 + exclude = ("attachment_video", "attachment_link", "creator", "legend", "starred") + formfield_overrides = {FileField: {"widget": AdminImageWidget}} + + +@admin.register(models.CustomContribution) +class CustomContributionAdmin(LeafletGeoAdmin, admin.ModelAdmin): + list_display = ("custom_type", "station", "portal", "validated", "contributed_at", "date_insert", "date_update") + list_filter = ("custom_type", "portal", "validated") + form = forms.CustomContributionForm + inlines = [CustomContribAttachmentInline] + + def get_readonly_fields(self, request, obj=None): + if not obj or not obj.pk: + return ("data",) + return [] diff --git a/georiviere/contribution/forms.py b/georiviere/contribution/forms.py index 2403ed84..e6026755 100644 --- a/georiviere/contribution/forms.py +++ b/georiviere/contribution/forms.py @@ -1,25 +1,27 @@ from crispy_forms.layout import Div, Field from dal import autocomplete +from django import forms from django.utils.translation import gettext_lazy as _ - +from django_jsonform.forms.fields import JSONFormField from geotrek.common.forms import CommonForm -from georiviere.contribution.models import Contribution from georiviere.knowledge.models import FollowUp, Knowledge from georiviere.maintenance.models import Intervention +from . import models + class ContributionForm(autocomplete.FutureModelForm, CommonForm): - geomfields = ['geom'] + geomfields = ["geom"] linked_object = autocomplete.Select2GenericForeignKeyModelField( model_choice=[ # Get the values 'name' for each object of each models - (Knowledge, 'name'), - (Intervention, 'name'), - (FollowUp, 'name') + (Knowledge, "name"), + (Intervention, "name"), + (FollowUp, "name"), ], - label=_('Linked object'), + label=_("Linked object"), required=False, initial=None, ) @@ -34,26 +36,92 @@ class ContributionForm(autocomplete.FutureModelForm, CommonForm): "email_author", "assigned_user", "status_contribution", - Field('linked_object', css_class="chosen-select"), + Field("linked_object", css_class="chosen-select"), ) ] class Meta(CommonForm): - fields = ["description", "severity", "published", "portal", "email_author", "geom", "assigned_user", - "status_contribution", "validated", "linked_object"] - model = Contribution + fields = [ + "description", + "severity", + "published", + "portal", + "email_author", + "geom", + "assigned_user", + "status_contribution", + "validated", + "linked_object", + ] + model = models.Contribution def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['portal'].widget.attrs['readonly'] = True - self.fields['geom'].widget.modifiable = False - self.fields['email_author'].widget.attrs['readonly'] = True + self.fields["portal"].widget.attrs["readonly"] = True + self.fields["geom"].widget.modifiable = False + self.fields["email_author"].widget.attrs["readonly"] = True def clean_portal(self): return self.instance.portal def clean_linked_object(self): - linked_object = self.cleaned_data['linked_object'] + linked_object = self.cleaned_data["linked_object"] if linked_object == "": return None return linked_object + + +class CustomContributionForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["station"].disabled = True + + if self.instance.pk: + schema = self.instance.custom_type.get_json_schema_form() + self.fields["data"] = JSONFormField(schema=schema, label=_("Data")) + + stations = self.instance.custom_type.stations.all() + self.fields["station"].queryset = stations + + if stations.exists(): + self.fields["station"].disabled = False + self.fields["station"].required = True + + class Meta: + model = models.CustomContribution + fields = "__all__" + + +class CustomContributionFieldForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + schema = self.instance.get_customization_json_schema_form() + self.fields["customization"] = JSONFormField( + schema=schema, required=False, label=_("Customization") + ) + + class Meta: + model = models.CustomContributionTypeField + fields = ( + "custom_type", + "label", + "value_type", + "required", + "help_text", + "customization", + ) + + +class CustomContributionFieldInlineForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields["value_type"].disabled = True + self.fields["value_type"].help_text = _( + "You can't change value type after creation. Delete and/or create another one." + ) + + class Meta: + model = models.CustomContributionTypeField + fields = ("label", "value_type", "required", "help_text", "order") diff --git a/georiviere/contribution/locale/en/LC_MESSAGES/django.po b/georiviere/contribution/locale/en/LC_MESSAGES/django.po index 34fffc62..58230866 100644 --- a/georiviere/contribution/locale/en/LC_MESSAGES/django.po +++ b/georiviere/contribution/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,15 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "Field" +msgstr "" + +msgid "Fields" +msgstr "" + +msgid "Customization" +msgstr "" + msgid "Contribution" msgstr "" @@ -45,6 +54,13 @@ msgstr "" msgid "Linked object" msgstr "" +msgid "Data" +msgstr "" + +msgid "" +"You can't change value type after creation. Delete and/or create another one." +msgstr "" + msgid "Label" msgstr "" @@ -109,10 +125,10 @@ msgstr "" msgid "Feedback from {email}" msgstr "" -msgid "Contribution potential damage type" +msgid "Landing type" msgstr "" -msgid "Contribution potential damage types" +msgid "Landing types" msgstr "" msgid "Jam type" @@ -157,9 +173,6 @@ msgstr "" msgid "Trampling by livestock (impacting)" msgstr "" -msgid "Landing type" -msgstr "" - msgid "Excessive cutting length (in meters)" msgstr "" @@ -318,6 +331,126 @@ msgstr "" msgid "Knowledge" msgstr "" +msgid "Stations" +msgstr "" + +msgid "Password" +msgstr "" + +msgid "Define if password is required to send the form" +msgstr "" + +msgid "Custom contribution type" +msgstr "" + +msgid "Custom contribution types" +msgstr "" + +msgid "String" +msgstr "" + +msgid "Text" +msgstr "" + +msgid "Integer" +msgstr "" + +msgid "Float" +msgstr "" + +msgid "Date" +msgstr "" + +msgid "Datetime" +msgstr "" + +msgid "Boolean" +msgstr "" + +msgid "Field label in forms and public portal." +msgstr "" + +msgid "Internal identifier" +msgstr "" + +msgid "Internal identifier for field." +msgstr "" + +msgid "Key" +msgstr "" + +msgid "Key used in JSON data field." +msgstr "" + +msgid "Required" +msgstr "" + +msgid "Set if field is required to validate form." +msgstr "" + +msgid "Help text" +msgstr "" + +msgid "Set a help text for the field." +msgstr "" + +msgid "Options" +msgstr "" + +msgid "Internal options for type JSON schema." +msgstr "" + +msgid "Field customization." +msgstr "" + +msgid "Order" +msgstr "" + +msgid "Order of field in form." +msgstr "" + +msgid "Custom contribution type." +msgstr "" + +msgid "Values" +msgstr "" + +msgid "Placeholder" +msgstr "" + +msgid "Min. length" +msgstr "" + +msgid "Max. length" +msgstr "" + +msgid "Min. value" +msgstr "" + +msgid "Max. value" +msgstr "" + +msgid "Custom contribution type field" +msgstr "" + +msgid "Custom contribution type fields" +msgstr "" + +msgid "Contributed at" +msgstr "" + +msgid "Custom contribution" +msgstr "" + +msgid "Custom contributions" +msgstr "" + +msgid "Observed species" +msgstr "" + +msgid "Observation type" +msgstr "" + msgid "None" msgstr "" diff --git a/georiviere/contribution/locale/fr/LC_MESSAGES/django.po b/georiviere/contribution/locale/fr/LC_MESSAGES/django.po index 9e554886..ba1a05a8 100644 --- a/georiviere/contribution/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/contribution/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-11-10 12:01+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,15 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "Field" +msgstr "Champ" + +msgid "Fields" +msgstr "Champs" + +msgid "Customization" +msgstr "Personnalisation" + msgid "Contribution" msgstr "Contribution" @@ -45,6 +54,15 @@ msgstr "Type" msgid "Linked object" msgstr "Objet lié" +msgid "Data" +msgstr "Données" + +msgid "" +"You can't change value type after creation. Delete and/or create another one." +msgstr "" +"Vous ne pouvez pas changer le type de valeur après la création. Supprimez et/" +"ou créez-en un autre." + msgid "Label" msgstr "Label" @@ -64,7 +82,7 @@ msgid "First name author" msgstr "Prénom auteur" msgid "Email" -msgstr "Mail" +msgstr "Courriel" msgid "Observation's date" msgstr "Date de l'observation" @@ -211,7 +229,7 @@ msgid "Number heritage species" msgstr "Nombre d'individus patrimoniale" msgid "Number fish species" -msgstr "Nombre d'espèces pisicole" +msgstr "Nombre d'espèces piscicole" msgid "Contribution fauna-flora" msgstr "Contribution faune-flore" @@ -315,6 +333,122 @@ msgstr "Contentieux" msgid "Knowledge" msgstr "Connaissance" +msgid "Stations" +msgstr "Stations" + +msgid "Password" +msgstr "Mot de passe" + +msgid "Define if password is required to send the form" +msgstr "" +"Définir si un mot de passe est nécessaire pour envoyer / valider le " +"formulaire." + +msgid "Custom contribution type" +msgstr "Type de Contribution personnalisée" + +msgid "Custom contribution types" +msgstr "Types de Contributions personnalisées" + +msgid "String" +msgstr "Chaîne de caractères" + +msgid "Text" +msgstr "Texte" + +msgid "Integer" +msgstr "Nombre entier" + +msgid "Float" +msgstr "Nombre réel" + +msgid "Date" +msgstr "Date" + +msgid "Datetime" +msgstr "Date / Heure" + +msgid "Boolean" +msgstr "Booléen" + +msgid "Field label in forms and public portal." +msgstr "Libellé du champ dans les formulaires et sur les portails publics." + +msgid "Internal identifier" +msgstr "Identifiant interne" + +msgid "Internal identifier for field." +msgstr "Identifiant interne du champ." + +msgid "Key" +msgstr "Clé" + +msgid "Key used in JSON data field." +msgstr "Clé utilisée dans les données JSON." + +msgid "Required" +msgstr "Requis" + +msgid "Set if field is required to validate form." +msgstr "Définir si le champ est requis pour valider le formulaire." + +msgid "Help text" +msgstr "Texte d'aide" + +msgid "Set a help text for the field." +msgstr "Définir un texte d'aide pour le champ." + +msgid "Options" +msgstr "Options" + +msgid "Internal options for type JSON schema." +msgstr "Options interne du schéma JSON du type." + +msgid "Field customization." +msgstr "Personnalisation du champ" + +msgid "Order" +msgstr "Ordre" + +msgid "Order of field in form." +msgstr "Ordre du champ dans le formulaire." + +msgid "Custom contribution type." +msgstr "Type de Contribution personnalisée." + +msgid "Values" +msgstr "Valeurs" + +msgid "Placeholder" +msgstr "Remplissage" + +msgid "Min. length" +msgstr "Longueur mini." + +msgid "Max. length" +msgstr "Longueur maxi." + +msgid "Min. value" +msgstr "Valeur mini." + +msgid "Max. value" +msgstr "Valeur maxi." + +msgid "Custom contribution type field" +msgstr "Champ de type de contribution personnalisée" + +msgid "Custom contribution type fields" +msgstr "Champs de types de contributions personnalisées" + +msgid "Contributed at" +msgstr "Contribué le" + +msgid "Custom contribution" +msgstr "Contribution personnalisée" + +msgid "Custom contributions" +msgstr "Contributions personnalisées" + msgid "Observed species" msgstr "Espèce observée" diff --git a/georiviere/contribution/managers.py b/georiviere/contribution/managers.py deleted file mode 100644 index f2485506..00000000 --- a/georiviere/contribution/managers.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.contrib.gis.db import models - - -class SelectableUserManager(models.Manager): - - def get_queryset(self): - return super().get_queryset().filter(userprofile__isnull=False) diff --git a/georiviere/contribution/migrations/0010_customcontribution_customcontributiontype_customcontributiontypefield.py b/georiviere/contribution/migrations/0010_customcontribution_customcontributiontype_customcontributiontypefield.py new file mode 100644 index 00000000..95b63268 --- /dev/null +++ b/georiviere/contribution/migrations/0010_customcontribution_customcontributiontype_customcontributiontypefield.py @@ -0,0 +1,62 @@ +# Generated by Django 3.1.14 on 2024-04-11 08:30 +from django.conf import settings +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal', '0006_auto_20230915_1756'), + ('contribution', '0009_auto_20231110_1214'), + ] + + operations = [ + migrations.CreateModel( + name='CustomContributionType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=128, unique=True, verbose_name='Label')), + ], + options={ + 'verbose_name': 'Custom contribution type', + 'verbose_name_plural': 'Custom contribution types', + }, + ), + migrations.CreateModel( + name='CustomContribution', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_insert', models.DateTimeField(auto_now_add=True, verbose_name='Insertion date')), + ('date_update', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Update date')), + ('geom', django.contrib.gis.db.models.fields.GeometryField(srid=settings.SRID)), + ('properties', models.JSONField(blank=True, default=dict)), + ('validated', models.BooleanField(default=False, verbose_name='Validated')), + ('custom_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='contributions', to='contribution.customcontributiontype')), + ('portal', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.PROTECT, related_name='custom_contributions', to='portal.portal', verbose_name='Portal')), + ], + options={ + 'verbose_name': 'Custom contribution', + 'verbose_name_plural': 'Custom contributions', + }, + ), + migrations.CreateModel( + name='CustomContributionTypeField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=128, verbose_name='Label')), + ('key', models.SlugField(editable=False, help_text='Key used in JSON field', max_length=150, verbose_name='Type')), + ('value_type', models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('float', 'Float'), ('date', 'Date'), ('datetime', 'Datetime'), ('boolean', 'Boolean')], default='text', max_length=16, verbose_name='Label')), + ('required', models.BooleanField(default=False, verbose_name='Required')), + ('options', models.JSONField(blank=True, default=dict)), + ('order', models.PositiveSmallIntegerField(default=0, verbose_name='Order')), + ('custom_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='contribution.customcontributiontype')), + ], + options={ + 'verbose_name': 'Custom contribution type field', + 'verbose_name_plural': 'Custom contribution type fields', + 'unique_together': {('label', 'custom_type')}, + }, + ), + ] diff --git a/georiviere/contribution/migrations/0011_auto_20240411_0859.py b/georiviere/contribution/migrations/0011_auto_20240411_0859.py new file mode 100644 index 00000000..eee0e1b3 --- /dev/null +++ b/georiviere/contribution/migrations/0011_auto_20240411_0859.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.14 on 2024-04-11 08:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contribution', '0010_customcontribution_customcontributiontype_customcontributiontypefield'), + ] + + operations = [ + migrations.AlterField( + model_name='customcontributiontypefield', + name='key', + field=models.SlugField(editable=False, help_text='Key used in JSON field', max_length=150, verbose_name='Key'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='value_type', + field=models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('float', 'Float'), ('date', 'Date'), ('datetime', 'Datetime'), ('boolean', 'Boolean')], default='text', max_length=16, verbose_name='Type'), + ), + ] diff --git a/georiviere/contribution/migrations/0012_auto_20240411_0921.py b/georiviere/contribution/migrations/0012_auto_20240411_0921.py new file mode 100644 index 00000000..c39ed157 --- /dev/null +++ b/georiviere/contribution/migrations/0012_auto_20240411_0921.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.14 on 2024-04-11 09:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal', '0006_auto_20230915_1756'), + ('contribution', '0011_auto_20240411_0859'), + ] + + operations = [ + migrations.AlterField( + model_name='customcontribution', + name='portal', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='custom_contributions', to='portal.portal', verbose_name='Portal'), + ), + ] diff --git a/georiviere/contribution/migrations/0013_customcontributiontype_linked_to_station.py b/georiviere/contribution/migrations/0013_customcontributiontype_linked_to_station.py new file mode 100644 index 00000000..a78c1113 --- /dev/null +++ b/georiviere/contribution/migrations/0013_customcontributiontype_linked_to_station.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2024-04-11 09:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contribution', '0012_auto_20240411_0921'), + ] + + operations = [ + migrations.AddField( + model_name='customcontributiontype', + name='linked_to_station', + field=models.BooleanField(default=False, verbose_name='Linked to station'), + ), + ] diff --git a/georiviere/contribution/migrations/0014_auto_20240412_1505.py b/georiviere/contribution/migrations/0014_auto_20240412_1505.py new file mode 100644 index 00000000..96c16dc9 --- /dev/null +++ b/georiviere/contribution/migrations/0014_auto_20240412_1505.py @@ -0,0 +1,91 @@ +# Generated by Django 3.1.14 on 2024-04-12 15:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0023_auto_20230220_1703'), + ('contribution', '0013_customcontributiontype_linked_to_station'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customcontributiontypefield', + options={'ordering': ('order', 'custom_type'), 'verbose_name': 'Custom contribution type field', 'verbose_name_plural': 'Custom contribution type fields'}, + ), + migrations.RemoveField( + model_name='customcontribution', + name='properties', + ), + migrations.RemoveField( + model_name='customcontributiontype', + name='linked_to_station', + ), + migrations.AddField( + model_name='customcontribution', + name='data', + field=models.JSONField(blank=True, default=dict, verbose_name='Data'), + ), + migrations.AddField( + model_name='customcontributiontype', + name='stations', + field=models.ManyToManyField(blank=True, to='observations.Station', verbose_name='Stations'), + ), + migrations.AddField( + model_name='customcontributiontypefield', + name='customization', + field=models.JSONField(blank=True, default=dict, help_text='Field customization.', verbose_name='Customization'), + ), + migrations.AddField( + model_name='customcontributiontypefield', + name='help_text', + field=models.CharField(blank=True, default='', help_text='Set a help text for the field.', max_length=256, verbose_name='Help text'), + ), + migrations.AlterField( + model_name='customcontribution', + name='custom_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='contributions', to='contribution.customcontributiontype', verbose_name='Custom contribution type'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='custom_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='contribution.customcontributiontype', verbose_name='Custom contribution type.'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='key', + field=models.SlugField(editable=False, help_text='Key used in JSON data field.', max_length=150, verbose_name='Key'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='label', + field=models.CharField(help_text='Field label.', max_length=128, verbose_name='Label'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='options', + field=models.JSONField(blank=True, default=dict, editable=False, help_text='Internal options for type JSON schema.', verbose_name='Options'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='order', + field=models.PositiveSmallIntegerField(default=0, help_text='Order of field in form.', verbose_name='Order'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='required', + field=models.BooleanField(default=False, help_text='Set if field is required to validate form.', verbose_name='Required'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='value_type', + field=models.CharField(choices=[('string', 'String'), ('text', 'Text'), ('integer', 'Integer'), ('float', 'Float'), ('date', 'Date'), ('datetime', 'Datetime'), ('boolean', 'Boolean')], default='text', max_length=16, verbose_name='Type'), + ), + migrations.AlterIndexTogether( + name='customcontributiontypefield', + index_together={('order', 'custom_type')}, + ), + ] diff --git a/georiviere/contribution/migrations/0015_auto_20240412_2038.py b/georiviere/contribution/migrations/0015_auto_20240412_2038.py new file mode 100644 index 00000000..324bb9b6 --- /dev/null +++ b/georiviere/contribution/migrations/0015_auto_20240412_2038.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.14 on 2024-04-12 20:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0023_auto_20230220_1703'), + ('contribution', '0014_auto_20240412_1505'), + ] + + operations = [ + migrations.AddField( + model_name='customcontribution', + name='station', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='custom_contributions', to='observations.station', verbose_name='Station'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='value_type', + field=models.CharField(choices=[('string', 'String'), ('text', 'Text'), ('integer', 'Integer'), ('float', 'Float'), ('date', 'Date'), ('datetime', 'Datetime'), ('boolean', 'Boolean')], default='string', max_length=16, verbose_name='Type'), + ), + ] diff --git a/georiviere/contribution/migrations/0016_auto_20240423_1452.py b/georiviere/contribution/migrations/0016_auto_20240423_1452.py new file mode 100644 index 00000000..f06e94cb --- /dev/null +++ b/georiviere/contribution/migrations/0016_auto_20240423_1452.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.14 on 2024-04-23 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contribution', '0015_auto_20240412_2038'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customcontributiontype', + options={'ordering': ('label',), 'verbose_name': 'Custom contribution type', 'verbose_name_plural': 'Custom contribution types'}, + ), + migrations.AddField( + model_name='customcontributiontype', + name='description', + field=models.CharField(blank=True, default='', max_length=512, verbose_name='Description'), + ), + ] diff --git a/georiviere/contribution/migrations/0017_auto_20240423_1542.py b/georiviere/contribution/migrations/0017_auto_20240423_1542.py new file mode 100644 index 00000000..3d9270a3 --- /dev/null +++ b/georiviere/contribution/migrations/0017_auto_20240423_1542.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.14 on 2024-04-23 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0023_auto_20230220_1703'), + ('contribution', '0016_auto_20240423_1452'), + ] + + operations = [ + migrations.AlterField( + model_name='customcontributiontype', + name='stations', + field=models.ManyToManyField(blank=True, related_name='custom_contribution_types', to='observations.Station', verbose_name='Stations'), + ), + ] diff --git a/georiviere/contribution/migrations/0018_auto_20240425_1328.py b/georiviere/contribution/migrations/0018_auto_20240425_1328.py new file mode 100644 index 00000000..12b97fe0 --- /dev/null +++ b/georiviere/contribution/migrations/0018_auto_20240425_1328.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.14 on 2024-04-25 13:28 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('contribution', '0017_auto_20240423_1542'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customcontribution', + options={'ordering': ('-contributed_at',), 'verbose_name': 'Custom contribution', 'verbose_name_plural': 'Custom contributions'}, + ), + migrations.AddField( + model_name='customcontribution', + name='contributed_at', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Contributed at'), + ), + ] diff --git a/georiviere/contribution/migrations/0019_auto_20240426_1532.py b/georiviere/contribution/migrations/0019_auto_20240426_1532.py new file mode 100644 index 00000000..d5490988 --- /dev/null +++ b/georiviere/contribution/migrations/0019_auto_20240426_1532.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.14 on 2024-04-26 15:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contribution', '0018_auto_20240425_1328'), + ] + + operations = [ + migrations.AddField( + model_name='customcontributiontypefield', + name='internal_identifier', + field=models.CharField(blank=True, help_text='Internal identifier for field.', max_length=128, null=True, unique=True, verbose_name='Internal identifier'), + ), + migrations.AlterField( + model_name='customcontributiontypefield', + name='label', + field=models.CharField(help_text='Field label in forms and public portal.', max_length=128, verbose_name='Label'), + ), + migrations.AlterUniqueTogether( + name='customcontributiontypefield', + unique_together={('label', 'custom_type'), ('internal_identifier', 'custom_type')}, + ), + ] diff --git a/georiviere/contribution/migrations/0020_customcontributiontype_password.py b/georiviere/contribution/migrations/0020_customcontributiontype_password.py new file mode 100644 index 00000000..a7613993 --- /dev/null +++ b/georiviere/contribution/migrations/0020_customcontributiontype_password.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2024-04-29 07:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contribution', '0019_auto_20240426_1532'), + ] + + operations = [ + migrations.AddField( + model_name='customcontributiontype', + name='password', + field=models.CharField(blank=True, default='', help_text='Define if password is required to send the form', max_length=128, verbose_name='Password'), + ), + ] diff --git a/georiviere/contribution/models.py b/georiviere/contribution/models.py deleted file mode 100644 index 3009a5a4..00000000 --- a/georiviere/contribution/models.py +++ /dev/null @@ -1,504 +0,0 @@ -import logging - -from django.conf import settings -from django.contrib.auth.models import User -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.contrib.gis.db import models -from django.core.mail import mail_managers -from django.template.loader import render_to_string -from django.utils.translation import gettext_lazy as _ - -from mapentity.models import MapEntityMixin - -from geotrek.common.mixins import TimeStampedModelMixin, BasePublishableMixin -from geotrek.common.utils import classproperty -from geotrek.zoning.mixins import ZoningPropertiesMixin - -from georiviere.contribution.managers import SelectableUserManager -from georiviere.description.models import Status, Morphology, Usage -from georiviere.river.models import Stream -from georiviere.knowledge.models import Knowledge -from georiviere.main.models import AddPropertyBufferMixin -from georiviere.observations.models import Station -from georiviere.proceeding.models import Proceeding -from georiviere.studies.models import Study -from georiviere.watershed.mixins import WatershedPropertiesMixin - -logger = logging.getLogger(__name__) - - -class SeverityType(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Severity type") - verbose_name_plural = _("Severity types") - - def __str__(self): - return self.label - - -class ContributionStatus(TimeStampedModelMixin, models.Model): - label = models.CharField(verbose_name=_("Status"), max_length=128) - - class Meta: - verbose_name = _("Status") - verbose_name_plural = _("Status") - - def __str__(self): - return self.label - - -class SelectableUser(User): - - objects = SelectableUserManager() - - class Meta: - proxy = True - - -def status_default(): - """Set status to New by default""" - new_status_query = ContributionStatus.objects.filter(label="Informé") - if new_status_query: - return new_status_query.get().pk - return None - - -class Contribution(BasePublishableMixin, TimeStampedModelMixin, WatershedPropertiesMixin, ZoningPropertiesMixin, - AddPropertyBufferMixin, MapEntityMixin): - """contribution model""" - geom = models.GeometryField(srid=settings.SRID, spatial_index=True) - name_author = models.CharField(max_length=128, verbose_name=_("Name author"), blank=True) - first_name_author = models.CharField(max_length=128, verbose_name=_("First name author"), blank=True) - email_author = models.EmailField(verbose_name=_("Email")) - date_observation = models.DateTimeField(editable=True, verbose_name=_("Observation's date")) - severity = models.ForeignKey(SeverityType, verbose_name=_("Severity"), on_delete=models.PROTECT, null=True, - blank=True, related_name='contributions') - description = models.TextField(verbose_name=_("Description"), help_text=_("Description of the contribution"), - blank=True) - published = models.BooleanField(verbose_name=_("Published"), default=False, - help_text=_("Make it visible on portal")) - portal = models.ForeignKey('portal.Portal', - verbose_name=_("Portal"), blank=True, related_name='contributions', - on_delete=models.PROTECT) - assigned_user = models.ForeignKey( - SelectableUser, - blank=True, - on_delete=models.PROTECT, - null=True, - verbose_name=_("Supervisor"), - related_name="contributions" - ) - status_contribution = models.ForeignKey( - "ContributionStatus", - on_delete=models.PROTECT, - null=True, - blank=True, - default=status_default, - verbose_name=_("Status"), - ) - validated = models.BooleanField(verbose_name=_("Validated"), default=False, - help_text=_("Validate the contribution")) - linked_object_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE) - linked_object_id = models.PositiveIntegerField(blank=True, null=True) - linked_object = GenericForeignKey('linked_object_type', 'linked_object_id') - - class Meta: - verbose_name = _("Contribution") - verbose_name_plural = _("Contributions") - - @classproperty - def category_verbose_name(cls): - return _("Category") - - @classproperty - def type_verbose_name(cls): - return _("Type") - - @classproperty - def linked_object_verbose_name(cls): - return _("Linked object") - - @property - def linked_object_model_name(self): - if self.linked_object: - return self.linked_object._meta.verbose_name - return None - - def __str__(self): - # Use the category and the type (One to One field) to generate what it will show on the list / detail / etc... - # It will generate like that : - # test@test.test Potential Damage Landing - if hasattr(self, 'potential_damage'): - return f'{self.email_author} {ContributionPotentialDamage._meta.verbose_name.title()} ' \ - f'{self.potential_damage.get_type_display()}' - elif hasattr(self, 'fauna_flora'): - return f'{self.email_author} {ContributionFaunaFlora._meta.verbose_name.title()} ' \ - f'{self.fauna_flora.get_type_display()}' - elif hasattr(self, 'quality'): - return f'{self.email_author} {ContributionQuality._meta.verbose_name.title()} ' \ - f'{self.quality.get_type_display()}' - elif hasattr(self, 'quantity'): - return f'{self.email_author} {ContributionQuantity._meta.verbose_name.title()} ' \ - f'{self.quantity.get_type_display()}' - elif hasattr(self, 'landscape_element'): - return f'{self.email_author} {ContributionLandscapeElements._meta.verbose_name.title()} ' \ - f'{self.landscape_element.get_type_display()}' - return f'{self.email_author}' - - @property - def category(self): - # The category is the reverse of the one to one fields : - # For example : - # Potential damage - if hasattr(self, 'potential_damage'): - return self.potential_damage - elif hasattr(self, 'fauna_flora'): - return self.fauna_flora - elif hasattr(self, 'quality'): - return self.quality - elif hasattr(self, 'quantity'): - return self.quantity - elif hasattr(self, 'landscape_element'): - return self.landscape_element - return _('No category') - - @property - def type(self): - if hasattr(self.category, 'get_type_display'): - return self.category.get_type_display() - return _('No type') - - @property - def category_display(self): - s = '%s' % (self.pk, - self.get_detail_url(), - self.category, - self.category) - if self.published: - s = ' ' % _("Published") + s - return s - - def send_report_to_managers(self, template_name="contribution/report_email.txt"): - # Send report to managers when a contribution has been created (MANAGERS settings) - subject = _("Feedback from {email}").format(email=self.email_author) - message = render_to_string(template_name, {"contribution": self}) - mail_managers(subject, message, fail_silently=False) - - def try_send_report_to_managers(self): - try: - self.send_report_to_managers() - except Exception as e: - logger.error("Email could not be sent to managers.") - logger.exception(e) # This sends an email to admins :) - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) # Contribution updates should do nothing more - self.try_send_report_to_managers() - - -# Contributions has a category in the list : -# Potential damage -# Fauna flora -# Quality -# Quantity -# Landscape elements - -# Contributions has a type depending on its category -# Potential damage => Landing, Excessive cutting of riparian forest, Rockslides, Disruptive jam, Bank erosion -# River bed incision (sinking), Fish diseases (appearance of fish), Fish mortality, -# Trampling by livestock (impacting) -# Fauna flora => Invasive species, Heritage species, Fish species -# Quantity => Dry, In the process of drying out, Overflow -# Quality => Algal development, Pollution, Water temperature -# Landscape elements => Sinkhole, Fountain, Chasm, Lesine, Pond, Losing stream, Resurgence - -# Depending on its type of contribution, some fields are available or not. -# Everything is summarize on : -# https://github.com/Georiviere/Georiviere-admin/issues/139 - - -class LandingType(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Landing type") - verbose_name_plural = _("Landing types") - - def __str__(self): - return self.label - - -class JamType(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Jam type") - verbose_name_plural = _("Jam types") - - def __str__(self): - return self.label - - -class DiseaseType(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Disease type") - verbose_name_plural = _("Disease types") - - def __str__(self): - return self.label - - -class DeadSpecies(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Dead species") - verbose_name_plural = _("Dead species") - - def __str__(self): - return self.label - - -class ContributionPotentialDamage(models.Model): - - class TypeChoice(models.IntegerChoices): - """Choices for local influence""" - LANDING = 1, _('Landing') - EXCESSIVE_CUTTING_RIPARIAN_FOREST = 2, _('Excessive cutting of riparian forest') - ROCKSLIDES = 3, _('Rockslides') - DISRUPTIVE_JAM = 4, _('Disruptive jam') - BANK_EROSION = 5, _('Bank erosion') - RIVER_BED_INCISION = 6, _('River bed incision (sinking)') - FISH_DISEASES = 7, _('Fish diseases (appearance of fish)') - FISH_MORTALITY = 8, _('Fish mortality') - TRAMPLING_LIVESTOCK = 9, _('Trampling by livestock (impacting)') - - type = models.IntegerField( - null=False, - choices=TypeChoice.choices, - default=TypeChoice.LANDING, - verbose_name=_("Type"), - ) - landing_type = models.ForeignKey(LandingType, on_delete=models.PROTECT, null=True, - verbose_name=_("Landing type")) - excessive_cutting_length = models.FloatField(default=0.0, null=True, blank=True, - verbose_name=_("Excessive cutting length (in meters)")) - jam_type = models.ForeignKey(JamType, on_delete=models.PROTECT, null=True) - length_bank_erosion = models.FloatField(default=0.0, null=True, blank=True, - verbose_name=_("Length bank erosion (in meters)"), - help_text=_('Distance between the foot of the bank and the foot of ' - 'the erosion.')) - bank_height = models.FloatField(default=0.0, null=True, blank=True, - verbose_name=_("Bank height (in meters)"), - help_text=_('Bank height (measured between the foot of the bank and the top ' - 'of the bank) in meters')) - disease_type = models.ForeignKey(DiseaseType, on_delete=models.PROTECT, null=True) - number_death = models.IntegerField(default=0, null=True, blank=True, - verbose_name=_("Number death"), - help_text=_('Number of dead individuals')) - dead_species = models.ForeignKey(DeadSpecies, on_delete=models.PROTECT, null=True) - contribution = models.OneToOneField(Contribution, parent_link=True, on_delete=models.CASCADE, - related_name='potential_damage') - - class Meta: - verbose_name = _("Contribution potential damage") - verbose_name_plural = _("contributions potential damage") - - def __str__(self): - return f'{ContributionPotentialDamage._meta.verbose_name.title()} {self.get_type_display()}' - - -class InvasiveSpecies(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Invasive species") - verbose_name_plural = _("Invasive species") - - def __str__(self): - return self.label - - -class HeritageSpecies(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Heritage species") - verbose_name_plural = _("Heritage species") - - def __str__(self): - return self.label - - -class HeritageObservation(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Heritage observation") - verbose_name_plural = _("Heritage observations") - - def __str__(self): - return self.label - - -class FishSpecies(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Fish species") - verbose_name_plural = _("Fish species") - - def __str__(self): - return self.label - - -class ContributionFaunaFlora(models.Model): - - class TypeChoice(models.IntegerChoices): - """Choices for local influence""" - INVASIVE_SPECIES = 1, _('Invasive species') - HERITAGE_SPECIES = 2, _('Heritage species') - FISH_SPECIES = 3, _('Fish species') - - type = models.IntegerField( - null=False, - choices=TypeChoice.choices, - default=TypeChoice.INVASIVE_SPECIES, - verbose_name=_("Type"), - ) - home_area = models.FloatField(default=0.0, null=True, blank=True, - verbose_name=_("Home area (in square meters)"), - help_text=_('Home area in square meters')) - invasive_species = models.ForeignKey(InvasiveSpecies, on_delete=models.PROTECT, null=True) - number_heritage_species = models.IntegerField(default=0, null=True, blank=True, - verbose_name=_("Number heritage species")) - heritage_species = models.ForeignKey(HeritageSpecies, on_delete=models.PROTECT, null=True) - heritage_observation = models.ForeignKey(HeritageObservation, on_delete=models.PROTECT, null=True) - number_fish_species = models.IntegerField(default=0, null=True, blank=True, - verbose_name=_("Number fish species")) - fish_species = models.ForeignKey(FishSpecies, on_delete=models.PROTECT, null=True) - contribution = models.OneToOneField(Contribution, parent_link=True, on_delete=models.CASCADE, - related_name='fauna_flora') - - class Meta: - verbose_name = _("Contribution fauna-flora") - verbose_name_plural = _("contributions fauna-flora") - - def __str__(self): - return f'{ContributionFaunaFlora._meta.verbose_name.title()} {self.get_type_display()}' - - -class ContributionQuantity(models.Model): - class TypeChoice(models.IntegerChoices): - """Choices for local influence""" - DRY = 1, _('Dry') - PROCESS_DRYING_OUT = 2, _('In the process of drying out') - OVERFLOW = 3, _('Overflow') - - type = models.IntegerField( - null=False, - choices=TypeChoice.choices, - default=TypeChoice.DRY, - verbose_name=_("Water level type"), - ) - landmark = models.TextField(blank=True, verbose_name='Landmark') - contribution = models.OneToOneField(Contribution, parent_link=True, on_delete=models.CASCADE, - related_name='quantity') - - class Meta: - verbose_name = _("Contribution quantity") - verbose_name_plural = _("contributions quantity") - - def __str__(self): - return f'{ContributionQuantity._meta.verbose_name.title()} {self.get_type_display()}' - - -class NaturePollution(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Nature pollution") - verbose_name_plural = _("Natures pollution") - - def __str__(self): - return self.label - - -class TypePollution(models.Model): - label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) - - class Meta: - verbose_name = _("Type pollution") - verbose_name_plural = _("Types pollution") - - def __str__(self): - return self.label - - -class ContributionQuality(models.Model): - class TypeChoice(models.IntegerChoices): - """Choices for local influence""" - ALGAL_DEVELOPMENT = 1, _('Algal development') - POLLUTION = 2, _('Pollution') - WATER_TEMPERATURE = 3, _('Water temperature') - - type = models.IntegerField( - null=False, - choices=TypeChoice.choices, - default=TypeChoice.ALGAL_DEVELOPMENT, - verbose_name=_("Quality water type"), - ) - nature_pollution = models.ForeignKey(NaturePollution, on_delete=models.PROTECT, null=True) - type_pollution = models.ForeignKey(TypePollution, on_delete=models.PROTECT, null=True) - contribution = models.OneToOneField(Contribution, parent_link=True, on_delete=models.CASCADE, - related_name='quality') - - class Meta: - verbose_name = _("Contribution quality") - verbose_name_plural = _("contributions quality") - - def __str__(self): - return f'{ContributionQuality._meta.verbose_name.title()} {self.get_type_display()}' - - -class ContributionLandscapeElements(models.Model): - class TypeChoice(models.IntegerChoices): - """Choices for local influence""" - SINKHOLE = 1, _('Sinkhole') - FOUNTAIN = 2, _('Fountain') - CHASM = 3, _('Chasm') - LESINE = 4, _('Lesine') - POND = 5, _('Pond') - LOSING_STREAM = 6, _('Losing stream') - RESURGENCE = 7, _('Resurgence') - - type = models.IntegerField( - null=False, - choices=TypeChoice.choices, - default=TypeChoice.SINKHOLE, - verbose_name=_("Type"), - ) - contribution = models.OneToOneField(Contribution, parent_link=True, on_delete=models.CASCADE, - related_name='landscape_element') - - class Meta: - verbose_name = _("Contribution landscape element") - verbose_name_plural = _("contributions landscape elements") - - def __str__(self): - return f'{ContributionLandscapeElements._meta.verbose_name.title()} {self.get_type_display()}' - - -Contribution.add_property('streams', Stream.within_buffer, _("Stream")) -Contribution.add_property('status', Status.within_buffer, _("Status")) -Contribution.add_property('morphologies', Morphology.within_buffer, _("Morphologies")) -Contribution.add_property('usages', Usage.within_buffer, _("Usages")) -Contribution.add_property('stations', Station.within_buffer, _("Station")) -Contribution.add_property('studies', Study.within_buffer, _("Study")) -Contribution.add_property('proceedings', Proceeding.within_buffer, _("Proceeding")) -Contribution.add_property('knowledges', Knowledge.within_buffer, _("Knowledge")) diff --git a/georiviere/contribution/models/__init__.py b/georiviere/contribution/models/__init__.py new file mode 100644 index 00000000..b77d0033 --- /dev/null +++ b/georiviere/contribution/models/__init__.py @@ -0,0 +1,922 @@ +import logging + +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.contrib.gis.db import models +from django.core.mail import mail_managers +from django.template.loader import render_to_string +from django.utils.text import slugify +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from geotrek.common.mixins import BasePublishableMixin, TimeStampedModelMixin +from geotrek.common.utils import classproperty +from geotrek.zoning.mixins import ZoningPropertiesMixin +from mapentity.models import MapEntityMixin + +from georiviere.description.models import Morphology, Status, Usage +from georiviere.knowledge.models import Knowledge +from georiviere.main.models import AddPropertyBufferMixin +from georiviere.observations.models import Station +from georiviere.proceeding.models import Proceeding +from georiviere.river.models import Stream +from georiviere.studies.models import Study +from georiviere.watershed.mixins import WatershedPropertiesMixin +from .managers import SelectableUserManager, CustomContributionManager + +logger = logging.getLogger(__name__) + + +class SeverityType(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Severity type") + verbose_name_plural = _("Severity types") + + def __str__(self): + return self.label + + +class ContributionStatus(TimeStampedModelMixin, models.Model): + label = models.CharField(verbose_name=_("Status"), max_length=128) + + class Meta: + verbose_name = _("Status") + verbose_name_plural = _("Status") + + def __str__(self): + return self.label + + +class SelectableUser(User): + objects = SelectableUserManager() + + class Meta: + proxy = True + + +def status_default(): + """Set status to New by default""" + new_status_query = ContributionStatus.objects.filter(label="Informé") + if new_status_query: + return new_status_query.get().pk + return None + + +class Contribution( + BasePublishableMixin, + TimeStampedModelMixin, + WatershedPropertiesMixin, + ZoningPropertiesMixin, + AddPropertyBufferMixin, + MapEntityMixin, +): + """contribution model""" + + geom = models.GeometryField(srid=settings.SRID, spatial_index=True) + name_author = models.CharField( + max_length=128, verbose_name=_("Name author"), blank=True + ) + first_name_author = models.CharField( + max_length=128, verbose_name=_("First name author"), blank=True + ) + email_author = models.EmailField(verbose_name=_("Email")) + date_observation = models.DateTimeField( + editable=True, verbose_name=_("Observation's date") + ) + severity = models.ForeignKey( + SeverityType, + verbose_name=_("Severity"), + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="contributions", + ) + description = models.TextField( + verbose_name=_("Description"), + help_text=_("Description of the contribution"), + blank=True, + ) + published = models.BooleanField( + verbose_name=_("Published"), + default=False, + help_text=_("Make it visible on portal"), + ) + portal = models.ForeignKey( + "portal.Portal", + verbose_name=_("Portal"), + blank=True, + related_name="contributions", + on_delete=models.PROTECT, + ) + assigned_user = models.ForeignKey( + SelectableUser, + blank=True, + on_delete=models.PROTECT, + null=True, + verbose_name=_("Supervisor"), + related_name="contributions", + ) + status_contribution = models.ForeignKey( + "ContributionStatus", + on_delete=models.PROTECT, + null=True, + blank=True, + default=status_default, + verbose_name=_("Status"), + ) + validated = models.BooleanField( + verbose_name=_("Validated"), + default=False, + help_text=_("Validate the contribution"), + ) + linked_object_type = models.ForeignKey( + ContentType, null=True, on_delete=models.CASCADE + ) + linked_object_id = models.PositiveIntegerField(blank=True, null=True) + linked_object = GenericForeignKey("linked_object_type", "linked_object_id") + + class Meta: + verbose_name = _("Contribution") + verbose_name_plural = _("Contributions") + + @classproperty + def category_verbose_name(cls): + return _("Category") + + @classproperty + def type_verbose_name(cls): + return _("Type") + + @classproperty + def linked_object_verbose_name(cls): + return _("Linked object") + + @property + def linked_object_model_name(self): + if self.linked_object: + return self.linked_object._meta.verbose_name + return None + + def __str__(self): + # Use the category and the type (One to One field) to generate what it will show on the list / detail / etc... + # It will generate like that : + # test@test.test Potential Damage Landing + if hasattr(self, "potential_damage"): + return ( + f"{self.email_author} {ContributionPotentialDamage._meta.verbose_name.title()} " + f"{self.potential_damage.get_type_display()}" + ) + elif hasattr(self, "fauna_flora"): + return ( + f"{self.email_author} {ContributionFaunaFlora._meta.verbose_name.title()} " + f"{self.fauna_flora.get_type_display()}" + ) + elif hasattr(self, "quality"): + return ( + f"{self.email_author} {ContributionQuality._meta.verbose_name.title()} " + f"{self.quality.get_type_display()}" + ) + elif hasattr(self, "quantity"): + return ( + f"{self.email_author} {ContributionQuantity._meta.verbose_name.title()} " + f"{self.quantity.get_type_display()}" + ) + elif hasattr(self, "landscape_element"): + return ( + f"{self.email_author} {ContributionLandscapeElements._meta.verbose_name.title()} " + f"{self.landscape_element.get_type_display()}" + ) + return f"{self.email_author}" + + @property + def category(self): + # The category is the reverse of the one to one fields : + # For example : + # Potential damage + if hasattr(self, "potential_damage"): + return self.potential_damage + elif hasattr(self, "fauna_flora"): + return self.fauna_flora + elif hasattr(self, "quality"): + return self.quality + elif hasattr(self, "quantity"): + return self.quantity + elif hasattr(self, "landscape_element"): + return self.landscape_element + return _("No category") + + @property + def type(self): + if hasattr(self.category, "get_type_display"): + return self.category.get_type_display() + return _("No type") + + @property + def category_display(self): + s = '%s' % ( + self.pk, + self.get_detail_url(), + self.category, + self.category, + ) + if self.published: + s = ( + ' ' + % _("Published") + + s + ) + return s + + def send_report_to_managers(self, template_name="contribution/report_email.txt"): + # Send report to managers when a contribution has been created (MANAGERS settings) + subject = _("Feedback from {email}").format(email=self.email_author) + message = render_to_string(template_name, {"contribution": self}) + mail_managers(subject, message, fail_silently=False) + + def try_send_report_to_managers(self): + try: + self.send_report_to_managers() + except Exception as e: + logger.error("Email could not be sent to managers.") + logger.exception(e) # This sends an email to admins :) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) # Contribution updates should do nothing more + self.try_send_report_to_managers() + + +# Contributions has a category in the list : +# Potential damage +# Fauna flora +# Quality +# Quantity +# Landscape elements + +# Contributions has a type depending on its category +# Potential damage => Landing, Excessive cutting of riparian forest, Rockslides, Disruptive jam, Bank erosion +# River bed incision (sinking), Fish diseases (appearance of fish), Fish mortality, +# Trampling by livestock (impacting) +# Fauna flora => Invasive species, Heritage species, Fish species +# Quantity => Dry, In the process of drying out, Overflow +# Quality => Algal development, Pollution, Water temperature +# Landscape elements => Sinkhole, Fountain, Chasm, Lesine, Pond, Losing stream, Resurgence + +# Depending on its type of contribution, some fields are available or not. +# Everything is summarize on : +# https://github.com/Georiviere/Georiviere-admin/issues/139 + + +class LandingType(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Landing type") + verbose_name_plural = _("Landing types") + + def __str__(self): + return self.label + + +class JamType(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Jam type") + verbose_name_plural = _("Jam types") + + def __str__(self): + return self.label + + +class DiseaseType(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Disease type") + verbose_name_plural = _("Disease types") + + def __str__(self): + return self.label + + +class DeadSpecies(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Dead species") + verbose_name_plural = _("Dead species") + + def __str__(self): + return self.label + + +class ContributionPotentialDamage(models.Model): + class TypeChoice(models.IntegerChoices): + """Choices for local influence""" + + LANDING = 1, _("Landing") + EXCESSIVE_CUTTING_RIPARIAN_FOREST = 2, _("Excessive cutting of riparian forest") + ROCKSLIDES = 3, _("Rockslides") + DISRUPTIVE_JAM = 4, _("Disruptive jam") + BANK_EROSION = 5, _("Bank erosion") + RIVER_BED_INCISION = 6, _("River bed incision (sinking)") + FISH_DISEASES = 7, _("Fish diseases (appearance of fish)") + FISH_MORTALITY = 8, _("Fish mortality") + TRAMPLING_LIVESTOCK = 9, _("Trampling by livestock (impacting)") + + type = models.IntegerField( + null=False, + choices=TypeChoice.choices, + default=TypeChoice.LANDING, + verbose_name=_("Type"), + ) + landing_type = models.ForeignKey( + LandingType, on_delete=models.PROTECT, null=True, verbose_name=_("Landing type") + ) + excessive_cutting_length = models.FloatField( + default=0.0, + null=True, + blank=True, + verbose_name=_("Excessive cutting length (in meters)"), + ) + jam_type = models.ForeignKey(JamType, on_delete=models.PROTECT, null=True) + length_bank_erosion = models.FloatField( + default=0.0, + null=True, + blank=True, + verbose_name=_("Length bank erosion (in meters)"), + help_text=_( + "Distance between the foot of the bank and the foot of " "the erosion." + ), + ) + bank_height = models.FloatField( + default=0.0, + null=True, + blank=True, + verbose_name=_("Bank height (in meters)"), + help_text=_( + "Bank height (measured between the foot of the bank and the top " + "of the bank) in meters" + ), + ) + disease_type = models.ForeignKey(DiseaseType, on_delete=models.PROTECT, null=True) + number_death = models.IntegerField( + default=0, + null=True, + blank=True, + verbose_name=_("Number death"), + help_text=_("Number of dead individuals"), + ) + dead_species = models.ForeignKey(DeadSpecies, on_delete=models.PROTECT, null=True) + contribution = models.OneToOneField( + Contribution, + parent_link=True, + on_delete=models.CASCADE, + related_name="potential_damage", + ) + + class Meta: + verbose_name = _("Contribution potential damage") + verbose_name_plural = _("contributions potential damage") + + def __str__(self): + return f"{ContributionPotentialDamage._meta.verbose_name.title()} {self.get_type_display()}" + + +class InvasiveSpecies(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Invasive species") + verbose_name_plural = _("Invasive species") + + def __str__(self): + return self.label + + +class HeritageSpecies(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Heritage species") + verbose_name_plural = _("Heritage species") + + def __str__(self): + return self.label + + +class HeritageObservation(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Heritage observation") + verbose_name_plural = _("Heritage observations") + + def __str__(self): + return self.label + + +class FishSpecies(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Fish species") + verbose_name_plural = _("Fish species") + + def __str__(self): + return self.label + + +class ContributionFaunaFlora(models.Model): + class TypeChoice(models.IntegerChoices): + """Choices for local influence""" + + INVASIVE_SPECIES = 1, _("Invasive species") + HERITAGE_SPECIES = 2, _("Heritage species") + FISH_SPECIES = 3, _("Fish species") + + type = models.IntegerField( + null=False, + choices=TypeChoice.choices, + default=TypeChoice.INVASIVE_SPECIES, + verbose_name=_("Type"), + ) + home_area = models.FloatField( + default=0.0, + null=True, + blank=True, + verbose_name=_("Home area (in square meters)"), + help_text=_("Home area in square meters"), + ) + invasive_species = models.ForeignKey( + InvasiveSpecies, on_delete=models.PROTECT, null=True + ) + number_heritage_species = models.IntegerField( + default=0, null=True, blank=True, verbose_name=_("Number heritage species") + ) + heritage_species = models.ForeignKey( + HeritageSpecies, on_delete=models.PROTECT, null=True + ) + heritage_observation = models.ForeignKey( + HeritageObservation, on_delete=models.PROTECT, null=True + ) + number_fish_species = models.IntegerField( + default=0, null=True, blank=True, verbose_name=_("Number fish species") + ) + fish_species = models.ForeignKey(FishSpecies, on_delete=models.PROTECT, null=True) + contribution = models.OneToOneField( + Contribution, + parent_link=True, + on_delete=models.CASCADE, + related_name="fauna_flora", + ) + + class Meta: + verbose_name = _("Contribution fauna-flora") + verbose_name_plural = _("contributions fauna-flora") + + def __str__(self): + return f"{ContributionFaunaFlora._meta.verbose_name.title()} {self.get_type_display()}" + + +class ContributionQuantity(models.Model): + class TypeChoice(models.IntegerChoices): + """Choices for local influence""" + + DRY = 1, _("Dry") + PROCESS_DRYING_OUT = 2, _("In the process of drying out") + OVERFLOW = 3, _("Overflow") + + type = models.IntegerField( + null=False, + choices=TypeChoice.choices, + default=TypeChoice.DRY, + verbose_name=_("Water level type"), + ) + landmark = models.TextField(blank=True, verbose_name="Landmark") + contribution = models.OneToOneField( + Contribution, + parent_link=True, + on_delete=models.CASCADE, + related_name="quantity", + ) + + class Meta: + verbose_name = _("Contribution quantity") + verbose_name_plural = _("contributions quantity") + + def __str__(self): + return f"{ContributionQuantity._meta.verbose_name.title()} {self.get_type_display()}" + + +class NaturePollution(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Nature pollution") + verbose_name_plural = _("Natures pollution") + + def __str__(self): + return self.label + + +class TypePollution(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + + class Meta: + verbose_name = _("Type pollution") + verbose_name_plural = _("Types pollution") + + def __str__(self): + return self.label + + +class ContributionQuality(models.Model): + class TypeChoice(models.IntegerChoices): + """Choices for local influence""" + + ALGAL_DEVELOPMENT = 1, _("Algal development") + POLLUTION = 2, _("Pollution") + WATER_TEMPERATURE = 3, _("Water temperature") + + type = models.IntegerField( + null=False, + choices=TypeChoice.choices, + default=TypeChoice.ALGAL_DEVELOPMENT, + verbose_name=_("Quality water type"), + ) + nature_pollution = models.ForeignKey( + NaturePollution, on_delete=models.PROTECT, null=True + ) + type_pollution = models.ForeignKey( + TypePollution, on_delete=models.PROTECT, null=True + ) + contribution = models.OneToOneField( + Contribution, parent_link=True, on_delete=models.CASCADE, related_name="quality" + ) + + class Meta: + verbose_name = _("Contribution quality") + verbose_name_plural = _("contributions quality") + + def __str__(self): + return f"{ContributionQuality._meta.verbose_name.title()} {self.get_type_display()}" + + +class ContributionLandscapeElements(models.Model): + class TypeChoice(models.IntegerChoices): + """Choices for local influence""" + + SINKHOLE = 1, _("Sinkhole") + FOUNTAIN = 2, _("Fountain") + CHASM = 3, _("Chasm") + LESINE = 4, _("Lesine") + POND = 5, _("Pond") + LOSING_STREAM = 6, _("Losing stream") + RESURGENCE = 7, _("Resurgence") + + type = models.IntegerField( + null=False, + choices=TypeChoice.choices, + default=TypeChoice.SINKHOLE, + verbose_name=_("Type"), + ) + contribution = models.OneToOneField( + Contribution, + parent_link=True, + on_delete=models.CASCADE, + related_name="landscape_element", + ) + + class Meta: + verbose_name = _("Contribution landscape element") + verbose_name_plural = _("contributions landscape elements") + + def __str__(self): + return f"{ContributionLandscapeElements._meta.verbose_name.title()} {self.get_type_display()}" + + +Contribution.add_property("streams", Stream.within_buffer, _("Stream")) +Contribution.add_property("status", Status.within_buffer, _("Status")) +Contribution.add_property("morphologies", Morphology.within_buffer, _("Morphologies")) +Contribution.add_property("usages", Usage.within_buffer, _("Usages")) +Contribution.add_property("stations", Station.within_buffer, _("Station")) +Contribution.add_property("studies", Study.within_buffer, _("Study")) +Contribution.add_property("proceedings", Proceeding.within_buffer, _("Proceeding")) +Contribution.add_property("knowledges", Knowledge.within_buffer, _("Knowledge")) + + +# Custom Contribution + + +class CustomContributionType(models.Model): + label = models.CharField(max_length=128, verbose_name=_("Label"), unique=True) + description = models.CharField( + max_length=512, verbose_name=_("Description"), blank=True, default="" + ) + stations = models.ManyToManyField( + "observations.Station", + verbose_name=_("Stations"), + related_name="custom_contribution_types", + blank=True, + ) + password = models.CharField( + max_length=128, + verbose_name=_("Password"), + blank=True, + default="", + help_text=_("Define if password is required to send the form"), + ) + + def __str__(self): + return self.label + + def get_json_schema_form(self): + linked_fields = self.fields.all() + fields = {} + for field in self.fields.all(): + fields[field.key] = field.get_field_schema() + return { + # "title": self.label, + "type": "object", + "properties": fields, + "required": [field.key for field in linked_fields if field.required], + } + + @property + def json_schema_form(self): + return self.get_json_schema_form() + + class Meta: + verbose_name = _("Custom contribution type") + verbose_name_plural = _("Custom contribution types") + ordering = ("label",) + + +class CustomContributionTypeField(models.Model): + class FieldTypeChoices(models.TextChoices): + """Choices for field type""" + + STRING = "string", _("String") + TEXT = "text", _("Text") + INTEGER = "integer", _("Integer") + FLOAT = "float", _("Float") + DATE = "date", _("Date") + DATETIME = "datetime", _("Datetime") + BOOLEAN = "boolean", _("Boolean") + + label = models.CharField( + max_length=128, + verbose_name=_("Label"), + help_text=_("Field label in forms and public portal."), + ) + internal_identifier = models.CharField( + max_length=128, + verbose_name=_("Internal identifier"), + help_text=_("Internal identifier for field."), + unique=True, + blank=True, + null=True, # allow null because it is not mandatory but unique if valued + ) + key = models.SlugField( + max_length=150, + verbose_name=_("Key"), + help_text=_("Key used in JSON data field."), + editable=False, + ) + value_type = models.CharField( + max_length=16, + verbose_name=_("Type"), + choices=FieldTypeChoices.choices, + default=FieldTypeChoices.STRING, + ) + required = models.BooleanField( + default=False, + verbose_name=_("Required"), + help_text=_("Set if field is required to validate form."), + ) + help_text = models.CharField( + max_length=256, + verbose_name=_("Help text"), + blank=True, + default="", + help_text=_("Set a help text for the field."), + ) + options = models.JSONField( + null=False, + blank=True, + editable=False, + verbose_name=_("Options"), + default=dict, + help_text=_("Internal options for type JSON schema."), + ) + customization = models.JSONField( + null=False, + blank=True, + default=dict, + verbose_name=_("Customization"), + help_text=_("Field customization."), + ) + order = models.PositiveSmallIntegerField( + default=0, verbose_name=_("Order"), help_text=_("Order of field in form.") + ) + custom_type = models.ForeignKey( + CustomContributionType, + on_delete=models.CASCADE, + related_name="fields", + verbose_name=_("Custom contribution type."), + ) + + def __str__(self): + return f"{self.label}: ({self.value_type})" + + def get_slug_as_field_name(self, label): + return slugify(label).replace("-", "_") + + def save(self, *args, **kwargs): + if not self.pk: + self.key = self.get_slug_as_field_name(self.label) + return super().save(*args, **kwargs) + + def get_customization_json_schema_form(self): + base_schema = { + "type": "object", + "properties": {}, + } + if self.value_type == self.FieldTypeChoices.STRING: + base_schema["properties"] = { + "choices": { + "type": "array", + "items": { + "type": "string", + "title": _("Values"), + }, + }, + "placeholder": { + "type": "string", + "title": _("Placeholder"), + }, + "minLength": { + "type": "integer", + "title": _("Min. length"), + }, + "maxLength": { + "type": "integer", + "title": _("Max. length"), + }, + } + if self.value_type == self.FieldTypeChoices.TEXT: + base_schema["properties"] = { + "placeholder": { + "type": "string", + "title": _("Placeholder"), + }, + "minLength": { + "type": "integer", + "title": _("Min. length"), + }, + "maxLength": { + "type": "integer", + "title": _("Max. length"), + }, + } + if self.value_type == self.FieldTypeChoices.INTEGER: + base_schema["properties"] = { + "minimum": { + "type": "integer", + "title": _("Min. value"), + }, + "maximum": { + "type": "integer", + "title": _("Max. value"), + }, + } + if self.value_type == self.FieldTypeChoices.FLOAT: + base_schema["properties"] = { + "minimum": { + "type": "number", + "title": _("Min. value"), + }, + "maximum": { + "type": "number", + "title": _("Max. value"), + }, + } + if self.value_type == self.FieldTypeChoices.BOOLEAN: + base_schema["properties"] = { + "widget": { + "type": "string", + "choices": [ + "radio", + "checkbox", + "select", + ], + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "title": "Title"}, + "value": { + "type": "boolean", + "title": "Value", + "choices": [ + {"title": "Yes", "value": True}, + {"title": "No", "value": False}, + ], + }, + }, + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": True, + }, + } + return base_schema + + def get_field_schema(self): + base_schema = { + "title": self.label, + "type": "string", + "helpText": self.help_text, + } + base_schema.update(self.options) + + if self.value_type == self.FieldTypeChoices.TEXT: + base_schema["widget"] = "textarea" + elif self.value_type == self.FieldTypeChoices.INTEGER: + base_schema["type"] = "integer" + elif self.value_type == self.FieldTypeChoices.FLOAT: + base_schema["type"] = "number" + elif self.value_type == self.FieldTypeChoices.BOOLEAN: + base_schema["type"] = "boolean" + elif self.value_type == self.FieldTypeChoices.DATE: + base_schema["format"] = "date" + elif self.value_type == self.FieldTypeChoices.DATETIME: + base_schema["format"] = "date-time" + + # drop empty choices + customization = self.customization + if "choices" in customization and not customization.get("choices"): + customization.pop("choices") + base_schema.update(self.customization) + + return base_schema + + class Meta: + verbose_name = _("Custom contribution type field") + verbose_name_plural = _("Custom contribution type fields") + unique_together = ( + ("label", "custom_type"), # label by type should be unique + ( + "internal_identifier", + "custom_type", + ), # internal_identifier by type should be unique + ) + index_together = (("order", "custom_type"),) + ordering = ("order", "custom_type") + + +class CustomContribution(TimeStampedModelMixin, models.Model): + geom = models.GeometryField(srid=settings.SRID, spatial_index=True) + attachments = GenericRelation(settings.PAPERCLIP_ATTACHMENT_MODEL) + station = models.ForeignKey( + "observations.Station", + on_delete=models.PROTECT, + related_name="custom_contributions", + verbose_name=_("Station"), + blank=True, + null=True, + ) + custom_type = models.ForeignKey( + CustomContributionType, + on_delete=models.PROTECT, + related_name="contributions", + verbose_name=_("Custom contribution type"), + ) + data = models.JSONField( + verbose_name=_("Data"), null=False, blank=True, default=dict + ) + portal = models.ForeignKey( + "portal.Portal", + verbose_name=_("Portal"), + blank=True, + null=True, + related_name="custom_contributions", + on_delete=models.PROTECT, + ) + contributed_at = models.DateTimeField(verbose_name=_("Contributed at"), default=now) + validated = models.BooleanField(default=False, verbose_name=_("Validated")) + objects = CustomContributionManager() + + class Meta: + verbose_name = _("Custom contribution") + verbose_name_plural = _("Custom contributions") + ordering = ("-contributed_at",) + + def __str__(self): + return f"{self.custom_type.label} - {self.pk}" diff --git a/georiviere/contribution/models/managers.py b/georiviere/contribution/models/managers.py new file mode 100644 index 00000000..958b3bd3 --- /dev/null +++ b/georiviere/contribution/models/managers.py @@ -0,0 +1,30 @@ +from django.contrib.gis.db import models +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast + + +class SelectableUserManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(userprofile__isnull=False) + + +class CustomContributionManager(models.Manager): + def with_type_values(self, custom_type): + annotations = {} + qs = self.get_queryset() + for field in custom_type.fields.all(): + output_field = models.CharField() + if field.value_type == 'integer': + output_field = models.IntegerField() + elif field.value_type == 'float': + output_field = models.FloatField() + elif field.value_type == 'boolean': + output_field = models.BooleanField() + elif field.value_type == 'date': + output_field = models.DateField() + elif field.value_type == 'datetime': + output_field = models.DateTimeField() + annotations[field.key] = Cast(KeyTextTransform(field.key, 'data'), output_field=output_field) + if annotations: + qs = qs.annotate(**annotations) + return qs diff --git a/georiviere/contribution/schema.py b/georiviere/contribution/schema.py index 0c85d301..333ba875 100644 --- a/georiviere/contribution/schema.py +++ b/georiviere/contribution/schema.py @@ -1,415 +1,375 @@ from django.utils.translation import gettext as _ -from georiviere.contribution.models import (ContributionQuantity, ContributionQuality, - ContributionFaunaFlora, ContributionLandscapeElements, - ContributionPotentialDamage, SeverityType, - LandingType, JamType, DiseaseType, DeadSpecies, - InvasiveSpecies, HeritageSpecies, HeritageObservation, FishSpecies, - NaturePollution, TypePollution) - -# The json schema is summarize on : +from . import models + + +# The json schema is summarized on : # https://github.com/Georiviere/Georiviere-admin/issues/139 -# Depending of the category and type of the contributions, some fields are available or not. +# Depending on the category and type of the contributions, some fields are available or not. # Here is the generation of the json schema used by the website portal. # The fields available depending on the type of contributions follow the documentation of jsonschema : # https://json-schema.org/understanding-json-schema/reference/conditionals.html def get_contribution_properties(): - """ Feature properties as form initial data format (name / value) """ + """Feature properties as form initial data format (name / value)""" # TODO: Use directly field definition for type / title / max length - results = {'name_author': { - 'type': "string", - 'title': _("Name author"), - "maxLength": 128 - }, 'first_name_author': { - 'type': "string", - 'title': _("First name author"), - "maxLength": 128 - }, 'email_author': { - 'type': "string", - 'title': _("Email"), - 'format': "email" - }, 'date_observation': { - 'type': "string", - 'title': _("Observation's date"), - 'format': 'date' - }, 'description': { - 'type': "string", - 'title': _('Description') - }, 'category': { - "type": "string", - "title": _("Category"), - # TODO: Loop on contribution one to one field to get all possibilities - "enum": [ - str(ContributionQuantity._meta.verbose_name.title()), - str(ContributionQuality._meta.verbose_name.title()), - str(ContributionFaunaFlora._meta.verbose_name.title()), - str(ContributionLandscapeElements._meta.verbose_name.title()), - str(ContributionPotentialDamage._meta.verbose_name.title()) - ], - } + results = { + "name_author": {"type": "string", "title": _("Name author"), "maxLength": 128}, + "first_name_author": { + "type": "string", + "title": _("First name author"), + "maxLength": 128, + }, + "email_author": {"type": "string", "title": _("Email"), "format": "email"}, + "date_observation": { + "type": "string", + "title": _("Observation's date"), + "format": "date", + }, + "description": {"type": "string", "title": _("Description")}, + "category": { + "type": "string", + "title": _("Category"), + # TODO: Loop on contribution one to one field to get all possibilities + "enum": [ + models.ContributionQuantity._meta.verbose_name.title(), + models.ContributionQuality._meta.verbose_name.title(), + models.ContributionFaunaFlora._meta.verbose_name.title(), + models.ContributionLandscapeElements._meta.verbose_name.title(), + models.ContributionPotentialDamage._meta.verbose_name.title(), + ], + }, } - if SeverityType.objects.exists(): - results['severity'] = { - 'type': "string", - 'title': _('Severity'), - 'enum': list(SeverityType.objects.values_list('label', flat=True)) + if models.SeverityType.objects.exists(): + results["severity"] = { + "type": "string", + "title": _("Severity"), + "enum": list(models.SeverityType.objects.values_list("label", flat=True)), } return results def get_landing(choices, meta): landing = { - 'if': { - 'properties': {'type': {'const': str(choices.LANDING.label)}} - }, - 'then': { - 'properties': { - 'landing_type': - { - 'type': "string", - 'title': str(meta.get_field('landing_type').related_model._meta.verbose_name.capitalize()), - 'enum': list(LandingType.objects.values_list('label', flat=True)) - } + "if": {"properties": {"type": {"const": choices.LANDING.label}}}, + "then": { + "properties": { + "landing_type": { + "type": "string", + "title": meta.get_field( + "landing_type" + ).related_model._meta.verbose_name.capitalize(), + "enum": list( + models.LandingType.objects.values_list("label", flat=True) + ), + } }, - } + }, } return landing def get_excessive_cutting_riparian_forest(choices, meta): excessive_cutting_riparian_forest = { - 'if': { - 'properties': { - 'type': {'const': str(choices.EXCESSIVE_CUTTING_RIPARIAN_FOREST.label)}} + "if": { + "properties": { + "type": {"const": choices.EXCESSIVE_CUTTING_RIPARIAN_FOREST.label} + } }, - 'then': { - 'properties': { - 'excessive_cutting_length': - { - 'type': "number", - 'title': str(meta.get_field( - 'excessive_cutting_length').verbose_name.capitalize()), - } + "then": { + "properties": { + "excessive_cutting_length": { + "type": "number", + "title": meta.get_field( + "excessive_cutting_length" + ).verbose_name.capitalize(), + } }, - } + }, } return excessive_cutting_riparian_forest def get_disruptive_jam(choices, meta): disruptive_jam = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.DISRUPTIVE_JAM.label)}} - }, - 'then': { - 'properties': { - 'jam_type': - { - 'type': "string", - 'title': str(meta.get_field( - 'jam_type').related_model._meta.verbose_name.capitalize()), - 'enum': list(JamType.objects.values_list('label', flat=True)) - } + "if": {"properties": {"type": {"const": choices.DISRUPTIVE_JAM.label}}}, + "then": { + "properties": { + "jam_type": { + "type": "string", + "title": meta.get_field( + "jam_type" + ).related_model._meta.verbose_name.capitalize(), + "enum": list( + models.JamType.objects.values_list("label", flat=True) + ), + } }, - } + }, } return disruptive_jam def get_bank_erosion(choices, meta): bank_erosion = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.BANK_EROSION.label)}} - }, - 'then': { - 'properties': { - 'length_bank_erosion': - { - 'type': "string", - 'title': str(meta.get_field( - 'length_bank_erosion').verbose_name.capitalize()), - } + "if": {"properties": {"type": {"const": choices.BANK_EROSION.label}}}, + "then": { + "properties": { + "length_bank_erosion": { + "type": "string", + "title": meta.get_field( + "length_bank_erosion" + ).verbose_name.capitalize(), + } }, - } + }, } return bank_erosion def get_river_bed_incision(choices, meta): river_bed_incision = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.RIVER_BED_INCISION.label)}} - }, - 'then': { - 'properties': { - 'bank_height': - { - 'type': "string", - 'title': str(meta.get_field( - 'bank_height').verbose_name.capitalize()), - } + "if": {"properties": {"type": {"const": choices.RIVER_BED_INCISION.label}}}, + "then": { + "properties": { + "bank_height": { + "type": "string", + "title": meta.get_field("bank_height").verbose_name.capitalize(), + } }, - } + }, } return river_bed_incision def get_fish_diseases(choices, meta): fish_diseases = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.FISH_DISEASES.label)}} - }, - 'then': { - 'properties': { - 'disease_type': - { - 'type': "string", - 'title': str(meta.get_field( - 'disease_type').related_model._meta.verbose_name.capitalize()), - 'enum': list(DiseaseType.objects.values_list('label', flat=True)) - } + "if": {"properties": {"type": {"const": choices.FISH_DISEASES.label}}}, + "then": { + "properties": { + "disease_type": { + "type": "string", + "title": meta.get_field( + "disease_type" + ).related_model._meta.verbose_name.capitalize(), + "enum": list( + models.DiseaseType.objects.values_list("label", flat=True) + ), + } }, - } + }, } return fish_diseases def get_fish_mortality(choices, meta): fish_mortality_property = { - 'number_death': - { - 'type': "number", - 'title': str(meta.get_field( - 'number_death').verbose_name.capitalize()) - }, + "number_death": { + "type": "number", + "title": meta.get_field("number_death").verbose_name.capitalize(), + }, } - if DeadSpecies.objects.exists(): - fish_mortality_property['dead_species'] = { - 'type': "string", - 'title': _("Observed species"), - 'enum': list(DeadSpecies.objects.values_list('label', flat=True)) + if models.DeadSpecies.objects.exists(): + fish_mortality_property["dead_species"] = { + "type": "string", + "title": _("Observed species"), + "enum": list(models.DeadSpecies.objects.values_list("label", flat=True)), } fish_mortality = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.FISH_MORTALITY.label)}} - }, - 'then': { - 'properties': fish_mortality_property - } + "if": {"properties": {"type": {"const": choices.FISH_MORTALITY.label}}}, + "then": {"properties": fish_mortality_property}, } return fish_mortality def get_potentialdamage_condition(): - potential_damage_choices = ContributionPotentialDamage.TypeChoice - meta_potential_damage = ContributionPotentialDamage._meta + potential_damage_choices = models.ContributionPotentialDamage.TypeChoice + meta_potential_damage = models.ContributionPotentialDamage._meta initial_condition = { - 'if': { - 'properties': {'category': {'const': str(meta_potential_damage.verbose_name.title())}} + "if": { + "properties": { + "category": {"const": meta_potential_damage.verbose_name.title()} + } }, - 'then': { - 'properties': { - 'type': { - 'type': "string", - 'title': str(meta_potential_damage.get_field('type').verbose_name.title()), - 'enum': list(potential_damage_choices.labels) + "then": { + "properties": { + "type": { + "type": "string", + "title": meta_potential_damage.get_field( + "type" + ).verbose_name.title(), + "enum": list(potential_damage_choices.labels), } }, - "required": ['type'], - } + "required": ["type"], + }, } conditions_each_type = [ initial_condition, - get_excessive_cutting_riparian_forest(potential_damage_choices, - meta_potential_damage), + get_excessive_cutting_riparian_forest( + potential_damage_choices, meta_potential_damage + ), get_bank_erosion(potential_damage_choices, meta_potential_damage), get_river_bed_incision(potential_damage_choices, meta_potential_damage), - get_fish_mortality(potential_damage_choices, meta_potential_damage) + get_fish_mortality(potential_damage_choices, meta_potential_damage), ] # 2 types in fish mortality - if LandingType.objects.exists(): - conditions_each_type.append(get_landing(potential_damage_choices, meta_potential_damage)) - if JamType.objects.exists(): - conditions_each_type.append(get_disruptive_jam(potential_damage_choices, meta_potential_damage)) - if DiseaseType.objects.exists(): - conditions_each_type.append(get_fish_diseases(potential_damage_choices, meta_potential_damage)) + if models.LandingType.objects.exists(): + conditions_each_type.append( + get_landing(potential_damage_choices, meta_potential_damage) + ) + if models.JamType.objects.exists(): + conditions_each_type.append( + get_disruptive_jam(potential_damage_choices, meta_potential_damage) + ) + if models.DiseaseType.objects.exists(): + conditions_each_type.append( + get_fish_diseases(potential_damage_choices, meta_potential_damage) + ) return conditions_each_type def get_invasive_species(choices, meta): invasive_species_property = { - 'home_area': - { - 'type': "string", - 'title': str(meta.get_field( - 'home_area').verbose_name.capitalize()) - }, + "home_area": { + "type": "string", + "title": meta.get_field("home_area").verbose_name.capitalize(), + }, } - if InvasiveSpecies.objects.exists(): - invasive_species_property['invasive_species'] = { - 'type': "string", - 'title': _("Observed species"), - 'enum': list(InvasiveSpecies.objects.values_list('label', flat=True)) + if models.InvasiveSpecies.objects.exists(): + invasive_species_property["invasive_species"] = { + "type": "string", + "title": _("Observed species"), + "enum": list( + models.InvasiveSpecies.objects.values_list("label", flat=True) + ), } invasive_species = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.INVASIVE_SPECIES.label)}} - }, - 'then': { - 'properties': invasive_species_property - } + "if": {"properties": {"type": {"const": choices.INVASIVE_SPECIES.label}}}, + "then": {"properties": invasive_species_property}, } return invasive_species def get_heritage_species(choices, meta): heritage_species_property = { - 'number_heritage_species': - { - 'type': "number", - 'title': str(meta.get_field( - 'number_heritage_species').verbose_name.capitalize()) - }, + "number_heritage_species": { + "type": "number", + "title": meta.get_field( + "number_heritage_species" + ).verbose_name.capitalize(), + }, } - if HeritageSpecies.objects.exists(): - heritage_species_property['heritage_species'] = { - 'type': "string", - 'title': _("Observed species"), - 'enum': list(HeritageSpecies.objects.values_list('label', flat=True)) + if models.HeritageSpecies.objects.exists(): + heritage_species_property["heritage_species"] = { + "type": "string", + "title": _("Observed species"), + "enum": list( + models.HeritageSpecies.objects.values_list("label", flat=True) + ), } - if HeritageObservation.objects.exists(): - heritage_species_property['heritage_observation'] = { - 'type': "string", - 'title': _("Observation type"), - 'enum': list(HeritageObservation.objects.values_list('label', flat=True)) + if models.HeritageObservation.objects.exists(): + heritage_species_property["heritage_observation"] = { + "type": "string", + "title": _("Observation type"), + "enum": list( + models.HeritageObservation.objects.values_list("label", flat=True) + ), } heritage_species = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.HERITAGE_SPECIES.label)}} - }, - 'then': { - 'properties': heritage_species_property - } + "if": {"properties": {"type": {"const": choices.HERITAGE_SPECIES.label}}}, + "then": {"properties": heritage_species_property}, } return heritage_species def get_fish_species(choices, meta): fish_species_property = { - 'number_fish_species': - { - 'type': "number", - 'title': str(meta.get_field( - 'number_fish_species').verbose_name.capitalize()) - }, + "number_fish_species": { + "type": "number", + "title": meta.get_field("number_fish_species").verbose_name.capitalize(), + }, } - if FishSpecies.objects.exists(): - fish_species_property['fish_species'] = { - 'type': "string", - 'title': _("Observed species"), - 'enum': list(FishSpecies.objects.values_list('label', flat=True)) + if models.FishSpecies.objects.exists(): + fish_species_property["fish_species"] = { + "type": "string", + "title": _("Observed species"), + "enum": list(models.FishSpecies.objects.values_list("label", flat=True)), } fish_species = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.FISH_SPECIES.label)}} - }, - 'then': { - 'properties': fish_species_property - } + "if": {"properties": {"type": {"const": choices.FISH_SPECIES.label}}}, + "then": {"properties": fish_species_property}, } return fish_species def get_faunaflora_condition(): - faunaflora_choices = ContributionFaunaFlora.TypeChoice - meta_faunaflora = ContributionFaunaFlora._meta + faunaflora_choices = models.ContributionFaunaFlora.TypeChoice + meta_faunaflora = models.ContributionFaunaFlora._meta initial_condition = { - 'if': { - 'properties': {'category': {'const': str(meta_faunaflora.verbose_name.title())}} + "if": { + "properties": {"category": {"const": meta_faunaflora.verbose_name.title()}} }, - 'then': { - 'properties': { - 'type': { - 'type': "string", - 'title': str(meta_faunaflora.get_field('type').verbose_name.title()), - 'enum': list(faunaflora_choices.labels) + "then": { + "properties": { + "type": { + "type": "string", + "title": meta_faunaflora.get_field("type").verbose_name.title(), + "enum": list(faunaflora_choices.labels), } }, - "required": ['type'], - } + "required": ["type"], + }, } conditions_each_type = [ initial_condition, get_invasive_species(faunaflora_choices, meta_faunaflora), get_heritage_species(faunaflora_choices, meta_faunaflora), - get_fish_species(faunaflora_choices, meta_faunaflora) + get_fish_species(faunaflora_choices, meta_faunaflora), ] return conditions_each_type def get_overflow(choices, meta): overflow = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.OVERFLOW.label)}} - }, - 'then': { - 'properties': { - 'landmark': - { - 'type': "string", - 'title': str(meta.get_field( - 'landmark').verbose_name.capitalize()) - }, + "if": {"properties": {"type": {"const": choices.OVERFLOW.label}}}, + "then": { + "properties": { + "landmark": { + "type": "string", + "title": meta.get_field("landmark").verbose_name.capitalize(), + }, } - } + }, } return overflow def get_quantity_condition(): - quantity_choices = ContributionQuantity.TypeChoice - meta_quantity = ContributionQuantity._meta + quantity_choices = models.ContributionQuantity.TypeChoice + meta_quantity = models.ContributionQuantity._meta initial_condition = { - 'if': { - 'properties': {'category': {'const': str(meta_quantity.verbose_name.title())}} + "if": { + "properties": {"category": {"const": meta_quantity.verbose_name.title()}} }, - 'then': { - 'properties': { - 'type': { - 'type': "string", - 'title': str(meta_quantity.get_field('type').verbose_name.capitalize()), - 'enum': list(quantity_choices.labels) + "then": { + "properties": { + "type": { + "type": "string", + "title": meta_quantity.get_field("type").verbose_name.capitalize(), + "enum": list(quantity_choices.labels), } }, - "required": ['type'], - } + "required": ["type"], + }, } conditions_each_type = [ @@ -421,85 +381,85 @@ def get_quantity_condition(): def get_pollution(choices, meta): pollution_property = {} - if NaturePollution.objects.exists(): - pollution_property['nature_pollution'] = { - 'type': "string", - 'title': str(meta.get_field( - 'nature_pollution').related_model._meta.verbose_name.capitalize()), - 'enum': list(NaturePollution.objects.values_list('label', flat=True)) + if models.NaturePollution.objects.exists(): + pollution_property["nature_pollution"] = { + "type": "string", + "title": meta.get_field( + "nature_pollution" + ).related_model._meta.verbose_name.capitalize(), + "enum": list( + models.NaturePollution.objects.values_list("label", flat=True) + ), } - if TypePollution.objects.exists(): - pollution_property['type_pollution'] = { - 'type': "string", - 'title': str(meta.get_field( - 'type_pollution').related_model._meta.verbose_name.capitalize()), - 'enum': list(TypePollution.objects.values_list('label', flat=True)) + if models.TypePollution.objects.exists(): + pollution_property["type_pollution"] = { + "type": "string", + "title": meta.get_field( + "type_pollution" + ).related_model._meta.verbose_name.capitalize(), + "enum": list(models.TypePollution.objects.values_list("label", flat=True)), } pollution = { - 'if': { - 'properties': { - 'type': { - 'const': str(choices.POLLUTION.label)}} - }, - 'then': { - 'properties': pollution_property - } + "if": {"properties": {"type": {"const": choices.POLLUTION.label}}}, + "then": {"properties": pollution_property}, } return pollution def get_quality_condition(): - quality_choices = ContributionQuality.TypeChoice - meta_quality = ContributionQuality._meta + quality_choices = models.ContributionQuality.TypeChoice + meta_quality = models.ContributionQuality._meta initial_condition = { - 'if': { - 'properties': {'category': {'const': str(meta_quality.verbose_name.title())}} + "if": { + "properties": {"category": {"const": meta_quality.verbose_name.title()}} }, - 'then': { - 'properties': { - 'type': { - 'type': "string", - 'title': str(meta_quality.get_field('type').verbose_name.title()), - 'enum': list(quality_choices.labels) + "then": { + "properties": { + "type": { + "type": "string", + "title": meta_quality.get_field("type").verbose_name.title(), + "enum": list(quality_choices.labels), } }, - "required": ['type'], - } + "required": ["type"], + }, } conditions_each_type = [ initial_condition, - ] - if NaturePollution.objects.exists() or TypePollution.objects.exists(): + if models.NaturePollution.objects.exists() or models.TypePollution.objects.exists(): conditions_each_type.append(get_pollution(quality_choices, meta_quality)) return conditions_each_type def get_landscapeelements_condition(): - landscapeelements_choices = ContributionLandscapeElements.TypeChoice - meta_landscapeelements = ContributionLandscapeElements._meta + landscapeelements_choices = models.ContributionLandscapeElements.TypeChoice + meta_landscapeelements = models.ContributionLandscapeElements._meta initial_condition = { - 'if': { - 'properties': {'category': {'const': str(meta_landscapeelements.verbose_name.title())}} + "if": { + "properties": { + "category": {"const": meta_landscapeelements.verbose_name.title()} + } }, - 'then': { - 'properties': { - 'type': { - 'type': "string", - 'title': str(meta_landscapeelements.get_field('type').verbose_name.title()), - 'enum': list(landscapeelements_choices.labels) + "then": { + "properties": { + "type": { + "type": "string", + "title": meta_landscapeelements.get_field( + "type" + ).verbose_name.title(), + "enum": list(landscapeelements_choices.labels), } }, - "required": ['type'], - } + "required": ["type"], + }, } conditions_each_type = [ initial_condition, - ] return conditions_each_type @@ -516,7 +476,7 @@ def get_contribution_allOf(): def get_contribution_json_schema(): return { "type": "object", - "required": ['email_author', 'date_observation', 'category'], + "required": ["email_author", "date_observation", "category"], "properties": get_contribution_properties(), - "allOf": get_contribution_allOf() + "allOf": get_contribution_allOf(), } diff --git a/georiviere/contribution/tests/factories.py b/georiviere/contribution/tests/factories.py index efd86da0..6e698b6a 100644 --- a/georiviere/contribution/tests/factories.py +++ b/georiviere/contribution/tests/factories.py @@ -1,7 +1,8 @@ -from factory import django, fuzzy, SubFactory, Sequence +from factory import Sequence, SubFactory, django, fuzzy, post_generation from mapentity.tests.factories import PointFactory from georiviere.contribution import models +from georiviere.observations.tests.factories import StationFactory from georiviere.portal.tests.factories import PortalFactory @@ -9,7 +10,7 @@ class ContributionStatusFactory(django.DjangoModelFactory): class Meta: model = models.ContributionStatus - label = Sequence(lambda n: f'Contribution status {n}') + label = Sequence(lambda n: f"Contribution status {n}") class ContributionFactory(PointFactory): @@ -18,7 +19,7 @@ class Meta: name_author = fuzzy.FuzzyText() email_author = Sequence(lambda n: f"mail{n}@mail.mail") - date_observation = '2020-03-17T00:00:00Z' + date_observation = "2020-03-17T00:00:00Z" portal = SubFactory(PortalFactory) @@ -61,74 +62,138 @@ class SeverityTypeTypeFactory(django.DjangoModelFactory): class Meta: model = models.SeverityType - label = Sequence(lambda n: f'Severity type {n}') + label = Sequence(lambda n: f"Severity type {n}") class LandingTypeFactory(django.DjangoModelFactory): class Meta: model = models.LandingType - label = Sequence(lambda n: f'Landing type {n}') + label = Sequence(lambda n: f"Landing type {n}") class JamTypeFactory(django.DjangoModelFactory): class Meta: model = models.JamType - label = Sequence(lambda n: f'Jam type {n}') + label = Sequence(lambda n: f"Jam type {n}") class DiseaseTypeFactory(django.DjangoModelFactory): class Meta: model = models.DiseaseType - label = Sequence(lambda n: f'Disease type {n}') + label = Sequence(lambda n: f"Disease type {n}") class DeadSpeciesFactory(django.DjangoModelFactory): class Meta: model = models.DeadSpecies - label = Sequence(lambda n: f'Dead species {n}') + label = Sequence(lambda n: f"Dead species {n}") class InvasiveSpeciesFactory(django.DjangoModelFactory): class Meta: model = models.InvasiveSpecies - label = Sequence(lambda n: f'Invasive species {n}') + label = Sequence(lambda n: f"Invasive species {n}") class HeritageSpeciesFactory(django.DjangoModelFactory): class Meta: model = models.HeritageSpecies - label = Sequence(lambda n: f'Heritage species {n}') + label = Sequence(lambda n: f"Heritage species {n}") class HeritageObservationFactory(django.DjangoModelFactory): class Meta: model = models.HeritageObservation - label = Sequence(lambda n: f'Heritage observation {n}') + label = Sequence(lambda n: f"Heritage observation {n}") class FishSpeciesFactory(django.DjangoModelFactory): class Meta: model = models.FishSpecies - label = Sequence(lambda n: f'Fish species {n}') + label = Sequence(lambda n: f"Fish species {n}") class NaturePollutionFactory(django.DjangoModelFactory): class Meta: model = models.NaturePollution - label = Sequence(lambda n: f'Nature pollution {n}') + label = Sequence(lambda n: f"Nature pollution {n}") class TypePollutionFactory(django.DjangoModelFactory): class Meta: model = models.TypePollution - label = Sequence(lambda n: f'Type pollution {n}') + label = Sequence(lambda n: f"Type pollution {n}") + + +class CustomContributionTypeFactory(django.DjangoModelFactory): + class Meta: + model = models.CustomContributionType + + label = Sequence(lambda n: f"Custom contribution type {n}") + + @post_generation + def add_stations(obj, create, extracted, **kwargs): + stations = kwargs.get("stations", []) + with_station = kwargs.get("with_station", False) + + if not stations and with_station: + stations = [StationFactory.create()] + + if stations: + obj.stations.set(stations) + + +class CustomContributionTypeFieldFactory(django.DjangoModelFactory): + class Meta: + model = models.CustomContributionTypeField + + label = Sequence(lambda n: f"Custom contribution type field {n}") + internal_identifier = Sequence(lambda n: f"custom_contribution_type_field_{n}") + required = False + custom_type = SubFactory(CustomContributionTypeFactory) + + +class CustomContributionTypeStringFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.STRING + + +class CustomContributionTypeTextFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.TEXT + + +class CustomContributionTypeIntegerFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.INTEGER + + +class CustomContributionTypeBooleanFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.BOOLEAN + + +class CustomContributionTypeFloatFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.FLOAT + + +class CustomContributionTypeDateFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.DATE + + +class CustomContributionTypeDatetimeFieldFactory(CustomContributionTypeFieldFactory): + value_type = models.CustomContributionTypeField.FieldTypeChoices.DATETIME + + +class CustomContributionFactory(django.DjangoModelFactory): + class Meta: + model = models.CustomContribution + + custom_type = SubFactory(CustomContributionTypeFactory) + geom = "SRID=2154;POINT(700000 6600000)" diff --git a/georiviere/contribution/tests/test_admin.py b/georiviere/contribution/tests/test_admin.py index 1bdc24af..7a620a19 100644 --- a/georiviere/contribution/tests/test_admin.py +++ b/georiviere/contribution/tests/test_admin.py @@ -3,9 +3,12 @@ from mapentity.tests.factories import SuperUserFactory +from georiviere.contribution.tests.factories import CustomContributionTypeFactory, CustomContributionTypeFieldFactory, \ + CustomContributionFactory +from georiviere.main.tests.factories import AttachmentFactory -class ContributionAdminTest(TestCase): +class ContributionAdminTest(TestCase): @classmethod def setUpTestData(cls): cls.super_user = SuperUserFactory.create() @@ -67,3 +70,87 @@ def test_get_typepollution_add_admin_view(self): url_add = reverse('admin:contribution_typepollution_add') response = self.client.get(url_add) self.assertEqual(response.status_code, 200) + + +class CustomContributionAdminTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.super_user = SuperUserFactory.create() + cls.custom_contribution_type = CustomContributionTypeFactory.create() + + def setUp(self): + self.client.force_login(self.super_user) + + def test_list_customcontributiontype_admin_view(self): + url_list = reverse('admin:contribution_customcontributiontype_changelist') + response = self.client.get(url_list) + self.assertEqual(response.status_code, 200) + + def test_detail_customcontributiontype_admin_view(self): + url_detail = reverse('admin:contribution_customcontributiontype_change', + args=[self.custom_contribution_type.pk]) + response = self.client.get(url_detail) + self.assertEqual(response.status_code, 200) + + def test_get_customcontributiontype_add_admin_view(self): + url_add = reverse('admin:contribution_customcontributiontype_add') + response = self.client.get(url_add) + self.assertEqual(response.status_code, 200) + + +class CustomContributionTypeFieldAdminTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.super_user = SuperUserFactory.create() + cls.custom_contribution_type = CustomContributionTypeFactory.create() + cls.field = CustomContributionTypeFieldFactory.create(custom_type=cls.custom_contribution_type) + + def setUp(self): + self.client.force_login(self.super_user) + + def test_list_customcontributiontypefield_admin_view(self): + url_list = reverse('admin:contribution_customcontributiontypefield_changelist') + response = self.client.get(url_list) + self.assertEqual(response.status_code, 200) + + def test_detail_customcontributiontypefield_admin_view(self): + """ Detail view of a custom type field in admin (extra config) """ + url_detail = reverse('admin:contribution_customcontributiontypefield_change', + args=[self.field.pk]) + response = self.client.get(url_detail) + self.assertEqual(response.status_code, 200) + + def test_get_customcontributiontypefield_add_admin_view(self): + """ Unable to add custom type field directly by admin """ + url_add = reverse('admin:contribution_customcontributiontypefield_add') + response = self.client.get(url_add) + self.assertEqual(response.status_code, 403) + + +class CustomContributionAdminTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.super_user = SuperUserFactory.create() + cls.simple_contrib = CustomContributionFactory(geom='SRID=2154;POINT(700000 6600000)') + cls.contrib_with_attachments = CustomContributionFactory(geom='SRID=2154;POINT(700000 6600000)') + AttachmentFactory.create_batch(2, content_object=cls.contrib_with_attachments) + + def setUp(self): + self.client.force_login(self.super_user) + + def test_list_custom_contribution_admin_view(self): + url_list = reverse('admin:contribution_customcontribution_changelist') + response = self.client.get(url_list) + self.assertEqual(response.status_code, 200) + + def test_detail_custom_contribution_admin_view(self): + url_detail = reverse('admin:contribution_customcontribution_change', + args=[self.contrib_with_attachments.pk]) + response = self.client.get(url_detail) + self.assertEqual(response.status_code, 200) + + def test_add_custom_contribution_admin_view(self): + """ Unable to add custom type field directly by admin """ + url_add = reverse('admin:contribution_customcontribution_add') + response = self.client.get(url_add) + self.assertEqual(response.status_code, 200) diff --git a/georiviere/contribution/tests/test_forms.py b/georiviere/contribution/tests/test_forms.py index 75acbcd8..e619bfa8 100644 --- a/georiviere/contribution/tests/test_forms.py +++ b/georiviere/contribution/tests/test_forms.py @@ -1,8 +1,27 @@ from django.test import TestCase +from django_jsonform.forms.fields import JSONFormField from georiviere.tests.factories import UserAllPermsFactory + +from ...observations.tests.factories import StationFactory +from ..forms import ( + ContributionForm, + CustomContributionFieldForm, + CustomContributionFieldInlineForm, + CustomContributionForm, +) from . import factories -from ..forms import ContributionForm +from .factories import ( + CustomContributionFactory, + CustomContributionTypeBooleanFieldFactory, + CustomContributionTypeDateFieldFactory, + CustomContributionTypeDatetimeFieldFactory, + CustomContributionTypeFactory, + CustomContributionTypeFieldFactory, + CustomContributionTypeFloatFieldFactory, + CustomContributionTypeIntegerFieldFactory, CustomContributionTypeStringFieldFactory, + CustomContributionTypeTextFieldFactory, +) class ContributionFormTestCase(TestCase): @@ -16,8 +35,101 @@ def setUpTestData(cls): def test_contribution(self): self.client.force_login(self.user) - contribution_form = ContributionForm(user=self.user, instance=self.quantity.contribution, - data={"geom": self.quantity.contribution.geom, - "email_author": self.quantity.contribution.email_author}, - can_delete=False) + contribution_form = ContributionForm( + user=self.user, + instance=self.quantity.contribution, + data={ + "geom": self.quantity.contribution.geom, + "email_author": self.quantity.contribution.email_author, + }, + can_delete=False, + ) self.assertEqual(True, contribution_form.is_valid()) + + +class CustomContributionFormTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.custom_type = CustomContributionTypeFactory() + cls.custom_type_with_station = CustomContributionTypeFactory() + cls.custom_type_with_station.stations.add(StationFactory()) + cls.contribution = CustomContributionFactory(custom_type=cls.custom_type) + cls.contribution_with_station = CustomContributionFactory( + custom_type=cls.custom_type_with_station + ) + + def test_station_field_disabled_by_default(self): + form = CustomContributionForm(instance=self.contribution) + self.assertTrue(form.fields["station"].disabled) + + def test_station_field_enabled_when_station_exists(self): + form = CustomContributionForm(instance=self.contribution_with_station) + self.assertFalse(form.fields["station"].disabled) + + def test_station_field_required_when_station_exists(self): + form = CustomContributionForm(instance=self.contribution_with_station) + self.assertTrue(form.fields["station"].required) + + def test_data_field_is_jsonformfield_when_instance_exists(self): + form = CustomContributionForm(instance=self.contribution) + self.assertIsInstance(form.fields["data"], JSONFormField) + + def test_data_field_schema_is_correct_when_instance_exists(self): + form = CustomContributionForm(instance=self.contribution) + self.assertEqual( + form.fields["data"].widget.schema, + self.contribution.custom_type.get_json_schema_form(), + ) + + +class CustomContributionFieldInlineFormTest(TestCase): + def setUp(self): + self.field = CustomContributionTypeFieldFactory() + + def test_value_type_field_disabled_when_instance_exists(self): + form = CustomContributionFieldInlineForm(instance=self.field) + self.assertTrue(form.fields["value_type"].disabled) + + def test_value_type_field_help_text_when_instance_exists(self): + form = CustomContributionFieldInlineForm(instance=self.field) + self.assertEqual( + form.fields["value_type"].help_text, + "You can't change value type after creation. Delete and/or create another one.", + ) + + +class CustomContributionFieldFormTest(TestCase): + def test_schema_string(self): + field = CustomContributionTypeStringFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) + + def test_schema_text(self): + field = CustomContributionTypeTextFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) + + def test_schema_integer(self): + field = CustomContributionTypeIntegerFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) + + def test_schema_number(self): + field = CustomContributionTypeFloatFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) + + def test_schema_boolean(self): + field = CustomContributionTypeBooleanFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) + + def test_schema_date(self): + field = CustomContributionTypeDateFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) + + def test_schema_datetime(self): + field = CustomContributionTypeDatetimeFieldFactory() + form = CustomContributionFieldForm(instance=field) + self.assertIsInstance(form.fields["customization"], JSONFormField) diff --git a/georiviere/contribution/tests/test_managers.py b/georiviere/contribution/tests/test_managers.py new file mode 100644 index 00000000..3c3b6493 --- /dev/null +++ b/georiviere/contribution/tests/test_managers.py @@ -0,0 +1,80 @@ +import datetime + +from django.test import TestCase +from pytz import UTC + +from georiviere.contribution.models import ( + CustomContribution, + CustomContributionTypeField, +) +from georiviere.contribution.tests.factories import ( + CustomContributionFactory, + CustomContributionTypeFactory, + CustomContributionTypeFieldFactory, +) + + +class CustomContributionManagerTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.custom_contrib_type = CustomContributionTypeFactory() + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.STRING, + label="Field string", + ) + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.BOOLEAN, + label="Field boolean", + ) + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.INTEGER, + label="Field integer", + ) + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.FLOAT, + label="Field float", + ) + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.TEXT, + label="Field text", + ) + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.DATE, + label="Field date", + ) + CustomContributionTypeFieldFactory( + custom_type=cls.custom_contrib_type, + value_type=CustomContributionTypeField.FieldTypeChoices.DATETIME, + label="Field datetime", + ) + CustomContributionFactory( + custom_type=cls.custom_contrib_type, + data={ + "field_string": "string", + "field_boolean": True, + "field_integer": 42, + "field_float": 42.42, + "field_text": "text", + "field_date": "2020-01-01", + "field_datetime": "2020-01-01T00:00:00Z", + }, + ) + + def test_annotated_queryset(self): + qs = CustomContribution.objects.with_type_values(self.custom_contrib_type) + instance = qs.first() + self.assertEqual(instance.field_string, "string") + self.assertEqual(instance.field_boolean, True) + self.assertEqual(instance.field_integer, 42) + self.assertEqual(instance.field_float, 42.42) + self.assertEqual(instance.field_text, "text") + self.assertEqual(instance.field_date, datetime.date(2020, 1, 1)) + self.assertEqual( + instance.field_datetime, datetime.datetime(2020, 1, 1, 0, 0, tzinfo=UTC) + ) diff --git a/georiviere/contribution/tests/test_models.py b/georiviere/contribution/tests/test_models.py index ae75692b..adaa74d3 100644 --- a/georiviere/contribution/tests/test_models.py +++ b/georiviere/contribution/tests/test_models.py @@ -1,29 +1,50 @@ from django.core import mail from django.test import override_settings, TestCase -from .factories import (ContributionFactory, ContributionPotentialDamageFactory, ContributionQualityFactory, - ContributionQuantityFactory, ContributionFaunaFloraFactory, - ContributionLandscapeElementsFactory, SeverityTypeTypeFactory, LandingTypeFactory, - JamTypeFactory, DiseaseTypeFactory, DeadSpeciesFactory, InvasiveSpeciesFactory, - HeritageSpeciesFactory, HeritageObservationFactory, FishSpeciesFactory, NaturePollutionFactory, - TypePollutionFactory, ContributionStatusFactory) +from .factories import ( + ContributionFactory, + ContributionPotentialDamageFactory, + ContributionQualityFactory, + ContributionQuantityFactory, + ContributionFaunaFloraFactory, + ContributionLandscapeElementsFactory, + SeverityTypeTypeFactory, + LandingTypeFactory, + JamTypeFactory, + DiseaseTypeFactory, + DeadSpeciesFactory, + InvasiveSpeciesFactory, + HeritageSpeciesFactory, + HeritageObservationFactory, + FishSpeciesFactory, + NaturePollutionFactory, + TypePollutionFactory, + ContributionStatusFactory, + CustomContributionTypeFactory, + CustomContributionTypeFieldFactory, +) +from ..models import CustomContributionTypeField -@override_settings(MANAGERS=[("Fake", "fake@fake.fake"), ]) +@override_settings( + MANAGERS=[ + ("Fake", "fake@fake.fake"), + ] +) class ContributionMetaTest(TestCase): """Test for Contribution model""" @override_settings(MANAGERS=["fake@fake.fake"]) def test_contribution_try_send_report_fail(self): self.assertEqual(len(mail.outbox), 0) - contribution = ContributionFactory(email_author='mail.mail@mail') + contribution = ContributionFactory(email_author="mail.mail@mail") self.assertEqual(str(contribution), "mail.mail@mail") self.assertEqual(len(mail.outbox), 0) def test_contribution_str(self): ContributionStatusFactory(label="Informé") self.assertEqual(len(mail.outbox), 0) - contribution = ContributionFactory(email_author='mail.mail@mail') + contribution = ContributionFactory(email_author="mail.mail@mail") self.assertEqual(str(contribution), "mail.mail@mail") self.assertEqual(contribution.category, "No category") self.assertEqual(len(mail.outbox), 1) @@ -31,11 +52,16 @@ def test_contribution_str(self): def test_potentialdamage_str(self): self.assertEqual(len(mail.outbox), 0) potential_damage = ContributionPotentialDamageFactory(type=2) - self.assertEqual(str(potential_damage), "Contribution Potential Damage Excessive cutting of riparian forest") + self.assertEqual( + str(potential_damage), + "Contribution Potential Damage Excessive cutting of riparian forest", + ) contribution = potential_damage.contribution - self.assertEqual(str(contribution), - f"{contribution.email_author} " - f"Contribution Potential Damage Excessive cutting of riparian forest") + self.assertEqual( + str(contribution), + f"{contribution.email_author} " + f"Contribution Potential Damage Excessive cutting of riparian forest", + ) self.assertEqual(contribution.category, potential_damage) self.assertEqual(len(mail.outbox), 1) @@ -44,20 +70,25 @@ def test_quality_str(self): quality = ContributionQualityFactory(type=2) self.assertEqual(str(quality), "Contribution Quality Pollution") contribution = quality.contribution - self.assertEqual(str(contribution), - f"{contribution.email_author} " - f"Contribution Quality Pollution") + self.assertEqual( + str(contribution), + f"{contribution.email_author} " f"Contribution Quality Pollution", + ) self.assertEqual(contribution.category, quality) self.assertEqual(len(mail.outbox), 1) def test_quantity_str(self): self.assertEqual(len(mail.outbox), 0) quantity = ContributionQuantityFactory(type=2) - self.assertEqual(str(quantity), "Contribution Quantity In the process of drying out") + self.assertEqual( + str(quantity), "Contribution Quantity In the process of drying out" + ) contribution = quantity.contribution - self.assertEqual(str(contribution), - f"{contribution.email_author} " - f"Contribution Quantity In the process of drying out") + self.assertEqual( + str(contribution), + f"{contribution.email_author} " + f"Contribution Quantity In the process of drying out", + ) self.assertEqual(contribution.category, quantity) self.assertEqual(len(mail.outbox), 1) @@ -66,20 +97,25 @@ def test_fauna_flora_str(self): fauna_flora = ContributionFaunaFloraFactory(type=2) self.assertEqual(str(fauna_flora), "Contribution Fauna-Flora Heritage species") contribution = fauna_flora.contribution - self.assertEqual(str(contribution), - f"{contribution.email_author} " - f"Contribution Fauna-Flora Heritage species") + self.assertEqual( + str(contribution), + f"{contribution.email_author} " + f"Contribution Fauna-Flora Heritage species", + ) self.assertEqual(contribution.category, fauna_flora) self.assertEqual(len(mail.outbox), 1) def test_landscape_elements_str(self): self.assertEqual(len(mail.outbox), 0) landscape_elements = ContributionLandscapeElementsFactory(type=2) - self.assertEqual(str(landscape_elements), "Contribution Landscape Element Fountain") + self.assertEqual( + str(landscape_elements), "Contribution Landscape Element Fountain" + ) contribution = landscape_elements.contribution - self.assertEqual(str(contribution), - f"{contribution.email_author} " - f"Contribution Landscape Element Fountain") + self.assertEqual( + str(contribution), + f"{contribution.email_author} " f"Contribution Landscape Element Fountain", + ) self.assertEqual(contribution.category, landscape_elements) self.assertEqual(len(mail.outbox), 1) @@ -88,13 +124,17 @@ def test_severitytype_str(self): self.assertEqual(str(severity_type), "Severity type 1") def test_contribution_category_display(self): - contribution = ContributionFactory(email_author='mail.mail@mail') - self.assertEqual(str(contribution.category_display), - f'No category') + contribution = ContributionFactory(email_author="mail.mail@mail") + self.assertEqual( + str(contribution.category_display), + f'No category', + ) contribution.published = True contribution.save() - self.assertEqual(contribution.category_display, - f' No category') + self.assertEqual( + contribution.category_display, + f' No category', + ) class ContributionPotentialDamageTest(TestCase): @@ -113,7 +153,7 @@ def test_diseasetype_str(self): self.assertEqual(str(disease_type), "Disease type 1") def test_deadspecies_str(self): - dead_species = DeadSpeciesFactory(label='Dead species 1') + dead_species = DeadSpeciesFactory(label="Dead species 1") self.assertEqual(str(dead_species), "Dead species 1") @@ -129,7 +169,9 @@ def test_heritagespecies_str(self): self.assertEqual(str(heritage_species), "Heritage species 1") def test_heritageobservations_str(self): - heritage_observation = HeritageObservationFactory(label="Heritage observation 1") + heritage_observation = HeritageObservationFactory( + label="Heritage observation 1" + ) self.assertEqual(str(heritage_observation), "Heritage observation 1") def test_fishspecies_str(self): @@ -155,3 +197,220 @@ class ContributionStatusTest(TestCase): def test_status_str(self): nature_pollution = ContributionStatusFactory(label="Contribution status 1") self.assertEqual(str(nature_pollution), "Contribution status 1") + + +class CustomContributionTypeTestCase(TestCase): + def test_str(self): + """CustomContributionType should return its label as string representation""" + custom_contribution_type = CustomContributionTypeFactory.create() + self.assertEqual(str(custom_contribution_type), custom_contribution_type.label) + + def test_json_schema_empty(self): + custom_contribution_type = CustomContributionTypeFactory.create() + self.assertDictEqual( + custom_contribution_type.json_schema_form, + {"type": "object", "properties": {}, "required": []}, + ) + + def test_json_schema_required_field(self): + """CustomContributionType JSON schema should include required fields in dedicated key""" + custom_contribution_type = CustomContributionTypeFactory.create() + required_field = CustomContributionTypeFieldFactory.create( + custom_type=custom_contribution_type, required=True + ) + optional_field = CustomContributionTypeFieldFactory.create( + custom_type=custom_contribution_type, required=False + ) + json_schema = custom_contribution_type.json_schema_form + self.assertIn(required_field.key, json_schema["required"]) + self.assertNotIn(optional_field.key, json_schema["required"]) + + +class CustomContributionTypeFieldTestCase(TestCase): + def test_str(self): + """CustomContributionTypeField should return its label / value as string representation""" + field = CustomContributionTypeFieldFactory.create() + self.assertEqual(str(field), f"{field.label}: ({field.value_type})") + + def test_specific_slug(self): + """CustomContributionTypeField generated key is its slugged label, but '-' should be replaced by '_'""" + field = CustomContributionTypeFieldFactory.create(label="Field label") + self.assertEqual(field.key, "field_label") + + def test_type_string_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.STRING + ) + self.assertDictEqual( + field.get_field_schema(), + {"type": "string", "helpText": "", "title": field.label}, + ) + + def test_type_string_schema_with_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.STRING, + help_text="Field help text", + customization={ + "choices": ["choice1", "choice2"], + "placeholder": "placeholder", + "minLength": 1, + "maxLength": 10, + }, + ) + self.assertDictEqual( + field.get_field_schema(), + { + "helpText": field.help_text, + "title": field.label, + "type": "string", + **field.customization, + }, + ) + + def test_type_text_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.TEXT + ) + self.assertDictEqual( + field.get_field_schema(), + { + "type": "string", + "helpText": "", + "title": field.label, + "widget": "textarea", + }, + ) + + def test_type_text_schema_with_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.TEXT, + help_text="Field help text", + customization={ + "widget": "textarea", + "placeholder": "placeholder", + "minLength": 1, + "maxLength": 10, + }, + ) + self.assertDictEqual( + field.get_field_schema(), + { + "helpText": field.help_text, + "title": field.label, + "type": "string", + "widget": "textarea", + **field.customization, + }, + ) + + def test_type_integer_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.INTEGER + ) + self.assertDictEqual( + field.get_field_schema(), + {"type": "integer", "helpText": "", "title": field.label}, + ) + + def test_type_integer_schema_with_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.INTEGER, + help_text="Field help text", + customization={"minimum": 0, "maximum": 10}, + ) + self.assertDictEqual( + field.get_field_schema(), + { + "helpText": field.help_text, + "title": field.label, + "type": "integer", + **field.customization, + }, + ) + + def test_type_float_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.FLOAT + ) + self.assertDictEqual( + field.get_field_schema(), + {"type": "number", "helpText": "", "title": field.label}, + ) + + def test_type_float_schema_with_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.FLOAT, + help_text="Field help text", + customization={"minimum": 0, "maximum": 10}, + ) + self.assertDictEqual( + field.get_field_schema(), + { + "helpText": field.help_text, + "title": field.label, + "type": "number", + **field.customization, + }, + ) + + def test_type_boolean_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.BOOLEAN + ) + self.assertDictEqual( + field.get_field_schema(), + {"type": "boolean", "helpText": "", "title": field.label}, + ) + + def test_type_boolean_schema_with_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.BOOLEAN, + help_text="Field help text", + customization={ + "widget": "radio", + "choices": [ + {"title": "Yes", "value": True}, + {"title": "No", "value": False}, + ], + }, + ) + self.assertDictEqual( + field.get_field_schema(), + { + "helpText": field.help_text, + "title": field.label, + "type": "boolean", + **field.customization, + }, + ) + + def test_type_date_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.DATE + ) + self.assertDictEqual( + field.get_field_schema(), + {"type": "string", "format": "date", "helpText": "", "title": field.label}, + ) + + def test_type_datetime_schema_without_customization(self): + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.DATETIME + ) + self.assertDictEqual( + field.get_field_schema(), + {"type": "string", "format": "date-time", "helpText": "", "title": field.label}, + ) + + def test_dropped_choices_empty(self): + """ If choices is defined but empty, it should not be included in the schema """ + field = CustomContributionTypeFieldFactory.create( + value_type=CustomContributionTypeField.FieldTypeChoices.STRING, + customization={ + "choices": [], + }, + ) + self.assertNotIn( + 'choices', + field.get_field_schema(), + ) diff --git a/georiviere/description/locale/en/LC_MESSAGES/django.po b/georiviere/description/locale/en/LC_MESSAGES/django.po index c5832267..e342172d 100644 --- a/georiviere/description/locale/en/LC_MESSAGES/django.po +++ b/georiviere/description/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/description/locale/fr/LC_MESSAGES/django.po b/georiviere/description/locale/fr/LC_MESSAGES/django.po index 7fc0a941..7f868a5a 100644 --- a/georiviere/description/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/description/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:04+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 14:20+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/description/sql/post_10_triggers.sql b/georiviere/description/sql/post_10_triggers.sql new file mode 100644 index 00000000..958dbec4 --- /dev/null +++ b/georiviere/description/sql/post_10_triggers.sql @@ -0,0 +1,15 @@ +CREATE TRIGGER description_morphology_10_elevation +BEFORE INSERT OR UPDATE OF geom ON description_morphology +FOR EACH ROW EXECUTE PROCEDURE elevation(); + +CREATE TRIGGER description_status_10_elevation +BEFORE INSERT OR UPDATE OF geom ON description_status +FOR EACH ROW EXECUTE PROCEDURE elevation(); + +CREATE TRIGGER description_land_10_elevation +BEFORE INSERT OR UPDATE OF geom ON description_land +FOR EACH ROW EXECUTE PROCEDURE elevation(); + +CREATE TRIGGER description_usage_10_elevation +BEFORE INSERT OR UPDATE OF geom ON description_usage +FOR EACH ROW EXECUTE PROCEDURE elevation(); \ No newline at end of file diff --git a/georiviere/description/sql/post_20_defaults.sql b/georiviere/description/sql/post_20_defaults.sql new file mode 100644 index 00000000..5128eb1b --- /dev/null +++ b/georiviere/description/sql/post_20_defaults.sql @@ -0,0 +1,3 @@ +ALTER TABLE description_morphology ALTER COLUMN full_edge_height SET DEFAULT 0.0; +ALTER TABLE description_morphology ALTER COLUMN full_edge_width SET DEFAULT 0.0; +ALTER TABLE description_morphology ALTER COLUMN description SET DEFAULT ''; diff --git a/georiviere/description/templates/description/description_detail_fragment.html b/georiviere/description/templates/description/description_detail_fragment.html index 38c0dc95..f7013bc6 100644 --- a/georiviere/description/templates/description/description_detail_fragment.html +++ b/georiviere/description/templates/description/description_detail_fragment.html @@ -45,9 +45,9 @@

{% trans "Description" %}

{% if modelname != "status" %} - {% trans "Status" %} + {% trans "Statuses" %} - {% if object.status %} + {% if object.statuses %} {% valuelist object.status field="status_types" %} {% else %} {% valuelist object.statuses field="status_types" %} @@ -58,10 +58,10 @@

{% trans "Description" %}

{% if modelname != "morphology" %} - {% trans "Morphology" %} + {% trans "Morphologies" %} - {% if object.morphology %} - {% valuelist object.morphology field="name" %} + {% if object.morphologies %} + {% valuelist object.morphologies field="name" %} {% else %} {% valuelist object.morphologies field="name" %} {% endif %} diff --git a/georiviere/description/tests/test_views.py b/georiviere/description/tests/test_views.py index 86940023..5e2b4f54 100644 --- a/georiviere/description/tests/test_views.py +++ b/georiviere/description/tests/test_views.py @@ -105,8 +105,8 @@ class SatusViewTestCase(TopologyTestCase): def get_expected_json_attrs(self): return { 'id': self.obj.pk, - 'date_update': '2020-03-17T00:00:00Z', - 'date_insert': '2020-03-17T00:00:00Z', + 'date_update': self.obj.date_update.isoformat().replace('+00:00', 'Z'), + 'date_insert': self.obj.date_insert.isoformat().replace('+00:00', 'Z'), 'description': '', 'length': self.obj.length, 'geom_3d': self.obj.geom_3d.ewkt, @@ -147,8 +147,8 @@ class MorphologyViewTestCase(TopologyTestCase): def get_expected_json_attrs(self): return { 'id': self.obj.pk, - 'date_update': '2020-03-17T00:00:00Z', - 'date_insert': '2020-03-17T00:00:00Z', + 'date_update': self.obj.date_update.isoformat().replace('+00:00', 'Z'), + 'date_insert': self.obj.date_insert.isoformat().replace('+00:00', 'Z'), 'description': '', 'bank_state_left': None, 'bank_state_right': None, diff --git a/georiviere/finances_administration/locale/en/LC_MESSAGES/django.po b/georiviere/finances_administration/locale/en/LC_MESSAGES/django.po index 3eebe7f1..4e253175 100644 --- a/georiviere/finances_administration/locale/en/LC_MESSAGES/django.po +++ b/georiviere/finances_administration/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/finances_administration/locale/fr/LC_MESSAGES/django.po b/georiviere/finances_administration/locale/fr/LC_MESSAGES/django.po index 88c02ee3..5a46fd1d 100644 --- a/georiviere/finances_administration/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/finances_administration/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:04+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 14:22+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" @@ -38,7 +38,7 @@ msgid "Revised budget" msgstr "Budget révisé" msgid "Revised budget of this phase" -msgstr "" +msgstr "Budget révisé pour cette phase" msgid "Actual budget" msgstr "Budget réel" @@ -282,6 +282,3 @@ msgstr "Ajouter un dossier administratif" msgid "Edit phase for" msgstr "Éditer les phases pour" - -#~ msgid "Create operation on" -#~ msgstr "Créer une opération sur" diff --git a/georiviere/flatpages/forms.py b/georiviere/flatpages/forms.py index e1a52572..a4526178 100644 --- a/georiviere/flatpages/forms.py +++ b/georiviere/flatpages/forms.py @@ -23,7 +23,7 @@ def __init__(self, *args, **kwargs): self.request = kwargs.pop('request', None) super().__init__(*args, **kwargs) for form in self.forms: - rooturl = self.request.build_absolute_uri('/') + rooturl = self.request.build_absolute_uri('/')[:-1] form.fields['picture'].widget = AdminFileWidget(attrs={'rooturl': rooturl}) diff --git a/georiviere/flatpages/locale/en/LC_MESSAGES/django.po b/georiviere/flatpages/locale/en/LC_MESSAGES/django.po index 8abcfefa..514843fa 100644 --- a/georiviere/flatpages/locale/en/LC_MESSAGES/django.po +++ b/georiviere/flatpages/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/flatpages/locale/fr/LC_MESSAGES/django.po b/georiviere/flatpages/locale/fr/LC_MESSAGES/django.po index 727a87e7..e2ea1601 100644 --- a/georiviere/flatpages/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/flatpages/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:04+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/flatpages/static/js/tinymce.js b/georiviere/flatpages/static/js/tinymce.js index c118b787..2fff4cf1 100644 --- a/georiviere/flatpages/static/js/tinymce.js +++ b/georiviere/flatpages/static/js/tinymce.js @@ -5,13 +5,13 @@ tinyMCE.init({ relative_urls : false, remove_script_host : false, plugins: [ - 'autolink lists link image', + 'autolink lists link image code', 'media table paste wordcount', 'visualblocks preview anchor' ], menubar: false, image_caption: true, - toolbar: 'undo redo | styleselect | blockquote | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image media | indent outdent | visualblocks', + toolbar: 'undo redo | styleselect | blockquote | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image media | indent outdent | visualblocks | code', formats: { informationFormat: { block: 'div', classes: 'information' diff --git a/georiviere/flatpages/templates/flatpages/clearable_file_input.html b/georiviere/flatpages/templates/flatpages/clearable_file_input.html index 4d74f224..ca9a4cbf 100644 --- a/georiviere/flatpages/templates/flatpages/clearable_file_input.html +++ b/georiviere/flatpages/templates/flatpages/clearable_file_input.html @@ -1,4 +1,4 @@ -{% if widget.is_initial %}

{{ widget.initial_text }}: {{ widget.attrs.rooturl }}{{ widget.value }}{% if not widget.required %} +{% if widget.is_initial %}

{{ widget.initial_text }}: {{ widget.attrs.rooturl }}{{ widget.value.url }}{% if not widget.required %} {% endif %}
diff --git a/georiviere/knowledge/locale/en/LC_MESSAGES/django.po b/georiviere/knowledge/locale/en/LC_MESSAGES/django.po index 82d091ac..94354787 100644 --- a/georiviere/knowledge/locale/en/LC_MESSAGES/django.po +++ b/georiviere/knowledge/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/knowledge/locale/fr/LC_MESSAGES/django.po b/georiviere/knowledge/locale/fr/LC_MESSAGES/django.po index f1c25eb9..11ea9087 100644 --- a/georiviere/knowledge/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/knowledge/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:03+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:08+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/knowledge/tests/test_views.py b/georiviere/knowledge/tests/test_views.py index 87cb717d..33248ff7 100644 --- a/georiviere/knowledge/tests/test_views.py +++ b/georiviere/knowledge/tests/test_views.py @@ -167,7 +167,7 @@ def test_detail_number_queries(self): self.login() station = self.modelfactory.create() - with self.assertNumQueries(45): + with self.assertNumQueries(47): self.client.get(station.get_detail_url()) diff --git a/georiviere/main/locale/en/LC_MESSAGES/django.po b/georiviere/main/locale/en/LC_MESSAGES/django.po index f8e433c3..1a6a058a 100644 --- a/georiviere/main/locale/en/LC_MESSAGES/django.po +++ b/georiviere/main/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/main/locale/fr/LC_MESSAGES/django.po b/georiviere/main/locale/fr/LC_MESSAGES/django.po index 5ceaa9c9..dbb6e8ec 100644 --- a/georiviere/main/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/main/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:05+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:31+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/main/management/commands/migrate.py b/georiviere/main/management/commands/migrate.py index 218fd87a..9ebcd8d4 100644 --- a/georiviere/main/management/commands/migrate.py +++ b/georiviere/main/management/commands/migrate.py @@ -20,13 +20,13 @@ def check_srid_has_meter_unit(): class Command(BaseCommand): def handle(self, *args, **options): check_srid_has_meter_unit() - # set_search_path() for app in apps.get_app_configs(): - # move_models_to_schemas(app) load_sql_files(app, 'pre') super().handle(*args, **options) for app in apps.get_app_configs(): - # move_models_to_schemas(app) - load_sql_files(app, 'post') + try: + load_sql_files(app, 'post') + except Exception: # NOQA + pass call_command('sync_translation_fields', '--noinput') call_command('update_translation_fields') diff --git a/georiviere/main/signals.py b/georiviere/main/signals.py index f10be4a8..758e1bf2 100644 --- a/georiviere/main/signals.py +++ b/georiviere/main/signals.py @@ -45,7 +45,7 @@ def save_objects_generate_distance_to_source(sender, instance, **kwargs): ).exclude(stream__in=streams).delete() elif hasattr(instance, 'topology'): - stream = annotate_distance_to_source(Stream.objects.all(), instance).get(pk=instance.topology.stream.pk) + stream = annotate_distance_to_source(Stream.objects.all(), instance).get(pk=instance.topology.stream_id) DistanceToSource.objects.update_or_create( object_id=instance.pk, content_type=ContentType.objects.get_for_model(instance._meta.model), diff --git a/georiviere/main/sql/post_10_functions.sql b/georiviere/main/sql/post_10_functions.sql new file mode 100644 index 00000000..c11aba35 --- /dev/null +++ b/georiviere/main/sql/post_10_functions.sql @@ -0,0 +1,16 @@ +CREATE FUNCTION elevation() RETURNS trigger SECURITY DEFINER AS $$ +DECLARE + elevation elevation_infos; +BEGIN + SELECT * FROM ft_elevation_infos(NEW.geom, {{ ALTIMETRIC_PROFILE_STEP }}) INTO elevation; + -- Update path geometry + NEW.geom_3d := elevation.draped; + NEW.length := ST_3DLength(elevation.draped); + NEW.slope := elevation.slope; + NEW.min_elevation := elevation.min_elevation; + NEW.max_elevation := elevation.max_elevation; + NEW.ascent := elevation.positive_gain; + NEW.descent := elevation.negative_gain; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/georiviere/main/sql/pre_10_cleanup.sql b/georiviere/main/sql/pre_10_cleanup.sql new file mode 100644 index 00000000..1c034839 --- /dev/null +++ b/georiviere/main/sql/pre_10_cleanup.sql @@ -0,0 +1 @@ +DROP FUNCTION IF EXISTS elevation() CASCADE; \ No newline at end of file diff --git a/georiviere/main/views.py b/georiviere/main/views.py index 23c55cd5..517f88e5 100644 --- a/georiviere/main/views.py +++ b/georiviere/main/views.py @@ -4,26 +4,32 @@ from django.urls import reverse from mapentity.registry import registry from mapentity import views as mapentity_views +import logging -@login_required(login_url='login') +logger = logging.getLogger(__name__) + + +@login_required(login_url="login") def home(request): - last = request.session.get('last_list') # set in MapEntityList + last = request.session.get("last_list") # set in MapEntityList for entity in registry.entities: - if reverse(entity.url_list) == last and request.user.has_perm(entity.model.get_permission_codename('list')): + if reverse(entity.url_list) == last and request.user.has_perm( + entity.model.get_permission_codename("list") + ): return redirect(entity.url_list) for entity in registry.entities: - if entity.menu and request.user.has_perm(entity.model.get_permission_codename('list')): + if entity.menu and request.user.has_perm( + entity.model.get_permission_codename("list") + ): return redirect(entity.url_list) - return redirect('river:river_list') + return redirect("river:river_list") class JSSettings(mapentity_views.JSSettings): def get_context_data(self): dictsettings = super().get_context_data() - dictsettings['map'].update( - snap_distance=settings.SNAP_DISTANCE - ) + dictsettings["map"].update(snap_distance=settings.SNAP_DISTANCE) return dictsettings @@ -32,6 +38,7 @@ class FormsetMixin: WARNING: this will only work for a single formset in form TODO: move this to Mapentity """ + context_name = None formset_class = None @@ -51,8 +58,8 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if self.request.POST: context[self.context_name] = self.formset_class( - self.request.POST, instance=self.object) + self.request.POST, instance=self.object + ) else: - context[self.context_name] = self.formset_class( - instance=self.object) + context[self.context_name] = self.formset_class(instance=self.object) return context diff --git a/georiviere/maintenance/locale/en/LC_MESSAGES/django.po b/georiviere/maintenance/locale/en/LC_MESSAGES/django.po index 07dc4991..7fc23816 100644 --- a/georiviere/maintenance/locale/en/LC_MESSAGES/django.po +++ b/georiviere/maintenance/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/maintenance/locale/fr/LC_MESSAGES/django.po b/georiviere/maintenance/locale/fr/LC_MESSAGES/django.po index a2ff4a4c..a1ec9142 100644 --- a/georiviere/maintenance/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/maintenance/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:05+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:31+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/observations/locale/en/LC_MESSAGES/django.po b/georiviere/observations/locale/en/LC_MESSAGES/django.po index 25ef1c36..b91040dc 100644 --- a/georiviere/observations/locale/en/LC_MESSAGES/django.po +++ b/georiviere/observations/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/observations/locale/fr/LC_MESSAGES/django.po b/georiviere/observations/locale/fr/LC_MESSAGES/django.po index 06c938b7..20d77f13 100644 --- a/georiviere/observations/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/observations/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:05+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:31+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/observations/migrations/0024_auto_20240514_0849.py b/georiviere/observations/migrations/0024_auto_20240514_0849.py new file mode 100644 index 00000000..628231e9 --- /dev/null +++ b/georiviere/observations/migrations/0024_auto_20240514_0849.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.14 on 2024-05-14 08:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0023_auto_20230220_1703'), + ] + + operations = [ + migrations.AlterModelOptions( + name='station', + options={'ordering': ('label', 'pk'), 'verbose_name': 'Station', 'verbose_name_plural': 'Stations'}, + ), + ] diff --git a/georiviere/observations/models.py b/georiviere/observations/models.py index 2302582a..c08d336e 100644 --- a/georiviere/observations/models.py +++ b/georiviere/observations/models.py @@ -89,6 +89,7 @@ class LocalInfluenceChoices(models.IntegerChoices): class Meta: verbose_name = _("Station") verbose_name_plural = _("Stations") + ordering = ('label', 'pk') def __str__(self): return "{1} ({0})".format(self.code, self.label) diff --git a/georiviere/observations/tests/test_views.py b/georiviere/observations/tests/test_views.py index f76965f4..b04468fd 100644 --- a/georiviere/observations/tests/test_views.py +++ b/georiviere/observations/tests/test_views.py @@ -100,5 +100,5 @@ def test_detail_number_queries(self): self.login() station = self.modelfactory.create() - with self.assertNumQueries(50): + with self.assertNumQueries(52): self.client.get(station.get_detail_url()) diff --git a/georiviere/portal/locale/en/LC_MESSAGES/django.po b/georiviere/portal/locale/en/LC_MESSAGES/django.po index 52da707d..e7c3b15a 100644 --- a/georiviere/portal/locale/en/LC_MESSAGES/django.po +++ b/georiviere/portal/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: 2023-09-15 17:40+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -39,6 +39,12 @@ msgstr "" msgid "Portal" msgstr "" +msgid "Stations" +msgstr "" + +msgid "Custom contributions" +msgstr "" + msgid "URL" msgstr "" @@ -87,43 +93,31 @@ msgstr "" msgid "Category is not valid" msgstr "" -msgid "An error occured" +msgid "An error occurred" msgstr "" -msgid "Watershed" +msgid "Password mismatch." msgstr "" -msgid "watersheds" +msgid "Watershed" msgstr "" msgid "City" msgstr "" -msgid "cities" -msgstr "" - msgid "Sensitivity" msgstr "" -msgid "sensitivities" -msgstr "" - msgid "District" msgstr "" -msgid "districts" -msgstr "" - msgid "Contribution" msgstr "" -msgid "contributions" -msgstr "" - msgid "Stream" msgstr "" -msgid "streams" +msgid "Station" msgstr "" msgid "Georiviere : Contribution" diff --git a/georiviere/portal/locale/fr/LC_MESSAGES/django.po b/georiviere/portal/locale/fr/LC_MESSAGES/django.po index 1b6141f2..20f879be 100644 --- a/georiviere/portal/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/portal/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:39+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -39,6 +39,12 @@ msgstr "Fonds de cartes" msgid "Portal" msgstr "Portail" +msgid "Stations" +msgstr "Stations" + +msgid "Custom contributions" +msgstr "Contributions personnalisées" + msgid "URL" msgstr "URL" @@ -87,44 +93,32 @@ msgstr "Portails" msgid "Category is not valid" msgstr "La catégorie n'est pas valide" -msgid "An error occured" -msgstr "Une erreur s'es produite" +msgid "An error occurred" +msgstr "Une erreur s'est produite" + +msgid "Password mismatch." +msgstr "Le mot de passe ne correspond pas." msgid "Watershed" msgstr "Bassin versant" -msgid "watersheds" -msgstr "bassins versants" - msgid "City" msgstr "Ville" -msgid "cities" -msgstr "villes" - msgid "Sensitivity" msgstr "Zone sensible" -msgid "sensitivities" -msgstr "zones sensibles" - msgid "District" msgstr "Secteur" -msgid "districts" -msgstr "secteurs" - msgid "Contribution" msgstr "Contribution" -msgid "contributions" -msgstr "contributions" - msgid "Stream" msgstr "Rivière" -msgid "streams" -msgstr "rivières" +msgid "Station" +msgstr "Station" msgid "Georiviere : Contribution" msgstr "Georiviere : Contribution" diff --git a/georiviere/portal/migrations/0007_auto_20240423_1647.py b/georiviere/portal/migrations/0007_auto_20240423_1647.py new file mode 100644 index 00000000..da5f45d7 --- /dev/null +++ b/georiviere/portal/migrations/0007_auto_20240423_1647.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.14 on 2024-04-23 16:47 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + + +def add_station_map_layer_to_portals(apps, schema_editor): + MapLayer = apps.get_model("portal", "MapLayer") + Portal = apps.get_model("portal", "Portal") + + for portal in Portal.objects.all(): + MapLayer.objects.create( + portal=portal, + layer_type="stations", + label=_("Stations"), + hidden=True, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("portal", "0006_auto_20230915_1756"), + ] + + operations = [ + migrations.RunPython( + add_station_map_layer_to_portals, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/georiviere/portal/migrations/0008_auto_20240429_1233.py b/georiviere/portal/migrations/0008_auto_20240429_1233.py new file mode 100644 index 00000000..cfaf76c7 --- /dev/null +++ b/georiviere/portal/migrations/0008_auto_20240429_1233.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.14 on 2024-04-29 12:33 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + + +def add_custom_contributions_map_layer_to_portals(apps, schema_editor): + MapLayer = apps.get_model("portal", "MapLayer") + Portal = apps.get_model("portal", "Portal") + + for portal in Portal.objects.all(): + MapLayer.objects.create( + portal=portal, + layer_type="contributions-custom", + label=_("Custom contributions"), + hidden=True, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("portal", "0007_auto_20240423_1647"), + ] + + operations = [ + migrations.RunPython( + add_custom_contributions_map_layer_to_portals, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/georiviere/portal/migrations/0009_auto_20240430_1309.py b/georiviere/portal/migrations/0009_auto_20240430_1309.py new file mode 100644 index 00000000..4028b3c5 --- /dev/null +++ b/georiviere/portal/migrations/0009_auto_20240430_1309.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.14 on 2024-04-30 13:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal', '0008_auto_20240429_1233'), + ] + + operations = [ + migrations.AlterModelOptions( + name='mapbaselayer', + options={'ordering': ('order',), 'verbose_name': 'Map base layer', 'verbose_name_plural': 'Map base layers'}, + ), + ] diff --git a/georiviere/portal/models.py b/georiviere/portal/models.py index 01739d9e..69407f51 100644 --- a/georiviere/portal/models.py +++ b/georiviere/portal/models.py @@ -24,7 +24,7 @@ class MapBaseLayer(models.Model): class Meta: verbose_name = _("Map base layer") verbose_name_plural = _("Map base layers") - ordering = ('label',) + ordering = ('order',) unique_together = ('label', 'portal') def __str__(self): diff --git a/georiviere/portal/serializers/contribution.py b/georiviere/portal/serializers/contribution.py index c9f5c1a4..8cd62a6d 100644 --- a/georiviere/portal/serializers/contribution.py +++ b/georiviere/portal/serializers/contribution.py @@ -1,33 +1,46 @@ from copy import deepcopy from rest_framework import serializers +from rest_framework.reverse import reverse from rest_framework_gis import serializers as geo_serializers from django.conf import settings from django.contrib.gis.geos import GEOSGeometry from django.db import transaction from django.db.models import ForeignKey -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext as _ -from georiviere.contribution.schema import (get_contribution_properties, get_contribution_allOf, - get_contribution_json_schema) -from georiviere.contribution.models import (Contribution, ContributionLandscapeElements, ContributionQuality, - ContributionQuantity, ContributionFaunaFlora, ContributionPotentialDamage, - SeverityType) +from georiviere.contribution.schema import ( + get_contribution_properties, + get_contribution_allOf, + get_contribution_json_schema, +) +from georiviere.contribution.models import ( + Contribution, + ContributionLandscapeElements, + ContributionQuality, + ContributionQuantity, + ContributionFaunaFlora, + ContributionPotentialDamage, + SeverityType, + CustomContributionType, + CustomContribution, +) +from georiviere.portal.serializers.mixins import SerializerAPIMixin from georiviere.portal.validators import validate_json_schema_data from georiviere.portal.serializers.main import AttachmentSerializer class ContributionGeojsonSerializer(geo_serializers.GeoFeatureModelSerializer): # Annotated geom field with API_SRID - geometry = geo_serializers.GeometryField(read_only=True, precision=7, source="geom_transformed") + geometry = geo_serializers.GeometryField( + read_only=True, precision=7, source="geom_transformed" + ) category = serializers.SerializerMethodField(read_only=True) class Meta: - geo_field = 'geometry' + geo_field = "geometry" model = Contribution - fields = ( - 'id', 'category', 'geometry' - ) + fields = ("id", "category", "geometry") def get_category(self, obj): return obj.category._meta.verbose_name.title() @@ -43,7 +56,13 @@ class ContributionSerializer(serializers.ModelSerializer): class Meta: model = Contribution fields = ( - 'id', 'properties', 'geom', 'type', 'category', 'description', 'attachments', + "id", + "properties", + "geom", + "type", + "category", + "description", + "attachments", ) def get_category(self, obj): @@ -59,7 +78,7 @@ def validate_properties(self, data): def create(self, validated_data): sid = transaction.savepoint() - msg = '' + msg = "" # Create a contribution depending on the data you get from the portal # The datas should follow the json schema generated in `georiviere/contribution/schema.py` # All the properties are flatten directly in the field properties @@ -67,28 +86,31 @@ def create(self, validated_data): # Check https://github.com/Georiviere/Georiviere-admin/issues/139 # For more informations try: - properties = validated_data.pop('properties') - category = properties.pop('category') - email_author = properties.pop('email_author') - name_author = properties.pop('name_author', '') - first_name_author = properties.pop('first_name_author', '') - date_observation = properties.pop('date_observation') - description = properties.pop('description', '') - severity = properties.pop('severity', '') + properties = validated_data.pop("properties") + category = properties.pop("category") + email_author = properties.pop("email_author") + name_author = properties.pop("name_author", "") + first_name_author = properties.pop("first_name_author", "") + date_observation = properties.pop("date_observation") + description = properties.pop("description", "") + severity = properties.pop("severity", "") severity_instance = False if severity: severity_instance = SeverityType.objects.filter(label=severity) - geom = validated_data.pop('geom') + geom = validated_data.pop("geom") geom = GEOSGeometry(geom, srid=4326) geom = geom.transform(settings.SRID, clone=True) - kwargs_contribution = {'geom': geom, 'email_author': email_author, - 'date_observation': date_observation, - 'portal_id': self.context.get('portal_pk'), - 'name_author': name_author, - 'description': description, - 'first_name_author': first_name_author} + kwargs_contribution = { + "geom": geom, + "email_author": email_author, + "date_observation": date_observation, + "portal_id": self.context.get("portal_pk"), + "name_author": name_author, + "description": description, + "first_name_author": first_name_author, + } if bool(severity_instance): - kwargs_contribution['severity'] = severity_instance.first() + kwargs_contribution["severity"] = severity_instance.first() main_contribution = Contribution.objects.create(**kwargs_contribution) model = None @@ -110,38 +132,40 @@ def create(self, validated_data): msg = _("Category is not valid") raise - type_prop = properties.pop('type') + type_prop = properties.pop("type") # All the categories have a field type. We get all choices available and check # if the type exists for this category types = {v: k for k, v in model.TypeChoice.choices} for key, prop in properties.items(): if isinstance(model._meta.get_field(key), ForeignKey): - properties[key] = model._meta.get_field(key).related_model.objects.get(label=prop) + properties[key] = model._meta.get_field( + key + ).related_model.objects.get(label=prop) # If the type doesn't exist for this category, the error is catched, a validationerror occur. # If it exists, the contribution of the category in properties is created. - model.objects.create(contribution=main_contribution, - type=types[type_prop], - **properties) + model.objects.create( + contribution=main_contribution, type=types[type_prop], **properties + ) transaction.savepoint_commit(sid) except Exception as e: transaction.savepoint_rollback(sid) if not msg: - msg = f'{e.__class__.__name__} {e}' - raise serializers.ValidationError({"Error": msg or _("An error occured")}) + msg = f"{e.__class__.__name__} {e}" + raise serializers.ValidationError({"Error": msg or _("An error occurred")}) return main_contribution # Serializer for the contribution json schema's following the jsonschema reference : # https://json-schema.org/understanding-json-schema/reference/conditionals.html class ContributionSchemaSerializer(serializers.Serializer): - type = serializers.CharField(default='object') - required = serializers.SerializerMethodField(method_name='get_required') + type = serializers.CharField(default="object") + required = serializers.SerializerMethodField(method_name="get_required") properties = serializers.SerializerMethodField() allOf = serializers.SerializerMethodField() def get_required(self, obj): # TODO: Loop on fields to get required - return ['email_author', 'date_observation', 'category'] + return ["email_author", "date_observation", "category"] def get_properties(self, obj): return get_contribution_properties() @@ -150,7 +174,140 @@ def get_allOf(self, obj): return get_contribution_allOf() class Meta: - geo_field = 'geom' + geo_field = "geom" + fields = ("type", "required", "properties", "allOf") + + +class CustomContributionTypeSerializer(serializers.ModelSerializer): + password_required = serializers.SerializerMethodField() + + def get_password_required(self, obj): + return bool(obj.password) + + class Meta: + model = CustomContributionType fields = ( - 'type', 'required', 'properties', 'allOf' + "id", + "label", + "description", + "json_schema_form", + "stations", + "password_required", + ) + + +class CustomContributionSerializer(SerializerAPIMixin, serializers.ModelSerializer): + geometry = geo_serializers.GeometryField(read_only=True, precision=7) + geom = geo_serializers.GeometryField(write_only=True) + contributed_at = serializers.DateTimeField(required=True) + json_url = serializers.SerializerMethodField() + geojson_url = serializers.SerializerMethodField() + attachments = AttachmentSerializer(many=True, read_only=True) + + def get_json_url(self, obj): + return reverse( + "api_portal:custom-contributions-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="json"), + ) + + def get_geojson_url(self, obj): + return reverse( + "api_portal:custom-contributions-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="geojson"), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + custom_type = self.context.get("custom_type", None) + if not custom_type: + return + + # add and customize fields from json schema + schema = custom_type.get_json_schema_form() + for key in schema.get("properties", {}).keys(): + field = schema.get("properties", {}).get(key) + output_field = serializers.CharField + if field.get("type") == "number": + output_field = serializers.FloatField + elif field.get("type") == "integer": + output_field = serializers.IntegerField + elif field.get("type") == "boolean": + output_field = serializers.BooleanField + elif field.get("type") == "string" and field.get("format") == "date": + output_field = serializers.DateField + elif field.get("type") == "string" and field.get("format") == "date-time": + output_field = serializers.DateTimeField + # make field required or not + self.fields[key] = output_field( + label=field.get("title"), required=key in schema.get("required", []) + ) + + # station is required if defined at custom_type level. Geom is not required because replace by station geom + if custom_type.stations.exists(): + self.fields["station"] = serializers.PrimaryKeyRelatedField( + queryset=custom_type.stations.all(), required=True + ) + self.fields["geom"].required = False + + # add password field if required + if custom_type.password: + self.fields["password"] = serializers.CharField( + required=True, write_only=True + ) + + def create(self, validated_data): + validated_data.pop("password", None) + custom_type = self.context.get("custom_type") + # add and customize fields from json schema + schema = custom_type.get_json_schema_form() + + data = {} + for key, value in schema.get("properties").items(): + if key in validated_data: + data[key] = validated_data.pop(key) + validated_data["data"] = data + return super().create(validated_data) + + def validate_password(self, value): + custom_type = self.context.get("custom_type") + if custom_type and custom_type.password and value != custom_type.password: + raise serializers.ValidationError(_("Password mismatch.")) + return value + + class Meta: + model = CustomContribution + exclude = ( + "data", + "custom_type", + "validated", + ) + + +class CustomContributionGeoJSONSerializer( + CustomContributionSerializer, + geo_serializers.GeoFeatureModelSerializer, +): + geometry = geo_serializers.GeometryField(read_only=True, precision=7) + geom = geo_serializers.GeometryField(write_only=True) + + class Meta(CustomContributionSerializer.Meta): + geo_field = "geometry" + exclude = ( + "data", + "custom_type", + "validated", + ) + + +class CustomContributionByStationSerializer(serializers.ModelSerializer): + attachments = AttachmentSerializer(many=True, read_only=True) + + class Meta(CustomContributionSerializer.Meta): + model = CustomContribution + exclude = ( + "data", + "validated", + "station", + "portal", ) + write_only_fields = ("geom",) diff --git a/georiviere/portal/serializers/map.py b/georiviere/portal/serializers/map.py index c1de1a4a..3b610e75 100644 --- a/georiviere/portal/serializers/map.py +++ b/georiviere/portal/serializers/map.py @@ -44,9 +44,9 @@ def get_geojson_url(self, obj): layer_type = obj.layer_type.split('-') # TODO: Make lang dynamic reverse_kwargs = {'lang': 'fr', 'format': 'geojson'} - if layer_type[0] in ['watersheds', 'pois', 'streams', 'contributions']: + if layer_type[0] in ['watersheds', 'pois', 'streams', 'contributions', 'stations', ]: reverse_kwargs['portal_pk'] = obj.portal.pk - if len(layer_type) == 2: + if layer_type[0] == 'pois': # If the layer type is poi, it's separated by category. filter_type = layer_type[-1] reverse_kwargs['category_pk'] = filter_type @@ -55,11 +55,11 @@ def get_geojson_url(self, obj): def get_url(self, obj): layer_type = obj.layer_type.split('-') - if layer_type[0] not in ['pois', 'streams', 'contributions', 'sensitivities']: + if layer_type[0] not in ['pois', 'streams', 'contributions', 'sensitivities', 'stations', ]: return None # TODO: Make lang dynamic - reverse_kwargs = {'lang': 'fr'} - if layer_type[0] in ['watersheds', 'pois', 'streams', 'contributions']: + reverse_kwargs = {'lang': 'fr', 'format': 'json'} + if layer_type[0] in ['watersheds', 'pois', 'streams', 'contributions', 'stations', 'contributions-custom']: reverse_kwargs['portal_pk'] = obj.portal.pk return reverse(f'api_portal:{layer_type[0]}-list', kwargs=reverse_kwargs) diff --git a/georiviere/portal/serializers/mixins.py b/georiviere/portal/serializers/mixins.py new file mode 100644 index 00000000..85780c96 --- /dev/null +++ b/georiviere/portal/serializers/mixins.py @@ -0,0 +1,8 @@ +class SerializerAPIMixin: + def _get_url_detail_kwargs(self, pk, format="json"): + return { + "lang": self.context["lang"], + "portal_pk": self.context["portal_pk"], + "format": format, + "pk": pk, + } diff --git a/georiviere/portal/serializers/observations.py b/georiviere/portal/serializers/observations.py new file mode 100644 index 00000000..25582c4c --- /dev/null +++ b/georiviere/portal/serializers/observations.py @@ -0,0 +1,53 @@ +from rest_framework import serializers +from rest_framework.reverse import reverse +from rest_framework.serializers import ModelSerializer +from rest_framework_gis import serializers as geo_serializers + +from georiviere.observations.models import Station +from georiviere.portal.serializers.mixins import SerializerAPIMixin + + +class StationMixin(SerializerAPIMixin, ModelSerializer): + url = serializers.CharField(source="annex_uri") + json_url = serializers.SerializerMethodField() + geojson_url = serializers.SerializerMethodField() + geometry = geo_serializers.GeometryField( + read_only=True, precision=7, source="geom_transformed" + ) + + def get_json_url(self, obj): + return reverse( + "api_portal:stations-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="json"), + ) + + def get_geojson_url(self, obj): + return reverse( + "api_portal:stations-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="geojson"), + ) + + class Meta: + model = Station + fields = ( + "id", + "code", + "label", + "description", + "custom_contribution_types", + "geometry", + "url", + "json_url", + "geojson_url" + ) + + +class StationGeojsonSerializer(StationMixin, geo_serializers.GeoFeatureModelSerializer): + class Meta(StationMixin.Meta): + geo_field = "geometry" + id_field = False + + +class StationSerializer(StationMixin, ModelSerializer): + class Meta(StationMixin.Meta): + pass diff --git a/georiviere/portal/serializers/river.py b/georiviere/portal/serializers/river.py index 9fdf35d0..95cfd57a 100644 --- a/georiviere/portal/serializers/river.py +++ b/georiviere/portal/serializers/river.py @@ -1,3 +1,7 @@ +from rest_framework import serializers +from rest_framework.reverse import reverse + +from georiviere.portal.serializers.mixins import SerializerAPIMixin from georiviere.river.models import Stream from georiviere.portal.serializers.main import AttachmentSerializer @@ -6,33 +10,57 @@ from rest_framework_gis.serializers import GeoFeatureModelSerializer -class StreamGeojsonSerializer(GeoFeatureModelSerializer): - geometry = geo_serializers.GeometryField(read_only=True, precision=7, source="geom_transformed") +class StreamMixin(SerializerAPIMixin, ModelSerializer): flow = SerializerMethodField() attachments = AttachmentSerializer(many=True) - - class Meta: - model = Stream - geo_field = 'geometry' - id_field = False - fields = ( - 'id', 'name', 'description', 'length', 'descent', 'flow', 'attachments', 'geometry' - ) + json_url = serializers.SerializerMethodField() + geojson_url = serializers.SerializerMethodField() def get_flow(self, obj): return obj.get_flow_display() + def get_json_url(self, obj): + return reverse( + "api_portal:streams-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="json"), + ) -class StreamSerializer(ModelSerializer): - attachments = AttachmentSerializer(many=True) - flow = SerializerMethodField() - geometry_center = geo_serializers.GeometryField(read_only=True, precision=7, source="centroid") + def get_geojson_url(self, obj): + return reverse( + "api_portal:streams-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="geojson"), + ) class Meta: model = Stream fields = ( - 'id', 'name', 'description', 'length', 'descent', 'flow', 'attachments', 'geometry_center' + "id", + "name", + "description", + "length", + "descent", + "flow", + "attachments", + "json_url", + "geojson_url", ) - def get_flow(self, obj): - return obj.get_flow_display() + +class StreamGeojsonSerializer(StreamMixin, GeoFeatureModelSerializer): + geometry = geo_serializers.GeometryField( + read_only=True, precision=7, source="geom_transformed" + ) + + class Meta(StreamMixin.Meta): + geo_field = "geometry" + id_field = False + fields = StreamMixin.Meta.fields + ("geometry",) + + +class StreamSerializer(StreamMixin, ModelSerializer): + geometry_center = geo_serializers.GeometryField( + read_only=True, precision=7, source="centroid" + ) + + class Meta(StreamMixin.Meta): + fields = StreamMixin.Meta.fields + ("geometry_center",) diff --git a/georiviere/portal/serializers/valorization.py b/georiviere/portal/serializers/valorization.py index cec2d9f0..3a644097 100644 --- a/georiviere/portal/serializers/valorization.py +++ b/georiviere/portal/serializers/valorization.py @@ -1,17 +1,18 @@ -from georiviere.valorization.models import POI, POICategory, POIType -from georiviere.portal.serializers.main import AttachmentSerializer - +from rest_framework import serializers +from rest_framework.reverse import reverse from rest_framework.serializers import ModelSerializer from rest_framework_gis import serializers as geo_serializers from rest_framework_gis.serializers import GeoFeatureModelSerializer +from georiviere.portal.serializers.main import AttachmentSerializer +from georiviere.portal.serializers.mixins import SerializerAPIMixin +from georiviere.valorization.models import POI, POICategory, POIType + class POICategorySerializer(ModelSerializer): class Meta: model = POICategory - fields = ( - 'id', 'label' - ) + fields = ("id", "label") class POITypeSerializer(ModelSerializer): @@ -19,31 +20,55 @@ class POITypeSerializer(ModelSerializer): class Meta: model = POIType - fields = ( - 'id', 'label', 'category', 'pictogram' - ) + fields = ("id", "label", "category", "pictogram") -class POIGeojsonSerializer(GeoFeatureModelSerializer): - type = POITypeSerializer() - geometry = geo_serializers.GeometryField(read_only=True, precision=7, source="geom_transformed") +class POIMixin(SerializerAPIMixin, ModelSerializer): attachments = AttachmentSerializer(many=True) + url = serializers.CharField(source='external_uri') + type = POITypeSerializer() + json_url = serializers.SerializerMethodField() + geojson_url = serializers.SerializerMethodField() + + def get_json_url(self, obj): + return reverse( + "api_portal:pois-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="json"), + ) + + def get_geojson_url(self, obj): + return reverse( + "api_portal:pois-detail", + kwargs=self._get_url_detail_kwargs(pk=obj.pk, format="geojson"), + ) class Meta: model = POI - geo_field = 'geometry' - id_field = False fields = ( - 'id', 'name', 'description', 'type', 'attachments', 'geometry' + "id", + "name", + "description", + "url", + "type", + "attachments", + "json_url", + "geojson_url", ) -class POISerializer(ModelSerializer): - type = POITypeSerializer() - attachments = AttachmentSerializer(many=True) +class POIGeojsonSerializer(POIMixin, GeoFeatureModelSerializer): + geometry = geo_serializers.GeometryField( + read_only=True, precision=7, source="geom_transformed" + ) - class Meta: - model = POI - fields = ( - 'id', 'name', 'description', 'type', 'attachments' + class Meta(POIMixin.Meta): + geo_field = "geometry" + id_field = False + fields = POIMixin.Meta.fields + ( + "geometry", ) + + +class POISerializer(POIMixin, ModelSerializer): + class Meta(POIMixin.Meta): + pass diff --git a/georiviere/portal/signals.py b/georiviere/portal/signals.py index e089c956..1fa75639 100644 --- a/georiviere/portal/signals.py +++ b/georiviere/portal/signals.py @@ -7,38 +7,73 @@ from georiviere.valorization.models import POICategory -@receiver(post_delete, sender=POICategory, dispatch_uid='delete_category_maplayer') +@receiver(post_delete, sender=POICategory, dispatch_uid="delete_category_maplayer") def delete_category_maplayer(sender, instance, **kwargs): # Remove every map layer (all portals) when a category is deleted - MapLayer.objects.filter(layer_type__startswith='pois', - layer_type__endswith=instance.pk).delete() + MapLayer.objects.filter( + layer_type__startswith="pois", layer_type__endswith=instance.pk + ).delete() -@receiver(post_save, sender=POICategory, dispatch_uid='save_category') +@receiver(post_save, sender=POICategory, dispatch_uid="save_category") def save_category_maplayer(sender, instance, created, **kwargs): if created: for portal in Portal.objects.all(): # Create a map layer for each portal for this POICategory - MapLayer.objects.create(label=instance.label, order=0, layer_type=f'pois-{instance.pk}', - portal=portal) + MapLayer.objects.create( + label=instance.label, + order=0, + layer_type=f"pois-{instance.pk}", + portal=portal, + ) -@receiver(post_save, sender=Portal, dispatch_uid='save_portal') +@receiver(post_save, sender=Portal, dispatch_uid="save_portal") def save_portal(sender, instance, created, **kwargs): if created: # Generate a default base layer - MapBaseLayer.objects.create(label='OSM', order=0, url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution='© Contributeurs OpenStreetMap', portal=instance) + MapBaseLayer.objects.create( + label="OSM", + order=0, + url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution="© Contributeurs OpenStreetMap", + portal=instance, + ) # Generate all layers - MapLayer.objects.create(label=_('Watershed'), order=0, layer_type='watersheds', portal=instance) - MapLayer.objects.create(label=_('City'), order=0, layer_type='cities', portal=instance) - MapLayer.objects.create(label=_('Sensitivity'), order=0, layer_type='sensitivities', portal=instance) - MapLayer.objects.create(label=_('District'), order=0, layer_type='districts', portal=instance) - MapLayer.objects.create(label=_('Contribution'), order=0, layer_type='contributions', portal=instance) + MapLayer.objects.create( + label=_("Watershed"), order=0, layer_type="watersheds", portal=instance + ) + MapLayer.objects.create( + label=_("City"), order=0, layer_type="cities", portal=instance + ) + MapLayer.objects.create( + label=_("Sensitivity"), order=0, layer_type="sensitivities", portal=instance + ) + MapLayer.objects.create( + label=_("District"), order=0, layer_type="districts", portal=instance + ) + MapLayer.objects.create( + label=_("Contribution"), + order=0, + layer_type="contributions", + portal=instance, + ) # We generate a map layer for each category of poi with the layer type separated by a - # We use it after in the serializer / view to generate a geojson url for each of them for category in POICategory.objects.all(): - MapLayer.objects.create(label=f'{category.label}', order=0, layer_type=f'pois-{category.pk}', - portal=instance) + MapLayer.objects.create( + label=f"{category.label}", + order=0, + layer_type=f"pois-{category.pk}", + portal=instance, + ) - MapLayer.objects.create(label=_('Stream'), order=0, layer_type='streams', portal=instance) + MapLayer.objects.create( + label=_("Stream"), order=0, layer_type="streams", portal=instance + ) + MapLayer.objects.create( + label=_("Station"), order=0, layer_type="stations", portal=instance + ) + MapLayer.objects.create( + label=_("Custom contributions"), order=0, layer_type="contributions-custom", portal=instance + ) diff --git a/georiviere/portal/tests/test_serializers/test_contribution.py b/georiviere/portal/tests/test_serializers/test_contribution.py index b3c1e62e..89a02266 100644 --- a/georiviere/portal/tests/test_serializers/test_contribution.py +++ b/georiviere/portal/tests/test_serializers/test_contribution.py @@ -3,15 +3,25 @@ from django.test import TestCase import json +from georiviere.contribution.models import CustomContribution +from georiviere.observations.tests.factories import StationFactory from georiviere.portal.validators import validate_json_schema from georiviere.contribution.tests.factories import (TypePollutionFactory, NaturePollutionFactory, FishSpeciesFactory, InvasiveSpeciesFactory, DeadSpeciesFactory, HeritageObservationFactory, HeritageSpeciesFactory, DiseaseTypeFactory, LandingTypeFactory, SeverityTypeTypeFactory, - JamTypeFactory) + JamTypeFactory, CustomContributionTypeFactory, + CustomContributionTypeBooleanFieldFactory, + CustomContributionTypeFloatFieldFactory, + CustomContributionTypeIntegerFieldFactory, + CustomContributionTypeStringFieldFactory, + CustomContributionTypeTextFieldFactory, + CustomContributionTypeDateFieldFactory, + CustomContributionTypeDatetimeFieldFactory, + CustomContributionFactory) -from georiviere.portal.serializers.contribution import ContributionSchemaSerializer +from georiviere.portal.serializers.contribution import ContributionSchemaSerializer, CustomContributionSerializer # TODO: Add tests on every possibilities validate with json schema @@ -59,3 +69,88 @@ def test_contribution_with_subtypes(self): with open(filename) as f: json_data = json.load(f) self.assertEqual(json_data, data) + + +class CustomContributionSerializerTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.custom_contrib_type = CustomContributionTypeFactory() + cls.bool_field = CustomContributionTypeBooleanFieldFactory(custom_type=cls.custom_contrib_type) + cls.float_field = CustomContributionTypeFloatFieldFactory(custom_type=cls.custom_contrib_type) + cls.integer_field = CustomContributionTypeIntegerFieldFactory(custom_type=cls.custom_contrib_type) + cls.string_field = CustomContributionTypeStringFieldFactory(custom_type=cls.custom_contrib_type) + cls.text_field = CustomContributionTypeTextFieldFactory(custom_type=cls.custom_contrib_type) + cls.date_field = CustomContributionTypeDateFieldFactory(custom_type=cls.custom_contrib_type) + cls.datetime_field = CustomContributionTypeDatetimeFieldFactory(custom_type=cls.custom_contrib_type) + cls.custom_contrib = CustomContributionFactory( + custom_type=cls.custom_contrib_type, + data={ + cls.bool_field.key: True, + cls.float_field.key: 1.0, + cls.integer_field.key: 1, + cls.string_field.key: "toto", + cls.text_field.key: "toto", + cls.date_field.key: "2020-01-01", + cls.datetime_field.key: "2020-01-01T00:00:00Z" + } + ) + + def get_serializer_context(self): + return { + "lang": "fr", + "portal_pk": 1, + "custom_type": self.custom_contrib_type + } + + def get_serializer(self, **kwargs): + custom_contrib = (CustomContribution.objects.with_type_values(self.custom_contrib_type) + .get(pk=self.custom_contrib.pk)) + return CustomContributionSerializer(custom_contrib, context=self.get_serializer_context(), **kwargs) + + def get_serializer_data(self): + serializer = self.get_serializer() + return serializer.data + + def test_full_render(self): + data = self.get_serializer_data() + fields = list(data.keys()) + self.assertIn(self.bool_field.key, fields) + self.assertIn(self.integer_field.key, fields) + self.assertIn(self.float_field.key, fields) + self.assertIn(self.string_field.key, fields) + self.assertIn(self.text_field.key, fields) + self.assertIn(self.date_field.key, fields) + self.assertIn(self.datetime_field.key, fields) + + def test_station_required_if_linked_to_custom_type(self): + self.custom_contrib_type.stations.add(StationFactory()) + serializer = self.get_serializer() + self.assertTrue(serializer.fields['station'].required) + + def test_station_not_required_if_linked_to_custom_type(self): + serializer = self.get_serializer() + self.assertFalse(serializer.fields['station'].required) + + def test_password_field_not_exists_if_not_defined_in_custom_type(self): + serializer = self.get_serializer() + self.assertNotIn('password', serializer.fields) + + def test_password_is_required_if_defined_in_custom_type(self): + self.custom_contrib_type.password = "toto" + self.custom_contrib_type.save() + serializer = self.get_serializer() + self.assertTrue(serializer.fields['password'].required) + + def test_password_should_match_if_defined_in_custom_type(self): + self.custom_contrib_type.password = "toto" + self.custom_contrib_type.save() + serializer = self.get_serializer(data={"password": "toto"}) + serializer.is_valid() + self.assertNotIn('password', serializer.errors) + serializer = self.get_serializer(data={"password": "tata"}) + serializer.is_valid() + self.assertIn('password', serializer.errors) + + def test_with_no_custom_type_in_context(self): + serializer = CustomContributionSerializer(self.custom_contrib, context={"lang": "fr", "portal_pk": 1}) + self.assertNotIn(self.bool_field.key, serializer.data) diff --git a/georiviere/portal/tests/test_serializers/test_portal.py b/georiviere/portal/tests/test_serializers/test_portal.py index 5dcdae95..0df5e2da 100644 --- a/georiviere/portal/tests/test_serializers/test_portal.py +++ b/georiviere/portal/tests/test_serializers/test_portal.py @@ -39,7 +39,7 @@ def test_portal_content(self): data = self.serializer_portal.data self.assertSetEqual(set(data.keys()), {'id', 'map', 'name', 'flatpages', 'title', 'description', 'extent', 'max_zoom', 'min_zoom', 'main_color'}) - self.assertEqual(len(data['map']['group'][0]['layers']), 5) + self.assertEqual(len(data['map']['group'][0]['layers']), 7) def test_portal_all_layers_grouped_content(self): data = self.serializer_portal_layers_all_group.data @@ -47,7 +47,7 @@ def test_portal_all_layers_grouped_content(self): self.assertEqual(data['map']['group'][0]['label'], 'Bar') self.assertSetEqual(set(data.keys()), {'id', 'map', 'name', 'flatpages', 'title', 'description', 'extent', 'max_zoom', 'min_zoom', 'main_color'}) - self.assertEqual(len(data['map']['group'][0]['layers']), 5) + self.assertEqual(len(data['map']['group'][0]['layers']), 7) def test_portal_without_se_content(self): data = self.serializer_portal_without_spatial_extent.data diff --git a/georiviere/portal/tests/test_signals.py b/georiviere/portal/tests/test_signals.py index 027b4724..67ac6d68 100644 --- a/georiviere/portal/tests/test_signals.py +++ b/georiviere/portal/tests/test_signals.py @@ -11,17 +11,17 @@ class PortalTest(TestCase): def test_create_portal(self): self.assertEqual(0, MapLayer.objects.count()) factories.PortalFactory.create() - self.assertEqual(6, MapLayer.objects.count()) + self.assertEqual(8, MapLayer.objects.count()) def test_create_portal_poi_category(self): POICategoryFactory.create() self.assertEqual(0, MapLayer.objects.count()) factories.PortalFactory.create() - self.assertEqual(7, MapLayer.objects.count()) + self.assertEqual(9, MapLayer.objects.count()) category = POICategoryFactory.create(label="New category") - self.assertEqual(8, MapLayer.objects.count()) + self.assertEqual(10, MapLayer.objects.count()) category.delete() - self.assertEqual(7, MapLayer.objects.count()) + self.assertEqual(9, MapLayer.objects.count()) diff --git a/georiviere/portal/tests/test_views/test_contribution.py b/georiviere/portal/tests/test_views/test_contribution.py index 42fea79c..394326b5 100644 --- a/georiviere/portal/tests/test_views/test_contribution.py +++ b/georiviere/portal/tests/test_views/test_contribution.py @@ -9,8 +9,13 @@ from georiviere.contribution.models import (Contribution, ContributionQuality, ContributionLandscapeElements, ContributionQuantity, ContributionFaunaFlora, ContributionPotentialDamage,) from georiviere.contribution.tests.factories import (ContributionFactory, ContributionQuantityFactory, - NaturePollutionFactory, SeverityTypeTypeFactory,) + NaturePollutionFactory, SeverityTypeTypeFactory, + CustomContributionTypeFactory, + CustomContributionTypeStringFieldFactory, + CustomContributionTypeBooleanFieldFactory, + CustomContributionFactory, ) from georiviere.main.models import Attachment +from georiviere.observations.tests.factories import StationFactory from georiviere.portal.tests.factories import PortalFactory from rest_framework.test import APITestCase @@ -312,3 +317,68 @@ def test_contribution_geojson_detail(self): self.assertSetEqual(set(response.json().keys()), {'id', 'type', 'geometry', 'properties'}) self.assertSetEqual(set(response.json()['properties']), {'category', }) + + +class CustomContributionTypeVIewSetAPITestCase(APITestCase): + @classmethod + def setUpTestData(cls): + cls.portal = PortalFactory.create() + cls.station = StationFactory() + cls.custom_contribution_type = CustomContributionTypeFactory() + cls.custom_contribution_type.stations.add(cls.station) + CustomContributionTypeStringFieldFactory( + custom_type=cls.custom_contribution_type, + label="Field string", + ) + CustomContributionTypeBooleanFieldFactory( + custom_type=cls.custom_contribution_type, + label="Field boolean", + ) + cls.contribution_validated = CustomContributionFactory(custom_type=cls.custom_contribution_type, + station=cls.station, validated=True, portal=cls.portal) + cls.contribution_unvalidated = CustomContributionFactory(custom_type=cls.custom_contribution_type, + station=cls.station, validated=False, portal=cls.portal) + + def get_list_url(self): + return reverse('api_portal:custom_contribution_types-list', + kwargs={'portal_pk': self.portal.pk, 'lang': 'fr', }) + + def get_contribution_url(self): + return reverse('api_portal:custom_contribution_types-custom-contributions', + kwargs={'portal_pk': self.portal.pk, 'lang': 'fr', 'pk': self.custom_contribution_type.pk}) + + def get_detail_url(self, pk): + return reverse('api_portal:custom_contribution_types-detail', + kwargs={'portal_pk': self.portal.pk, 'lang': 'fr', 'pk': pk, }) + + def test_list(self): + response = self.client.get(self.get_list_url()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + def test_detail(self): + response = self.client.get(self.get_detail_url(self.custom_contribution_type.pk)) + self.assertEqual(response.status_code, 200) + + def test_create(self): + data = { + "station": self.station.pk, + "field_string": "string", + "field_boolean": True, + "contributed_at": "2020-01-01T00:00" + } + response = self.client.post(self.get_contribution_url(), data=data) + data = response.json() + self.assertEqual(response.status_code, 201, data) + + def test_validated_not_in_list(self): + response = self.client.get(self.get_contribution_url()) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn(self.contribution_validated.pk, [c['id'] for c in data]) + + def test_unvalidated_not_in_list(self): + response = self.client.get(self.get_contribution_url()) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertNotIn(self.contribution_unvalidated.pk, [c['id'] for c in data]) diff --git a/georiviere/portal/tests/test_views/test_observations.py b/georiviere/portal/tests/test_views/test_observations.py new file mode 100644 index 00000000..7e3555ad --- /dev/null +++ b/georiviere/portal/tests/test_views/test_observations.py @@ -0,0 +1,88 @@ +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from georiviere.contribution.tests.factories import CustomContributionTypeFactory, CustomContributionFactory +from georiviere.observations.tests.factories import StationFactory +from georiviere.portal.tests.factories import PortalFactory + + +class StationViewSetTestCase(APITestCase): + @classmethod + def setUpTestData(cls): + cls.portal = PortalFactory() + cls.station_linked_to_type = StationFactory() + cls.custom_contribution_type = CustomContributionTypeFactory() + cls.custom_contribution_type.stations.add(cls.station_linked_to_type) + cls.station_not_linked = StationFactory() + + def test_only_station_linked_to_type_are_returned(self): + response = self.client.get( + reverse( + "api_portal:stations-list", + kwargs={"portal_pk": self.portal.pk, "lang": "fr"}, + ) + ) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["id"], self.station_linked_to_type.pk) + + def test_detail_station_linked_to_type(self): + response = self.client.get( + reverse( + "api_portal:stations-detail", + kwargs={ + "portal_pk": 1, + "lang": "fr", + "pk": self.station_linked_to_type.pk, + }, + ) + ) + self.assertEqual(response.status_code, 200) + + def test_list_geojson(self): + response = self.client.get( + reverse( + "api_portal:stations-list", + kwargs={"portal_pk": self.portal.pk, "lang": "fr", "format": "geojson"}, + ) + ) + self.assertEqual(response.status_code, 200) + self.assertIn("features", response.json()) + + def test_detail_geojson(self): + response = self.client.get( + reverse( + "api_portal:stations-detail", + kwargs={ + "portal_pk": 1, + "lang": "fr", + "format": "geojson", + "pk": self.station_linked_to_type.pk, + }, + ) + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("properties", data) + self.assertIn("geometry", data) + + def test_contributions(self): + validated = CustomContributionFactory(custom_type=self.custom_contribution_type, + station=self.station_linked_to_type, validated=True) + unvalidated = CustomContributionFactory(custom_type=self.custom_contribution_type, + station=self.station_linked_to_type, validated=False) + response = self.client.get( + reverse( + "api_portal:stations-custom-contributions", + kwargs={ + "portal_pk": 1, + "lang": "fr", + "pk": self.station_linked_to_type.pk, + }, + ) + ) + self.assertEqual(response.status_code, 200) + ids = [c["id"] for c in response.json()] + # validated conbtrib in results + self.assertIn(validated.pk, ids) + # unvalidated contrib not in results + self.assertNotIn(unvalidated.pk, ids) diff --git a/georiviere/portal/tests/test_views/test_river.py b/georiviere/portal/tests/test_views/test_river.py index 6f13fff5..76906142 100644 --- a/georiviere/portal/tests/test_views/test_river.py +++ b/georiviere/portal/tests/test_views/test_river.py @@ -13,42 +13,97 @@ def setUpTestData(cls): cls.stream.portals.add(cls.portal) def test_stream_detail_geojson_structure(self): - url = reverse('api_portal:streams-detail', - kwargs={'portal_pk': self.portal.pk, 'pk': self.stream.pk, 'lang': 'fr', 'format': 'geojson'}) + url = reverse( + "api_portal:streams-detail", + kwargs={ + "portal_pk": self.portal.pk, + "pk": self.stream.pk, + "lang": "fr", + "format": "geojson", + }, + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertSetEqual(set(response.json().keys()), {'geometry', 'properties', 'type'}) + self.assertSetEqual( + set(response.json().keys()), {"geometry", "properties", "type"} + ) def test_stream_detail_json_structure(self): - url = reverse('api_portal:streams-detail', - kwargs={'portal_pk': self.portal.pk, 'pk': self.stream.pk, 'lang': 'fr', 'format': 'json'}) + url = reverse( + "api_portal:streams-detail", + kwargs={ + "portal_pk": self.portal.pk, + "pk": self.stream.pk, + "lang": "fr", + "format": "json", + }, + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertSetEqual(set(response.json().keys()), {'attachments', 'length', 'flow', 'descent', 'name', - 'id', 'description', 'geometryCenter'}) + self.assertListEqual( + sorted(list(response.json().keys())), + sorted([ + "attachments", + "length", + "flow", + "descent", + "name", + "id", + "description", + "geometryCenter", + "geojsonUrl", + "jsonUrl", + ]), + ) def test_stream_list_geojson_structure(self): - url = reverse('api_portal:streams-list', - kwargs={'portal_pk': self.portal.pk, 'lang': 'fr', 'format': 'geojson'}) + url = reverse( + "api_portal:streams-list", + kwargs={"portal_pk": self.portal.pk, "lang": "fr", "format": "geojson"}, + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertSetEqual(set(response.json().keys()), {'type', 'features'}) + self.assertSetEqual(set(response.json().keys()), {"type", "features"}) def test_stream_list_json_structure(self): - url = reverse('api_portal:streams-list', - kwargs={'portal_pk': self.portal.pk, 'lang': 'fr', 'format': 'json'}) + url = reverse( + "api_portal:streams-list", + kwargs={"portal_pk": self.portal.pk, "lang": "fr", "format": "json"}, + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 1) - self.assertSetEqual(set(response.json()[0].keys()), {'attachments', 'flow', 'descent', 'length', - 'name', 'id', 'description', 'geometryCenter'}) + self.assertListEqual( + sorted(list(response.json()[0].keys())), + sorted([ + "attachments", + "length", + "flow", + "descent", + "name", + "id", + "description", + "geometryCenter", + "geojsonUrl", + "jsonUrl", + ]), + ) def test_stream_ranslation_according_url(self): - for lang in ['en', 'fr']: - url = reverse('api_portal:streams-detail', - kwargs={'portal_pk': self.portal.pk, 'pk': self.stream.pk, 'lang': lang, 'format': 'json'}) + for lang in ["en", "fr"]: + url = reverse( + "api_portal:streams-detail", + kwargs={ + "portal_pk": self.portal.pk, + "pk": self.stream.pk, + "lang": lang, + "format": "json", + }, + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data['flow'], 'To be defined' if lang == 'en' else 'À définir') + self.assertEqual( + data["flow"], "To be defined" if lang == "en" else "À définir" + ) diff --git a/georiviere/portal/tests/test_views/test_valorization.py b/georiviere/portal/tests/test_views/test_valorization.py index 2cfd6558..dc33adf4 100644 --- a/georiviere/portal/tests/test_views/test_valorization.py +++ b/georiviere/portal/tests/test_views/test_valorization.py @@ -25,7 +25,8 @@ def test_poi_detail_json_structure(self): kwargs={'portal_pk': self.portal.pk, 'pk': self.poi.pk, 'lang': 'fr', 'format': 'json'}) response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertSetEqual(set(response.json().keys()), {'attachments', 'type', 'name', 'id', 'description'}) + self.assertListEqual(sorted(list(response.json().keys())), sorted(['url', 'attachments', 'name', 'type', 'id', + 'description', 'geojsonUrl', 'jsonUrl'])) def test_poi_list_geojson_structure(self): url = reverse('api_portal:pois-list', @@ -40,7 +41,9 @@ def test_poi_list_json_structure(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 1) - self.assertSetEqual(set(response.json()[0].keys()), {'attachments', 'type', 'name', 'id', 'description'}) + self.assertListEqual(sorted(list(response.json()[0].keys())), + sorted(['url', 'attachments', 'name', 'type', 'id', + 'description', 'geojsonUrl', 'jsonUrl'])) def test_poi_category_list_json_structure(self): url = reverse('api_portal:pois-category', @@ -49,7 +52,9 @@ def test_poi_category_list_json_structure(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 1) - self.assertSetEqual(set(response.json()[0].keys()), {'attachments', 'name', 'type', 'id', 'description'}) + self.assertListEqual(sorted(list(response.json()[0].keys())), + sorted(['url', 'attachments', 'name', 'type', 'id', + 'description', 'geojsonUrl', 'jsonUrl'])) def test_poi_category_list_geojson_structure(self): url = reverse('api_portal:pois-category', diff --git a/georiviere/portal/urls.py b/georiviere/portal/urls.py index 9cbc9b73..e6d57cde 100644 --- a/georiviere/portal/urls.py +++ b/georiviere/portal/urls.py @@ -4,12 +4,21 @@ from georiviere.portal.views import GeoriviereVersionAPIView from georiviere.portal.views.flatpage import FlatPageViewSet -from georiviere.portal.views.contribution import ContributionViewSet +from georiviere.portal.views.contribution import ( + ContributionViewSet, + CustomContributionTypeViewSet, + CustomContributionViewSet, +) +from georiviere.portal.views.observations import StationViewSet from georiviere.portal.views.portal import PortalViewSet from georiviere.portal.views.river import StreamViewSet from georiviere.portal.views.sensitivity import SensitivityViewSet from georiviere.portal.views.valorization import POIViewSet -from georiviere.portal.views.zoning import CityViewSet, DistrictViewSet, WatershedViewSet +from georiviere.portal.views.zoning import ( + CityViewSet, + DistrictViewSet, + WatershedViewSet, +) from drf_spectacular.views import ( SpectacularAPIView, @@ -18,21 +27,53 @@ ) router = routers.DefaultRouter() -# Datas are available depending on portal or not. -router.register(r'(?P[a-z]{2})/(?P\d+)/pois', POIViewSet, basename='pois') - -router.register(r'(?P[a-z]{2})/(?P\d+)/streams', StreamViewSet, basename='streams') -router.register(r'(?P[a-z]{2})/(?P\d+)/flatpages', FlatPageViewSet, basename='flatpages') -router.register(r'(?P[a-z]{2})/(?P\d+)/contributions', ContributionViewSet, basename='contributions') -router.register(r'(?P[a-z]{2})/(?P\d+)/watersheds', WatershedViewSet, basename='watersheds') +# Datas are available depending on portal or not. +router.register( + r"(?P[a-z]{2})/(?P\d+)/pois", POIViewSet, basename="pois" +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/streams", StreamViewSet, basename="streams" +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/custom-contributions", + CustomContributionViewSet, + basename="custom-contributions", +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/stations", + StationViewSet, + basename="stations", +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/flatpages", + FlatPageViewSet, + basename="flatpages", +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/contributions", + ContributionViewSet, + basename="contributions", +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/custom-contribution-types", + CustomContributionTypeViewSet, + basename="custom_contribution_types", +) +router.register( + r"(?P[a-z]{2})/(?P\d+)/watersheds", + WatershedViewSet, + basename="watersheds", +) -router.register(r'(?P[a-z]{2})/portal', PortalViewSet, basename='portal') -router.register(r'(?P[a-z]{2})/cities', CityViewSet, basename='cities') -router.register(r'(?P[a-z]{2})/districts', DistrictViewSet, basename='districts') -router.register(r'(?P[a-z]{2})/sensitivities', SensitivityViewSet, basename='sensitivities') +router.register(r"(?P[a-z]{2})/portal", PortalViewSet, basename="portal") +router.register(r"(?P[a-z]{2})/cities", CityViewSet, basename="cities") +router.register(r"(?P[a-z]{2})/districts", DistrictViewSet, basename="districts") +router.register( + r"(?P[a-z]{2})/sensitivities", SensitivityViewSet, basename="sensitivities" +) -app_name = 'api_portal' +app_name = "api_portal" _urlpatterns = [] if settings.API_SCHEMA: # pragma: no cover @@ -54,7 +95,7 @@ ) ] _urlpatterns += [ - path('version', GeoriviereVersionAPIView.as_view(), name='version'), - path('', include(router.urls)), + path("version", GeoriviereVersionAPIView.as_view(), name="version"), + path("", include(router.urls)), ] -urlpatterns = [path('api/portal/', include(_urlpatterns))] +urlpatterns = [path("api/portal/", include(_urlpatterns))] diff --git a/georiviere/portal/views/contribution.py b/georiviere/portal/views/contribution.py index a45579ca..4e5dd736 100644 --- a/georiviere/portal/views/contribution.py +++ b/georiviere/portal/views/contribution.py @@ -1,58 +1,72 @@ import json +import logging import os -from PIL import Image - -from rest_framework.permissions import AllowAny +from PIL import Image from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.contrib.gis.db.models.functions import Transform from django.core.exceptions import ValidationError from django.core.files import File from django.core.mail import send_mail +from django.db import transaction from django.db.models import F, Q -from django.contrib.gis.db.models.functions import Transform from django.utils import translation from django.utils.translation import gettext_lazy as _ - -from djangorestframework_camel_case.render import CamelCaseJSONRenderer - -from georiviere.contribution.models import Contribution -from georiviere.main.models import Attachment, FileType -from georiviere.main.renderers import GeoJSONRenderer -from georiviere.portal.serializers.contribution import (ContributionSchemaSerializer, - ContributionSerializer, ContributionGeojsonSerializer) - -from rest_framework import filters -from rest_framework import viewsets -from rest_framework import mixins -from rest_framework import renderers +from rest_framework import filters, viewsets, mixins, renderers, permissions, status from rest_framework.decorators import action -from rest_framework.response import Response from rest_framework.pagination import LimitOffsetPagination from rest_framework.parsers import JSONParser, MultiPartParser, FormParser +from rest_framework.permissions import AllowAny +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response -import logging +from georiviere.contribution.models import ( + Contribution, + CustomContributionType, + CustomContribution, +) +from georiviere.main.models import Attachment, FileType +from georiviere.main.renderers import GeoJSONRenderer +from georiviere.portal.serializers.contribution import ( + ContributionSchemaSerializer, + ContributionSerializer, + ContributionGeojsonSerializer, + CustomContributionTypeSerializer, + CustomContributionSerializer, + CustomContributionGeoJSONSerializer, +) +from georiviere.portal.views.mixins import GeoriviereAPIMixin logger = logging.getLogger(__name__) -class ContributionViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, - viewsets.GenericViewSet): +class ContributionViewSet( + GeoriviereAPIMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): model = Contribution - permission_classes = [AllowAny, ] geojson_serializer_class = ContributionGeojsonSerializer serializer_class = ContributionSerializer + permission_classes = [ + AllowAny, + ] parser_classes = (MultiPartParser, FormParser, JSONParser) - pagination_class = LimitOffsetPagination - renderer_classes = [CamelCaseJSONRenderer, GeoJSONRenderer, ] filter_backends = [filters.OrderingFilter, filters.SearchFilter] # TODO: Fix search filter with IntegerField (choices). It might be possible using an annotate on this view. # search_fields = ['potential_damage__type', 'fauna_flora__type', 'quality__type', 'quantity__type', # 'landscape_element__type'] - @action(detail=False, url_name="json_schema", methods=['get'], - renderer_classes=[renderers.JSONRenderer], - serializer_class=ContributionSchemaSerializer) + @action( + detail=False, + url_name="json_schema", + methods=["get"], + renderer_classes=[renderers.JSONRenderer], + serializer_class=ContributionSchemaSerializer, + ) def json_schema(self, request, *args, **kwargs): serializer = self.get_serializer({}) return Response(serializer.data) @@ -62,35 +76,32 @@ def get_serializer_context(self): Extra context provided to the serializer class. """ context = super().get_serializer_context() - context['portal_pk'] = self.kwargs['portal_pk'] - translation.activate(self.kwargs['lang']) + translation.activate(self.kwargs["lang"]) return context def get_queryset(self): - portal_pk = self.kwargs['portal_pk'] + portal_pk = self.kwargs["portal_pk"] queryset = Contribution.objects.filter(portal_id=portal_pk, published=True) queryset = queryset.exclude( - Q(potential_damage__isnull=True) & Q(fauna_flora__isnull=True) & Q(quantity__isnull=True) - & Q(quality__isnull=True) & Q(landscape_element__isnull=True) + Q(potential_damage__isnull=True) + & Q(fauna_flora__isnull=True) + & Q(quantity__isnull=True) + & Q(quality__isnull=True) + & Q(landscape_element__isnull=True) + ) + queryset = queryset.annotate( + geom_transformed=Transform(F("geom"), settings.API_SRID) ) - queryset = queryset.annotate(geom_transformed=Transform(F('geom'), settings.API_SRID)) return queryset - def get_serializer_class(self): - """ Use specific Serializer for GeoJSON """ - renderer, media_type = self.perform_content_negotiation(self.request) - if getattr(renderer, 'format') == 'geojson': - return self.geojson_serializer_class - return self.serializer_class - def create(self, request, *args, **kwargs): response = super().create(request) for file in request._request.FILES.values(): attachment = Attachment( - filetype=FileType.objects.get_or_create(type=settings.CONTRIBUTION_FILETYPE)[ - 0 - ], + filetype=FileType.objects.get_or_create( + type=settings.CONTRIBUTION_FILETYPE + )[0], content_type=ContentType.objects.get_for_model(Contribution), object_id=response.data.get("id"), attachment_file=file, @@ -99,26 +110,35 @@ def create(self, request, *args, **kwargs): try: attachment.full_clean() # Check that file extension and mimetypes are allowed except ValidationError as e: - logger.error(f"Invalid attachment {name}{extension} for contribution {response.data.get('id')} : " - + str(e)) + logger.error( + f"Invalid attachment {name}{extension} for contribution {response.data.get('id')} : " + + str(e) + ) else: try: # Reencode file to bitmap then back to jpeg lfor safety if not os.path.exists(f"{settings.TMP_DIR}/contribution_file/"): os.mkdir(f"{settings.TMP_DIR}/contribution_file/") - tmp_bmp_path = os.path.join(f"{settings.TMP_DIR}/contribution_file/", f"{name}.bmp") - tmp_jpeg_path = os.path.join(f"{settings.TMP_DIR}/contribution_file/", f"{name}.jpeg") + tmp_bmp_path = os.path.join( + f"{settings.TMP_DIR}/contribution_file/", f"{name}.bmp" + ) + tmp_jpeg_path = os.path.join( + f"{settings.TMP_DIR}/contribution_file/", f"{name}.jpeg" + ) Image.open(file).save(tmp_bmp_path) Image.open(tmp_bmp_path).save(tmp_jpeg_path) - with open(tmp_jpeg_path, 'rb') as converted_file: - attachment.attachment_file = File(converted_file, name=f"{name}.jpeg") + with open(tmp_jpeg_path, "rb") as converted_file: + attachment.attachment_file = File( + converted_file, name=f"{name}.jpeg" + ) attachment.save() os.remove(tmp_bmp_path) os.remove(tmp_jpeg_path) except Exception as e: logger.error( - f"Failed to convert attachment {name}{extension} for report {response.data.get('id')}: " + str( - e)) + f"Failed to convert attachment {name}{extension} for contribution {response.data.get('id')}: " + + str(e) + ) if settings.SEND_REPORT_ACK and response.status_code == 201: send_mail( _("Georiviere : Contribution"), @@ -133,6 +153,175 @@ def create(self, request, *args, **kwargs): http://georiviere.fr""" ), settings.DEFAULT_FROM_EMAIL, - [json.loads(request.data.get("properties")).get('email_author'), ], + [ + json.loads(request.data.get("properties")).get("email_author"), + ], ) return response + + +class CustomContributionTypeViewSet( + GeoriviereAPIMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + queryset = CustomContributionType.objects.all().prefetch_related( + "stations", "fields" + ) + serializer_class = CustomContributionTypeSerializer + renderer_classes = ( + ( + renderers.BrowsableAPIRenderer, + JSONRenderer, + ) + if settings.DEBUG + else (JSONRenderer,) + ) + permission_classes = [permissions.AllowAny] + pagination_class = LimitOffsetPagination + + def create_contribution(self, request, *args, **kwargs): + sid = transaction.savepoint() + + try: + context = self.get_serializer_context() + custom_type = self.get_object() + context["custom_type"] = custom_type + serializer = self.get_serializer(data=request.data, context=context) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + extra_save_params = {} + # if station selected, save its geom + if "station" in serializer.validated_data: + extra_save_params["geom"] = serializer.validated_data["station"].geom + contribution = serializer.save(custom_type=custom_type, **extra_save_params) + # reload with extra fields + contribution = CustomContribution.objects.with_type_values(custom_type).get( + pk=contribution.pk + ) + + for file in request._request.FILES.values(): + attachment = Attachment( + filetype=FileType.objects.get_or_create( + type=settings.CONTRIBUTION_FILETYPE + )[0], + content_type=ContentType.objects.get_for_model(CustomContribution), + object_id=contribution.pk, + attachment_file=file, + ) + name, extension = os.path.splitext(file.name) + try: + attachment.full_clean() # Check that file extension and mimetypes are allowed + except ValidationError as e: + logger.error( + f"Invalid attachment {name}{extension} for contribution {contribution.pk} : " + + str(e) + ) + else: + try: + # Re-encode file to bitmap then back to jpeg for safety + if not os.path.exists(f"{settings.TMP_DIR}/contribution_file/"): + os.mkdir(f"{settings.TMP_DIR}/contribution_file/") + tmp_bmp_path = os.path.join( + f"{settings.TMP_DIR}/contribution_file/", f"{name}.bmp" + ) + tmp_jpeg_path = os.path.join( + f"{settings.TMP_DIR}/contribution_file/", f"{name}.jpeg" + ) + Image.open(file).save(tmp_bmp_path) + Image.open(tmp_bmp_path).save(tmp_jpeg_path) + with open(tmp_jpeg_path, "rb") as converted_file: + attachment.attachment_file = File( + converted_file, name=f"{name}.jpeg" + ) + attachment.save() + os.remove(tmp_bmp_path) + os.remove(tmp_jpeg_path) + except Exception as e: + logger.error( + f"Failed to convert attachment {name}{extension} for contribution {contribution.pk}: " + + str(e) + ) + transaction.savepoint_commit(sid) + + return Response( + CustomContributionSerializer(contribution, context=context).data, + status=status.HTTP_201_CREATED, + ) + + except Exception as e: + transaction.savepoint_rollback(sid) + logger.error(f"Error {str(e)}") + return Response( + str(e), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def list_contributions(self, request, *args, **kwargs): + custom_type = self.get_object() + context = self.get_serializer_context() + context["custom_type"] = custom_type + qs = CustomContribution.objects.with_type_values(custom_type).filter(validated=True).prefetch_related("attachments") + + renderer, media_type = self.perform_content_negotiation(self.request) + if getattr(renderer, "format") == "geojson": + self.geojson_serializer_class = CustomContributionGeoJSONSerializer + qs = qs.annotate(geometry=Transform(F("geom"), settings.API_SRID)) + + serializer = self.get_serializer(qs, context=context, many=True) + return Response(serializer.data) + + @action( + detail=True, + url_name="custom-contributions", + url_path="contributions", + methods=["get", "post"], + renderer_classes=(renderers.JSONRenderer, GeoJSONRenderer), + parser_classes=(MultiPartParser, FormParser, JSONParser), + serializer_class=CustomContributionSerializer, + ) + def contributions(self, request, *args, **kwargs): + if request.method == "GET": + return self.list_contributions(request, *args, **kwargs) + elif request.method == "POST": + return self.create_contribution(request, *args, **kwargs) + + +class CustomContributionViewSet( + GeoriviereAPIMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + serializer_class = CustomContributionSerializer + geojson_serializer_class = CustomContributionGeoJSONSerializer + renderer_classes = ( + ( + renderers.BrowsableAPIRenderer, + JSONRenderer, + GeoJSONRenderer, + ) + if settings.DEBUG + else (JSONRenderer,) + ) + permission_classes = [permissions.AllowAny] + pagination_class = LimitOffsetPagination + + def get_queryset(self): + portal_pk = self.kwargs["portal_pk"] + queryset = CustomContribution.objects.filter(portal_id=portal_pk, validated=True).annotate( + geometry=Transform(F("geom"), settings.API_SRID) + ).prefetch_related("attachments") + return queryset + + def retrieve(self, request, *args, **kwargs): + """ Customize retrieve method to add custom type values to the response""" + instance = self.get_object() + instance = CustomContribution.objects.with_type_values(instance.custom_type).annotate( + geometry=Transform(F("geom"), settings.API_SRID) + ).prefetch_related("attachments").get(pk=instance.pk) + context = self.get_serializer_context() + context['custom_type'] = instance.custom_type + serializer = self.get_serializer(instance, context=context) + return Response(serializer.data) diff --git a/georiviere/portal/views/mixins.py b/georiviere/portal/views/mixins.py new file mode 100644 index 00000000..43694f79 --- /dev/null +++ b/georiviere/portal/views/mixins.py @@ -0,0 +1,29 @@ +from django.conf import settings +from djangorestframework_camel_case.render import CamelCaseJSONRenderer +from rest_framework import renderers, permissions +from rest_framework.pagination import LimitOffsetPagination + +from georiviere.main.renderers import GeoJSONRenderer + + +class GeoriviereAPIMixin: + renderer_classes = ( + renderers.BrowsableAPIRenderer, + CamelCaseJSONRenderer, + GeoJSONRenderer, + ) if settings.DEBUG else (CamelCaseJSONRenderer, GeoJSONRenderer) + permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] + pagination_class = LimitOffsetPagination + + def get_serializer_class(self): + """Use specific Serializer for GeoJSON""" + renderer, media_type = self.perform_content_negotiation(self.request) + if getattr(renderer, "format") == "geojson": + return self.geojson_serializer_class + return self.serializer_class + + def get_serializer_context(self): + context = super().get_serializer_context() + context["lang"] = self.kwargs["lang"] + context["portal_pk"] = self.kwargs["portal_pk"] + return context diff --git a/georiviere/portal/views/observations.py b/georiviere/portal/views/observations.py new file mode 100644 index 00000000..a09d2a9d --- /dev/null +++ b/georiviere/portal/views/observations.py @@ -0,0 +1,41 @@ +from django.conf import settings +from django.contrib.gis.db.models.functions import Transform +from django.db.models import F +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from georiviere.observations.models import Station +from georiviere.portal.serializers.contribution import ( + CustomContributionByStationSerializer, +) +from georiviere.portal.serializers.observations import ( + StationGeojsonSerializer, + StationSerializer, +) +from georiviere.portal.views.mixins import GeoriviereAPIMixin + + +class StationViewSet(GeoriviereAPIMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = StationSerializer + geojson_serializer_class = StationGeojsonSerializer + + def get_queryset(self): + qs = Station.objects.filter(custom_contribution_types__isnull=False) + return qs.annotate(geom_transformed=Transform(F("geom"), settings.API_SRID)) + + @action( + detail=True, + methods=["get"], + url_name="custom-contributions", + serializer_class=CustomContributionByStationSerializer, + ) + def custom_contributions(self, request, *args, **kwargs): + station = self.get_object() + qs = ( + station.custom_contributions.filter(validated=True) + .prefetch_related("attachments") + .defer(*CustomContributionByStationSerializer.Meta.exclude) + ) + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data) diff --git a/georiviere/portal/views/river.py b/georiviere/portal/views/river.py index 9deca0a0..f53a8b0b 100644 --- a/georiviere/portal/views/river.py +++ b/georiviere/portal/views/river.py @@ -1,41 +1,42 @@ -from georiviere.river.models import Stream from django.conf import settings -from django.db.models import F from django.contrib.gis.db.models.functions import Centroid, Transform +from django.db.models import F, Prefetch +from rest_framework import filters, viewsets +from georiviere.main.models import Attachment from georiviere.portal.filters import SearchNoAccentFilter -from georiviere.portal.serializers.river import StreamGeojsonSerializer, StreamSerializer -from georiviere.main.renderers import GeoJSONRenderer - - -from djangorestframework_camel_case.render import CamelCaseJSONRenderer - -from rest_framework import filters, viewsets -from rest_framework import permissions as rest_permissions -from rest_framework.pagination import LimitOffsetPagination +from georiviere.portal.serializers.river import ( + StreamGeojsonSerializer, + StreamSerializer, +) +from georiviere.portal.views.mixins import GeoriviereAPIMixin +from georiviere.river.models import Stream -class StreamViewSet(viewsets.ReadOnlyModelViewSet): +class StreamViewSet(GeoriviereAPIMixin, viewsets.ReadOnlyModelViewSet): model = Stream geojson_serializer_class = StreamGeojsonSerializer serializer_class = StreamSerializer - renderer_classes = (CamelCaseJSONRenderer, GeoJSONRenderer, ) - permission_classes = [rest_permissions.DjangoModelPermissionsOrAnonReadOnly] filter_backends = [filters.OrderingFilter, SearchNoAccentFilter] - pagination_class = LimitOffsetPagination - ordering_fields = ['name', 'date_insert'] - search_fields = ['&name'] + + ordering_fields = ["name", "date_insert"] + search_fields = ["&name"] + + def get_model(self): + return self.model def get_queryset(self): - portal_pk = self.kwargs['portal_pk'] - queryset = Stream.objects.filter(portals__id=portal_pk) - queryset = queryset.annotate(geom_transformed=Transform(F('geom'), settings.API_SRID)).annotate( - centroid=Centroid('geom_transformed')) + portal_pk = self.kwargs["portal_pk"] + queryset = Stream.objects.filter(portals__id=portal_pk).prefetch_related( + Prefetch( + "attachments", + queryset=Attachment.objects.all().select_related("filetype"), + ) + ) + queryset = queryset.annotate( + geom_transformed=Transform(F("geom"), settings.API_SRID) + ).annotate(centroid=Centroid("geom_transformed")) return queryset - def get_serializer_class(self): - """ Use specific Serializer for GeoJSON """ - renderer, media_type = self.perform_content_negotiation(self.request) - if getattr(renderer, 'format') == 'geojson': - return self.geojson_serializer_class - return self.serializer_class + def view_cache_key(self): + return f"stream-{self.kwargs['portal_pk']}" diff --git a/georiviere/portal/views/valorization.py b/georiviere/portal/views/valorization.py index 00a12e4b..74730a8c 100644 --- a/georiviere/portal/views/valorization.py +++ b/georiviere/portal/views/valorization.py @@ -2,50 +2,50 @@ from django.db.models import F from django.contrib.gis.db.models.functions import Transform from django.shortcuts import get_object_or_404 +from rest_framework.permissions import AllowAny -from georiviere.portal.serializers.valorization import POIGeojsonSerializer, POISerializer -from georiviere.main.renderers import GeoJSONRenderer +from georiviere.portal.serializers.valorization import ( + POIGeojsonSerializer, + POISerializer, +) +from georiviere.portal.views.mixins import GeoriviereAPIMixin from georiviere.valorization.models import POI, POICategory from rest_framework import viewsets, filters -from rest_framework.permissions import AllowAny from rest_framework.decorators import action -from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response -from djangorestframework_camel_case.render import CamelCaseJSONRenderer - - -class POIViewSet(viewsets.ReadOnlyModelViewSet): +class POIViewSet(GeoriviereAPIMixin, viewsets.ReadOnlyModelViewSet): geojson_serializer_class = POIGeojsonSerializer serializer_class = POISerializer - permission_classes = [AllowAny, ] - renderer_classes = [CamelCaseJSONRenderer, GeoJSONRenderer] filter_backends = [filters.OrderingFilter, filters.SearchFilter] - pagination_class = LimitOffsetPagination - ordering_fields = ['name', 'date_insert'] - search_fields = ['name', 'type__label', 'type__category__label'] + permission_classes = [ + AllowAny, + ] + ordering_fields = ["name", "date_insert"] + search_fields = ["name", "type__label", "type__category__label"] def get_queryset(self): - portal_pk = self.kwargs['portal_pk'] - queryset = POI.objects.select_related('type') - queryset = queryset.filter(portals__id=portal_pk).annotate(geom_transformed=Transform(F('geom'), settings.API_SRID)) + portal_pk = self.kwargs["portal_pk"] + queryset = POI.objects.select_related("type__category") + queryset = queryset.filter(portals__id=portal_pk).annotate( + geom_transformed=Transform(F("geom"), settings.API_SRID) + ) return queryset - def get_serializer_class(self): - """ Use specific Serializer for GeoJSON """ - renderer, media_type = self.perform_content_negotiation(self.request) - if getattr(renderer, 'format') == 'geojson': - return self.geojson_serializer_class - return self.serializer_class - - @action(detail=False, methods=['get'], permission_classes=[], - url_path=r'category/(?P\d+)', url_name='category') + @action( + detail=False, + methods=["get"], + permission_classes=[], + url_path=r"category/(?P\d+)", + url_name="category", + ) def category(self, request, *args, **kwargs): - category_pk = self.kwargs['category_pk'] + category_pk = self.kwargs["category_pk"] category = get_object_or_404(POICategory.objects.all(), pk=category_pk) - qs = self.filter_queryset(POI.objects.filter(type__in=category.types.all()).annotate( - geom_transformed=Transform(F('geom'), settings.API_SRID))) + qs = self.filter_queryset( + self.get_queryset().filter(type__in=category.types.all()) + ) serializer = self.get_serializer(qs, many=True) return Response(serializer.data) diff --git a/georiviere/proceeding/locale/en/LC_MESSAGES/django.po b/georiviere/proceeding/locale/en/LC_MESSAGES/django.po index 369ecfc3..66dd8f2d 100644 --- a/georiviere/proceeding/locale/en/LC_MESSAGES/django.po +++ b/georiviere/proceeding/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: 2023-09-15 17:15+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/proceeding/locale/fr/LC_MESSAGES/django.po b/georiviere/proceeding/locale/fr/LC_MESSAGES/django.po index 1cfa78ae..9e70eb69 100644 --- a/georiviere/proceeding/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/proceeding/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:05+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:30+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/proceeding/sql/post_10_triggers.sql b/georiviere/proceeding/sql/post_10_triggers.sql new file mode 100644 index 00000000..fd0e2b8f --- /dev/null +++ b/georiviere/proceeding/sql/post_10_triggers.sql @@ -0,0 +1,3 @@ +CREATE TRIGGER proceeding_proceeding_10_elevation +BEFORE INSERT OR UPDATE OF geom ON proceeding_proceeding +FOR EACH ROW EXECUTE PROCEDURE elevation(); diff --git a/georiviere/river/locale/en/LC_MESSAGES/django.po b/georiviere/river/locale/en/LC_MESSAGES/django.po index 374ee70f..52dd1685 100644 --- a/georiviere/river/locale/en/LC_MESSAGES/django.po +++ b/georiviere/river/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: 2023-09-15 17:16+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/river/locale/fr/LC_MESSAGES/django.po b/georiviere/river/locale/fr/LC_MESSAGES/django.po index ea0c1f51..fdccb02f 100644 --- a/georiviere/river/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/river/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:05+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:31+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/river/management/__init__.py b/georiviere/river/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/georiviere/river/management/commands/__init__.py b/georiviere/river/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/georiviere/river/management/commands/load_rivers.py b/georiviere/river/management/commands/load_rivers.py new file mode 100644 index 00000000..1d522725 --- /dev/null +++ b/georiviere/river/management/commands/load_rivers.py @@ -0,0 +1,57 @@ +from itertools import islice + +from django.contrib.gis.gdal import DataSource +from django.contrib.gis.geos import Point +from django.core.management import BaseCommand +from django.utils.translation import gettext as _ + +from georiviere.river.models import Stream + + +class Command(BaseCommand): + help = 'Load Rivers' + + def add_arguments(self, parser): + parser.add_argument('file_path', help="File's path to import.") + parser.add_argument('--name-attribute', '-n', action='store', dest='name', default='nom', + help="Attribute name in file to use as river name") + parser.add_argument('--flush', '-f', action='store_true', dest='flush', default=False, + help="Flush rivers before import.") + parser.add_argument('--batch-size', '-bs', action='store', dest='batch_size', default=50, + help="Size of batch to use for bulk_create. Default is 50.") + parser.add_argument('--default-name-attribute', '-nd', action='store', dest='default_name', default=_('River'), + help="Default name to use if attribute name specified is empty") + + def handle(self, *args, **options): + file_path = options.get('file_path') + name_column = options.get('name') + default_name = options.get('default_name') + flush = options.get('flush') + batch_size = options.get('batch_size') + data_source = DataSource(file_path) + layer = data_source[0] + total_count = len(layer) + + self.stdout.write(f"Load rivers: {total_count} features to import") + if flush: + self.stdout.write("Delete streams.....", ending="") + Stream.objects.truncate() + self.stdout.write(self.style.SUCCESS("done!")) + + objs = (Stream(geom=feat.geom.geos, + source_location=Point(feat.geom.geos[0]), + name=feat.get(name_column) or default_name) for feat in layer if feat.geom.geos.geom_typeid == 1) + count = 0 + while True: + batch = list(islice(objs, batch_size)) + count += len(batch) + if not batch: + break + self.stdout.write(f"{count} / {total_count}", ending="") + try: + Stream.objects.bulk_create(batch, batch_size) + self.stdout.write(self.style.SUCCESS(" ok!")) + except Exception: + self.stdout.write(self.style.ERROR(" error!")) + + self.stdout.write(self.style.SUCCESS(f"Successfully import {total_count} rivers and associated morphologies / status")) diff --git a/georiviere/river/managers.py b/georiviere/river/managers.py new file mode 100644 index 00000000..b3060af7 --- /dev/null +++ b/georiviere/river/managers.py @@ -0,0 +1,6 @@ +from django.db import models +from georiviere.utils.mixins.managers import TruncateManagerMixin + + +class RiverManager(TruncateManagerMixin, models.Manager): + pass diff --git a/georiviere/river/models.py b/georiviere/river/models.py index 41f37cbd..37b50b2c 100644 --- a/georiviere/river/models.py +++ b/georiviere/river/models.py @@ -19,12 +19,13 @@ from georiviere.main.models import AddPropertyBufferMixin from georiviere.altimetry import AltimetryMixin from georiviere.finances_administration.models import AdministrativeFile -from georiviere.functions import ClosestPoint, LineSubString +from georiviere.functions import ClosestPoint from georiviere.knowledge.models import Knowledge, FollowUp from georiviere.main.models import DistanceToSource from georiviere.observations.models import Station from georiviere.proceeding.models import Proceeding from georiviere.maintenance.models import Intervention +from georiviere.river.managers import RiverManager from georiviere.studies.models import Study from georiviere.watershed.mixins import WatershedPropertiesMixin @@ -90,24 +91,27 @@ class FlowChoices(models.IntegerChoices): portals = models.ManyToManyField('portal.Portal', blank=True, related_name='streams', verbose_name=_("Published portals")) - + objects = RiverManager() capture_map_image_waitfor = '.other_object_enum_loaded' class Meta: verbose_name = _("Stream") verbose_name_plural = _("Streams") - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for model_topology in self.model_topologies: - setattr(self, model_topology._meta.model_name, self.get_topology(model_topology._meta.model_name)) - def __str__(self): return self.name def is_public(self): return self.portals.exists() + @property + def statuses(self): + return self.get_topologies('status') + + @property + def morphologies(self): + return self.get_topologies('morphology') + def get_printcontext_with_other_objects(self, modelnames): maplayers = [ settings.LEAFLET_CONFIG['TILES'][0][0], @@ -166,7 +170,7 @@ def name_display(self): self, self) - def get_topology(self, value): + def get_topologies(self, value): topologies = self.topologies.filter(**{f'{value}__isnull': False}) topologies = [getattr(topology, value) for topology in topologies] return topologies @@ -216,18 +220,6 @@ def __str__(self): else: return _("Topology {}").format(self.pk) - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - geom_topology = self._meta.model.objects.filter(pk=self.pk) \ - .annotate(substring=LineSubString(self.stream.geom, self.start_position, self.end_position)).first().substring - if hasattr(self, 'status'): - self.status.geom = geom_topology - self.status.save() - elif hasattr(self, 'morphology'): - self.morphology.geom = geom_topology - self.morphology.save() - super().save(force_insert=False) - class Meta: verbose_name = _("Topology") verbose_name_plural = _("Topologies") diff --git a/georiviere/river/signals.py b/georiviere/river/signals.py index 77e46a0e..3be48afa 100644 --- a/georiviere/river/signals.py +++ b/georiviere/river/signals.py @@ -3,33 +3,18 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.gis.db.models.functions import Distance, Length, LineLocatePoint from django.db.models.signals import post_save -from django.db.models.fields.reverse_related import OneToOneRel from django.db.models import F, FloatField, Case, When from django.dispatch import receiver from georiviere.functions import ClosestPoint, LineSubString from georiviere.main.models import DistanceToSource -from georiviere.river.models import Stream, Topology, TopologyMixin +from georiviere.river.models import Stream, TopologyMixin from mapentity.models import MapEntityMixin -@receiver(post_save, sender=Stream) -def save_stream(sender, instance, **kwargs): - if kwargs['created']: - class_topos = [field.related_model for field in instance.topologies.model._meta.get_fields() - if isinstance(field, OneToOneRel)] - for class_topo in class_topos: - topology = Topology.objects.create(start_position=0, end_position=1, stream=instance) - class_topo.objects.create(topology=topology, geom=instance.geom) - else: - for topology in instance.topologies.all(): - topology.save() - - @receiver(post_save, sender=Stream) def save_stream_generate_distance_to_source(sender, instance, **kwargs): - for model in apps.get_models(): if issubclass(model, MapEntityMixin) and not issubclass(model, TopologyMixin) and model != Stream and 'geom' in [field.name for field in model._meta.get_fields()]: distances_to_sources = [] diff --git a/georiviere/river/sql/post_10_triggers.sql b/georiviere/river/sql/post_10_triggers.sql new file mode 100644 index 00000000..39d0de5f --- /dev/null +++ b/georiviere/river/sql/post_10_triggers.sql @@ -0,0 +1,62 @@ +CREATE TRIGGER river_stream_10_elevation +BEFORE INSERT OR UPDATE OF geom ON river_stream +FOR EACH ROW EXECUTE PROCEDURE elevation(); + +CREATE FUNCTION update_topology_geom() RETURNS trigger SECURITY DEFINER AS $$ +DECLARE + stream_geom geometry; +BEGIN + SELECT r.geom FROM river_stream r WHERE NEW.stream_id = r.id INTO stream_geom; + UPDATE description_morphology + SET geom = ST_LINESUBSTRING(stream_geom, NEW.start_position, NEW.end_position) + WHERE topology_id = NEW.id; + UPDATE description_status + SET geom = ST_LINESUBSTRING(stream_geom, NEW.start_position, NEW.end_position) + WHERE topology_id = NEW.id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER core_path_10_elevation_iu_tgr +BEFORE INSERT OR UPDATE ON river_topology +FOR EACH ROW EXECUTE PROCEDURE update_topology_geom(); + +CREATE FUNCTION create_topologies() RETURNS trigger SECURITY DEFINER AS $$ +DECLARE + topology_morphology integer; + topology_status integer; +BEGIN + INSERT INTO river_topology (stream_id, start_position, end_position, qualified) + VALUES (NEW.id, 0, 1, FALSE) RETURNING id INTO topology_morphology; + INSERT INTO description_morphology (topology_id, geom, description, date_insert, date_update) + VALUES (topology_morphology, NEW.geom, '', NOW(), NOW()); + INSERT INTO river_topology (stream_id, start_position, end_position, qualified) + VALUES (NEW.id, 0, 1, FALSE) RETURNING id INTO topology_status; + INSERT INTO description_status (topology_id, geom, regulation, referencial, description, date_insert, date_update) + VALUES (topology_status, NEW.geom, FALSE, FALSE, '', NOW(), NOW()); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER core_path_10_elevation_iu_tgr +AFTER INSERT ON river_stream +FOR EACH ROW EXECUTE PROCEDURE create_topologies(); + +CREATE FUNCTION update_topologies() RETURNS trigger SECURITY DEFINER AS $$ +BEGIN + UPDATE description_morphology m + SET geom = ST_LINESUBSTRING(NEW.geom, t.start_position, t.end_position) + FROM river_topology t + WHERE m.topology_id = t.id AND t.stream_id = NEW.id; + + UPDATE description_status s + SET geom = ST_LINESUBSTRING(NEW.geom, t.start_position, t.end_position) + FROM river_topology t + WHERE s.topology_id = t.id AND t.stream_id = NEW.id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER river_change_geom_update_topology_tgr +BEFORE UPDATE OF geom ON river_stream +FOR EACH ROW EXECUTE PROCEDURE update_topologies(); diff --git a/georiviere/river/sql/pre_10_cleanup.sql b/georiviere/river/sql/pre_10_cleanup.sql new file mode 100644 index 00000000..e332b66b --- /dev/null +++ b/georiviere/river/sql/pre_10_cleanup.sql @@ -0,0 +1,3 @@ +DROP FUNCTION IF EXISTS update_topology_geom() CASCADE; +DROP FUNCTION IF EXISTS update_topologies() CASCADE; +DROP FUNCTION IF EXISTS create_topologies() CASCADE; diff --git a/georiviere/river/tests/test_models.py b/georiviere/river/tests/test_models.py index d37a240e..69c8ac89 100644 --- a/georiviere/river/tests/test_models.py +++ b/georiviere/river/tests/test_models.py @@ -90,16 +90,16 @@ def test_get_map_image_extent(self): self.assertAlmostEqual(lat_max, -5.655019875165679) def test_distance_to_source(self): - """Test distance from a given object to stream source according to differents geom""" + """Test distance from a given object to stream source according to different geom""" self.assertAlmostEqual(self.stream1.distance_to_source(self.usage_point), 28.2842712) self.assertEqual(self.stream1.distance_to_source(self.administrative_file), None) - self.assertEqual(DistanceToSource.objects.count(), 6) + self.assertEqual(DistanceToSource.objects.count(), 2) usage_point = UsageFactory.create( geom=Point(10000, 10010) ) - self.assertEqual(DistanceToSource.objects.count(), 8) + self.assertEqual(DistanceToSource.objects.count(), 4) usage_point.delete() - self.assertEqual(DistanceToSource.objects.count(), 6) + self.assertEqual(DistanceToSource.objects.count(), 2) class SnapTest(TestCase): diff --git a/georiviere/river/tests/test_signals.py b/georiviere/river/tests/test_signals.py index 148669ad..7924962e 100644 --- a/georiviere/river/tests/test_signals.py +++ b/georiviere/river/tests/test_signals.py @@ -24,11 +24,20 @@ def test_update_stream_move_topologies(self): stream_2 = StreamFactory.create() stream.geom = stream_2.geom stream.save() + stream.refresh_from_db() self.assertEqual(str(stream), stream.name) - morphologies = Morphology.objects.values_list('geom', flat=True) - status = Status.objects.values_list('geom', flat=True) - - self.assertEqual(morphologies[0], morphologies[1]) + stream_1_morpho_geom = stream.morphologies[0].geom + stream_2_morpho_geom = stream_2.morphologies[0].geom + + self.assertEqual(len(stream.morphologies), 1) + self.assertEqual(len(stream_2.morphologies), 1) + status = Status.objects.values_list("geom", flat=True) + self.assertEqual(stream_1_morpho_geom.length, stream_1_morpho_geom.length) + self.assertEqual( + stream_1_morpho_geom.ewkt, + stream_2_morpho_geom.ewkt, + f"{stream_1_morpho_geom.ewkt} - {stream_2_morpho_geom.ewkt}", + ) self.assertEqual(status[0], status[1]) diff --git a/georiviere/river/tests/test_views.py b/georiviere/river/tests/test_views.py index cfd3f077..5e2a667c 100644 --- a/georiviere/river/tests/test_views.py +++ b/georiviere/river/tests/test_views.py @@ -237,15 +237,15 @@ def test_document_report_stream_succeed(self, mock_prepare_map_image, mocked_pre template_name='river/stream_report_pdf.html') self.assertEqual(response.context['status_types'][self.status_type.label]['percentage'], 75.0) self.assertEqual(response.context['status_types'][self.status_type_2.label]['percentage'], 25.0) - self.assertEqual(os.path.join(settings.VAR_DIR, 'media', 'maps', f'stream-{self.stream.pk}-usages.png'), + self.assertEqual(os.path.join(settings.MEDIA_ROOT, 'maps', f'stream-{self.stream.pk}-usages.png'), response.context['map_path_usage']) - self.assertEqual(os.path.join(settings.VAR_DIR, 'media', 'maps', f'stream-{self.stream.pk}-studies.png'), + self.assertEqual(os.path.join(settings.MEDIA_ROOT, 'maps', f'stream-{self.stream.pk}-studies.png'), response.context['map_path_study']) - self.assertEqual(os.path.join(settings.VAR_DIR, 'media', 'maps', f'stream-{self.stream.pk}-followups.png'), + self.assertEqual(os.path.join(settings.MEDIA_ROOT, 'maps', f'stream-{self.stream.pk}-followups.png'), response.context['map_path_other_followups']) - self.assertEqual(os.path.join(settings.VAR_DIR, 'media', 'maps', f'stream-{self.stream.pk}-interventions.png'), + self.assertEqual(os.path.join(settings.MEDIA_ROOT, 'maps', f'stream-{self.stream.pk}-interventions.png'), response.context['map_path_other_interventions']) - self.assertEqual(os.path.join(settings.VAR_DIR, 'media', 'maps', f'knowledge-{self.knowledge.pk}.png'), + self.assertEqual(os.path.join(settings.MEDIA_ROOT, 'maps', f'knowledge-{self.knowledge.pk}.png'), response.context['map_path_knowledge'][self.knowledge.pk]) @mock.patch('georiviere.river.models.Stream.prepare_map_image_with_other_objects') diff --git a/georiviere/settings/__init__.py b/georiviere/settings/__init__.py index 4daf8d0f..b06a38a2 100644 --- a/georiviere/settings/__init__.py +++ b/georiviere/settings/__init__.py @@ -139,6 +139,7 @@ def construct_relative_path_mock(current_template_name, relative_name): 'mapentity', # mapentity should be placed after app declaring paperclip models 'leaflet', 'paperclip', + 'django_jsonform', 'crispy_forms', 'rest_framework', 'geotrek.altimetry', @@ -206,6 +207,11 @@ def construct_relative_path_mock(current_template_name, relative_name): 'mapentity.middleware.AutoLoginMiddleware', ] +LOCALE_PATHS = ( + # override locale + os.path.join(PROJECT_DIR, 'locales'), +) + ROOT_URLCONF = 'georiviere.urls' TEMPLATES = [ @@ -476,8 +482,9 @@ def construct_relative_path_mock(current_template_name, relative_name): SESSION_COOKIE_SECURE = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -# Override with custom settings -custom_settings_file = os.getenv('CUSTOM_SETTINGS_FILE') -if custom_settings_file and os.path.exists(custom_settings_file) and not TEST: - with open(custom_settings_file, 'r') as f: - exec(f.read()) +if 'test' not in sys.argv: + # Override with custom settings + custom_settings_file = os.getenv('CUSTOM_SETTINGS_FILE') + if custom_settings_file and os.path.exists(custom_settings_file) and not TEST: + with open(custom_settings_file, 'r') as f: + exec(f.read()) diff --git a/georiviere/settings/tests.py b/georiviere/settings/tests.py new file mode 100644 index 00000000..17fb51da --- /dev/null +++ b/georiviere/settings/tests.py @@ -0,0 +1,28 @@ +import shutil +from tempfile import TemporaryDirectory + +from . import * # NOQA + + +CACHES['default'] = { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'default', +} +CACHES['fat'] = { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'fat', +} + +# recreate TMP_DIR for tests, and it as base dir forl all files +TMP_DIR = os.path.join(TMP_DIR, 'tests') +if os.path.exists(TMP_DIR): + shutil.rmtree(TMP_DIR) +else: + os.makedirs(TMP_DIR) +SESSIONS_DIR = os.path.join(TMP_DIR, 'sessions') +os.makedirs(SESSIONS_DIR) + +SESSION_FILE_PATH = SESSIONS_DIR # sessions files +MEDIA_ROOT = TemporaryDirectory(dir=TMP_DIR).name # media files +# Use postgis image template to make postgres/postgis extensions available in test database (postgres_raster) +DATABASES['default'].setdefault('TEST', {'TEMPLATE': 'template_postgis'}) diff --git a/georiviere/static/images/favicon.png b/georiviere/static/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..4e6e264769cb23f96b4549d24e64449579eb01eb GIT binary patch literal 9773 zcmV+|Ceqo7P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3*tb{o5rg#Tj|y#&liEC=H`y@OtUe^I1l*|OjD zJ>8ZiN)%Ov%8ZNwMR)$^zpwi*{*`Rza%on3t)73m=N<>&n1B82&)4Ad^Y!^x^ZR$< z=jYw$FCtGR9@Fc`Hr}rvJYRnN!N)nm{P}rj@83jwAL#vyj{_#1Ir7c^x=5anujj*i zA+DeIlDoC9OP%rcxIR{FKR)|@UioG`wDN8_{beS1DS zALfT&oND{!Cm$>H5b4TwA%`7CxZymHD=e0nm%IQQcpV> zJzViPdX`iCTuXS)+n@8TV4QgeUK#@jQ``Ue;r`Pv|ILTHcd10d+h?sU@JX+PQ0%0`08f#VvTwlZlVz- zRp!a)(4bj47x2@vfuT`S$)%K9S}|9888y{hORcq4k=$shp~ zHhSy{4lKR))~)wGMs$M{4URQ<{ot7yXPSAIS!bIz`y31Sth~yqt1Vl7jU9K|(8R90 zZQFg16WoC*PCn(-(~g~f#szCP-F(Zfw_Usaj?b>WyZY_pUvw?JyB42L>2>Y1Ydq^( z`#3}pofP$qj>R14cvA-ebX3p0g`A^0r=Iy98Hy5FWKnP24(b>!%qPTh!)Nb)bndVE z=8FAQ-{L=Y&Z%|(ADwfw?t9;U)3q&~TTjPcQ>dEy#Pso+5zX=GJzu^();S@;6weu~ zxE4E-zKt@+J?gcY&*Ym~RvD*nH~Q%;gYin?j8qq+RUgVleVUy%?hL$$hgoH zDNSpm}g(Pk>T zZ0gOqXWv%OX~2A}dS}Uf9jI(WBgP8_mzFLaz$kS;TR0{9<15|0jX%K?tWwr7qnwMQ zZP3>TO^SYTj_HrLgBxl*!>>Xzj=&c`_PtVFNBayEupxXv!}+y}2M+FUar2)Zq2%K7BeYqUGr$HD zQ<#fD(ONsr2kViO=UtgRUjOywor}^WcRbi+L?(@C@U4OT8`qxDK2}L~FXjVlhqa3u z-!miz&h`y3cAVqQ0YUSPV`J{IQFR@N1EFH7WVza-8Bl9&aU5tULJPZvVty@N+foYf z0>ra`12{UR^aD{Bi5=8lE)0@5G4iw+OFtMK#`I}(8KHk`{Oi&AKm6kFAIDhB8n$L4 zm0{yJf$N=|o$d!LP=`mcW&oCXdw@P+_2O9_R|;D%)AV5>xcuR`%(l;nEkm`n&rUUX z5%hH{Qy|eZu!y`hMhU5p%PgQvn~u~fxI^fR6+xnO=CNz^P~yn5gf=Im(jpFjMEPVxD4)YCMD*WYt5Wd8_2Ns z!m?)%yH7;4U5AyZHW?wL6EAzOd6NjCeq|eDC3Zh!+8xHS?+)cK2e9R2GB%j#I12d*RUoh#?00qS=5 ziM~Nc7zu3A-5WZ;P|U8xij6(UXhG<80lBCD_5!&33vT@KsC1DApJx_7_rX!abs^vF z0+gKl+?L^sdc=6O<_XFgHE46SL+Gcv3(1EDWRu2X zB~qe-0r74@WoM2b53oGV;jtXF1**V=$A}&RT&T~@J;7MMtug9JS&Z_5*hlaowpo$A zf(?XBsQU=bsBMo2g9Q{o@|C6>7cqTMZK`8j9Sg;c9ZZ5BcmcoBA;W-DkLLG+*(p~& zOh;HZj;y{=jd3|pAfiJOUG8RKzwl82m?uZacVw>S!bbQ{GeJHsqyy=joMLfh(O7Ra zm(UKZ-Hy{38-53p$?tN6vuxD_EOPCALQWhAr_h+Q!MHZ$6pxC7OZhr{SY?!oW*iy{ zFLvO%+84seJK&veXj{Eh8}ZN51jY;RGl8q?839{1foN(BglW7Q%h|{m?(gt$5hiFw z9Fm6^n=^fmNnR=784$?PH{8gWO3o6M0Sb6c0B1I#@g5k5aE~-CAe>|ctOC~uwjMa( zYC4>aStQ(;jN-lu0EP@;L$#g?_vAYi-6h8SN!a+JHh-c?WN{bgE7rpYY?z*h;pi8G zzM2ZMG1y%C;0nh~Oy0WP!(dF~fJp)X>w!~|Fx`>V7pN2KN5o&GfeS*xWb6~F?f7Mn zaraLL@urLPP#W5R_@>K%MY)gcLS$ShA&!oxTjs~fwJk!+A)n2U`V=%AkkcuKQN|C& ziHmylMz(l4f1b{tHuvKZEM8Bp*o!35FS7#naN38+JMed2j-8w_;BKaXyk;33;ZPZQ zeDb|=qP@%MAqptllKBU#1e(m)F)^^(W!bEzh$j*kN6gy!L2s!C8-F9#e#5B=71{Fd z>*fIWJS$c$g1B@r$%G4xmBLWml27*-)clVsc)$+}0!l@ES&GaM(1ApgI*w%Dl9kd> zb=BFns<}7gRwL3va7_$9G&f-qnvRcnfmf=7!6H~Hiw1{NuILwOY9)pwknuEE~^fK)N^aNJ4|33=$Es`W5;nVgg5mtnNB z7>uCJB?J)dj-Xw@z?M!&@1QFH&5<@F;ZMN4T;NHv7y*KyuyB=}(aVzLm49R1+|N&x zcC)FG$p0Y{sf{!b{Hl#pR)cwjAJoQ9HMu?!#WcsV5j@DG<)6&uzx9$YGg{J;MbnHy zc#Ns`LzU4-h6rY-(aFfTtZN_K0}Qo4oj+1%82cl&SzeV(cc3K+c zcpA7TS%S`|Hzzbl6eMRy;JqR!SlEV2ofs|>FO+eZP+3^TvG+vhKiBC0XxP6UsK9Y^ zk)uI+in3yIA($RaIzof4+z6S~V& zME{L=|NIDXbNt%~es=dul>5SKLhw|EiR}rsz%}VKxWLKvjf@MQXg<&0T^O z9B?Bjis>Oc=D(hapPNWOV#I(7>22@{quhdbuh0*O)ds@G`H)%_f0|oN`f7JoZ4qI2 zA?*ZV)3FCp2-NdMY7ti#v+gjd4;J5!W58w`TboJvEgVNAi)V<7zWYLs z)oxlv@aRxcu;YT!xIK}%W2ussjM3jjm_A)E(R+J0eCfsPC>Km^j>);93_qOPrEAFPL1|?S<(@B|K18cA5slxz zus*)9iD=QJLlV!@77yXybh(5ggiRS5hYCGOCFVsY z2!3$1_MLdLWs~#45(6XFWh-(xAj48Utd^CsXyl68|6Gs1js(uk1);PUI1iezW8I&A zaqPF5@=Q@cQUOWMH80FHJ?X1qCPEF?p@v2Mk0SeZ8M{FfaX;Eu%&}}BpNSz=-n1r# zZY;^rVA6^R@z21(24cu!zW9U7lvSs_IBdT*LTpn;PH-wdkMv8&7zai%)4s6MJQ~rf zwSZ1kZ_A0WF5$`{Kr+PTKufSsu}O318<6F3XU6aW%q8;;UG`3+3ImoV4TVE9;+-6E z_B8dd!-@($+J1xzBNiT)_PHe!|47(H9yq(I9o$(MxPrHQ?3!Ip{Mfa=U@_S>IN@al zaSA|DRuCkH3yZ0XURz`B?F9?g2A4=vJHfj(Tx-6_VQf1DSm!kQ3MH{ft)8ld8jXz2 z)LuAySQ2t~fcY^r9G9p@&!}X@MBjBQna{>r(re-;nHV~fS#1A!BbgEgNMu>_ny0H*U}LzG0%dT@Y|hVGZ&Mf-8>35x=4GDa!Gl|6LB41hg6 zFyluqbxKMJ7dbD~fv|KS*TAE8KU#!$0@fl@=7+Ylr)ShQzEkcC103<0{=XES=TSET z_E4p1ox~kyYXCSpDw`H*P+|1dI?@5-fLDr}p_mxgs~v16h|#JWt4x1htpjmF8H2rC zTa`gjBoBx)`qM#3hvqZBzr43%@DFfSQ`zG_1##~lt;67wCRZTsb;p8F&KS>*CX@!_ zH7pb%0m(O|G%h4-3#$owH^3>GxCBPd-1xOVz13TngO!++MU7FTZrHY?eSf_M!5>Gv zw|MnuHw?L=sbfrI6~Lmw%nERvWw{-$m}H?YL~Yy~9GW$~S}IujG#q4{RKq1>)qq5h zw@H%&Qv$O0m~^d?2N_`g!Bv(SkNDmr)GjY{LCRoCA)cv@L@tXzfe$4QFgXbd9&%98 z7S9mEIe!OELHIz3o_iwpPp1YAMf!?9gv!PDe{Wyl?fd_jnIg95=u_s9m;TtR^1 z_t@N}pc;i-RsJ=WZnaT;NLAe2pN*HN`d2$loNmFq^epim>V^z3p0s})I8EZ^nKtIF zc=)^2lywvG=+44ZD!sjAXj?;C!0A5j_0#~WI65&a>~njkE|_Ur8wj(3PV3gb-b!)9 zCdH%fPym@HA0!@g-$$%~u4gEO_Hdvp4_M(nFv^po=}xq1{OQde7vTav@p5@Y4f;Eg z2Nr|l%a0X6RZhMXn|c5Jk$^IV%ODOT#G>)M;j}C^#%(-TwzY9Jn4G;_O01TN2=o9Z z4Ny;DI>_Xh=5eX~pr#}w-|^NZz4JrlFt>GoAv>y!8<`kd0`j`cOh!~jp0>9rZ=oAn zx&Sf{KFe#*zDJ;J2OCKiXcmU*H->(mRDPO$Ulm4CbQXEG+$OQ(Nv(qF=?q?OaClW%uw6c0cWoQ?nI14v6A}Rc1e=g8 zVhWIQ6dW_cH$bprM7UJj){$NY(DkH!**4mxlCbxHkMJz_MQz>A{k-MZ_ZCH6rklWn z6d}Rd&DIv(b{?+?#RsfWA!?;^$V`|vj?#9n6@CXxVUs~2eeLElI*1ycMK_stTuH=i zF5c;}x~!CWwHEPo+8Gdz267O!bJ#ivqU;Q>wu*ss#nAt3-JHI`e?qd*o^@ZflN*O! z)SYo5OgtsOP(8|<6Jqwn@FvBisazZ$)~Zt*p^+V8QX5g|sxAiH#?wWNQjmCb7$Vl^ z-dDwQhN+!O#){D=X5Q?$`b(e*L545o+P7KMo^BxedR~#J%JC0D!na*BKr+s{H-uH; zC*X%OascS1VP3bM{$aq?Hl#3%>WG1q+F7on&@e#tm^dL`xp z%xJPJBy)6yfohB@BhSJZNmxU>LUBvyq&dKlm2};Ip*;IUn^6|E(RCJAFcF8sZM{kG zEbTT!mS7oOIGQZN4$&x_;af#P!2n;a54!uY2}o2yd-V=Y3|+xPOc@i17s^mb+7#)8 zJ?}Y4rS?Pv>j87m_I$(&HoeJ$q%93^4hYr4DG~Zee5W~O(J<<>+Qu#ILC$BDdot6J z&`#^SW-owgIGcH`xX0-DE6sx5bwdFe`}VP)c^(HFHrJpX>*q}w#@HRXKD6~R8gK}P z9LGUi*9}UXZi34>8}X)TgB4d1=`fB_?-VH*OiXtL&6_p@lcG%kxRP*{Sz1_*X(Uli zcqoy^8P27xQIaR)N~3!(IQgvm2h#{m9?ebid}j$;oB?d*d4dCM@CT!h7f?fiz?r&J zEJ1_-8C8@}hJ_fd8Yw1Hv>*5I z4?2F4Tr#;zVB}ap1u7)R5B>+gyEXHZ6K+yC4s^cQ_Qwbi+yxpn+x|Yb?ZycZcm}St zmcLR5WT(>lN54hX`2A*`ukQ^yM)1S`+?`QN)S)l(G=w5SsYwqLp z0Z3C<$s6F{5Ev~`_PWQrL+!o&d#2gn4y{D4^ z000SaNLh0L02dJe02dJf$|mza00007bV*G`2jmP05FjZ1QW^3901jnIL_t(|+U=ct za9q`W$3N%XyQ|%mR+5n{3Ck9?F@_ijCNnLIdDIw!92(kAr^GGH;5L~|(snu_QN~vbx8sJ)B4X_;O0@{F9APhwE zhopfFFb<3XgTMgr0q{1kS41W@Uly*~YgW|+BoAN(unD*gxCvMVSS31z2OI=;0eh2t<;JOMoVK?w2;HgNc;k{cr)WQT3PdLrMy};*zPgKbb z`g7o)fhT%z#}6klLPl%yR>2_))k?y9weS{ zRzxG=$AJC7162%zeA^xXK4@=={$o7hEPreJqxIn-@q`lv{xpAq4b-L_;9J1My*;5+ zO#=DRffT`jy(NDkmr@@(3OsNkHMwoey0+Q@5>Gf?z_)?BsZVVOe!sUTG+5O@p6gFz zX?-{FFF-r>tzqD^o@buDYgMHBl6pMh*p}Ac0DiClU;r!yerQ?xZ{i8ZswxkOC!AK` zd%*21OuYbnrne__V%~wo6V6KDdEiPGw%!Fk)!P#~G;cuS3FjK%zw4+MRf=BNoxMGw zy%h%%PdMxIFqSTI_(-zf)Y}uIkr*wsjk+;HkXrN(bcG4<`Zs z66jsDal!f&@E3U-mIg?`wt9iDEZVkc{Yk*K?kq`Ck0+d^z+3o*1&bfo<%#(U9KQQ zj*T7)qNd4dc(hD1i>VAd#4(7;OI!F;cEb{?{I zSF#g0&>2wSo{(l)SkY)Jy=dZ#%Lt5QjTy)Y2OOhZ;X;L8Dd6(HSaj&39-_W~ko&va zzX~)SpAwEwd60y5Tbb2iO;=dav4fd@tl~$8N&w|L&g;R^fe8v?2AQ*FP1~TG!qPf z5m)p`Qux25VQD~_RbkDtkXjIMCZ>D{2Qos^let`P1o$K1&!+W|t-F#T{A%%{o{4v! zf)iFMWGPiI5Yn!gBN^d9#xUkeDQ~)AU`1annw`!=?qLoE3dg5JAD{AI0_hI=aJs@; z1vNDXC+mqEb3{H&8;(thF4F}p1%3;7;v*h%-)j@tTI~RCE#(;%$jSyKJrS+CLK;g| zFC15p!HkjqjA0-vxS`!a^GM%IS4ERSef z0+sI!oXm+C$Qbr#j4GmsR7&Z*?H@TEvbMZ>l8~i;4BRpwEo!rsSrgH8m$@_Gcp`^0 zA|Iqno}A~Z9ckCwxb5av-$O!{zN8%CYPK46MUPH+s0rw4P-b;R(;3oAmmuF5L5@2{ z4x~@#g=)-UH%Dx%6*zG!WNoAh8Y;k{tms2okC67{im)~-BbsOtAuTy2rax`im!8$g zdA?=k8EiiV#8T=F;QDHBaX=wgMzpNDfY%(FH0I5urzQ=xIv{=B75OD~|4%0xfRohN>ZWBu zWv*;IlWSZ-2GU0Mri_~Kq!yuMfL0s0f*M?Eb~GpYXwIWaduCmeC6`6CTGgoa%0?~6 zGREvl8b;ij3R_`dwT)jpRI^GM^#4wH>`r>Ju2Gvcjan@WYrQP28FGx-n=%~B)eM+x zZD1{RpeaH9B4yOuDI-@mYO}UUtB#P?9U;v~R?MEH$I{0Hs}LMJ^@&yTozU(z$D+`?9qEv=Rbs(A}z)`G1VNn}O8Fzk|Y&n>%I5z+cr zJ8Zh=$y*k5ebq@%RD9UwL_V*a9x_|Lx2$i{m4@PJ{3^NytE`}29Cg*i)VZFbfOXB5 zzNOVN6`UBG)cBiqEokKg^~#vbl=*1xSP{|smR8HFP*Wx}Fv_Axd8mCU?}EvEc|=?5 zA{7Z=V;VTVh{M-lcKYRHYnv)Ef*jO9ViAR}j^OkK76Dz6Ofq2OZ!)}~6;#MIjat@5 zt!b@3=$J~Bk?psEJqr;+lZ9E)py_H*bcD3B)bvAutS9ouL`CxY9vj#Px=0cd7i2(q)?+=?o}o4=A-HpiFB} zsU}8%2!ro@b7MGfV|9L^e@m2*Q{6;{zb z`^-)LwpwSe4|xXo`h2vvE1=B!7K=-Q#jO(&K}K>Shckvljv;M|PwMj&uAV;i$7vur zWBP+y?*eYDR7(`dFE?AVx=9xmLUIP_pY(Wl(nuBLfnxQlYvlCw)#;0PYC{XaW0h(} zNF$ruZ7weYhHH>_Cp_7He2UkTM%6G3fXANLaHh`W{0n&oI8p8vgf;TXHk&2E!l#D@ zGsgVq&=k8<({5W)OBw^Vo&Aq2iCCtzc7|Ezv+>FpR*PCp`1gxTkA)$-(*h zc3&)-nHET1-vYAbYQcsU%M`lE(-marsB7Lyn|i7NaDcx(|DPYZz#S)2$ME03RB~M& zQf7HX>q0%_)lpXt=cL{WGQP9pL~8J&K>9ad3h*%g0+N!+Qpm?!tb#+$TPHnpr2b%} zfxpVznu~=jz7UHZ$%n3z>8gm9aDhvnN3&9IVd|8=`EV?H>_Q)0@M81B^{?aa_FodM zjudX!JxQ+)S#SjS>+WejoYqQxv1kha&1!SjQd^lqmo{fT%#oa^It22id~dceAOM3- zZX5n9jOL^jp~CGt;few34aj!KGvA--3o`X+fIQ-O=GobNaaM+U+IO>u@ZUjR_!y+f9p%Y~T{67ZFag{p#p zza8EFLR;jS;zCb%SgQtA(_w2La8KTLr3I3QG78)U3>9*MtdXJ$m2Hh$AGK-~$Z_DV zzF2gukb?@234O8X2f$`ve746V-Qvd+tDCg$2vp?~=9x4OZ0?Ii4-|S}Q9-&d7Tulq zlvzO;&lP`@E8wO}EY+xMCUlJhcl5=gZx(ZK(OI)E7TpEhk?&v6s6)=&Y#Fwc+SF!C z4L}(NZtsgl-zfG6=G2YJpZF%=;7s2eb48!Oj<1MuiN15GO-J>1g%1E5^3j8N0g{KZ zAGiT{b*AsVpPG9b2wO^RYPY$r@{2Jqrk?<@e12tKf#jhK1Dk-qpZ4}~j2@e#mjGym zy1LoYceUHnSsls#(R`R1DaDVK^ac0%?Pfm%{1ec6{^zZhk~^2$*d^L}T!5o#W8O>} zb+Yoi$c+PENW0#)Z8x`;>_=5O=J2-eO7`GC2EOAX-Pn*eH(X+sw3Jf>4rh$nlQL@D ztq7Pn@cF)2bfDBftjaa1`g=c1DI>BK_y*@~@V~xEn{~~l-$g=zVaJ&FCyg4&mIcf( z@Q_yO@qfLcsVs(5Wu4^~!`7coE&=`=_yf+Wl)RzY@-A=EWh?be<=1(<@2~TiE1ao( ze*9WrEIM9J*He{v?4bT(H}_w^W)1$!OYS9Kb9+;ZC6_gpw-DV33}uZum^K`8M47@K zdU?Q;z}Ft%x%YrP7%RW6D)}7fj+aO14mVr@{4uZ<2*sK$Z*>_xKdOqQxd?1^E z+wd!rmY4W6hk@7dmyo?Auy1ousA_DWsjzLN00000NkvXX Hu0mjfAbi<8 literal 0 HcmV?d00001 diff --git a/georiviere/studies/locale/en/LC_MESSAGES/django.po b/georiviere/studies/locale/en/LC_MESSAGES/django.po index 021d3de5..f47440ea 100644 --- a/georiviere/studies/locale/en/LC_MESSAGES/django.po +++ b/georiviere/studies/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: 2023-09-15 17:16+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/studies/locale/fr/LC_MESSAGES/django.po b/georiviere/studies/locale/fr/LC_MESSAGES/django.po index d4079ad8..2f953198 100644 --- a/georiviere/studies/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/studies/locale/fr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:05+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: 2021-11-23 15:31+0000\n" "Last-Translator: Jean-Etienne Castagnede , 2021\n" "Language-Team: French (https://www.transifex.com/georiviere/teams/128005/" diff --git a/georiviere/tests/__init__.py b/georiviere/tests/__init__.py index 405b4d65..212fb5b4 100644 --- a/georiviere/tests/__init__.py +++ b/georiviere/tests/__init__.py @@ -1,10 +1,10 @@ from django.contrib.auth.models import Permission -from django.contrib.contenttypes.models import ContentType +# from django.contrib.contenttypes.models import ContentType from mapentity.tests import MapEntityTest -from georiviere.main.models import DistanceToSource -from georiviere.river.models import Stream +# from georiviere.main.models import DistanceToSource +# from georiviere.river.models import Stream from georiviere.tests.factories import UserAllPermsFactory from geotrek.authent.tests.factories import StructureFactory @@ -107,17 +107,18 @@ def test_update_not_same_structure_no_permission(self): response = self.client.get(obj.get_update_url()) self.assertRedirects(response, obj.get_detail_url()) - def test_distance_to_source_is_available(self): - if self.model is None or not hasattr(self.modelfactory, 'with_stream'): - return # Abstract test should not run - self.login() - obj = self.modelfactory.create(with_stream=True) - response = self.client.get(obj.get_detail_url()) - self.assertEqual(response.status_code, 200) - self.assertContains(response, - f'''\n" "Language-Team: LANGUAGE \n" @@ -51,6 +51,9 @@ msgstr "" msgid "History, details, ..." msgstr "" +msgid "POI link" +msgstr "External link" + msgid "Type" msgstr "" diff --git a/georiviere/valorization/locale/fr/LC_MESSAGES/django.po b/georiviere/valorization/locale/fr/LC_MESSAGES/django.po index a79b6d08..66f88b24 100644 --- a/georiviere/valorization/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/valorization/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:06+0000\n" +"POT-Creation-Date: 2024-05-03 12:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -31,7 +31,7 @@ msgid "POI Category" msgstr "Catégorie POI" msgid "POI Categories" -msgstr "Catgéories POI" +msgstr "Catégories POI" msgid "Category" msgstr "Catégorie" @@ -51,6 +51,9 @@ msgstr "Description" msgid "History, details, ..." msgstr "Historique, détails, ..." +msgid "POI link" +msgstr "Lien externe" + msgid "Type" msgstr "Type " diff --git a/georiviere/valorization/migrations/0004_poi_external_uri.py b/georiviere/valorization/migrations/0004_poi_external_uri.py new file mode 100644 index 00000000..00ca73c7 --- /dev/null +++ b/georiviere/valorization/migrations/0004_poi_external_uri.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2024-05-03 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('valorization', '0003_auto_20230515_2011'), + ] + + operations = [ + migrations.AddField( + model_name='poi', + name='external_uri', + field=models.URLField(blank=True, default='', verbose_name='POI link'), + ), + ] diff --git a/georiviere/valorization/models.py b/georiviere/valorization/models.py index be8ada1f..c2917267 100644 --- a/georiviere/valorization/models.py +++ b/georiviere/valorization/models.py @@ -62,6 +62,7 @@ def __str__(self): class POI(AddPropertyBufferMixin, TimeStampedModelMixin, StructureRelated, MapEntityMixin): name = models.CharField(max_length=128, verbose_name=_("Name")) description = models.TextField(verbose_name=_("Description"), blank=True, help_text=_("History, details, ...")) + external_uri = models.URLField(blank=True, default="", verbose_name=_("POI link")) geom = models.PointField(srid=settings.SRID, spatial_index=True) type = models.ForeignKey(POIType, related_name='pois', verbose_name=_("Type"), diff --git a/georiviere/valorization/templates/valorization/poi_detail_attributes.html b/georiviere/valorization/templates/valorization/poi_detail_attributes.html index 2c91b0c7..f738706b 100644 --- a/georiviere/valorization/templates/valorization/poi_detail_attributes.html +++ b/georiviere/valorization/templates/valorization/poi_detail_attributes.html @@ -11,6 +11,10 @@ {{ object|verbose:"description" }} {{ object.description|linebreaks }} + + {{ object|verbose:"external_uri" }} + {{ object.external_uri }} + {% trans "Category" %} {{ object.type.category }} diff --git a/georiviere/valorization/tests/test_views.py b/georiviere/valorization/tests/test_views.py index 3fb2d09e..174eb07d 100644 --- a/georiviere/valorization/tests/test_views.py +++ b/georiviere/valorization/tests/test_views.py @@ -27,6 +27,7 @@ def get_expected_json_attrs(self): 'date_update': '2020-03-17T00:00:00Z', 'date_insert': '2020-03-17T00:00:00Z', 'description': self.obj.description, + 'external_uri': '', 'geom': self.obj.geom.ewkt, 'type': self.obj.type.pk, 'name': self.obj.name, diff --git a/georiviere/watershed/locale/en/LC_MESSAGES/django.po b/georiviere/watershed/locale/en/LC_MESSAGES/django.po index 7c0cda06..8b48c85a 100644 --- a/georiviere/watershed/locale/en/LC_MESSAGES/django.po +++ b/georiviere/watershed/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: 2023-09-15 17:16+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/georiviere/watershed/locale/fr/LC_MESSAGES/django.po b/georiviere/watershed/locale/fr/LC_MESSAGES/django.po index 3cf35592..0c8b932b 100644 --- a/georiviere/watershed/locale/fr/LC_MESSAGES/django.po +++ b/georiviere/watershed/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-15 17:06+0000\n" +"POT-Creation-Date: 2024-04-29 13:13+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/requirements.in b/requirements.in index a4d9353d..22f972ec 100644 --- a/requirements.in +++ b/requirements.in @@ -14,6 +14,7 @@ sentry-sdk django-admin-ordering djangorestframework-camel-case jsonschema +django-jsonform # Use until latest version of geotrek (> 2.99.0) drf-spectacular diff --git a/requirements.txt b/requirements.txt index 60c38c92..bc2e80d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile @@ -88,6 +88,7 @@ django==3.1.14 # django-filter # django-geojson # django-js-asset + # django-jsonform # django-leaflet # django-modeltranslation # django-mptt @@ -136,6 +137,8 @@ django-js-asset==2.0.0 # via # django-admin-ordering # django-mptt +django-jsonform==2.22.0 + # via -r requirements.in django-leaflet==0.19.post9 # via mapentity django-modelcluster==5.2 @@ -251,7 +254,7 @@ persistent==4.7.0 # via # btrees # zodb -pillow==10.3.0 +pillow==9.5.0 # via # cairosvg # easy-thumbnails