feat: add external_reference field to assets with permission support, improve intervention contract assignment logic, and sanitize template strings.

This commit is contained in:
kdeterme 2026-08-26 16:52:09 +02:00
parent 4135c6ef47
commit 0b190526f8
9 changed files with 351 additions and 33 deletions

View file

@ -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."))

View file

@ -10,6 +10,7 @@ def sync_parent_data(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
atomic = False
dependencies = [ dependencies = [
('assets', '0020_trafficlightlantern_geojson_trafficlightlantern_geom_and_more'), ('assets', '0020_trafficlightlantern_geojson_trafficlightlantern_geom_and_more'),

View file

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

View file

@ -268,6 +268,7 @@ class AbstractAsset(models.Model):
code = models.CharField(max_length=50) code = models.CharField(max_length=50)
name_fr = models.CharField(max_length=200,null=True,blank=True) name_fr = models.CharField(max_length=200,null=True,blank=True)
name_nl = 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) 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) obsolescence = models.CharField(choices=ASSET_OBSOLESCENCE_CHOICES, default='unknown', null=True, blank=True)
installation_date = models.DateTimeField(null=True,blank=True) installation_date = models.DateTimeField(null=True,blank=True)

View file

@ -14,32 +14,32 @@ from common.models import UserConfig, UserThematics, UserAssetAccess, UserAssetT
# Fields editable by role for assets # Fields editable by role for assets
ASSET_EDITABLE_FIELDS_BY_ROLE = { ASSET_EDITABLE_FIELDS_BY_ROLE = {
'admin': [ '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', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'manager': [ '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', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'controller': [ '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', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'external_manager': [ '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': [], 'operator': [],
'observer': [], 'observer': [],
'viewer': [], 'viewer': [],
'editor': [ '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', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',

View file

@ -222,6 +222,21 @@
</div> </div>
</td> </td>
</tr> </tr>
<!-- Référence externe -->
{% if asset.external_reference or 'external_reference' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="external_reference" data-can-edit="{% if 'external_reference' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Référence externe" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.external_reference|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.external_reference|default:'' }}">
</div>
</td>
</tr>
{% endif %}
<!-- Nom FR --> <!-- Nom FR -->
<tr class="asset-editable-field" data-field="name_fr" data-can-edit="{% if 'name_fr' in allowed_update_fields %}true{% else %}false{% endif %}"> <tr class="asset-editable-field" data-field="name_fr" data-can-edit="{% if 'name_fr' in allowed_update_fields %}true{% else %}false{% endif %}">

View file

@ -908,7 +908,7 @@
} }
}) })
.catch(err => { .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 (submitBtn) submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none'); if (spinner) spinner.classList.add('d-none');
}); });

View file

@ -1738,12 +1738,6 @@ def interventions_add(request, thematic_code):
intervention.save() 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 # 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): if intervention.status == 'assigned' and not getattr(intervention, 'assigned_member_id', None):
from contracts.models import CompanyMember from contracts.models import CompanyMember

View file

@ -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) thematic_code = thematic.code if hasattr(thematic, 'code') else (str(thematic) if thematic else None)
# 1. Contrat / prestataire direct du symptôme # 1. Routage direct (thématique 'structures') - prioritaire sur les contrats par défaut
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')
if not contract and thematic_code == 'structures': if not contract and thematic_code == 'structures':
structure = None structure = None
if asset: if asset:
@ -3310,10 +3302,10 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo
if not assigned_provider: if not assigned_provider:
assigned_provider = contract.company assigned_provider = contract.company
# 3. Contrat actif lié au maintenance_type du symptôme # 2. Contrat actif lié à la localisation/équipement et au maintenance_type
if not contract and symptom and symptom.maintenance_type: if not contract:
mt = symptom.maintenance_type mt = symptom.maintenance_type if symptom else None
if thematic_code == 'trafficlights': if thematic_code == 'trafficlights' and mt:
intersection_id = None intersection_id = None
if location: if location:
if isinstance(location, TrafficLightIntersection): if isinstance(location, TrafficLightIntersection):
@ -3351,17 +3343,26 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo
structure_id = asset.structure_id structure_id = asset.structure_id
if structure_id: if structure_id:
struct_contract = StructureContract.objects.filter( struct_contract = None
structure_id=structure_id, if mt:
maintenance_type=mt, struct_contract = StructureContract.objects.filter(
status='active' structure_id=structure_id,
).select_related('contract__company').first() 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: if struct_contract and struct_contract.contract:
contract = struct_contract.contract contract = struct_contract.contract
if not assigned_provider: if not assigned_provider:
assigned_provider = contract.company assigned_provider = contract.company
elif thematic_code == 'clean': elif thematic_code == 'clean' and mt:
clean_loc_id = None clean_loc_id = None
if location: if location:
if isinstance(location, CleanLocation): if isinstance(location, CleanLocation):
@ -3385,7 +3386,7 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo
if not assigned_provider: if not assigned_provider:
assigned_provider = contract.company assigned_provider = contract.company
elif thematic_code == 'its': elif thematic_code == 'its' and mt:
its_contract = None its_contract = None
if asset and isinstance(asset, ITSGeoAsset): if asset and isinstance(asset, ITSGeoAsset):
its_contract = ITSGeoAssetContract.objects.filter( its_contract = ITSGeoAssetContract.objects.filter(
@ -3405,6 +3406,14 @@ def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, lo
if not assigned_provider: if not assigned_provider:
assigned_provider = contract.company 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) # 4. Fallback contrat par défaut de l'utilisateur (limit_interventions_to_contracts)
user_config = None user_config = None
if user: if user: