Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Affichage des cartos SAGE #207

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions envergo/geodata/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from envergo.geodata.forms import DepartmentForm
from envergo.geodata.models import Department, Map, Parcel, Zone
from envergo.geodata.tasks import process_shapefile_map
from envergo.geodata.tasks import generate_map_preview, process_shapefile_map
from envergo.geodata.utils import count_features, extract_shapefile


Expand Down Expand Up @@ -60,7 +60,7 @@ def queryset(self, request, queryset):


@admin.register(Map)
class MapAdmin(admin.ModelAdmin):
class MapAdmin(gis_admin.GISModelAdmin):
form = MapForm
list_display = [
"name",
Expand All @@ -80,7 +80,7 @@ class MapAdmin(admin.ModelAdmin):
"task_status",
"import_error_msg",
]
actions = ["process"]
actions = ["process", "generate_preview"]
exclude = ["task_id"]
search_fields = ["name", "display_name"]
list_filter = ["import_status", "map_type", "data_type", DepartmentsListFilter]
Expand Down Expand Up @@ -161,6 +161,18 @@ def process(self, request, queryset):
)
self.message_user(request, msg, level=messages.INFO)

@admin.action(description=_("Generate the simplified preview geometry"))
def generate_preview(self, request, queryset):
if queryset.count() > 1:
error = _("Please only select one map for this action.")
self.message_user(request, error, level=messages.ERROR)
return

map = queryset[0]
generate_map_preview.delay(map.id)
msg = _("The map preview will be updated soon.")
self.message_user(request, msg, level=messages.INFO)

@admin.display(description=_("Extracted zones"))
def zone_count(self, obj):
count = Zone.objects.filter(map=obj).count()
Expand Down
20 changes: 20 additions & 0 deletions envergo/geodata/migrations/0003_map_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2 on 2023-06-16 12:36

import django.contrib.gis.db.models.fields
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("geodata", "0002_map_imported_zones"),
]

operations = [
migrations.AddField(
model_name="map",
name="geometry",
field=django.contrib.gis.db.models.fields.MultiPolygonField(
geography=True, null=True, srid=4326, verbose_name="Simplified geometry"
),
),
]
4 changes: 3 additions & 1 deletion envergo/geodata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ class Map(models.Model):
choices=DEPARTMENT_CHOICES,
),
)

geometry = gis_models.MultiPolygonField(
_("Simplified geometry"), geography=True, null=True
)
created_at = models.DateTimeField(_("Date created"), default=timezone.now)
expected_zones = models.IntegerField(_("Expected zones"), default=0)
imported_zones = models.IntegerField(_("Imported zones"), null=True, blank=True)
Expand Down
20 changes: 18 additions & 2 deletions envergo/geodata/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,36 @@

from config.celery_app import app
from envergo.geodata.models import STATUSES, Map
from envergo.geodata.utils import process_shapefile
from envergo.geodata.utils import process_shapefile, simplify_map

logger = logging.getLogger(__name__)


@app.task(bind=True)
@transaction.atomic
def process_shapefile_map(task, map_id):
logger.info(f"Starting import on map {map_id}")

map = Map.objects.get(pk=map_id)

# Store the task data in the model, so we can display progression
# in the admin page.
map.task_id = task.request.id
map.import_error_msg = ""
map.import_status = None
map.save()

map.zones.all().delete()
# Proceed with the map import
try:
with transaction.atomic():
map.zones.all().delete()
process_shapefile(map, map.file, task)
map.geometry = simplify_map(map)
except Exception as e:
map.import_error_msg = f"Erreur d'import ({e})"
logger.error(map.import_error_msg)

# Update the map status and metadata
nb_imported_zones = map.zones.all().count()
if map.expected_zones == nb_imported_zones:
map.import_status = STATUSES.success
Expand All @@ -38,3 +45,12 @@ def process_shapefile_map(task, map_id):
map.task_id = None
map.imported_zones = nb_imported_zones
map.save()


@app.task(bind=True)
def generate_map_preview(task, map_id):
logger.info(f"Starting preview generation on map {map_id}")

map = Map.objects.get(pk=map_id)
map.geometry = simplify_map(map)
map.save()
53 changes: 50 additions & 3 deletions envergo/geodata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@

import requests
from django.contrib.gis.gdal import DataSource
from django.contrib.gis.geos import GEOSGeometry
from django.contrib.gis.geos import GEOSGeometry, MultiPolygon, Polygon
from django.contrib.gis.utils.layermapping import LayerMapping
from django.core.serializers import serialize
from django.db import connection
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _

from envergo.geodata.models import Zone

logger = logging.getLogger(__name__)

EPSG_WGS84 = 4326


class CeleryDebugStream:
"""A sys.stdout proxy that also updates the celery task states.
Expand Down Expand Up @@ -49,6 +52,8 @@ def write(self, msg):


class CustomMapping(LayerMapping):
"""A custom LayerMapping that allows to pass extra arguments to the generated model."""

def __init__(self, *args, **kwargs):
self.extra_kwargs = kwargs.pop("extra_kwargs")
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -127,8 +132,6 @@ def to_geojson(obj, geometry_field="geometry"):
srid.
"""

EPSG_WGS84 = 4326

if isinstance(obj, (QuerySet, list)):
geojson = serialize("geojson", obj, geometry_field=geometry_field)
elif hasattr(obj, "geojson"):
Expand Down Expand Up @@ -191,3 +194,47 @@ def merge_geometries(polygons):
pass

return merged


def simplify_map(map):
"""Generates a simplified geometry for the entire map.

This methods takes a map and generates a single polygon that is the union
of all the polygons in the map.

We also simplify the polygon because this is for display purpose only.

We use native postgis methods those operations, because it's way faster.

As for simplification, we don't preserve topology (ST_Simplify instead of
ST_SimplifyPreserveTopology) because we want to be able to drop small
holes in the polygon.

Because of that, we also have to call ST_MakeValid to avoid returning invalid
polygons."""

with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
ST_AsText(
ST_MakeValid(
ST_Simplify(
ST_Union(z.geometry::geometry),
0.0001
),
'method=structure keepcollapsed=false'
)
)
AS polygon
FROM geodata_zone as z
WHERE z.map_id = %s
""",
[map.id],
)
row = cursor.fetchone()

polygon = GEOSGeometry(row[0], srid=EPSG_WGS84)
if isinstance(polygon, Polygon):
polygon = MultiPolygon(polygon)
return polygon
11 changes: 8 additions & 3 deletions envergo/moulinette/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance as D
from django.db import models
from django.db.models import F
from django.db.models import Case, F, When
from django.db.models.functions import Cast
from django.utils.translation import gettext_lazy as _

Expand Down Expand Up @@ -158,7 +158,7 @@ def __init__(self, data, raw_data):
self.catalog["config"] = self.department.moulinette_config

self.perimeters = self.get_perimeters()
self.criterions = self.get_criterions()
self.criterions_classes = self.get_criterions()

# This is a clear case of circular references, since the Moulinette
# holds references to the regulations it's computing, but regulations and
Expand Down Expand Up @@ -251,7 +251,12 @@ def get_perimeters(self):
Perimeter.objects.filter(
map__zones__geometry__dwithin=(coords, F("activation_distance"))
)
.annotate(geometry=F("map__zones__geometry"))
.annotate(
geometry=Case(
When(map__geometry__isnull=False, then=F("map__geometry")),
default=F("map__zones__geometry"),
)
)
.annotate(distance=Distance("map__zones__geometry", coords))
.order_by("distance", "map__name")
.select_related("map", "contact")
Expand Down
28 changes: 20 additions & 8 deletions envergo/moulinette/regulations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from abc import ABC
from dataclasses import dataclass
from enum import Enum
from functools import cached_property
Expand All @@ -21,18 +22,24 @@ def __str__(self):
return self.text


class MoulinetteRegulation:
"""Run the moulinette for a single regulation (e.g Loi sur l'eau)."""
class MoulinetteRegulation(ABC):
"""Run the moulinette for a single regulation (e.g Loi sur l'eau).

This class is meant to be inherited to implement actual regulations.
"""

# Implement this in subclasses
criterion_classes = []

def __init__(self, moulinette):
self.moulinette = moulinette
self.moulinette.catalog.update(self.get_catalog_data())

# Instanciate the criterions
self.criterions = [
Criterion(moulinette)
for Criterion in self.criterion_classes
if Criterion in moulinette.criterions
perimeter.criterion(moulinette, perimeter)
for perimeter in moulinette.perimeters
if perimeter.criterion in self.criterion_classes
]

def get_catalog_data(self):
Expand Down Expand Up @@ -119,7 +126,11 @@ def _get_map(self):

@dataclass
class MapPolygon:
"""Data that can be displayed and labeled on a leaflet map as a polygon."""
"""Data that can be displayed and labeled on a leaflet map as a polygon.

A `MapPolygon is meant to represent a single entry on a map:
a polygon with a given color and label.
"""

perimeters: list # List of `envergo.geofr.Perimeter` objects
color: str
Expand Down Expand Up @@ -185,7 +196,7 @@ def sources(self):
return maps


class MoulinetteCriterion:
class MoulinetteCriterion(ABC):
"""Run a single moulinette check."""

# Prevent template engine to instanciate the class since we sometimes want
Expand All @@ -198,9 +209,10 @@ class MoulinetteCriterion:
# "Nomenclature réglementations & critères" document.
CODES = ["soumis", "non_soumis", "action_requise", "non_concerne"]

def __init__(self, moulinette):
def __init__(self, moulinette, perimeter):
self.moulinette = moulinette
self.moulinette.catalog.update(self.get_catalog_data())
self.perimeter = perimeter

def get_catalog_data(self):
"""Get data to inject to the global catalog."""
Expand Down
27 changes: 27 additions & 0 deletions envergo/moulinette/regulations/sage.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,30 @@ def result(self):
result = RESULTS.non_disponible

return result

def _get_map(self):
# Let's find the first map that we can display
perimeter = next(
(
criterion.perimeter
for criterion in self.criterions
if criterion.perimeter.map.display_for_user
and criterion.perimeter.map.geometry
),
None,
)
if not perimeter:
return None

map_polygons = [MapPolygon([perimeter], "red", "Sage")]
caption = "Le projet se situe dans le périmètre du Sage."

map = Map(
center=self.catalog["coords"],
entries=map_polygons,
caption=caption,
truncate=False,
zoom=None,
)

return map
Loading