diff --git a/compensation/admin.py b/compensation/admin.py index 4d148582..ced9bfe7 100644 --- a/compensation/admin.py +++ b/compensation/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from compensation.models import Compensation, CompensationAction, CompensationState, Payment, \ EcoAccountDeduction, EcoAccount +from konova.admin import BaseObjectAdmin class CompensationStateAdmin(admin.ModelAdmin): @@ -22,7 +23,7 @@ class CompensationActionAdmin(admin.ModelAdmin): ] -class CompensationAdmin(admin.ModelAdmin): +class CompensationAdmin(BaseObjectAdmin): list_display = [ "id", "identifier", diff --git a/compensation/models/compensation.py b/compensation/models/compensation.py index ccf511a1..1759523b 100644 --- a/compensation/models/compensation.py +++ b/compensation/models/compensation.py @@ -16,12 +16,13 @@ from django.utils.translation import gettext_lazy as _ from compensation.managers import CompensationManager from compensation.models import CompensationState, CompensationAction from compensation.utils.quality import CompensationQualityChecker -from konova.models import BaseObject, AbstractDocument, Geometry, Deadline, generate_document_file_upload_path +from konova.models import BaseObject, AbstractDocument, Deadline, generate_document_file_upload_path, \ + GeoReferencedMixin from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE from user.models import UserActionLogEntry -class AbstractCompensation(BaseObject): +class AbstractCompensation(BaseObject, GeoReferencedMixin): """ Abstract compensation model which holds basic attributes, shared by subclasses like the regular Compensation, EMA or EcoAccount. @@ -41,8 +42,6 @@ class AbstractCompensation(BaseObject): deadlines = models.ManyToManyField("konova.Deadline", blank=True, related_name="+") - geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL) - class Meta: abstract = True diff --git a/ema/models/ema.py b/ema/models/ema.py index 8c55bd4b..e6d87f3f 100644 --- a/ema/models/ema.py +++ b/ema/models/ema.py @@ -7,15 +7,17 @@ Created on: 15.11.21 """ import shutil -from django.contrib.auth.models import User +from django.contrib import messages from django.db import models from django.db.models import QuerySet +from django.http import HttpRequest from compensation.models import AbstractCompensation from ema.managers import EmaManager from ema.utils.quality import EmaQualityChecker from konova.models import AbstractDocument, generate_document_file_upload_path, RecordableObjectMixin, ShareableObjectMixin from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE +from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin): @@ -91,6 +93,13 @@ class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin): ) return docs + def set_status_messages(self, request: HttpRequest): + if not self.is_shared_with(request.user): + messages.info(request, DATA_UNSHARED_EXPLANATION) + self._set_overlapped_by_message(request) + self._set_overlapping_message(request) + return request + class EmaDocument(AbstractDocument): """ diff --git a/ema/views.py b/ema/views.py index 02bf53e1..dc2fa49a 100644 --- a/ema/views.py +++ b/ema/views.py @@ -138,8 +138,7 @@ def detail_view(request: HttpRequest, id: str): sum_after_states = after_states.aggregate(Sum("surface"))["surface__sum"] or 0 diff_states = abs(sum_before_states - sum_after_states) - if not is_data_shared: - messages.info(request, DATA_UNSHARED_EXPLANATION) + ema.set_status_messages(request) context = { "obj": ema, diff --git a/intervention/admin.py b/intervention/admin.py index f65cb332..bae89830 100644 --- a/intervention/admin.py +++ b/intervention/admin.py @@ -1,10 +1,10 @@ from django.contrib import admin from intervention.models import Intervention, Responsibility, Legal, Revocation, InterventionDocument -from konova.admin import AbstractDocumentAdmin +from konova.admin import AbstractDocumentAdmin, BaseObjectAdmin -class InterventionAdmin(admin.ModelAdmin): +class InterventionAdmin(BaseObjectAdmin): list_display = [ "id", "identifier", @@ -13,9 +13,11 @@ class InterventionAdmin(admin.ModelAdmin): "deleted", ] + class InterventionDocumentAdmin(AbstractDocumentAdmin): pass + class ResponsibilityAdmin(admin.ModelAdmin): list_display = [ "id", diff --git a/intervention/models/intervention.py b/intervention/models/intervention.py index fd1a1c2a..ef7f6040 100644 --- a/intervention/models/intervention.py +++ b/intervention/models/intervention.py @@ -7,6 +7,7 @@ Created on: 15.11.21 """ import shutil +from django.contrib import messages from django.contrib.auth.models import User from django.db import models, transaction from django.db.models import QuerySet @@ -18,13 +19,15 @@ from intervention.models.legal import Legal from intervention.models.responsibility import Responsibility from intervention.models.revocation import RevocationDocument, Revocation from intervention.utils.quality import InterventionQualityChecker -from konova.models import generate_document_file_upload_path, AbstractDocument, Geometry, BaseObject, ShareableObjectMixin, \ - RecordableObjectMixin, CheckableObjectMixin +from konova.models import generate_document_file_upload_path, AbstractDocument, BaseObject, \ + ShareableObjectMixin, \ + RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin from konova.settings import LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT, DEFAULT_SRID_RLP +from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION from user.models import UserActionLogEntry -class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, CheckableObjectMixin): +class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin): """ Interventions are e.g. construction sites where nature used to be. """ @@ -42,7 +45,6 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec blank=True, help_text="Holds data on legal dates or law" ) - geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL) objects = InterventionManager() @@ -263,6 +265,13 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec if self.checked: self.set_unchecked() + def set_status_messages(self, request: HttpRequest): + if not self.is_shared_with(request.user): + messages.info(request, DATA_UNSHARED_EXPLANATION) + request = self._set_overlapping_message(request) + request = self._set_overlapped_by_message(request) + return request + class InterventionDocument(AbstractDocument): """ diff --git a/intervention/views.py b/intervention/views.py index a3f05f7d..60d75249 100644 --- a/intervention/views.py +++ b/intervention/views.py @@ -255,8 +255,7 @@ def detail_view(request: HttpRequest, id: str): "LANIS_LINK": intervention.get_LANIS_link() } - if not is_data_shared: - messages.info(request, DATA_UNSHARED_EXPLANATION) + request = intervention.set_status_messages(request) context = BaseContext(request, context).context return render(request, template, context) diff --git a/konova/admin.py b/konova/admin.py index c57117af..e62a8cff 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 +from konova.models import Geometry, Deadline, GeometryConflict class GeometryAdmin(admin.ModelAdmin): @@ -17,6 +17,14 @@ class GeometryAdmin(admin.ModelAdmin): ] +class GeometryConflictAdmin(admin.ModelAdmin): + list_display = [ + "conflicting_geometry", + "existing_geometry", + "detected_on", + ] + + class AbstractDocumentAdmin(admin.ModelAdmin): list_display = [ "id", @@ -35,5 +43,14 @@ class DeadlineAdmin(admin.ModelAdmin): ] +class BaseObjectAdmin(admin.ModelAdmin): + readonly_fields = [ + "modified", + "deleted", + "created", + ] + + admin.site.register(Geometry, GeometryAdmin) +admin.site.register(GeometryConflict, GeometryConflictAdmin) admin.site.register(Deadline, DeadlineAdmin) diff --git a/konova/management/commands/sanitize_db.py b/konova/management/commands/sanitize_db.py index de3c249d..c5526517 100644 --- a/konova/management/commands/sanitize_db.py +++ b/konova/management/commands/sanitize_db.py @@ -208,7 +208,7 @@ class Command(BaseKonovaCommand): if num_entries > 0: self._write_error(f"Found {num_entries} geometries not attached to anything. Delete now...") unattached_geometries.delete() - self._write_success("Deadlines deleted.") + self._write_success("Geometries deleted.") else: self._write_success("No unattached geometries found.") self._break_line() diff --git a/konova/models/geometry.py b/konova/models/geometry.py index 736e1b3b..db36c03b 100644 --- a/konova/models/geometry.py +++ b/konova/models/geometry.py @@ -6,13 +6,93 @@ Created on: 15.11.21 """ from django.contrib.gis.db.models import MultiPolygonField +from django.db import models -from konova.models import BaseResource +from konova.models import BaseResource, UuidModel class Geometry(BaseResource): """ - Outsourced geometry model so multiple versions of the same object can refer to the same geometry if it is not changed + Geometry model """ from konova.settings import DEFAULT_SRID - geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID) \ No newline at end of file + geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.check_for_conflicts() + + def check_for_conflicts(self): + """ Checks for geometry overlaps + + Creates a new GeometryConflict entry for each overlap to another geometry, which has already been there before + + Returns: + + """ + # 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): + return None + + check_timestamp_obj = self.modified or self.created + ts = check_timestamp_obj.timestamp + overlapping_geoms = Geometry.objects.filter( + modified__timestamp__lte=ts, + geom__overlaps=self.geom, + ) + + # Drop known conflicts for this object to replace with new ones + self.conflicts_geometries.all().delete() + for match in overlapping_geoms: + GeometryConflict.objects.get_or_create(conflicting_geometry=self, existing_geometry=match) + + # Rerun the conflict check for all conflicts where this object is not the cause but the one that already existed. + # It may be possible that this object has been edited, so the conflicts would be resolved in the newer entries. + existing_conflicts = self.conflicted_by_geometries.all() + for conflict in existing_conflicts: + conflicting_geom = conflict.conflicting_geometry + conflicting_geom.check_for_conflicts() + + def get_data_objects(self): + """ Getter for all objects which are related to this geometry + + Returns: + objs (list): The list of objects + """ + objs = [] + sets = [ + self.intervention_set, + self.compensation_set, + self.ema_set, + self.ecoaccount_set, + ] + for _set in sets: + set_objs = _set.filter( + deleted=None + ) + objs += set_objs + return objs + + +class GeometryConflict(UuidModel): + """ + Geometry conflicts model + + If a new/edited geometry overlays an existing geometry, there will be a new GeometryConflict on the db + """ + conflicting_geometry = models.ForeignKey( + Geometry, + on_delete=models.CASCADE, + help_text="The geometry which came second", + related_name="conflicts_geometries" + ) + existing_geometry = models.ForeignKey( + Geometry, + on_delete=models.CASCADE, + help_text="The geometry which came first", + related_name="conflicted_by_geometries" + ) + detected_on = models.DateTimeField(auto_now_add=True, null=True) + + def __str__(self): + return f"{self.conflicting_geometry.id} conflicts with {self.existing_geometry.id}" diff --git a/konova/models/object.py b/konova/models/object.py index 5b8f9b72..b1e78f8d 100644 --- a/konova/models/object.py +++ b/konova/models/object.py @@ -7,6 +7,7 @@ Created on: 15.11.21 """ import uuid +from abc import abstractmethod from django.contrib import messages from django.contrib.auth.models import User @@ -20,7 +21,8 @@ from ema.settings import EMA_ACCOUNT_IDENTIFIER_LENGTH, EMA_ACCOUNT_IDENTIFIER_T from intervention.settings import INTERVENTION_IDENTIFIER_LENGTH, INTERVENTION_IDENTIFIER_TEMPLATE from konova.utils import generators from konova.utils.generators import generate_random_string -from konova.utils.message_templates import CHECKED_RECORDED_RESET +from konova.utils.message_templates import CHECKED_RECORDED_RESET, GEOMETRY_OVERLAPS_WITH_TEMPLATE, \ + GEOMETRY_OVERLAPPED_BY_TEMPLATE from user.models import UserActionLogEntry, UserAction @@ -94,6 +96,10 @@ class BaseObject(BaseResource): class Meta: abstract = True + @abstractmethod + def set_status_messages(self, request: HttpRequest): + raise NotImplementedError + def mark_as_deleted(self, user: User): """ Mark an entry as deleted @@ -407,3 +413,34 @@ class ShareableObjectMixin(models.Model): id__in=accessing_users ) self.share_with_list(users) + + +class GeoReferencedMixin(models.Model): + geometry = models.ForeignKey("konova.Geometry", null=True, blank=True, on_delete=models.SET_NULL) + + class Meta: + abstract = True + + def _set_overlapping_message(self, request: HttpRequest): + geom_conflicts = self.geometry.conflicts_geometries.all() + if geom_conflicts: + data_objs = [] + for conflict in geom_conflicts: + data_objs += conflict.existing_geometry.get_data_objects() + data_identifiers = [x.identifier for x in data_objs] + data_identifiers = ", ".join(data_identifiers) + message_str = GEOMETRY_OVERLAPS_WITH_TEMPLATE.format(data_identifiers) + messages.info(request, message_str) + return request + + def _set_overlapped_by_message(self, request: HttpRequest): + geom_conflicts = self.geometry.conflicted_by_geometries.all() + if geom_conflicts: + data_objs = [] + for conflict in geom_conflicts: + data_objs += conflict.conflicting_geometry.get_data_objects() + data_identifiers = [x.identifier for x in data_objs] + data_identifiers = ", ".join(data_identifiers) + message_str = GEOMETRY_OVERLAPPED_BY_TEMPLATE.format(data_identifiers) + messages.info(request, message_str) + return request \ No newline at end of file diff --git a/konova/utils/message_templates.py b/konova/utils/message_templates.py index 5b26c460..260435ff 100644 --- a/konova/utils/message_templates.py +++ b/konova/utils/message_templates.py @@ -25,4 +25,8 @@ CANCEL_ACC_RECORDED_OR_DEDUCTED = _("Action canceled. Eco account is recorded or EDITED_GENERAL_DATA = _("Edited general data") ADDED_COMPENSATION_STATE = _("Added compensation state") ADDED_DEADLINE = _("Added deadline") -ADDED_COMPENSATION_ACTION = _("Added compensation action") \ No newline at end of file +ADDED_COMPENSATION_ACTION = _("Added compensation action") + +# Geometry conflicts +GEOMETRY_OVERLAPS_WITH_TEMPLATE = _("Geometry overlaps {}") +GEOMETRY_OVERLAPPED_BY_TEMPLATE = _("Geometry overlapped by {}")