#50 Overlaying geometries

* refactors geometry field into GeoReferencedMixin, holding more handy methods and used in all models, formerly holding the geometry field
* refactors backend admin configuration, so modified, deleted and created are not editable in the backend which also skips loading of all possible choices
* fixes typo in sanitize_db command
* introduces GeometryConflict model, holding a link between two geometries, where one overlaps the other
* adds first (WIP) messages into detail views of ema and intervention for test purposes
This commit is contained in:
mpeltriaux 2021-12-15 13:59:52 +01:00
parent 0ec1744158
commit 2bd94a7618
12 changed files with 179 additions and 23 deletions

View File

@ -2,6 +2,7 @@ from django.contrib import admin
from compensation.models import Compensation, CompensationAction, CompensationState, Payment, \ from compensation.models import Compensation, CompensationAction, CompensationState, Payment, \
EcoAccountDeduction, EcoAccount EcoAccountDeduction, EcoAccount
from konova.admin import BaseObjectAdmin
class CompensationStateAdmin(admin.ModelAdmin): class CompensationStateAdmin(admin.ModelAdmin):
@ -22,7 +23,7 @@ class CompensationActionAdmin(admin.ModelAdmin):
] ]
class CompensationAdmin(admin.ModelAdmin): class CompensationAdmin(BaseObjectAdmin):
list_display = [ list_display = [
"id", "id",
"identifier", "identifier",

View File

@ -16,12 +16,13 @@ from django.utils.translation import gettext_lazy as _
from compensation.managers import CompensationManager from compensation.managers import CompensationManager
from compensation.models import CompensationState, CompensationAction from compensation.models import CompensationState, CompensationAction
from compensation.utils.quality import CompensationQualityChecker 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 konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE
from user.models import UserActionLogEntry 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, Abstract compensation model which holds basic attributes, shared by subclasses like the regular Compensation,
EMA or EcoAccount. EMA or EcoAccount.
@ -41,8 +42,6 @@ class AbstractCompensation(BaseObject):
deadlines = models.ManyToManyField("konova.Deadline", blank=True, related_name="+") deadlines = models.ManyToManyField("konova.Deadline", blank=True, related_name="+")
geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL)
class Meta: class Meta:
abstract = True abstract = True

View File

@ -7,15 +7,17 @@ Created on: 15.11.21
""" """
import shutil import shutil
from django.contrib.auth.models import User from django.contrib import messages
from django.db import models from django.db import models
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest
from compensation.models import AbstractCompensation from compensation.models import AbstractCompensation
from ema.managers import EmaManager from ema.managers import EmaManager
from ema.utils.quality import EmaQualityChecker from ema.utils.quality import EmaQualityChecker
from konova.models import AbstractDocument, generate_document_file_upload_path, RecordableObjectMixin, ShareableObjectMixin from konova.models import AbstractDocument, generate_document_file_upload_path, RecordableObjectMixin, ShareableObjectMixin
from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE
from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION
class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin): class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin):
@ -91,6 +93,13 @@ class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin):
) )
return docs 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): class EmaDocument(AbstractDocument):
""" """

View File

@ -138,8 +138,7 @@ def detail_view(request: HttpRequest, id: str):
sum_after_states = after_states.aggregate(Sum("surface"))["surface__sum"] or 0 sum_after_states = after_states.aggregate(Sum("surface"))["surface__sum"] or 0
diff_states = abs(sum_before_states - sum_after_states) diff_states = abs(sum_before_states - sum_after_states)
if not is_data_shared: ema.set_status_messages(request)
messages.info(request, DATA_UNSHARED_EXPLANATION)
context = { context = {
"obj": ema, "obj": ema,

View File

@ -1,10 +1,10 @@
from django.contrib import admin from django.contrib import admin
from intervention.models import Intervention, Responsibility, Legal, Revocation, InterventionDocument 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 = [ list_display = [
"id", "id",
"identifier", "identifier",
@ -13,9 +13,11 @@ class InterventionAdmin(admin.ModelAdmin):
"deleted", "deleted",
] ]
class InterventionDocumentAdmin(AbstractDocumentAdmin): class InterventionDocumentAdmin(AbstractDocumentAdmin):
pass pass
class ResponsibilityAdmin(admin.ModelAdmin): class ResponsibilityAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"id", "id",

View File

@ -7,6 +7,7 @@ Created on: 15.11.21
""" """
import shutil import shutil
from django.contrib import messages
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models, transaction from django.db import models, transaction
from django.db.models import QuerySet 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.responsibility import Responsibility
from intervention.models.revocation import RevocationDocument, Revocation from intervention.models.revocation import RevocationDocument, Revocation
from intervention.utils.quality import InterventionQualityChecker from intervention.utils.quality import InterventionQualityChecker
from konova.models import generate_document_file_upload_path, AbstractDocument, Geometry, BaseObject, ShareableObjectMixin, \ from konova.models import generate_document_file_upload_path, AbstractDocument, BaseObject, \
RecordableObjectMixin, CheckableObjectMixin ShareableObjectMixin, \
RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin
from konova.settings import LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT, DEFAULT_SRID_RLP 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 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. Interventions are e.g. construction sites where nature used to be.
""" """
@ -42,7 +45,6 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec
blank=True, blank=True,
help_text="Holds data on legal dates or law" help_text="Holds data on legal dates or law"
) )
geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL)
objects = InterventionManager() objects = InterventionManager()
@ -263,6 +265,13 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec
if self.checked: if self.checked:
self.set_unchecked() 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): class InterventionDocument(AbstractDocument):
""" """

View File

@ -255,8 +255,7 @@ def detail_view(request: HttpRequest, id: str):
"LANIS_LINK": intervention.get_LANIS_link() "LANIS_LINK": intervention.get_LANIS_link()
} }
if not is_data_shared: request = intervention.set_status_messages(request)
messages.info(request, DATA_UNSHARED_EXPLANATION)
context = BaseContext(request, context).context context = BaseContext(request, context).context
return render(request, template, context) return render(request, template, context)

View File

@ -7,7 +7,7 @@ Created on: 22.07.21
""" """
from django.contrib import admin from django.contrib import admin
from konova.models import Geometry, Deadline from konova.models import Geometry, Deadline, GeometryConflict
class GeometryAdmin(admin.ModelAdmin): 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): class AbstractDocumentAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"id", "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(Geometry, GeometryAdmin)
admin.site.register(GeometryConflict, GeometryConflictAdmin)
admin.site.register(Deadline, DeadlineAdmin) admin.site.register(Deadline, DeadlineAdmin)

View File

@ -208,7 +208,7 @@ class Command(BaseKonovaCommand):
if num_entries > 0: if num_entries > 0:
self._write_error(f"Found {num_entries} geometries not attached to anything. Delete now...") self._write_error(f"Found {num_entries} geometries not attached to anything. Delete now...")
unattached_geometries.delete() unattached_geometries.delete()
self._write_success("Deadlines deleted.") self._write_success("Geometries deleted.")
else: else:
self._write_success("No unattached geometries found.") self._write_success("No unattached geometries found.")
self._break_line() self._break_line()

View File

@ -6,13 +6,93 @@ Created on: 15.11.21
""" """
from django.contrib.gis.db.models import MultiPolygonField 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): 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 from konova.settings import DEFAULT_SRID
geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID) 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}"

View File

@ -7,6 +7,7 @@ Created on: 15.11.21
""" """
import uuid import uuid
from abc import abstractmethod
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.models import User 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 intervention.settings import INTERVENTION_IDENTIFIER_LENGTH, INTERVENTION_IDENTIFIER_TEMPLATE
from konova.utils import generators from konova.utils import generators
from konova.utils.generators import generate_random_string 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 from user.models import UserActionLogEntry, UserAction
@ -94,6 +96,10 @@ class BaseObject(BaseResource):
class Meta: class Meta:
abstract = True abstract = True
@abstractmethod
def set_status_messages(self, request: HttpRequest):
raise NotImplementedError
def mark_as_deleted(self, user: User): def mark_as_deleted(self, user: User):
""" Mark an entry as deleted """ Mark an entry as deleted
@ -407,3 +413,34 @@ class ShareableObjectMixin(models.Model):
id__in=accessing_users id__in=accessing_users
) )
self.share_with_list(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

View File

@ -25,4 +25,8 @@ CANCEL_ACC_RECORDED_OR_DEDUCTED = _("Action canceled. Eco account is recorded or
EDITED_GENERAL_DATA = _("Edited general data") EDITED_GENERAL_DATA = _("Edited general data")
ADDED_COMPENSATION_STATE = _("Added compensation state") ADDED_COMPENSATION_STATE = _("Added compensation state")
ADDED_DEADLINE = _("Added deadline") ADDED_DEADLINE = _("Added deadline")
ADDED_COMPENSATION_ACTION = _("Added compensation action") ADDED_COMPENSATION_ACTION = _("Added compensation action")
# Geometry conflicts
GEOMETRY_OVERLAPS_WITH_TEMPLATE = _("Geometry overlaps {}")
GEOMETRY_OVERLAPPED_BY_TEMPLATE = _("Geometry overlapped by {}")