feat: add Territory model for default map boundary, and separate daily report quick action to second row

This commit is contained in:
kdeterme 2026-09-21 10:04:39 +02:00
parent 63c888fdbd
commit e6a09eaf37
8 changed files with 362 additions and 98 deletions

View file

@ -1,4 +1,5 @@
from django.contrib import admin
from django.contrib.gis import admin as gis_admin
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.contrib import messages
@ -9,6 +10,7 @@ from datetime import timedelta
from django.db.models import Case, When, IntegerField, Value
from .models import (
MapBackground,
Territory,
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
UserThematicStatusPermission, UserContractStatusPermission,
@ -24,6 +26,16 @@ from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
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)
class ThematicAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")

View file

@ -286,72 +286,113 @@ def organization_info(request):
"""
Context processor that exposes organization details from settings.
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.
"""
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
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:
cache_key = f"user_map_bounds_{request.user.pk}"
cached_bounds = cache.get(cache_key)
if cached_bounds is not None:
center = cached_bounds.get('center', center)
zoom = cached_bounds.get('zoom', zoom)
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:
try:
user_config = getattr(request.user, 'config', None)
if user_config:
# Récupérer les contrats accessibles ayant des communes associées
contracts_to_check = []
if user_config.default_contract and user_config.default_contract.municipalities.exists():
contracts_to_check.append(user_config.default_contract)
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:
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct())
geoms_4326 = []
for c in contracts_to_check:
for m in c.municipalities.exclude(geom=None):
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
geoms_4326.append(g)
if geoms_4326:
min_lon = min(g.extent[0] for g in geoms_4326)
min_lat = min(g.extent[1] 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)
# Léger buffer (5% d'expansion de chaque côté pour aérer le cadre)
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(cache_key, {
'center': center,
'zoom': zoom,
'extent': extent,
}, 86400)
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:
user_config = getattr(request.user, 'config', None)
if user_config:
contracts_to_check = []
if user_config.default_contract and user_config.default_contract.municipalities.exists():
contracts_to_check.append(user_config.default_contract)
else:
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct())
geoms_4326 = []
for c in contracts_to_check:
for m in c.municipalities.exclude(geom=None):
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
geoms_4326.append(g)
if geoms_4326:
min_lon = min(g.extent[0] for g in geoms_4326)
min_lat = min(g.extent[1] 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)
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(cache_key, {
'center': center,
'zoom': zoom,
'extent': extent,
}, 86400)
except Exception:
pass
return {
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),

View 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'],
},
),
]

View file

@ -1390,5 +1390,62 @@ class MapBackground(models.Model):
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
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog

View file

@ -390,6 +390,11 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
mock_attach.assert_called_once_with(fake_path)
mock_send.assert_called_once()
from django.test import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache
@ -488,3 +493,50 @@ class ContextProcessorsCachingTests(TestCase):
access.delete()
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")

View file

@ -152,23 +152,86 @@ def get_nearest_address(request):
@require_GET
def get_territory_mask_geojson(request):
"""
import json
Renvoie le masque cartographique inversé (calque grisé transparent en dehors de la commune)
et le tracé du contour communal pour mettre en évidence le territoire à gérer.
Renvoie le masque cartographique inversé (calque grisé transparent en dehors du territoire)
et le tracé du contour 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
user = request.user
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 = []
if muni_params:
for p in muni_params:
if str(p).isdigit():
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:
# 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
@ -179,9 +242,22 @@ def get_territory_mask_geojson(request):
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))
elif user and user.is_authenticated:
muni_ids = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
else:
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True)[:1])
accessible = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
if accessible:
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:
return JsonResponse({"type": "FeatureCollection", "features": []})
@ -202,7 +278,6 @@ def get_territory_mask_geojson(request):
for m in munis:
names.append(m.name)
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)
if combined_geom is None:
combined_geom = g_simp
@ -212,44 +287,8 @@ def get_territory_mask_geojson(request):
if not combined_geom:
return JsonResponse({"type": "FeatureCollection", "features": []})
# Boîte englobante mondiale pour le masque inversé
world = Polygon.from_bbox((-180, -85, 180, 85))
try:
mask = world.difference(combined_geom)
except Exception:
mask = world.difference(combined_geom.buffer(0))
return _build_mask_geojson(combined_geom, ", ".join(names), cache_key)
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)

View file

@ -26,15 +26,26 @@ function statusFormatter(value) {
const mapContainer = document.getElementById("map");
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({
container: mapContainer,
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [4.568, 50.645], // Court-Saint-Étienne default
zoom: 12.0,
center: defaultCenter,
zoom: defaultZoom,
minzoom: 8,
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');
let currentMode = 'heatmap'; // 'heatmap' or 'points'
@ -115,10 +126,26 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
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) {
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

View file

@ -94,7 +94,9 @@
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
</a>
{% endif %}
</div>
{% if can_generate_daily_report %}
<div class="quick-actions-bar quick-actions-reports-bar mt-2">
<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">
<span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
@ -118,8 +120,8 @@
</li>
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>