From 0b190526f8cc25e0886dd57db822916ea6ce5493 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 26 Aug 2026 16:52:09 +0200 Subject: [PATCH] feat: add external_reference field to assets with permission support, improve intervention contract assignment logic, and sanitize template strings. --- .../management/commands/sync_all_geojson.py | 85 +++++++ .../migrations/0021_sync_asset_parent_data.py | 1 + ...orkfountain_external_reference_and_more.py | 213 ++++++++++++++++++ loko/assets/models/core.py | 1 + loko/assets/permissions.py | 12 +- .../templates/assets/detail_generic.html | 15 ++ .../assets/structures_location_detail.html | 2 +- loko/interventions/views/detail.py | 6 - loko/interventions/views/helpers.py | 49 ++-- 9 files changed, 351 insertions(+), 33 deletions(-) create mode 100644 loko/assets/management/commands/sync_all_geojson.py create mode 100644 loko/assets/migrations/0025_artworkfountain_external_reference_and_more.py diff --git a/loko/assets/management/commands/sync_all_geojson.py b/loko/assets/management/commands/sync_all_geojson.py new file mode 100644 index 0000000..b17a144 --- /dev/null +++ b/loko/assets/management/commands/sync_all_geojson.py @@ -0,0 +1,85 @@ +from django.core.management.base import BaseCommand +from django.apps import apps +from django.db import connection, transaction + + +class Command(BaseCommand): + help = "Synchronizes the geojson field (and lon/lat if missing) from geom for all assets/models using PostGIS SQL." + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Perform a dry run without modifying the database.', + ) + parser.add_argument( + '--only-missing', + action='store_true', + help='Only update records where geojson is null or empty.', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + only_missing = options['only_missing'] + + total_scanned = 0 + total_updated = 0 + + with connection.cursor() as cursor: + for model in apps.get_models(): + fields = [f.name for f in model._meta.get_fields()] + if 'geom' not in fields or 'geojson' not in fields: + continue + + table = model._meta.db_table + is_json_field = model._meta.get_field('geojson').get_internal_type() == 'JSONField' + has_lon = 'lon' in fields + has_lat = 'lat' in fields + + # Count total candidates with non-null geom + cursor.execute(f'SELECT COUNT(*) FROM "{table}" WHERE geom IS NOT NULL;') + count = cursor.fetchone()[0] + if count == 0: + continue + + self.stdout.write(f"Processing {model._meta.app_label}.{model.__name__} ({table}, {count} candidate records)...") + + if only_missing: + where_clause = "geom IS NOT NULL AND (geojson IS NULL OR geojson::text = '' OR geojson::text = 'null')" + else: + where_clause = "geom IS NOT NULL" + + cursor.execute(f'SELECT COUNT(*) FROM "{table}" WHERE {where_clause};') + records_to_update = cursor.fetchone()[0] + + if records_to_update == 0: + self.stdout.write(self.style.SUCCESS(f" {model.__name__}: 0 records need update.")) + continue + + if dry_run: + self.stdout.write(self.style.SUCCESS(f" [DRY-RUN] {model.__name__}: Would update {records_to_update}/{count} records.")) + total_scanned += count + total_updated += records_to_update + continue + + cast_geojson = "ST_AsGeoJSON(ST_Transform(geom, 4326))::jsonb" if is_json_field else "ST_AsGeoJSON(ST_Transform(geom, 4326))" + + set_clauses = [f"geojson = {cast_geojson}"] + if has_lon: + set_clauses.append("lon = COALESCE(lon, ST_X(ST_PointOnSurface(ST_Transform(geom, 4326))))") + if has_lat: + set_clauses.append("lat = COALESCE(lat, ST_Y(ST_PointOnSurface(ST_Transform(geom, 4326))))") + + set_str = ", ".join(set_clauses) + update_sql = f'UPDATE "{table}" SET {set_str} WHERE {where_clause};' + + with transaction.atomic(): + cursor.execute(update_sql) + updated_count = cursor.rowcount + + self.stdout.write(self.style.SUCCESS(f" {model.__name__}: {updated_count}/{count} records updated in DB.")) + total_scanned += count + total_updated += updated_count + + prefix = "[DRY-RUN] Would update" if dry_run else "Successfully updated" + self.stdout.write(self.style.SUCCESS(f"\n{prefix} {total_updated} / {total_scanned} records across all models.")) diff --git a/loko/assets/migrations/0021_sync_asset_parent_data.py b/loko/assets/migrations/0021_sync_asset_parent_data.py index cd382a4..e013395 100644 --- a/loko/assets/migrations/0021_sync_asset_parent_data.py +++ b/loko/assets/migrations/0021_sync_asset_parent_data.py @@ -10,6 +10,7 @@ def sync_parent_data(apps, schema_editor): class Migration(migrations.Migration): + atomic = False dependencies = [ ('assets', '0020_trafficlightlantern_geojson_trafficlightlantern_geom_and_more'), diff --git a/loko/assets/migrations/0025_artworkfountain_external_reference_and_more.py b/loko/assets/migrations/0025_artworkfountain_external_reference_and_more.py new file mode 100644 index 0000000..dac8e1a --- /dev/null +++ b/loko/assets/migrations/0025_artworkfountain_external_reference_and_more.py @@ -0,0 +1,213 @@ +# Generated by Django 6.0.7 on 2026-08-26 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0024_structureexceptionaltransport_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='artworkfountain', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='artworkmonument', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='buildingasset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='cleanglasscontainer', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='cleanlitterbin', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='controlcentergeoasset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='itsgeoasset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='naturebench', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='naturegreensurface', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='naturerwiasset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='natureshrub', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='naturetree', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='outdoorasset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='parkingspot', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='publiclightingelectricalcabinet', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='publiclightinglightfixture', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='publiclightingpole', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='roadconcreteblockset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='roaddrain', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='roadfurniture', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='roadsidewalk', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='roadway', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='signpanel', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='signparkingspot', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='signpole', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='structuregeoasset', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightaccessory', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightcable', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightcontroller', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightcontrollerhardware', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightdetector', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightelectricalcabinet', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightelectricalcabinetcontent', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightlantern', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightnetworkhardware', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightpole', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightprogramming', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightradar', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightradarpole', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + migrations.AddField( + model_name='trafficlightvoltagehardware', + name='external_reference', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'), + ), + ] diff --git a/loko/assets/models/core.py b/loko/assets/models/core.py index 99b887d..d8ab50e 100644 --- a/loko/assets/models/core.py +++ b/loko/assets/models/core.py @@ -268,6 +268,7 @@ class AbstractAsset(models.Model): code = models.CharField(max_length=50) name_fr = models.CharField(max_length=200,null=True,blank=True) name_nl = models.CharField(max_length=200,null=True,blank=True) + external_reference = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Référence externe")) status = models.CharField(choices=ASSET_STATUS_CHOICES, default='active', max_length=50) obsolescence = models.CharField(choices=ASSET_OBSOLESCENCE_CHOICES, default='unknown', null=True, blank=True) installation_date = models.DateTimeField(null=True,blank=True) diff --git a/loko/assets/permissions.py b/loko/assets/permissions.py index 27213ee..1decb21 100644 --- a/loko/assets/permissions.py +++ b/loko/assets/permissions.py @@ -14,32 +14,32 @@ from common.models import UserConfig, UserThematics, UserAssetAccess, UserAssetT # Fields editable by role for assets ASSET_EDITABLE_FIELDS_BY_ROLE = { 'admin': [ - 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', + 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], 'manager': [ - 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', + 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], 'controller': [ - 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', + 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], 'external_manager': [ - 'name_fr', 'name_nl', 'category', 'brand', 'serial_number', 'funding_program' + 'name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program' ], - 'technician': ['name_fr', 'name_nl', 'category', 'brand', 'serial_number', 'funding_program'], + 'technician': ['name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program'], 'operator': [], 'observer': [], 'viewer': [], 'editor': [ - 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', + 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', diff --git a/loko/assets/templates/assets/detail_generic.html b/loko/assets/templates/assets/detail_generic.html index 4b43268..c222753 100644 --- a/loko/assets/templates/assets/detail_generic.html +++ b/loko/assets/templates/assets/detail_generic.html @@ -222,6 +222,21 @@ + + + {% if asset.external_reference or 'external_reference' in allowed_update_fields %} + + {% translate "Référence externe" %} + +
+ {{ asset.external_reference|default:"-" }} +
+
+ +
+ + + {% endif %} diff --git a/loko/assets/templates/assets/structures_location_detail.html b/loko/assets/templates/assets/structures_location_detail.html index bbd9630..6f7e742 100644 --- a/loko/assets/templates/assets/structures_location_detail.html +++ b/loko/assets/templates/assets/structures_location_detail.html @@ -908,7 +908,7 @@ } }) .catch(err => { - alert('{% translate "Une erreur est survenue lors de l\'enregistrement." %}'); + alert("{% filter escapejs %}{% translate "Une erreur est survenue lors de l'enregistrement." %}{% endfilter %}"); if (submitBtn) submitBtn.disabled = false; if (spinner) spinner.classList.add('d-none'); }); diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index ec86e26..a4d0e66 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -1738,12 +1738,6 @@ def interventions_add(request, thematic_code): intervention.save() - # Si aucun contrat encore défini via la commande, on utilise celui du symptôme - if not intervention.contract and intervention.symptom: - intervention.contract = intervention.symptom.contract - if intervention.symptom and not intervention.assigned_provider: - intervention.assigned_provider = intervention.symptom.provider - # Si le statut est "assigned" (pris en charge) et aucun membre n'est assigné, assigner l'utilisateur créateur et sa première équipe if intervention.status == 'assigned' and not getattr(intervention, 'assigned_member_id', None): from contracts.models import CompanyMember diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 6b2788e..991ac6b 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -3269,15 +3269,7 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo thematic_code = thematic.code if hasattr(thematic, 'code') else (str(thematic) if thematic else None) - # 1. Contrat / prestataire direct du symptôme - if symptom: - if symptom.contract: - contract = symptom.contract - assigned_provider = symptom.provider or (contract.company if contract else None) - elif symptom.provider: - assigned_provider = symptom.provider - - # 2. Routage direct (thématique 'structures') + # 1. Routage direct (thématique 'structures') - prioritaire sur les contrats par défaut if not contract and thematic_code == 'structures': structure = None if asset: @@ -3310,10 +3302,10 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo if not assigned_provider: assigned_provider = contract.company - # 3. Contrat actif lié au maintenance_type du symptôme - if not contract and symptom and symptom.maintenance_type: - mt = symptom.maintenance_type - if thematic_code == 'trafficlights': + # 2. Contrat actif lié à la localisation/équipement et au maintenance_type + if not contract: + mt = symptom.maintenance_type if symptom else None + if thematic_code == 'trafficlights' and mt: intersection_id = None if location: if isinstance(location, TrafficLightIntersection): @@ -3351,17 +3343,26 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo structure_id = asset.structure_id if structure_id: - struct_contract = StructureContract.objects.filter( - structure_id=structure_id, - maintenance_type=mt, - status='active' - ).select_related('contract__company').first() + struct_contract = None + if mt: + struct_contract = StructureContract.objects.filter( + structure_id=structure_id, + maintenance_type=mt, + status='active' + ).select_related('contract__company').first() + if not struct_contract: + struct_contracts = StructureContract.objects.filter( + structure_id=structure_id, + status='active' + ).select_related('contract__company') + if struct_contracts.count() == 1: + struct_contract = struct_contracts.first() if struct_contract and struct_contract.contract: contract = struct_contract.contract if not assigned_provider: assigned_provider = contract.company - elif thematic_code == 'clean': + elif thematic_code == 'clean' and mt: clean_loc_id = None if location: if isinstance(location, CleanLocation): @@ -3385,7 +3386,7 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo if not assigned_provider: assigned_provider = contract.company - elif thematic_code == 'its': + elif thematic_code == 'its' and mt: its_contract = None if asset and isinstance(asset, ITSGeoAsset): its_contract = ITSGeoAssetContract.objects.filter( @@ -3405,6 +3406,14 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo if not assigned_provider: assigned_provider = contract.company + # 3. Contrat / prestataire direct du symptôme (fallback par défaut du symptôme) + if not contract and symptom: + if symptom.contract: + contract = symptom.contract + assigned_provider = symptom.provider or (contract.company if contract else None) + elif symptom.provider: + assigned_provider = symptom.provider + # 4. Fallback contrat par défaut de l'utilisateur (limit_interventions_to_contracts) user_config = None if user: