feat: add Territory model for default map boundary, and separate daily report quick action to second row
This commit is contained in:
parent
63c888fdbd
commit
e6a09eaf37
8 changed files with 362 additions and 98 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.gis import admin as gis_admin
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
@ -9,6 +10,7 @@ from datetime import timedelta
|
||||||
from django.db.models import Case, When, IntegerField, Value
|
from django.db.models import Case, When, IntegerField, Value
|
||||||
from .models import (
|
from .models import (
|
||||||
MapBackground,
|
MapBackground,
|
||||||
|
Territory,
|
||||||
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
||||||
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
||||||
UserThematicStatusPermission, UserContractStatusPermission,
|
UserThematicStatusPermission, UserContractStatusPermission,
|
||||||
|
|
@ -24,6 +26,16 @@ from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
|
||||||
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
|
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Territory)
|
||||||
|
class TerritoryAdmin(gis_admin.GISModelAdmin):
|
||||||
|
list_display = ("name_fr", "name_nl", "code", "is_default", "is_active", "updated_at")
|
||||||
|
list_filter = ("is_default", "is_active")
|
||||||
|
search_fields = ("name_fr", "name_nl", "code")
|
||||||
|
list_editable = ("is_default", "is_active")
|
||||||
|
ordering = ("-is_default", "name_fr")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Thematic)
|
@admin.register(Thematic)
|
||||||
class ThematicAdmin(admin.ModelAdmin):
|
class ThematicAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
|
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
|
||||||
|
|
|
||||||
|
|
@ -286,26 +286,68 @@ def organization_info(request):
|
||||||
"""
|
"""
|
||||||
Context processor that exposes organization details from settings.
|
Context processor that exposes organization details from settings.
|
||||||
Dynamically adapts map_default_center, map_default_zoom and map_default_extent
|
Dynamically adapts map_default_center, map_default_zoom and map_default_extent
|
||||||
when the authenticated user has an assigned contract associated with a municipality.
|
when a default Territory is defined, or when the authenticated user has an
|
||||||
|
assigned contract associated with a municipality.
|
||||||
Caches calculated bounds in Django cache to avoid heavy GIS and SQL processing on every page.
|
Caches calculated bounds in Django cache to avoid heavy GIS and SQL processing on every page.
|
||||||
"""
|
"""
|
||||||
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
|
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
|
||||||
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
|
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
|
||||||
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
|
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
|
||||||
|
|
||||||
|
# 1. Si l'utilisateur est authentifié et a ses bornes en cache, les utiliser directement (0 requête)
|
||||||
|
user_cached = False
|
||||||
if hasattr(request, 'user') and request.user.is_authenticated:
|
if hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
cache_key = f"user_map_bounds_{request.user.pk}"
|
cache_key = f"user_map_bounds_{request.user.pk}"
|
||||||
cached_bounds = cache.get(cache_key)
|
cached_bounds = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_bounds is not None:
|
if cached_bounds is not None:
|
||||||
center = cached_bounds.get('center', center)
|
center = cached_bounds.get('center', center)
|
||||||
zoom = cached_bounds.get('zoom', zoom)
|
zoom = cached_bounds.get('zoom', zoom)
|
||||||
extent = cached_bounds.get('extent', extent)
|
extent = cached_bounds.get('extent', extent)
|
||||||
|
user_cached = True
|
||||||
|
|
||||||
|
# 2. Si non présent dans le cache utilisateur et aucun extent dans settings, chercher le Territoire par défaut
|
||||||
|
if not user_cached and extent is None:
|
||||||
|
cached_ter_bounds = cache.get("default_territory_bounds")
|
||||||
|
if cached_ter_bounds is not None:
|
||||||
|
if cached_ter_bounds:
|
||||||
|
center = cached_ter_bounds.get('center', center)
|
||||||
|
zoom = cached_ter_bounds.get('zoom', zoom)
|
||||||
|
extent = cached_ter_bounds.get('extent', extent)
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
from .models import Territory
|
||||||
|
def_ter = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
|
||||||
|
if def_ter:
|
||||||
|
g = def_ter.geom.transform(4326, clone=True) if def_ter.geom.srid != 4326 else def_ter.geom
|
||||||
|
min_lon, min_lat, max_lon, max_lat = g.extent
|
||||||
|
delta_lon = (max_lon - min_lon) * 0.05
|
||||||
|
delta_lat = (max_lat - min_lat) * 0.05
|
||||||
|
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
|
||||||
|
extent = [
|
||||||
|
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
|
||||||
|
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
|
||||||
|
]
|
||||||
|
span = max(max_lon - min_lon, max_lat - min_lat)
|
||||||
|
if span < 0.08:
|
||||||
|
zoom = 14
|
||||||
|
elif span < 0.2:
|
||||||
|
zoom = 13
|
||||||
|
elif span < 0.5:
|
||||||
|
zoom = 12
|
||||||
|
else:
|
||||||
|
zoom = 11
|
||||||
|
cache.set("default_territory_bounds", {'center': center, 'zoom': zoom, 'extent': extent}, 86400)
|
||||||
|
else:
|
||||||
|
cache.set("default_territory_bounds", {}, 86400)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Si utilisateur authentifié sans cache, calculer et mettre en cache ses bornes
|
||||||
|
if not user_cached and hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
|
cache_key = f"user_map_bounds_{request.user.pk}"
|
||||||
try:
|
try:
|
||||||
user_config = getattr(request.user, 'config', None)
|
user_config = getattr(request.user, 'config', None)
|
||||||
if user_config:
|
if user_config:
|
||||||
# Récupérer les contrats accessibles ayant des communes associées
|
|
||||||
contracts_to_check = []
|
contracts_to_check = []
|
||||||
if user_config.default_contract and user_config.default_contract.municipalities.exists():
|
if user_config.default_contract and user_config.default_contract.municipalities.exists():
|
||||||
contracts_to_check.append(user_config.default_contract)
|
contracts_to_check.append(user_config.default_contract)
|
||||||
|
|
@ -324,7 +366,6 @@ def organization_info(request):
|
||||||
max_lon = max(g.extent[2] for g in geoms_4326)
|
max_lon = max(g.extent[2] for g in geoms_4326)
|
||||||
max_lat = max(g.extent[3] for g in geoms_4326)
|
max_lat = max(g.extent[3] for g in geoms_4326)
|
||||||
|
|
||||||
# Léger buffer (5% d'expansion de chaque côté pour aérer le cadre)
|
|
||||||
delta_lon = (max_lon - min_lon) * 0.05
|
delta_lon = (max_lon - min_lon) * 0.05
|
||||||
delta_lat = (max_lat - min_lat) * 0.05
|
delta_lat = (max_lat - min_lat) * 0.05
|
||||||
|
|
||||||
|
|
|
||||||
34
loko/common/migrations/0017_territory.py
Normal file
34
loko/common/migrations/0017_territory.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-21 08:03
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0016_mapbackground'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Territory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
|
||||||
|
('name_nl', models.CharField(blank=True, max_length=255, verbose_name='Nom (NL)')),
|
||||||
|
('code', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Code')),
|
||||||
|
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
|
||||||
|
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Géométrie')),
|
||||||
|
('is_default', models.BooleanField(default=False, help_text='Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.', verbose_name='Territoire par défaut')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Modifié le')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Territoire',
|
||||||
|
'verbose_name_plural': 'Territoires',
|
||||||
|
'ordering': ['-is_default', 'name_fr'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1390,5 +1390,62 @@ class MapBackground(models.Model):
|
||||||
cache.delete("active_map_backgrounds")
|
cache.delete("active_map_backgrounds")
|
||||||
|
|
||||||
|
|
||||||
|
class Territory(models.Model):
|
||||||
|
"""
|
||||||
|
Définit une zone territoriale administrative ou contractuelle (ex: Région de Bruxelles-Capitale).
|
||||||
|
Permet d'illuminer directement un polygone global sans jointure complexe de communes,
|
||||||
|
évitant ainsi les artefacts visuels de lignes intérieures.
|
||||||
|
"""
|
||||||
|
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
|
||||||
|
name_nl = models.CharField(max_length=255, blank=True, verbose_name=_("Nom (NL)"))
|
||||||
|
code = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("Code"))
|
||||||
|
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||||
|
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie"))
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Territoire par défaut"),
|
||||||
|
help_text=_("Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.")
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Modifié le"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
lang = get_language()
|
||||||
|
if lang == 'nl':
|
||||||
|
return self.name_nl or self.name_fr
|
||||||
|
return self.name_fr or self.name_nl
|
||||||
|
|
||||||
|
objects = LanguageManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Territoire")
|
||||||
|
verbose_name_plural = _("Territoires")
|
||||||
|
ordering = ['-is_default', 'name_fr']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
code_str = f" [{self.code}]" if self.code else ""
|
||||||
|
return f"{self.name}{code_str}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.is_default:
|
||||||
|
Territory.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
self._clear_cache()
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
self._clear_cache()
|
||||||
|
|
||||||
|
def _clear_cache(self):
|
||||||
|
from django.core.cache import cache
|
||||||
|
cache.delete(f"territory_mask_geojson_territory_{self.id}")
|
||||||
|
cache.delete(f"territory_mask_geojson_default_{self.id}")
|
||||||
|
cache.delete("territory_mask_geojson_default")
|
||||||
|
cache.delete("default_territory_bounds")
|
||||||
|
|
||||||
|
|
||||||
# Import des modèles de tâches planifiées
|
# Import des modèles de tâches planifiées
|
||||||
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,11 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
|
||||||
|
|
||||||
mock_attach.assert_called_once_with(fake_path)
|
mock_attach.assert_called_once_with(fake_path)
|
||||||
mock_send.assert_called_once()
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from django.test import RequestFactory
|
from django.test import RequestFactory
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
@ -488,3 +493,50 @@ class ContextProcessorsCachingTests(TestCase):
|
||||||
access.delete()
|
access.delete()
|
||||||
self.assertIsNone(cache.get(cache_key))
|
self.assertIsNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
|
||||||
|
class TerritoryTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from django.contrib.gis.geos import Polygon, MultiPolygon
|
||||||
|
from common.models import Territory
|
||||||
|
# Create a simple square polygon in EPSG:3812
|
||||||
|
poly = Polygon(((650000, 670000), (660000, 670000), (660000, 680000), (650000, 680000), (650000, 670000)))
|
||||||
|
self.geom = MultiPolygon(poly, srid=3812)
|
||||||
|
self.territory = Territory.objects.create(
|
||||||
|
name_fr="Territoire Test",
|
||||||
|
name_nl="Test Gebied",
|
||||||
|
code="TEST",
|
||||||
|
geom=self.geom,
|
||||||
|
is_default=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_single_default_territory_enforced(self):
|
||||||
|
from common.models import Territory
|
||||||
|
t2 = Territory.objects.create(
|
||||||
|
name_fr="Territoire 2",
|
||||||
|
geom=self.geom,
|
||||||
|
is_default=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.territory.refresh_from_db()
|
||||||
|
self.assertFalse(self.territory.is_default)
|
||||||
|
self.assertTrue(t2.is_default)
|
||||||
|
|
||||||
|
def test_get_territory_mask_geojson_uses_default_territory(self):
|
||||||
|
import json
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from common.views import get_territory_mask_geojson
|
||||||
|
|
||||||
|
factory = RequestFactory()
|
||||||
|
req = factory.get("/common/territory-mask-geojson/")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
res = get_territory_mask_geojson(req)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = json.loads(res.content.decode())
|
||||||
|
self.assertEqual(len(data["features"]), 2)
|
||||||
|
boundary_feature = next(f for f in data["features"] if f["id"] == "territory_boundary")
|
||||||
|
self.assertEqual(boundary_feature["properties"]["name"], "Territoire Test")
|
||||||
|
self.assertEqual(boundary_feature["geometry"]["type"], "MultiPolygon")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,23 +152,86 @@ def get_nearest_address(request):
|
||||||
@require_GET
|
@require_GET
|
||||||
def get_territory_mask_geojson(request):
|
def get_territory_mask_geojson(request):
|
||||||
"""
|
"""
|
||||||
import json
|
Renvoie le masque cartographique inversé (calque grisé transparent en dehors du territoire)
|
||||||
Renvoie le masque cartographique inversé (calque grisé transparent en dehors de la commune)
|
et le tracé du contour pour mettre en évidence le territoire à gérer.
|
||||||
et le tracé du contour communal pour mettre en évidence le territoire à gérer.
|
Supporte un modèle Territory par défaut (contour direct sans jointure de communes),
|
||||||
|
ou une liste de communes (filtrage par contrat/utilisateur).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
user = request.user
|
user = request.user
|
||||||
muni_params = request.GET.getlist('municipality')
|
muni_params = request.GET.getlist('municipality')
|
||||||
|
territory_param = request.GET.get('territory_id') or request.GET.get('territory')
|
||||||
|
|
||||||
|
def _build_mask_geojson(target_geom, territory_name, cache_key):
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return JsonResponse(cached)
|
||||||
|
|
||||||
|
g = target_geom.transform(4326, clone=True) if target_geom.srid != 4326 else target_geom
|
||||||
|
try:
|
||||||
|
num_points = sum(len(ring) for poly in g for ring in poly)
|
||||||
|
except Exception:
|
||||||
|
num_points = 0
|
||||||
|
simp_geom = g.simplify(0.00003, preserve_topology=True) if num_points > 800 else g
|
||||||
|
|
||||||
|
world = Polygon.from_bbox((-180, -85, 180, 85))
|
||||||
|
try:
|
||||||
|
mask = world.difference(simp_geom)
|
||||||
|
except Exception:
|
||||||
|
mask = world.difference(simp_geom.buffer(0))
|
||||||
|
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "territory_mask",
|
||||||
|
"properties": {
|
||||||
|
"layer": "mask",
|
||||||
|
"type": "mask",
|
||||||
|
"name": territory_name
|
||||||
|
},
|
||||||
|
"geometry": json.loads(mask.geojson)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "territory_boundary",
|
||||||
|
"properties": {
|
||||||
|
"layer": "boundary",
|
||||||
|
"type": "boundary",
|
||||||
|
"name": territory_name
|
||||||
|
},
|
||||||
|
"geometry": json.loads(simp_geom.geojson)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": features
|
||||||
|
}
|
||||||
|
cache.set(cache_key, result, 86400)
|
||||||
|
return JsonResponse(result)
|
||||||
|
|
||||||
|
from common.models import Municipality, Territory
|
||||||
|
from contracts.models import Contract
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
|
||||||
|
# 1. Si un territoire explicite est demandé par identifiant ou code
|
||||||
|
if territory_param:
|
||||||
|
t_filter = Q(code=territory_param)
|
||||||
|
if str(territory_param).isdigit():
|
||||||
|
t_filter |= Q(id=int(territory_param))
|
||||||
|
explicit_territory = Territory.objects.filter(t_filter, is_active=True, geom__isnull=False).first()
|
||||||
|
if explicit_territory:
|
||||||
|
return _build_mask_geojson(
|
||||||
|
explicit_territory.geom,
|
||||||
|
explicit_territory.name,
|
||||||
|
f"territory_mask_geojson_territory_{explicit_territory.id}"
|
||||||
|
)
|
||||||
|
|
||||||
muni_ids = []
|
muni_ids = []
|
||||||
if muni_params:
|
if muni_params:
|
||||||
for p in muni_params:
|
for p in muni_params:
|
||||||
if str(p).isdigit():
|
if str(p).isdigit():
|
||||||
muni_ids.append(int(p))
|
muni_ids.append(int(p))
|
||||||
|
|
||||||
from common.models import Municipality
|
|
||||||
from contracts.models import Contract
|
|
||||||
from assets.permissions import get_user_accessible_municipalities
|
|
||||||
|
|
||||||
if not muni_ids:
|
if not muni_ids:
|
||||||
# Priorité 1 : contrat par défaut ou contrats de l'utilisateur
|
# Priorité 1 : contrat par défaut ou contrats de l'utilisateur
|
||||||
user_config = getattr(user, 'config', None) if user and user.is_authenticated else None
|
user_config = getattr(user, 'config', None) if user and user.is_authenticated else None
|
||||||
|
|
@ -179,9 +242,22 @@ def get_territory_mask_geojson(request):
|
||||||
elif Contract.objects.filter(municipalities__isnull=False).count() == 1:
|
elif Contract.objects.filter(municipalities__isnull=False).count() == 1:
|
||||||
muni_ids = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True))
|
muni_ids = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True))
|
||||||
elif user and user.is_authenticated:
|
elif user and user.is_authenticated:
|
||||||
muni_ids = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
|
accessible = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
|
||||||
else:
|
if accessible:
|
||||||
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True)[:1])
|
muni_ids = accessible
|
||||||
|
|
||||||
|
# Priorité 2 : Si aucune restriction communale n'est requise, vérifier s'il existe un Territoire par défaut
|
||||||
|
if not muni_ids:
|
||||||
|
default_territory = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
|
||||||
|
if default_territory:
|
||||||
|
return _build_mask_geojson(
|
||||||
|
default_territory.geom,
|
||||||
|
default_territory.name,
|
||||||
|
f"territory_mask_geojson_default_{default_territory.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Repli : Si aucun Territoire n'est défini, illuminer toutes les communes existantes
|
||||||
|
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True))
|
||||||
|
|
||||||
if not muni_ids:
|
if not muni_ids:
|
||||||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
@ -202,7 +278,6 @@ def get_territory_mask_geojson(request):
|
||||||
for m in munis:
|
for m in munis:
|
||||||
names.append(m.name)
|
names.append(m.name)
|
||||||
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
|
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
|
||||||
# Légère simplification (~3 mètres) pour optimiser les perfs du navigateur
|
|
||||||
g_simp = g.simplify(0.00003, preserve_topology=True)
|
g_simp = g.simplify(0.00003, preserve_topology=True)
|
||||||
if combined_geom is None:
|
if combined_geom is None:
|
||||||
combined_geom = g_simp
|
combined_geom = g_simp
|
||||||
|
|
@ -212,44 +287,8 @@ def get_territory_mask_geojson(request):
|
||||||
if not combined_geom:
|
if not combined_geom:
|
||||||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
||||||
# Boîte englobante mondiale pour le masque inversé
|
return _build_mask_geojson(combined_geom, ", ".join(names), cache_key)
|
||||||
world = Polygon.from_bbox((-180, -85, 180, 85))
|
|
||||||
try:
|
|
||||||
mask = world.difference(combined_geom)
|
|
||||||
except Exception:
|
|
||||||
mask = world.difference(combined_geom.buffer(0))
|
|
||||||
|
|
||||||
territory_name = ", ".join(names)
|
|
||||||
features = [
|
|
||||||
{
|
|
||||||
"type": "Feature",
|
|
||||||
"id": "territory_mask",
|
|
||||||
"properties": {
|
|
||||||
"layer": "mask",
|
|
||||||
"type": "mask",
|
|
||||||
"name": territory_name
|
|
||||||
},
|
|
||||||
"geometry": json.loads(mask.geojson)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Feature",
|
|
||||||
"id": "territory_boundary",
|
|
||||||
"properties": {
|
|
||||||
"layer": "boundary",
|
|
||||||
"type": "boundary",
|
|
||||||
"name": territory_name
|
|
||||||
},
|
|
||||||
"geometry": json.loads(combined_geom.geojson)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"type": "FeatureCollection",
|
|
||||||
"features": features
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(cache_key, result, 86400)
|
|
||||||
return JsonResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,26 @@ function statusFormatter(value) {
|
||||||
const mapContainer = document.getElementById("map");
|
const mapContainer = document.getElementById("map");
|
||||||
|
|
||||||
if (mapContainer && typeof maplibregl !== 'undefined') {
|
if (mapContainer && typeof maplibregl !== 'undefined') {
|
||||||
|
const defaultCenter = (window.MAP_DEFAULT_CENTER && Array.isArray(window.MAP_DEFAULT_CENTER))
|
||||||
|
? window.MAP_DEFAULT_CENTER
|
||||||
|
: [4.3572, 50.8477];
|
||||||
|
const defaultZoom = (typeof window.MAP_DEFAULT_ZOOM === 'number')
|
||||||
|
? window.MAP_DEFAULT_ZOOM
|
||||||
|
: 11.5;
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: mapContainer,
|
container: mapContainer,
|
||||||
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
center: [4.568, 50.645], // Court-Saint-Étienne default
|
center: defaultCenter,
|
||||||
zoom: 12.0,
|
zoom: defaultZoom,
|
||||||
minzoom: 8,
|
minzoom: 8,
|
||||||
maxzoom: 20
|
maxzoom: 20
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
|
||||||
|
map.fitBounds(window.MAP_DEFAULT_EXTENT, { padding: 35, duration: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-left');
|
map.addControl(new maplibregl.NavigationControl(), 'top-left');
|
||||||
|
|
||||||
let currentMode = 'heatmap'; // 'heatmap' or 'points'
|
let currentMode = 'heatmap'; // 'heatmap' or 'points'
|
||||||
|
|
@ -115,10 +126,26 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
|
||||||
duration: 1000
|
duration: 1000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
|
||||||
|
map.fitBounds(window.MAP_DEFAULT_EXTENT, {
|
||||||
|
padding: { top: 35, bottom: 35, left: 35, right: 35 },
|
||||||
|
duration: 1000
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
|
||||||
|
map.fitBounds(window.MAP_DEFAULT_EXTENT, {
|
||||||
|
padding: { top: 35, bottom: 35, left: 35, right: 35 },
|
||||||
|
duration: 1000
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Could not load territory mask:", e);
|
console.warn("Could not load territory mask:", e);
|
||||||
|
if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
|
||||||
|
map.fitBounds(window.MAP_DEFAULT_EXTENT, {
|
||||||
|
padding: { top: 35, bottom: 35, left: 35, right: 35 },
|
||||||
|
duration: 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Charger les données d'activité isolées par contrat
|
// 2. Charger les données d'activité isolées par contrat
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@
|
||||||
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
|
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if can_generate_daily_report %}
|
{% if can_generate_daily_report %}
|
||||||
|
<div class="quick-actions-bar quick-actions-reports-bar mt-2">
|
||||||
<div class="dropdown d-inline-block">
|
<div class="dropdown d-inline-block">
|
||||||
<button class="quick-action-btn quick-action-report dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="quick-action-btn quick-action-report dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
|
<span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
|
||||||
|
|
@ -118,8 +120,8 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue