diff --git a/loko/common/admin.py b/loko/common/admin.py index a2e564f..4069d53 100644 --- a/loko/common/admin.py +++ b/loko/common/admin.py @@ -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") diff --git a/loko/common/context_processors.py b/loko/common/context_processors.py index b0e6954..40a35ad 100644 --- a/loko/common/context_processors.py +++ b/loko/common/context_processors.py @@ -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'), diff --git a/loko/common/migrations/0017_territory.py b/loko/common/migrations/0017_territory.py new file mode 100644 index 0000000..3280631 --- /dev/null +++ b/loko/common/migrations/0017_territory.py @@ -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'], + }, + ), + ] diff --git a/loko/common/models.py b/loko/common/models.py index b462a39..75f2078 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -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 + diff --git a/loko/common/tests.py b/loko/common/tests.py index a2105a4..0188af0 100644 --- a/loko/common/tests.py +++ b/loko/common/tests.py @@ -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") + + diff --git a/loko/common/views.py b/loko/common/views.py index 466e857..3459267 100644 --- a/loko/common/views.py +++ b/loko/common/views.py @@ -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) diff --git a/loko/home/static/home/home_index.js b/loko/home/static/home/home_index.js index 0744d7a..3fff8d0 100644 --- a/loko/home/static/home/home_index.js +++ b/loko/home/static/home/home_index.js @@ -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 diff --git a/loko/home/templates/home/home_index.html b/loko/home/templates/home/home_index.html index 4c4b33d..c4d6bac 100644 --- a/loko/home/templates/home/home_index.html +++ b/loko/home/templates/home/home_index.html @@ -94,7 +94,9 @@ {% translate "Nouvelle commande" %} {% endif %} + {% if can_generate_daily_report %} +
+ {% endif %}