From e0216329445114b3b4b5095d7861a8e1265977ed Mon Sep 17 00:00:00 2001 From: Jean-Etienne Castagnede Date: Mon, 29 Apr 2024 21:49:17 +0200 Subject: [PATCH] Contributions custom (#248) * add load river command * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * fix command * lint * add load river command * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use pg triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * use internal triggers * fix command * lint * Add custom contribution types (#244) * Configure custom contribution type in admin * hotfix pillow * improve data perfs * improve data perfs * improve data perfs * add custom contrib type description * add station endpoint * add station endpoint * add station endpoint * handle 500 * handle 500 * handle 500 * handle 500 * fix station endpoint * fix station endpoint * fix station endpoint * fix station endpoint * fix station endpoint * fix station endpoint * fix station endpoint * fix station endpoint * fix station endpoint * revert default json * fix and explicit default json in api responses * fix and explicit default json in api responses * fix and explicit default json in api responses * fix and add contribution endpoint * improve load_river command * fix contributions * fix contributions * pep8 * pep8 * fix migration * fix tests * fix tests and station layer migration * fix migration * add shared contribution_at field * add shared contribution_at field * add station json / geojson urls * add contributions by station endpoint * add contributions by station endpoint * add contributions by station endpoint * add contributions by station endpoint * add internal field * add internal field * fix translations * add password management and allow send files * fix password check * Improve contribution API by station * fix test * add custom contributions endpoint and layer * fix serializer * add serializer for attachments * add attachments in admin * add attachments in admin * add attachments in admin * add attachments in admin * fix signal * fix river trigger * fix test * allow deploy develop branch * set changelog --- .github/workflows/ci.yml | 45 +- Dockerfile | 2 +- Makefile | 2 + dev-requirements.in | 2 +- dev-requirements.txt | 29 +- docs/changelog.rst | 3 +- georiviere/contribution/admin.py | 146 ++- georiviere/contribution/forms.py | 101 +- .../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 | 923 ++++++++++++++++++ georiviere/contribution/models/managers.py | 30 + georiviere/contribution/schema.py | 579 +++++------ .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../description_detail_fragment.html | 10 +- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 7 +- .../flatpages/locale/en/LC_MESSAGES/django.po | 2 +- .../flatpages/locale/fr/LC_MESSAGES/django.po | 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 +- 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 +- 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 + georiviere/portal/serializers/contribution.py | 239 ++++- georiviere/portal/serializers/map.py | 10 +- georiviere/portal/serializers/mixins.py | 8 + georiviere/portal/serializers/observations.py | 49 + georiviere/portal/serializers/river.py | 62 +- georiviere/portal/serializers/valorization.py | 67 +- georiviere/portal/signals.py | 69 +- .../tests/test_serializers/test_portal.py | 4 +- georiviere/portal/tests/test_signals.py | 8 +- .../portal/tests/test_views/test_river.py | 91 +- .../tests/test_views/test_valorization.py | 8 +- georiviere/portal/urls.py | 75 +- georiviere/portal/views/contribution.py | 289 +++++- georiviere/portal/views/mixins.py | 29 + georiviere/portal/views/observations.py | 34 + georiviere/portal/views/river.py | 61 +- georiviere/portal/views/valorization.py | 56 +- .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../river/locale/en/LC_MESSAGES/django.po | 2 +- .../river/locale/fr/LC_MESSAGES/django.po | 2 +- .../river/management/commands/load_rivers.py | 2 +- georiviere/river/models.py | 15 +- georiviere/river/sql/post_10_triggers.sql | 21 +- georiviere/river/tests/test_signals.py | 17 +- georiviere/settings/__init__.py | 6 + .../studies/locale/en/LC_MESSAGES/django.po | 2 +- .../studies/locale/fr/LC_MESSAGES/django.po | 2 +- georiviere/tests/__init__.py | 2 +- georiviere/urls.py | 1 + .../locale/en/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 4 +- .../watershed/locale/en/LC_MESSAGES/django.po | 2 +- .../watershed/locale/fr/LC_MESSAGES/django.po | 2 +- requirements.in | 1 + requirements.txt | 5 +- 84 files changed, 3181 insertions(+), 1274 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/portal/migrations/0007_auto_20240423_1647.py create mode 100644 georiviere/portal/migrations/0008_auto_20240429_1233.py create mode 100644 georiviere/portal/serializers/mixins.py create mode 100644 georiviere/portal/serializers/observations.py create mode 100644 georiviere/portal/views/mixins.py create mode 100644 georiviere/portal/views/observations.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db461ff8..c5f0c8a0 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,40 +93,6 @@ 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] @@ -132,7 +101,7 @@ jobs: 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 @@ -163,7 +132,7 @@ jobs: needs: [ build-and-push-image ] permissions: contents: write # required to attach zip to release - if: ${{ github.event_name == 'release' && github.event.action == 'created' }} + if: ${{ (github.event_name == 'release' && github.event.action == 'created' }} 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 eb2033f4..5b2b772e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile dev-requirements.in # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx asgiref==3.3.1 # via @@ -12,7 +12,7 @@ asgiref==3.3.1 # django babel==2.14.0 # via sphinx -build==1.0.3 +build==1.2.1 # via pip-tools certifi==2020.12.5 # via @@ -56,7 +56,7 @@ faker==9.7.1 # via # -c requirements.txt # factory-boy -flake8==6.1.0 +flake8==7.0.0 # via -r dev-requirements.in freezegun==1.1.0 # via @@ -68,7 +68,7 @@ idna==2.10 # requests imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.0 +importlib-metadata==7.1.0 # via # build # sphinx @@ -91,7 +91,7 @@ pip-tools==6.10.0 # via -r dev-requirements.in pycodestyle==2.11.1 # via flake8 -pyflakes==3.1.0 +pyflakes==3.2.0 # via flake8 pygments==2.17.2 # via sphinx @@ -126,27 +126,22 @@ sphinx==5.1.1 # via # -r dev-requirements.in # sphinx-rtd-theme - # sphinxcontrib-applehelp - # sphinxcontrib-devhelp - # sphinxcontrib-htmlhelp # sphinxcontrib-jquery - # sphinxcontrib-qthelp - # sphinxcontrib-serializinghtml sphinx-rtd-theme==2.0.0 # via -r dev-requirements.in -sphinxcontrib-applehelp==1.0.7 +sphinxcontrib-applehelp==1.0.8 # via sphinx -sphinxcontrib-devhelp==1.0.5 +sphinxcontrib-devhelp==1.0.6 # via sphinx -sphinxcontrib-htmlhelp==2.0.4 +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.6 +sphinxcontrib-qthelp==1.0.7 # via sphinx -sphinxcontrib-serializinghtml==1.1.9 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx sqlparse==0.4.1 # via @@ -167,9 +162,9 @@ urllib3==1.26.3 # via # -c requirements.txt # requests -wheel==0.42.0 +wheel==0.43.0 # via pip-tools -zipp==3.17.0 +zipp==3.18.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/docs/changelog.rst b/docs/changelog.rst index 6dce6fcc..516f478f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,7 +7,8 @@ CHANGELOG **New features** -- add load_rivers command +- Add load_rivers command +- Create custom contribution types from the admin with specific field schema **Bug fix** diff --git a/georiviere/contribution/admin.py b/georiviere/contribution/admin.py index a1b3d9ca..24c58338 100644 --- a/georiviere/contribution/admin.py +++ b/georiviere/contribution/admin.py @@ -1,19 +1,131 @@ +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 + fieldsets = ( + ( + None, + { + "fields": ( + "custom_type", + "label", + "internal_identifier", + "key", + "value_type", + "required", + "help_text", + ) + }, + ), + ( + _("Customization"), + { + "fields": ("customization", "options"), + }, + ), + ) + + def get_readonly_fields(self, request, obj=None): + if obj and obj.pk: + return ["custom_type", "key", "options"] + return [] + + 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", "portal", "validated", "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..7d7a7f8a 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,95 @@ 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") + ) + # self.fields["value_type"].choices = [ + # (self.instance.value_type, self.instance.get_value_type_display()) + # ] + + 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..bf1e828a --- /dev/null +++ b/georiviere/contribution/models/__init__.py @@ -0,0 +1,923 @@ +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",) + + +# noinspection PyTypedDict +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..f61eede8 --- /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.DateField() + 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 1315881c..333ba875 100644 --- a/georiviere/contribution/schema.py +++ b/georiviere/contribution/schema.py @@ -1,11 +1,7 @@ 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) +from . import models + # The json schema is summarized on : # https://github.com/Georiviere/Georiviere-admin/issues/139 @@ -16,407 +12,364 @@ 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' + "name_author": {"type": "string", "title": _("Name author"), "maxLength": 128}, + "first_name_author": { + "type": "string", + "title": _("First name author"), + "maxLength": 128, }, - 'description': { - 'type': "string", - 'title': _('Description') + "email_author": {"type": "string", "title": _("Email"), "format": "email"}, + "date_observation": { + "type": "string", + "title": _("Observation's date"), + "format": "date", }, - 'category': { + "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()) + 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) + "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(), } - } - }, - 'then': { - 'properties': { - 'length_bank_erosion': - { - 'type': "string", - 'title': str(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 = [ @@ -428,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 @@ -523,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/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/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/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/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/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/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/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/serializers/contribution.py b/georiviere/portal/serializers/contribution.py index c9f5c1a4..2dfb26cd 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") == "date": + output_field = serializers.DateField + elif field.get("type") == "datetime": + 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..ca552322 --- /dev/null +++ b/georiviere/portal/serializers/observations.py @@ -0,0 +1,49 @@ +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): + 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", + ) + + +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..cd4983c7 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,53 @@ 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) + 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", + "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_portal.py b/georiviere/portal/tests/test_serializers/test_portal.py index 5dcdae95..93f0e65e 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']), 6) 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']), 6) 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..5fd8d5fb 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(7, 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(8, MapLayer.objects.count()) category = POICategoryFactory.create(label="New category") - self.assertEqual(8, MapLayer.objects.count()) + self.assertEqual(9, MapLayer.objects.count()) category.delete() - self.assertEqual(7, MapLayer.objects.count()) + self.assertEqual(8, MapLayer.objects.count()) 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..9bb26666 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.assertSetEqual(set(response.json().keys()), {'attachments', 'name', 'type', 'id', 'description', + 'geojsonUrl', 'jsonUrl'}) def test_poi_list_geojson_structure(self): url = reverse('api_portal:pois-list', @@ -40,7 +41,7 @@ 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.assertSetEqual(set(response.json()[0].keys()), {'attachments', 'name', 'type', 'id', 'description', 'geojsonUrl', 'jsonUrl'}) def test_poi_category_list_json_structure(self): url = reverse('api_portal:pois-category', @@ -49,7 +50,8 @@ 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.assertSetEqual(set(response.json()[0].keys()), + {'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..25e899f3 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,171 @@ 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).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, +): + queryset = CustomContribution.objects.filter(validated=True).annotate( + geometry=Transform(F("geom"), settings.API_SRID) + ).prefetch_related("attachments") + 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 retrieve(self, request, *args, **kwargs): + """ Customize retrieve method to add custom type values to the response""" + object = self.get_object() + object = CustomContribution.objects.with_type_values(object.custom_type).annotate( + geometry=Transform(F("geom"), settings.API_SRID) + ).prefetch_related("attachments").get(pk=object.pk) + context = self.get_serializer_context() + context['custom_type'] = object.custom_type + serializer = self.get_serializer(object, 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..777c4875 --- /dev/null +++ b/georiviere/portal/views/observations.py @@ -0,0 +1,34 @@ +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).distinct( + "pk" + ) # filter station linked to custom contrib + 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..10b36e5a 100644 --- a/georiviere/portal/views/river.py +++ b/georiviere/portal/views/river.py @@ -1,41 +1,48 @@ -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.decorators import view_cache_response_content, view_cache_latest +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']}" + + # @view_cache_latest() + # @view_cache_response_content() + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) 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/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/commands/load_rivers.py b/georiviere/river/management/commands/load_rivers.py index 38a8668c..1d522725 100644 --- a/georiviere/river/management/commands/load_rivers.py +++ b/georiviere/river/management/commands/load_rivers.py @@ -40,7 +40,7 @@ def handle(self, *args, **options): 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) + 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)) diff --git a/georiviere/river/models.py b/georiviere/river/models.py index af65cd85..37b50b2c 100644 --- a/georiviere/river/models.py +++ b/georiviere/river/models.py @@ -98,17 +98,20 @@ 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], @@ -167,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 diff --git a/georiviere/river/sql/post_10_triggers.sql b/georiviere/river/sql/post_10_triggers.sql index f83233af..39d0de5f 100644 --- a/georiviere/river/sql/post_10_triggers.sql +++ b/georiviere/river/sql/post_10_triggers.sql @@ -40,4 +40,23 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER core_path_10_elevation_iu_tgr AFTER INSERT ON river_stream -FOR EACH ROW EXECUTE PROCEDURE create_topologies(); \ No newline at end of file +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/tests/test_signals.py b/georiviere/river/tests/test_signals.py index f947d67d..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], f"{morphologies[0].ewkt} - {morphologies[1].ewkt}", ) + 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/settings/__init__.py b/georiviere/settings/__init__.py index 4daf8d0f..fc40a458 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 = [ 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..4d45ac5b 100644 --- a/georiviere/tests/__init__.py +++ b/georiviere/tests/__init__.py @@ -120,4 +120,4 @@ def test_distance_to_source_is_available(self): object_id=obj.pk, content_type=ContentType.objects.get_for_model(obj) ).distance - self.assertContains(response, f'''({round(distance , 1 ) if distance else 0} m)''') + self.assertContains(response, f'''({round(distance, 1) if distance else 0} m)''') diff --git a/georiviere/urls.py b/georiviere/urls.py index bbb94289..374483ee 100644 --- a/georiviere/urls.py +++ b/georiviere/urls.py @@ -12,6 +12,7 @@ admin.autodiscover() + urlpatterns = [ path('', home, name='home'), path('login/', views.LoginView.as_view(), name='login'), diff --git a/georiviere/valorization/locale/en/LC_MESSAGES/django.po b/georiviere/valorization/locale/en/LC_MESSAGES/django.po index d244a0e7..162750a0 100644 --- a/georiviere/valorization/locale/en/LC_MESSAGES/django.po +++ b/georiviere/valorization/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/valorization/locale/fr/LC_MESSAGES/django.po b/georiviere/valorization/locale/fr/LC_MESSAGES/django.po index a79b6d08..155f4188 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-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" @@ -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" 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 3a3449a4..bc2e80d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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