diff --git a/analysis/utils/report.py b/analysis/utils/report.py index 3bfd6370..960ca2cc 100644 --- a/analysis/utils/report.py +++ b/analysis/utils/report.py @@ -64,8 +64,8 @@ class TimespanReport: responsible__conservation_office__id=id, legal__registration_date__gt=LKOMPVZVO_PUBLISH_DATE, deleted=None, - created__timestamp__gte=date_from, - created__timestamp__lte=date_to, + created__timestamp__date__gte=date_from, + created__timestamp__date__lte=date_to, ) self.queryset_checked = self.queryset.filter( checked__isnull=False @@ -231,8 +231,8 @@ class TimespanReport: intervention__responsible__conservation_office__id=id, intervention__legal__registration_date__gt=LKOMPVZVO_PUBLISH_DATE, deleted=None, - intervention__created__timestamp__gte=date_from, - intervention__created__timestamp__lte=date_to, + intervention__created__date__timestamp__gte=date_from, + intervention__created__date__timestamp__lte=date_to, ) self.queryset_checked = self.queryset.filter( intervention__checked__isnull=False @@ -400,8 +400,8 @@ class TimespanReport: self.queryset = EcoAccount.objects.filter( responsible__conservation_office__id=id, deleted=None, - created__timestamp__gte=date_from, - created__timestamp__lte=date_to, + created__timestamp__date__gte=date_from, + created__timestamp__date__lte=date_to, ) self.queryset_recorded = self.queryset.filter( recorded__isnull=False @@ -479,8 +479,8 @@ class TimespanReport: legal__registration_date__lte=LKOMPVZVO_PUBLISH_DATE, responsible__conservation_office__id=id, deleted=None, - created__timestamp__gte=date_from, - created__timestamp__lte=date_to, + created__timestamp__date__gte=date_from, + created__timestamp__date__lte=date_to, ) self.queryset_intervention_recorded = self.queryset_intervention.filter( recorded__isnull=False diff --git a/compensation/models/compensation.py b/compensation/models/compensation.py index 703f7cd4..65a62b6e 100644 --- a/compensation/models/compensation.py +++ b/compensation/models/compensation.py @@ -170,7 +170,7 @@ class AbstractCompensation(BaseObject, GeoReferencedMixin): """ if not self.is_shared_with(request.user): messages.info(request, DATA_UNSHARED_EXPLANATION) - request = self._set_geometry_conflict_message(request) + request = self.set_geometry_conflict_message(request) return request diff --git a/compensation/templates/compensation/detail/compensation/view.html b/compensation/templates/compensation/detail/compensation/view.html index 581bf799..c210986a 100644 --- a/compensation/templates/compensation/detail/compensation/view.html +++ b/compensation/templates/compensation/detail/compensation/view.html @@ -110,7 +110,10 @@ {% include 'map/geom_form.html' %}
- {% include 'konova/comment_card.html' %} + {% include 'konova/includes/parcels.html' %} +
+
+ {% include 'konova/includes/comment_card.html' %}
diff --git a/compensation/templates/compensation/detail/eco_account/view.html b/compensation/templates/compensation/detail/eco_account/view.html index 717d6d53..493a9292 100644 --- a/compensation/templates/compensation/detail/eco_account/view.html +++ b/compensation/templates/compensation/detail/eco_account/view.html @@ -93,7 +93,10 @@ {% include 'map/geom_form.html' %}
- {% include 'konova/comment_card.html' %} + {% include 'konova/includes/parcels.html' %} +
+
+ {% include 'konova/includes/comment_card.html' %}
diff --git a/compensation/templates/compensation/report/compensation/report.html b/compensation/templates/compensation/report/compensation/report.html index 711ce16d..df991ad0 100644 --- a/compensation/templates/compensation/report/compensation/report.html +++ b/compensation/templates/compensation/report/compensation/report.html @@ -37,6 +37,9 @@
{% include 'map/geom_form.html' %}
+
+ {% include 'konova/includes/parcels.html' %} +

{% trans 'Open in browser' %}

diff --git a/compensation/templates/compensation/report/eco_account/report.html b/compensation/templates/compensation/report/eco_account/report.html index e71670a1..1aef1d4b 100644 --- a/compensation/templates/compensation/report/eco_account/report.html +++ b/compensation/templates/compensation/report/eco_account/report.html @@ -54,6 +54,9 @@
{% include 'map/geom_form.html' %}
+
+ {% include 'konova/includes/parcels.html' %} +

{% trans 'Open in browser' %}

diff --git a/compensation/views/compensation.py b/compensation/views/compensation.py index b5efc984..71efb5ee 100644 --- a/compensation/views/compensation.py +++ b/compensation/views/compensation.py @@ -170,6 +170,7 @@ def detail_view(request: HttpRequest, id: str): template = "compensation/detail/compensation/view.html" comp = get_object_or_404(Compensation, id=id) geom_form = SimpleGeomForm(instance=comp) + parcels = comp.get_underlying_parcels() _user = request.user is_data_shared = comp.intervention.is_shared_with(_user) @@ -189,6 +190,7 @@ def detail_view(request: HttpRequest, id: str): context = { "obj": comp, "geom_form": geom_form, + "parcels": parcels, "has_access": is_data_shared, "actions": actions, "before_states": before_states, @@ -451,6 +453,7 @@ def report_view(request: HttpRequest, id: str): geom_form = SimpleGeomForm( instance=comp ) + parcels = comp.get_underlying_parcels() qrcode_img = generate_qr_code( request.build_absolute_uri(reverse("compensation:report", args=(id,))), 10 @@ -472,6 +475,7 @@ def report_view(request: HttpRequest, id: str): "before_states": before_states, "after_states": after_states, "geom_form": geom_form, + "parcels": parcels, "actions": actions, } context = BaseContext(request, context).context diff --git a/compensation/views/eco_account.py b/compensation/views/eco_account.py index 7b4c27e2..0895823e 100644 --- a/compensation/views/eco_account.py +++ b/compensation/views/eco_account.py @@ -181,6 +181,7 @@ def detail_view(request: HttpRequest, id: str): id=id ) geom_form = SimpleGeomForm(instance=acc) + parcels = acc.get_underlying_parcels() _user = request.user is_data_shared = acc.is_shared_with(_user) @@ -207,6 +208,7 @@ def detail_view(request: HttpRequest, id: str): context = { "obj": acc, "geom_form": geom_form, + "parcels": parcels, "has_access": is_data_shared, "before_states": before_states, "after_states": after_states, @@ -553,6 +555,7 @@ def report_view(request:HttpRequest, id: str): geom_form = SimpleGeomForm( instance=acc ) + parcels = acc.get_underlying_parcels() qrcode_img = generate_qr_code( request.build_absolute_uri(reverse("ema:report", args=(id,))), 10 @@ -580,6 +583,7 @@ def report_view(request:HttpRequest, id: str): "before_states": before_states, "after_states": after_states, "geom_form": geom_form, + "parcels": parcels, "actions": actions, "deductions": deductions, } diff --git a/ema/models/ema.py b/ema/models/ema.py index 8bc4ac65..2c61a241 100644 --- a/ema/models/ema.py +++ b/ema/models/ema.py @@ -106,7 +106,7 @@ class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin): """ if not self.is_shared_with(request.user): messages.info(request, DATA_UNSHARED_EXPLANATION) - self._set_geometry_conflict_message(request) + self.set_geometry_conflict_message(request) return request diff --git a/ema/templates/ema/detail/view.html b/ema/templates/ema/detail/view.html index 6635569a..8ed7aa26 100644 --- a/ema/templates/ema/detail/view.html +++ b/ema/templates/ema/detail/view.html @@ -2,7 +2,13 @@ {% load i18n l10n static fontawesome_5 humanize %} {% block head %} - + {% comment %} + dal documentation (django-autocomplete-light) states using form.media for adding needed scripts. + This does not work properly with modal forms, as the scripts are not loaded properly inside the modal. + Therefore the script linkages from form.media have been extracted and put inside dal/scripts.html to ensure + these scripts are loaded when needed. + {% endcomment %} + {% include 'dal/scripts.html' %} {% endblock %} {% block body %} @@ -77,7 +83,15 @@
+
{% include 'map/geom_form.html' %} +
+
+ {% include 'konova/includes/parcels.html' %} +
+
+ {% include 'konova/includes/comment_card.html' %} +

diff --git a/ema/templates/ema/report/report.html b/ema/templates/ema/report/report.html index 38dd7f23..10ec7aa1 100644 --- a/ema/templates/ema/report/report.html +++ b/ema/templates/ema/report/report.html @@ -41,6 +41,9 @@
{% include 'map/geom_form.html' %}
+
+ {% include 'konova/includes/parcels.html' %} +

{% trans 'Open in browser' %}

diff --git a/ema/views.py b/ema/views.py index dc2fa49a..0ae886e4 100644 --- a/ema/views.py +++ b/ema/views.py @@ -125,6 +125,7 @@ def detail_view(request: HttpRequest, id: str): ema = get_object_or_404(Ema, id=id, deleted=None) geom_form = SimpleGeomForm(instance=ema) + parcels = ema.get_underlying_parcels() _user = request.user is_data_shared = ema.is_shared_with(_user) @@ -143,6 +144,7 @@ def detail_view(request: HttpRequest, id: str): context = { "obj": ema, "geom_form": geom_form, + "parcels": parcels, "has_access": is_data_shared, "before_states": before_states, "after_states": after_states, @@ -464,6 +466,7 @@ def report_view(request:HttpRequest, id: str): geom_form = SimpleGeomForm( instance=ema, ) + parcels = ema.get_underlying_parcels() qrcode_img = generate_qr_code( request.build_absolute_uri(reverse("ema:report", args=(id,))), 10 @@ -485,6 +488,7 @@ def report_view(request:HttpRequest, id: str): "before_states": before_states, "after_states": after_states, "geom_form": geom_form, + "parcels": parcels, "actions": actions, } context = BaseContext(request, context).context diff --git a/intervention/models/intervention.py b/intervention/models/intervention.py index a31eb9a3..b54bbcf5 100644 --- a/intervention/models/intervention.py +++ b/intervention/models/intervention.py @@ -278,7 +278,7 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec """ if not self.is_shared_with(request.user): messages.info(request, DATA_UNSHARED_EXPLANATION) - request = self._set_geometry_conflict_message(request) + request = self.set_geometry_conflict_message(request) return request diff --git a/intervention/templates/intervention/detail/view.html b/intervention/templates/intervention/detail/view.html index 408b3939..e51f3ecd 100644 --- a/intervention/templates/intervention/detail/view.html +++ b/intervention/templates/intervention/detail/view.html @@ -127,7 +127,10 @@ {% include 'map/geom_form.html' %}
- {% include 'konova/comment_card.html' %} + {% include 'konova/includes/parcels.html' %} +
+
+ {% include 'konova/includes/comment_card.html' %}
diff --git a/intervention/templates/intervention/report/report.html b/intervention/templates/intervention/report/report.html index 25e6aac6..ac6c29ea 100644 --- a/intervention/templates/intervention/report/report.html +++ b/intervention/templates/intervention/report/report.html @@ -100,6 +100,9 @@
{% include 'map/geom_form.html' %}
+
+ {% include 'konova/includes/parcels.html' %} +

{% trans 'Open in browser' %}

diff --git a/intervention/views.py b/intervention/views.py index 60d75249..fc1625da 100644 --- a/intervention/views.py +++ b/intervention/views.py @@ -236,6 +236,8 @@ def detail_view(request: HttpRequest, id: str): instance=intervention, ) + parcels = intervention.get_underlying_parcels() + # Inform user about revocation if intervention.legal.revocations.exists(): messages.error( @@ -249,6 +251,7 @@ def detail_view(request: HttpRequest, id: str): "compensations": compensations, "has_access": is_data_shared, "geom_form": geom_form, + "parcels": parcels, "is_default_member": in_group(_user, DEFAULT_GROUP), "is_zb_member": in_group(_user, ZB_GROUP), "is_ets_member": in_group(_user, ETS_GROUP), @@ -544,6 +547,7 @@ def report_view(request:HttpRequest, id: str): geom_form = SimpleGeomForm( instance=intervention ) + parcels = intervention.get_underlying_parcels() distinct_deductions = intervention.deductions.all().distinct( "account" @@ -562,6 +566,7 @@ def report_view(request:HttpRequest, id: str): "qrcode": qrcode_img, "qrcode_lanis": qrcode_img_lanis, "geom_form": geom_form, + "parcels": parcels, } context = BaseContext(request, context).context return render(request, template, context) diff --git a/konova/admin.py b/konova/admin.py index 213e0412..02568de9 100644 --- a/konova/admin.py +++ b/konova/admin.py @@ -7,7 +7,7 @@ Created on: 22.07.21 """ from django.contrib import admin -from konova.models import Geometry, Deadline, GeometryConflict +from konova.models import Geometry, Deadline, GeometryConflict, Parcel, District class GeometryAdmin(admin.ModelAdmin): @@ -17,6 +17,25 @@ class GeometryAdmin(admin.ModelAdmin): ] +class ParcelAdmin(admin.ModelAdmin): + list_display = [ + "id", + "gmrkng", + "flr", + "flrstck_nnr", + "flrstck_zhlr", + "updated_on", + ] + + +class DistrictAdmin(admin.ModelAdmin): + list_display = [ + "id", + "gmnd", + "krs", + ] + + class GeometryConflictAdmin(admin.ModelAdmin): list_display = [ "conflicting_geometry", @@ -52,5 +71,7 @@ class BaseObjectAdmin(admin.ModelAdmin): admin.site.register(Geometry, GeometryAdmin) +admin.site.register(Parcel, ParcelAdmin) +admin.site.register(District, DistrictAdmin) admin.site.register(GeometryConflict, GeometryConflictAdmin) admin.site.register(Deadline, DeadlineAdmin) diff --git a/konova/contexts.py b/konova/contexts.py index b415889b..470161c9 100644 --- a/konova/contexts.py +++ b/konova/contexts.py @@ -15,18 +15,17 @@ class BaseContext: """ Holds all base data which is needed for every context rendering """ - context = { - "base_title": BASE_TITLE, - "base_frontend_title": BASE_FRONTEND_TITLE, - "language": LANGUAGE_CODE, - "user": None, - "current_role": None, - "help_link": HELP_LINK, - } + context = None def __init__(self, request: HttpRequest, additional_context: dict = {}): - self.context["language"] = request.LANGUAGE_CODE - self.context["user"] = request.user + self.context = { + "base_title": BASE_TITLE, + "base_frontend_title": BASE_FRONTEND_TITLE, + "language": request.LANGUAGE_CODE, + "user": request.user, + "current_role": None, + "help_link": HELP_LINK + } # Add additional context, derived from given parameters self.context.update(additional_context) diff --git a/konova/forms.py b/konova/forms.py index 43c83498..e9d9c8d7 100644 --- a/konova/forms.py +++ b/konova/forms.py @@ -287,6 +287,7 @@ class SimpleGeomForm(BaseForm): geometry = self.instance.geometry geometry.geom = self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID)) geometry.modified = action + geometry.update_parcels() geometry.save() except LookupError: # No geometry or linked instance holding a geometry exist --> create a new one! diff --git a/konova/management/commands/sanitize_db.py b/konova/management/commands/sanitize_db.py index c5526517..0eecdc03 100644 --- a/konova/management/commands/sanitize_db.py +++ b/konova/management/commands/sanitize_db.py @@ -9,7 +9,7 @@ from compensation.models import CompensationState, Compensation, EcoAccount, Com from ema.models import Ema from intervention.models import Intervention from konova.management.commands.setup import BaseKonovaCommand -from konova.models import Deadline, Geometry +from konova.models import Deadline, Geometry, Parcel, District from user.models import UserActionLogEntry @@ -23,6 +23,7 @@ class Command(BaseKonovaCommand): self.sanitize_actions() self.sanitize_deadlines() self.sanitize_geometries() + self.sanitize_parcels_and_districts() except KeyboardInterrupt: self._break_line() exit(-1) @@ -266,3 +267,34 @@ class Command(BaseKonovaCommand): self._write_success("No unused states found.") self._break_line() + def sanitize_parcels_and_districts(self): + """ Removes unattached parcels and districts + + Returns: + + """ + self._write_warning("=== Sanitize parcels and districts ===") + unrelated_parcels = Parcel.objects.filter( + geometries=None, + ) + num_unrelated_parcels = unrelated_parcels.count() + if num_unrelated_parcels > 0: + self._write_error(f"Found {num_unrelated_parcels} unrelated parcel entries. Delete now...") + unrelated_parcels.delete() + self._write_success("Unrelated parcels deleted.") + else: + self._write_success("No unrelated parcels found.") + + unrelated_districts = District.objects.filter( + parcels=None, + ) + num_unrelated_districts = unrelated_districts.count() + if num_unrelated_districts > 0: + self._write_error(f"Found {num_unrelated_districts} unrelated district entries. Delete now...") + unrelated_districts.delete() + self._write_success("Unrelated districts deleted.") + else: + self._write_success("No unrelated districts found.") + + self._break_line() + diff --git a/konova/management/commands/update_all_parcels.py b/konova/management/commands/update_all_parcels.py new file mode 100644 index 00000000..9d96ebae --- /dev/null +++ b/konova/management/commands/update_all_parcels.py @@ -0,0 +1,41 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 04.01.22 + +""" +from konova.management.commands.setup import BaseKonovaCommand +from konova.models import Geometry, Parcel, District + + +class Command(BaseKonovaCommand): + help = "Checks the database' sanity and removes unused entries" + + def handle(self, *args, **options): + try: + self.update_all_parcels() + except KeyboardInterrupt: + self._break_line() + exit(-1) + + def update_all_parcels(self): + num_parcels_before = Parcel.objects.count() + num_districts_before = District.objects.count() + self._write_warning("=== Update parcels and districts ===") + geometries = Geometry.objects.all().exclude( + geom=None + ) + self._write_warning(f"Process parcels for {geometries.count()} geometry entries now ...") + for geometry in geometries: + geometry.update_parcels() + + num_parcels_after = Parcel.objects.count() + num_districts_after = District.objects.count() + if num_parcels_after != num_parcels_before: + self._write_error(f"Parcels have changed: {num_parcels_before} to {num_parcels_after} entries. You should run the sanitize command.") + if num_districts_after != num_districts_before: + self._write_error(f"Districts have changed: {num_districts_before} to {num_districts_after} entries. You should run the sanitize command.") + + self._write_success("Updating parcels done!") + self._break_line() diff --git a/konova/models/__init__.py b/konova/models/__init__.py index c60ecaa9..c9156061 100644 --- a/konova/models/__init__.py +++ b/konova/models/__init__.py @@ -9,3 +9,4 @@ from .object import * from .deadline import * from .document import * from .geometry import * +from .parcel import * diff --git a/konova/models/geometry.py b/konova/models/geometry.py index 8c155732..aad39d6f 100644 --- a/konova/models/geometry.py +++ b/konova/models/geometry.py @@ -7,9 +7,10 @@ Created on: 15.11.21 """ from django.contrib.gis.db.models import MultiPolygonField from django.db import models -from django.db.models import Q +from django.utils import timezone from konova.models import BaseResource, UuidModel +from konova.utils.wfs.spatial import ParcelWFSFetcher class Geometry(BaseResource): @@ -32,7 +33,7 @@ class Geometry(BaseResource): """ # If no geometry is given or important data is missing, we can not perform any checks - if self.geom is None or (self.created is None and self.modified is None): + if self.geom is None: return None self.recheck_existing_conflicts() @@ -44,7 +45,10 @@ class Geometry(BaseResource): ).distinct() for match in overlapping_geoms: - GeometryConflict.objects.get_or_create(conflicting_geometry=self, affected_geometry=match) + # Make sure this conflict is not already known but in a swapped constellation + conflict_exists_swapped = GeometryConflict.objects.filter(conflicting_geometry=match, affected_geometry=self).exists() + if not conflict_exists_swapped: + GeometryConflict.objects.get_or_create(conflicting_geometry=self, affected_geometry=match) def recheck_existing_conflicts(self): """ Rechecks GeometryConflict entries @@ -69,7 +73,6 @@ class Geometry(BaseResource): resolved_conflicts = all_conflicted_by_conflicts.exclude(id__in=still_conflicting_conflicts) resolved_conflicts.delete() - def get_data_objects(self): """ Getter for all objects which are related to this geometry @@ -90,6 +93,54 @@ class Geometry(BaseResource): objs += set_objs return objs + def update_parcels(self): + """ Updates underlying parcel information + + Returns: + + """ + from konova.models import Parcel, District + parcel_fetcher = ParcelWFSFetcher( + geometry_id=self.id, + ) + typename = "ave:Flurstueck" + fetched_parcels = parcel_fetcher.get_features( + typename + ) + underlying_parcels = [] + for result in fetched_parcels: + fetched_parcel = result[typename] + parcel_obj = Parcel.objects.get_or_create( + gmrkng=fetched_parcel["ave:gemarkung"], + flr=fetched_parcel["ave:flur"], + flrstck_nnr=fetched_parcel['ave:flstnrnen'], + flrstck_zhlr=fetched_parcel['ave:flstnrzae'], + )[0] + district = District.objects.get_or_create( + gmnd=fetched_parcel["ave:gemeinde"], + krs=fetched_parcel["ave:kreis"], + )[0] + parcel_obj.district = district + parcel_obj.updated_on = timezone.now() + parcel_obj.save() + underlying_parcels.append(parcel_obj) + + self.parcels.set(underlying_parcels) + + def get_underlying_parcels(self): + """ Getter for related parcels and their districts + + Returns: + parcels (QuerySet): The related parcels as queryset + """ + parcels = self.parcels.all().prefetch_related( + "district" + ).order_by( + "gmrkng", + ) + + return parcels + class GeometryConflict(UuidModel): """ diff --git a/konova/models/object.py b/konova/models/object.py index ded39ad1..49c1f4e0 100644 --- a/konova/models/object.py +++ b/konova/models/object.py @@ -420,13 +420,29 @@ class GeoReferencedMixin(models.Model): class Meta: abstract = True - def _set_geometry_conflict_message(self, request: HttpRequest): + def get_underlying_parcels(self): + """ Getter for related parcels + + Returns: + parcels (Iterable): An empty list or a Queryset + """ + if self.geometry is not None: + return self.geometry.get_underlying_parcels() + else: + return [] + + def set_geometry_conflict_message(self, request: HttpRequest): + if self.geometry is None: + return request + instance_objs = [] add_message = False conflicts = self.geometry.conflicts_geometries.all() + for conflict in conflicts: instance_objs += conflict.affected_geometry.get_data_objects() add_message = True + conflicts = self.geometry.conflicted_by_geometries.all() for conflict in conflicts: instance_objs += conflict.conflicting_geometry.get_data_objects() diff --git a/konova/models/parcel.py b/konova/models/parcel.py new file mode 100644 index 00000000..487225e6 --- /dev/null +++ b/konova/models/parcel.py @@ -0,0 +1,79 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.12.21 + +""" +from django.db import models + +from konova.models import UuidModel + + +class Parcel(UuidModel): + """ The Parcel model holds administrative data on the covered properties. + + Due to the unique but relevant naming of the administrative data, we have to use these namings as field + names in german. Any try to translate them to English result in strange or insufficient translations. + + All fields have to be CharFields as well, since there are e.g. Flurstücksnummer holding e.g. '123____' which + can not be realized using numerical fields. + + To avoid conflicts due to german Umlaute, the field names are shortened and vocals are dropped. + + """ + geometries = models.ManyToManyField("konova.Geometry", related_name="parcels", blank=True) + district = models.ForeignKey("konova.District", on_delete=models.SET_NULL, null=True, blank=True, related_name="parcels") + gmrkng = models.CharField( + max_length=1000, + help_text="Gemarkung", + null=True, + blank=True, + ) + flrstck_nnr = models.CharField( + max_length=1000, + help_text="Flurstücksnenner", + null=True, + blank=True, + ) + flrstck_zhlr = models.CharField( + max_length=1000, + help_text="Flurstückszähler", + null=True, + blank=True, + ) + flr = models.CharField( + max_length=1000, + help_text="Flur", + null=True, + blank=True, + ) + updated_on = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.gmrkng} | {self.flr} | {self.flrstck_zhlr} | {self.flrstck_nnr}" + + +class District(UuidModel): + """ The model District holds more coarse information, such as Kreis, Verbandsgemeinde and Gemeinde. + + There might be the case that a geometry lies on a hundred Parcel entries but only on one District entry. + Therefore a geometry can have a lot of relations to Parcel entries but only a few or only a single one to one + District. + + """ + gmnd = models.CharField( + max_length=1000, + help_text="Gemeinde", + null=True, + blank=True, + ) + krs = models.CharField( + max_length=1000, + help_text="Kreis", + null=True, + blank=True, + ) + + def __str__(self): + return f"{self.gmnd} | {self.krs}" diff --git a/konova/settings.py b/konova/settings.py index c80c54fc..35b3f722 100644 --- a/konova/settings.py +++ b/konova/settings.py @@ -80,3 +80,8 @@ LANIS_ZOOM_LUT = { 1000: 30, 500: 31, } + +# Parcel WFS settings +PARCEL_WFS_BASE_URL = "https://www.geoportal.rlp.de/registry/wfs/519" +PARCEL_WFS_USER = "ksp" +PARCEL_WFS_PW = "CHANGE_ME" \ No newline at end of file diff --git a/konova/templates/konova/comment_card.html b/konova/templates/konova/includes/comment_card.html similarity index 100% rename from konova/templates/konova/comment_card.html rename to konova/templates/konova/includes/comment_card.html diff --git a/konova/templates/konova/includes/parcels.html b/konova/templates/konova/includes/parcels.html new file mode 100644 index 00000000..e15fa3d0 --- /dev/null +++ b/konova/templates/konova/includes/parcels.html @@ -0,0 +1,29 @@ +{% load i18n %} +
+

{% trans 'Spatial reference' %}

+
+
+ + + + + + + + + + + + {% for parcel in parcels %} + + + + + + + + {% endfor %} + + +
{% trans 'Kreis' %}{% trans 'Gemarkung' %}{% trans 'Parcel' %}{% trans 'Parcel counter' %}{% trans 'Parcel number' %}
{{parcel.district.krs|default_if_none:"-"}}{{parcel.gmrkng|default_if_none:"-"}}{{parcel.flr|default_if_none:"-"}}{{parcel.flrstck_zhlr|default_if_none:"-"}}{{parcel.flrstck_nnr|default_if_none:"-"}}
+
\ No newline at end of file diff --git a/konova/tests/test_geometries.py b/konova/tests/test_geometries.py index a9a840f5..69a4ef71 100644 --- a/konova/tests/test_geometries.py +++ b/konova/tests/test_geometries.py @@ -9,7 +9,7 @@ from django.contrib.gis.db.models.functions import Translate from konova.models import Geometry, GeometryConflict from konova.tests.test_views import BaseTestCase -from user.models import UserActionLogEntry +from konova.utils.wfs.spatial import ParcelWFSFetcher class GeometryTestCase(BaseTestCase): @@ -17,16 +17,29 @@ class GeometryTestCase(BaseTestCase): def setUpTestData(cls): super().setUpTestData() geom = cls.create_dummy_geometry() - action = UserActionLogEntry.get_created_action(cls.superuser) cls.geom_1 = Geometry.objects.create( geom=geom, - created=action, ) cls.geom_2 = Geometry.objects.create( geom=geom, - created=action, ) + def test_geometry_parcel_caluclation(self): + """ Tests whether newly created geometries already have parcels calculated during save + + Returns: + + """ + has_parcels = self.geom_1.parcels.all().exists() + self.assertFalse(has_parcels, msg=f"{self.geom_1.id} has parcels but should not!") + self.geom_1.update_parcels() + self.geom_1.refresh_from_db() + parcels = self.geom_1.parcels.all() + has_parcels = parcels.exists() + parcel_districts = parcels.values_list("district", flat=True) + self.assertTrue(has_parcels, msg=f"{self.geom_1.id} has no parcels but should!") + self.assertEqual(parcels.count(), len(parcel_districts), msg=f"Not every parcel has exactly one district!") + def test_geometry_conflict(self): """ Tests whether a geometry conflict will be present in case of identical/overlaying geometries and if the conflict will be resolved if one geometry is edited. @@ -34,8 +47,9 @@ class GeometryTestCase(BaseTestCase): Returns: """ - self.geom_1.check_for_conflicts() - conflict = GeometryConflict.objects.all().first() + conflict = GeometryConflict.objects.all() + self.assertEqual(1, conflict.count()) + conflict = conflict.first() self.assertEqual(conflict.conflicting_geometry, self.geom_2) self.assertEqual(conflict.affected_geometry, self.geom_1) @@ -47,3 +61,19 @@ class GeometryTestCase(BaseTestCase): self.geom_1.check_for_conflicts() num_conflict = GeometryConflict.objects.all().count() self.assertEqual(0, num_conflict) + + def test_wfs_fetch(self): + """ Tests the fetching functionality of ParcelWFSFetcher + + +++ Test relies on the availability of the RLP Flurstück WFS +++ + + Returns: + + """ + fetcher = ParcelWFSFetcher( + geometry_id=self.geom_1.id, + ) + features = fetcher.get_features( + "ave:Flurstueck", + ) + self.assertNotEqual(0, len(features), msg="Spatial wfs get feature did not work!") diff --git a/konova/tests/test_views.py b/konova/tests/test_views.py index ae5d4524..d31726ff 100644 --- a/konova/tests/test_views.py +++ b/konova/tests/test_views.py @@ -214,7 +214,7 @@ class BaseTestCase(TestCase): Returns: """ - polygon = Polygon.from_bbox((7.157593, 49.882247, 7.816772, 50.266521)) + polygon = Polygon.from_bbox((7.592449, 50.359385, 7.593382, 50.359874)) polygon.srid = 4326 polygon = polygon.transform(3857, clone=True) return MultiPolygon(polygon, srid=3857) # 3857 is the default srid used for MultiPolygonField in the form diff --git a/konova/utils/wfs/spatial.py b/konova/utils/wfs/spatial.py new file mode 100644 index 00000000..6d0ddda9 --- /dev/null +++ b/konova/utils/wfs/spatial.py @@ -0,0 +1,175 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 17.12.21 + +""" +from abc import abstractmethod + +import requests +import xmltodict +from django.contrib.gis.db.models.functions import AsGML, Transform +from requests.auth import HTTPDigestAuth + +from konova.settings import DEFAULT_SRID_RLP, PARCEL_WFS_USER, PARCEL_WFS_PW + + +class AbstractWFSFetcher: + """ Base class for fetching WFS data + + """ + # base_url represents not the capabilities url but the parameter-free base url + base_url = None + version = None + auth_user = None + auth_pw = None + auth_digest_obj = None + + class Meta: + abstract = True + + def __init__(self, base_url: str, version: str = "1.1.0", auth_user: str = None, auth_pw: str = None, *args, **kwargs): + self.base_url = base_url + self.version = version + self.auth_pw = auth_pw + self.auth_user = auth_user + + self._create_auth_obj() + + def _create_auth_obj(self): + if self.auth_pw is not None and self.auth_user is not None: + self.auth_digest_obj = HTTPDigestAuth( + self.auth_user, + self.auth_pw + ) + + @abstractmethod + def get_features(self, feature_identifier: str, filter_str: str): + raise NotImplementedError + + +class ParcelWFSFetcher(AbstractWFSFetcher): + """ Fetches features from a special parcel WFS + + """ + geometry_id = None + geometry_property_name = None + count = 100 + + def __init__(self, geometry_id: str, geometry_property_name: str = "msGeometry", *args, **kwargs): + super().__init__( + version="2.0.0", + base_url="https://www.geoportal.rlp.de/registry/wfs/519", + auth_user=PARCEL_WFS_USER, + auth_pw=PARCEL_WFS_PW, + *args, + **kwargs + ) + self.geometry_id = geometry_id + self.geometry_property_name = geometry_property_name + + def _create_spatial_filter(self, + geometry_operation: str, + filter_srid: str = None): + """ Creates a xml spatial filter according to the WFS filter specification + + Args: + geometry_operation (str): One of the WFS supported spatial filter operations (according to capabilities) + filter_srid (str): Used to transform the geometry into the spatial reference system identified by this srid + + Returns: + spatial_filter (str): The spatial filter element + """ + from konova.models import Geometry + if filter_srid is None: + filter_srid = DEFAULT_SRID_RLP + geom_gml = Geometry.objects.filter( + id=self.geometry_id + ).annotate( + transformed=Transform(srid=filter_srid, expression="geom") + ).annotate( + gml=AsGML('transformed') + ).first().gml + spatial_filter = f"<{geometry_operation}>{self.geometry_property_name}{geom_gml}" + return spatial_filter + + def _create_post_data(self, + geometry_operation: str, + filter_srid: str = None, + typenames: str = None, + start_index: int = 0, + ): + """ Creates a POST body content for fetching features + + Args: + geometry_operation (str): One of the WFS supported spatial filter operations (according to capabilities) + filter_srid (str): Used to transform the geometry into the spatial reference system identified by this srid + + Returns: + _filter (str): A proper xml WFS filter + """ + start_index = str(start_index) + spatial_filter = self._create_spatial_filter( + geometry_operation, + filter_srid + ) + _filter = f'{spatial_filter}' + return _filter + + def get_features(self, + typenames: str, + spatial_operator: str = "Intersects", + filter_srid: str = None, + start_index: int = 0, + ): + """ Fetches features from the WFS using POST + + POST is required since GET has a character limit around 4000. Having a larger filter would result in errors, + which do not occur in case of POST. + + Args: + typenames (str): References to parameter 'typenames' in a WFS GetFeature request + spatial_operator (str): Defines the spatial operation for filtering + filter_srid (str): Defines the spatial reference system, the geometry shall be transformed into for filtering + start_index (str): References to parameter 'startindex' in a + + Returns: + features (list): A list of returned features + """ + features = [] + while start_index is not None: + post_body = self._create_post_data( + spatial_operator, + filter_srid, + typenames, + start_index + ) + response = requests.post( + url=self.base_url, + data=post_body, + auth=self.auth_digest_obj + ) + + content = response.content.decode("utf-8") + content = xmltodict.parse(content) + collection = content.get( + "wfs:FeatureCollection", + {}, + ) + + members = collection.get( + "wfs:member", + [], + ) + if len(members) > 1: + features += members + else: + features += [members] + + if collection.get("@next", None) is not None: + start_index += self.count + else: + start_index = None + + return features diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index bd3e82d8..b03903cb 100644 Binary files a/locale/de/LC_MESSAGES/django.mo and b/locale/de/LC_MESSAGES/django.mo differ diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 04aefdf0..fbfccf67 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -11,15 +11,15 @@ #: intervention/forms/forms.py:52 intervention/forms/forms.py:154 #: intervention/forms/forms.py:166 intervention/forms/modalForms.py:125 #: intervention/forms/modalForms.py:138 intervention/forms/modalForms.py:151 -#: konova/forms.py:139 konova/forms.py:240 konova/forms.py:308 -#: konova/forms.py:335 konova/forms.py:345 konova/forms.py:358 -#: konova/forms.py:370 konova/forms.py:388 user/forms.py:38 +#: konova/forms.py:139 konova/forms.py:240 konova/forms.py:309 +#: konova/forms.py:336 konova/forms.py:346 konova/forms.py:359 +#: konova/forms.py:371 konova/forms.py:389 user/forms.py:38 #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-12-16 09:17+0100\n" +"POT-Creation-Date: 2022-01-05 14:04+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -329,7 +329,7 @@ msgstr "Automatisch generiert" #: intervention/templates/intervention/detail/includes/documents.html:28 #: intervention/templates/intervention/detail/view.html:31 #: intervention/templates/intervention/report/report.html:12 -#: konova/forms.py:334 +#: konova/forms.py:335 msgid "Title" msgstr "Bezeichnung" @@ -356,7 +356,7 @@ msgstr "Kompensation XY; Flur ABC" #: intervention/templates/intervention/detail/includes/documents.html:31 #: intervention/templates/intervention/detail/includes/payments.html:34 #: intervention/templates/intervention/detail/includes/revocation.html:38 -#: konova/forms.py:369 konova/templates/konova/comment_card.html:16 +#: konova/forms.py:370 konova/templates/konova/includes/comment_card.html:16 msgid "Comment" msgstr "Kommentar" @@ -472,7 +472,7 @@ msgstr "Zahlung wird an diesem Datum erwartet" #: compensation/forms/modalForms.py:62 compensation/forms/modalForms.py:239 #: compensation/forms/modalForms.py:317 intervention/forms/modalForms.py:152 -#: konova/forms.py:371 +#: konova/forms.py:372 msgid "Additional comment, maximum {} letters" msgstr "Zusätzlicher Kommentar, maximal {} Zeichen" @@ -793,7 +793,7 @@ msgstr "Dokumente" #: compensation/templates/compensation/detail/eco_account/includes/documents.html:14 #: ema/templates/ema/detail/includes/documents.html:14 #: intervention/templates/intervention/detail/includes/documents.html:14 -#: konova/forms.py:387 +#: konova/forms.py:388 msgid "Add new document" msgstr "Neues Dokument hinzufügen" @@ -1056,41 +1056,41 @@ msgstr "Kompensation {} hinzugefügt" msgid "Compensation {} edited" msgstr "Kompensation {} bearbeitet" -#: compensation/views/compensation.py:228 compensation/views/eco_account.py:307 -#: ema/views.py:181 intervention/views.py:474 +#: compensation/views/compensation.py:230 compensation/views/eco_account.py:309 +#: ema/views.py:181 intervention/views.py:477 msgid "Log" msgstr "Log" -#: compensation/views/compensation.py:251 +#: compensation/views/compensation.py:253 msgid "Compensation removed" msgstr "Kompensation entfernt" -#: compensation/views/compensation.py:272 compensation/views/eco_account.py:459 +#: compensation/views/compensation.py:274 compensation/views/eco_account.py:461 #: ema/views.py:348 intervention/views.py:129 msgid "Document added" msgstr "Dokument hinzugefügt" -#: compensation/views/compensation.py:341 compensation/views/eco_account.py:353 +#: compensation/views/compensation.py:343 compensation/views/eco_account.py:355 #: ema/views.py:286 msgid "State added" msgstr "Zustand hinzugefügt" -#: compensation/views/compensation.py:362 compensation/views/eco_account.py:374 +#: compensation/views/compensation.py:364 compensation/views/eco_account.py:376 #: ema/views.py:307 msgid "Action added" msgstr "Maßnahme hinzugefügt" -#: compensation/views/compensation.py:383 compensation/views/eco_account.py:439 +#: compensation/views/compensation.py:385 compensation/views/eco_account.py:441 #: ema/views.py:328 msgid "Deadline added" msgstr "Frist/Termin hinzugefügt" -#: compensation/views/compensation.py:405 compensation/views/eco_account.py:396 +#: compensation/views/compensation.py:407 compensation/views/eco_account.py:398 #: ema/views.py:418 msgid "State removed" msgstr "Zustand gelöscht" -#: compensation/views/compensation.py:427 compensation/views/eco_account.py:418 +#: compensation/views/compensation.py:429 compensation/views/eco_account.py:420 #: ema/views.py:440 msgid "Action removed" msgstr "Maßnahme entfernt" @@ -1103,45 +1103,45 @@ msgstr "Ökokonto {} hinzugefügt" msgid "Eco-Account {} edited" msgstr "Ökokonto {} bearbeitet" -#: compensation/views/eco_account.py:255 +#: compensation/views/eco_account.py:257 msgid "Eco-account removed" msgstr "Ökokonto entfernt" -#: compensation/views/eco_account.py:283 +#: compensation/views/eco_account.py:285 msgid "Deduction removed" msgstr "Abbuchung entfernt" -#: compensation/views/eco_account.py:328 ema/views.py:261 -#: intervention/views.py:516 +#: compensation/views/eco_account.py:330 ema/views.py:261 +#: intervention/views.py:519 msgid "{} unrecorded" msgstr "{} entzeichnet" -#: compensation/views/eco_account.py:328 ema/views.py:261 -#: intervention/views.py:516 +#: compensation/views/eco_account.py:330 ema/views.py:261 +#: intervention/views.py:519 msgid "{} recorded" msgstr "{} verzeichnet" -#: compensation/views/eco_account.py:529 intervention/views.py:497 +#: compensation/views/eco_account.py:531 intervention/views.py:500 msgid "Deduction added" msgstr "Abbuchung hinzugefügt" -#: compensation/views/eco_account.py:612 ema/views.py:516 -#: intervention/views.py:372 +#: compensation/views/eco_account.py:614 ema/views.py:516 +#: intervention/views.py:375 msgid "{} has already been shared with you" msgstr "{} wurde bereits für Sie freigegeben" -#: compensation/views/eco_account.py:617 ema/views.py:521 -#: intervention/views.py:377 +#: compensation/views/eco_account.py:619 ema/views.py:521 +#: intervention/views.py:380 msgid "{} has been shared with you" msgstr "{} ist nun für Sie freigegeben" -#: compensation/views/eco_account.py:624 ema/views.py:528 -#: intervention/views.py:384 +#: compensation/views/eco_account.py:626 ema/views.py:528 +#: intervention/views.py:387 msgid "Share link invalid" msgstr "Freigabelink ungültig" -#: compensation/views/eco_account.py:647 ema/views.py:551 -#: intervention/views.py:407 +#: compensation/views/eco_account.py:649 ema/views.py:551 +#: intervention/views.py:410 msgid "Share settings updated" msgstr "Freigabe Einstellungen aktualisiert" @@ -1333,7 +1333,7 @@ msgstr "Kompensationen und Zahlungen geprüft" msgid "Run check" msgstr "Prüfung vornehmen" -#: intervention/forms/modalForms.py:196 konova/forms.py:453 +#: intervention/forms/modalForms.py:196 konova/forms.py:454 msgid "" "I, {} {}, confirm that all necessary control steps have been performed by " "myself." @@ -1472,31 +1472,31 @@ msgstr "" msgid "Intervention {} added" msgstr "Eingriff {} hinzugefügt" -#: intervention/views.py:243 +#: intervention/views.py:245 msgid "This intervention has {} revocations" msgstr "Dem Eingriff liegen {} Widersprüche vor" -#: intervention/views.py:290 +#: intervention/views.py:293 msgid "Intervention {} edited" msgstr "Eingriff {} bearbeitet" -#: intervention/views.py:325 +#: intervention/views.py:328 msgid "{} removed" msgstr "{} entfernt" -#: intervention/views.py:346 +#: intervention/views.py:349 msgid "Revocation removed" msgstr "Widerspruch entfernt" -#: intervention/views.py:428 +#: intervention/views.py:431 msgid "Check performed" msgstr "Prüfung durchgeführt" -#: intervention/views.py:450 +#: intervention/views.py:453 msgid "Revocation added" msgstr "Widerspruch hinzugefügt" -#: intervention/views.py:521 +#: intervention/views.py:524 msgid "There are errors on this intervention:" msgstr "Es liegen Fehler in diesem Eingriff vor:" @@ -1525,11 +1525,11 @@ msgstr "Speichern" msgid "Not editable" msgstr "Nicht editierbar" -#: konova/forms.py:138 konova/forms.py:307 +#: konova/forms.py:138 konova/forms.py:308 msgid "Confirm" msgstr "Bestätige" -#: konova/forms.py:150 konova/forms.py:316 +#: konova/forms.py:150 konova/forms.py:317 msgid "Remove" msgstr "Löschen" @@ -1542,56 +1542,56 @@ msgstr "Sie sind dabei {} {} zu löschen" msgid "Geometry" msgstr "Geometrie" -#: konova/forms.py:317 +#: konova/forms.py:318 msgid "Are you sure?" msgstr "Sind Sie sicher?" -#: konova/forms.py:344 +#: konova/forms.py:345 msgid "Created on" msgstr "Erstellt" -#: konova/forms.py:346 +#: konova/forms.py:347 msgid "When has this file been created? Important for photos." msgstr "Wann wurde diese Datei erstellt oder das Foto aufgenommen?" -#: konova/forms.py:357 +#: konova/forms.py:358 #: venv/lib/python3.7/site-packages/django/db/models/fields/files.py:231 msgid "File" msgstr "Datei" -#: konova/forms.py:359 +#: konova/forms.py:360 msgid "Allowed formats: pdf, jpg, png. Max size 15 MB." msgstr "Formate: pdf, jpg, png. Maximal 15 MB." -#: konova/forms.py:405 +#: konova/forms.py:406 msgid "Unsupported file type" msgstr "Dateiformat nicht unterstützt" -#: konova/forms.py:412 +#: konova/forms.py:413 msgid "File too large" msgstr "Datei zu groß" -#: konova/forms.py:421 +#: konova/forms.py:422 msgid "Added document" msgstr "Dokument hinzugefügt" -#: konova/forms.py:444 +#: konova/forms.py:445 msgid "Confirm record" msgstr "Verzeichnen bestätigen" -#: konova/forms.py:452 +#: konova/forms.py:453 msgid "Record data" msgstr "Daten verzeichnen" -#: konova/forms.py:459 +#: konova/forms.py:460 msgid "Confirm unrecord" msgstr "Entzeichnen bestätigen" -#: konova/forms.py:460 +#: konova/forms.py:461 msgid "Unrecord data" msgstr "Daten entzeichnen" -#: konova/forms.py:461 +#: konova/forms.py:462 msgid "I, {} {}, confirm that this data must be unrecorded." msgstr "" "Ich, {} {}, bestätige, dass diese Daten wieder entzeichnet werden müssen." @@ -1663,6 +1663,30 @@ msgstr "Anzeigen" msgid "Deduct" msgstr "Abbuchen" +#: konova/templates/konova/includes/parcels.html:3 +msgid "Spatial reference" +msgstr "Raumreferenz" + +#: konova/templates/konova/includes/parcels.html:9 +msgid "Kreis" +msgstr "Kreis" + +#: konova/templates/konova/includes/parcels.html:10 +msgid "Gemarkung" +msgstr "Gemarkung" + +#: konova/templates/konova/includes/parcels.html:11 +msgid "Parcel" +msgstr "Flur" + +#: konova/templates/konova/includes/parcels.html:12 +msgid "Parcel counter" +msgstr "Flurstückzähler" + +#: konova/templates/konova/includes/parcels.html:13 +msgid "Parcel number" +msgstr "Flurstücknenner" + #: konova/templates/konova/widgets/generate-content-input.html:6 msgid "Generate new" msgstr "Neu generieren" @@ -1726,8 +1750,8 @@ msgid "" "Action canceled. Eco account is recorded or deductions exist. Only " "conservation office member can perform this action." msgstr "" -"Aktion abgebrochen. Ökokonto ist bereits verzeichnet oder Abbuchungen liegen vor. Nur " -"Eintragungsstellennutzer können diese Aktion jetzt durchführen." +"Aktion abgebrochen. Ökokonto ist bereits verzeichnet oder Abbuchungen liegen " +"vor. Nur Eintragungsstellennutzer können diese Aktion jetzt durchführen." #: konova/utils/message_templates.py:25 msgid "Edited general data" diff --git a/requirements.txt b/requirements.txt index c29cb37f..620316ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,10 +14,14 @@ django-tables2==2.3.4 et-xmlfile==1.1.0 idna==2.10 importlib-metadata==2.1.1 -itsdangerous<1.0.0 +itsdangerous==0.24 openpyxl==3.0.9 +OWSLib==0.25.0 psycopg2-binary==2.9.1 +pyproj==3.2.1 +python-dateutil==2.8.2 pytz==2020.4 +PyYAML==6.0 qrcode==7.3.1 requests==2.25.0 six==1.15.0 @@ -25,4 +29,5 @@ soupsieve==2.2.1 sqlparse==0.4.1 urllib3==1.26.2 webservices==0.7 +xmltodict==0.12.0 zipp==3.4.1