feat: add external reference fields and implement migration script for asset data synchronization

This commit is contained in:
Karl Determe 2026-09-08 17:04:34 +02:00
parent 5417ca32b0
commit 1c7e369fe3
3 changed files with 455 additions and 102 deletions

View file

@ -1,6 +1,10 @@
import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from django.contrib.gis.db.models.functions import Distance
from django.conf import settings
from common.models import AdrStreet
from assets.models.sign import ( from assets.models.sign import (
SignStreet as AssetsSignStreet, SignStreet as AssetsSignStreet,
SignStreetDocument as AssetsSignStreetDocument, SignStreetDocument as AssetsSignStreetDocument,
@ -13,6 +17,7 @@ from assets.models.sign import (
) )
from sign.models import ( from sign.models import (
Organization,
SignStreet, SignStreet,
SignStreetDocument, SignStreetDocument,
SignPoleModel, SignPoleModel,
@ -27,10 +32,74 @@ from sign.models import (
class Command(BaseCommand): class Command(BaseCommand):
help = "Copy sign-related data from assets app into sign app models." help = "Copy sign-related data from assets app into sign app models."
def add_arguments(self, parser):
parser.add_argument(
"--organization",
type=str,
default=None,
help="Code or name of the Organization to associate with copied assets.",
)
parser.add_argument(
"--clean",
action="store_true",
default=False,
help="Clear existing sign app data before copying.",
)
@transaction.atomic @transaction.atomic
def handle(self, *args, **options): def handle(self, *args, **options):
self.stdout.write(self.style.WARNING("Starting copy of sign data from assets to sign...")) self.stdout.write(self.style.WARNING("Starting copy of sign data from assets to sign..."))
if options.get("clean"):
self.stdout.write(self.style.WARNING("Cleaning existing sign app data..."))
ComplementaryPoliceRegulation.objects.all().delete()
SignStreetDocument.objects.all().delete()
SignPanel.objects.all().delete()
SignPole.objects.all().delete()
SignStreet.objects.all().delete()
SignPanelModel.objects.all().delete()
SignPoleModel.objects.all().delete()
SignPanelType.objects.all().delete()
self.stdout.write(self.style.SUCCESS("Existing sign data deleted."))
# 0) Resolve default organization
default_org = None
org_arg = options.get("organization")
if org_arg:
default_org = (
Organization.objects.filter(code__iexact=org_arg).first()
or Organization.objects.filter(name_fr__icontains=org_arg).first()
or Organization.objects.filter(name_nl__icontains=org_arg).first()
)
if not default_org:
self.stdout.write(
self.style.WARNING(f"Specified organization '{org_arg}' not found in DB.")
)
if not default_org:
default_org = (
Organization.objects.filter(code__iexact="SPRB").first()
or Organization.objects.filter(organization_type="region").first()
or Organization.objects.first()
)
if default_org:
self.stdout.write(self.style.NOTICE(f"Using default organization: {default_org.code} - {default_org.name}"))
else:
self.stdout.write(self.style.NOTICE("No default organization found; organization will be null where permitted."))
def get_organization_for_street(src_street):
if src_street and src_street.municipality:
muni = src_street.municipality
muni_name = muni.name_fr or muni.name_nl or ""
org = Organization.objects.filter(name_fr__iexact=muni_name).first()
if org:
return org
if hasattr(muni, "code") and muni.code:
org = Organization.objects.filter(code__iexact=muni.code).first()
if org:
return org
return default_org
# Maps for ID relationships # Maps for ID relationships
type_map = {} type_map = {}
pole_model_map = {} pole_model_map = {}
@ -38,6 +107,60 @@ class Command(BaseCommand):
street_map = {} street_map = {}
pole_map = {} pole_map = {}
panel_map = {} panel_map = {}
adr_street_cache = {}
def get_adr_street(src_street, geom=None):
"""Resolve corresponding common.AdrStreet from an AssetsSignStreet or geometry."""
if src_street:
if src_street.id in adr_street_cache:
return adr_street_cache[src_street.id]
adr = None
# Try match by French name
if src_street.name_fr:
qs = AdrStreet.objects.filter(name_fr__iexact=src_street.name_fr.strip())
if src_street.municipality_id:
adr = qs.filter(municipality_id=src_street.municipality_id).first()
if not adr:
adr = qs.first()
# Try match by Dutch name
if not adr and src_street.name_nl:
qs = AdrStreet.objects.filter(name_nl__iexact=src_street.name_nl.strip())
if src_street.municipality_id:
adr = qs.filter(municipality_id=src_street.municipality_id).first()
if not adr:
adr = qs.first()
# Try spatial proximity with src_street geometry
if not adr and src_street.geom:
try:
adr = (
AdrStreet.objects.filter(geom__isnull=False)
.annotate(distance=Distance("geom", src_street.geom))
.order_by("distance")
.first()
)
except Exception:
pass
adr_street_cache[src_street.id] = adr
if adr:
return adr
# Fallback to asset point geometry
if geom:
try:
return (
AdrStreet.objects.filter(geom__isnull=False)
.annotate(distance=Distance("geom", geom))
.order_by("distance")
.first()
)
except Exception:
pass
return None
# 1) Panel Types # 1) Panel Types
for src in AssetsSignPanelType.objects.all(): for src in AssetsSignPanelType.objects.all():
@ -48,10 +171,13 @@ class Command(BaseCommand):
"name_nl": src.name_nl, "name_nl": src.name_nl,
"description": src.description, "description": src.description,
"sgv_picture": src.sgv_picture, "sgv_picture": src.sgv_picture,
"default_width": getattr(src, "default_width", None),
"default_height": getattr(src, "default_height", None),
"default_diameter": getattr(src, "default_diameter", None),
}, },
) )
type_map[src.id] = dst type_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied {len(type_map)} panel types")) self.stdout.write(self.style.SUCCESS(f"Copied/Updated {len(type_map)} panel types"))
# 2) Pole Models # 2) Pole Models
for src in AssetsSignPoleModel.objects.all(): for src in AssetsSignPoleModel.objects.all():
@ -68,10 +194,11 @@ class Command(BaseCommand):
"diameter": src.diameter, "diameter": src.diameter,
"material": src.material, "material": src.material,
"category": src.category, "category": src.category,
"galvanized": getattr(src, "galvanized", False),
}, },
) )
pole_model_map[src.id] = dst pole_model_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied {len(pole_model_map)} pole models")) self.stdout.write(self.style.SUCCESS(f"Copied/Updated {len(pole_model_map)} pole models"))
# 3) Panel Models # 3) Panel Models
for src in AssetsSignPanelModel.objects.all(): for src in AssetsSignPanelModel.objects.all():
@ -90,123 +217,197 @@ class Command(BaseCommand):
"diameter": src.diameter, "diameter": src.diameter,
"material": src.material, "material": src.material,
"category": src.category, "category": src.category,
"reflective": getattr(src, "reflective", False),
"illuminated": getattr(src, "illuminated", False),
"dynamic": getattr(src, "dynamic", False),
}, },
) )
panel_model_map[src.id] = dst panel_model_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied {len(panel_model_map)} panel models")) self.stdout.write(self.style.SUCCESS(f"Copied/Updated {len(panel_model_map)} panel models"))
# 4) Streets # 4) Streets (SignStreet)
for src in AssetsSignStreet.objects.all(): for src in AssetsSignStreet.objects.all():
dst = SignStreet.objects.create( street_code = src.code or f"STR-{src.id}"
code=src.code, dst = SignStreet.objects.filter(code=street_code).first()
name_fr=src.name_fr, if not dst:
name_nl=src.name_nl, dst = SignStreet(code=street_code)
lon=src.lon,
lat=src.lat,
geom=src.geom,
geojson=src.geojson,
municipality=src.municipality,
road_code=getattr(src, "road_code", None),
section_code=getattr(src, "section_code", None),
street_level=getattr(src, "street_level", None),
) dst.name_fr = src.name_fr
dst.name_nl = src.name_nl
dst.lon = src.lon
dst.lat = src.lat
dst.geom = src.geom
dst.geojson = src.geojson
dst.municipality = src.municipality
dst.road_code = getattr(src, "road_code", None)
dst.section_code = getattr(src, "section_code", None)
dst.street_level = getattr(src, "street_level", None)
dst.save()
street_map[src.id] = dst street_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied {len(street_map)} streets")) self.stdout.write(self.style.SUCCESS(f"Copied/Updated {len(street_map)} streets"))
# 5) Poles # 5) Poles (SignPole)
for src in AssetsSignPole.objects.all(): for src in AssetsSignPole.objects.all():
dst = SignPole.objects.create( dst_code = src.code or f"POLE-{src.id}"
code=src.code, dst = SignPole.objects.filter(code=dst_code).first()
name_fr=src.name_fr, if not dst:
name_nl=src.name_nl, dst = SignPole(code=dst_code)
status=src.status,
installation_date=src.installation_date,
uninstallation_date=src.uninstallation_date,
serial_number=src.serial_number,
warranty_duration=src.warranty_duration,
last_inspection_date=src.last_inspection_date,
last_preventive_date=src.last_preventive_date,
last_corrective_date=src.last_corrective_date,
last_survey_date=src.last_survey_date,
category=src.category,
brand=src.brand,
lon=src.lon,
lat=src.lat,
geom=src.geom,
geojson=src.geojson,
model= pole_model_map.get(getattr(src.model, "id", None)),
street= street_map.get(getattr(src.street, "id", None)),
# organization intentionally left None; define mapping if available
)
pole_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied {len(pole_map)} poles"))
# 6) Panels dst_model = pole_model_map.get(getattr(src.model, "id", None))
dst_org = get_organization_for_street(src.street)
adr_street = get_adr_street(src.street, src.geom)
dst.name_fr = src.name_fr
dst.name_nl = src.name_nl
dst.status = src.status or "active"
dst.installation_date = src.installation_date
dst.uninstallation_date = src.uninstallation_date
dst.serial_number = src.serial_number
dst.warranty_duration = src.warranty_duration
dst.last_inspection_date = src.last_inspection_date
dst.last_preventive_date = src.last_preventive_date
dst.last_corrective_date = src.last_corrective_date
dst.last_survey_date = src.last_survey_date
dst.category = src.category
dst.brand = src.brand
dst.lon = src.lon
dst.lat = src.lat
dst.geom = src.geom
dst.geojson = src.geojson
dst.model = dst_model
dst.street = adr_street
dst.organization = dst_org
dst.orientation = getattr(src, "orientation", 90) or 90
dst.external_reference = getattr(src, "external_reference", None)
dst.funding_program = getattr(src, "funding_program", None)
dst.inspection_status = getattr(src, "inspection_status", "unknown") or "unknown"
dst.note = getattr(src, "note", None)
dst.validation_status = getattr(src, "validation_status", None)
dst.save()
pole_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied/Updated {len(pole_map)} poles"))
# 6) Panels (SignPanel)
for src in AssetsSignPanel.objects.all(): for src in AssetsSignPanel.objects.all():
dst = SignPanel.objects.create( dst_code = src.code or f"PANEL-{src.id}"
code=src.code, dst = SignPanel.objects.filter(code=dst_code).first()
name_fr=src.name_fr, if not dst:
name_nl=src.name_nl, dst = SignPanel(code=dst_code)
status=src.status,
installation_date=src.installation_date, dst_model = panel_model_map.get(getattr(src.model, "id", None))
uninstallation_date=src.uninstallation_date, dst_type = type_map.get(getattr(src.signpanel_type, "id", None))
serial_number=src.serial_number, dst_pole = pole_map.get(getattr(src.pole, "id", None))
warranty_duration=src.warranty_duration, dst_org = (
last_inspection_date=src.last_inspection_date, dst_pole.organization
last_preventive_date=src.last_preventive_date, if dst_pole and dst_pole.organization
last_corrective_date=src.last_corrective_date, else get_organization_for_street(src.street)
last_survey_date=src.last_survey_date,
category=src.category,
brand=src.brand,
lon=src.lon,
lat=src.lat,
geom=src.geom,
geojson=src.geojson,
vertical_order=src.vertical_order,
signpanel_text=src.signpanel_text,
signpanel_value=src.signpanel_value,
orientation=src.orientation,
support_type=src.support_type,
is_temporary=src.is_temporary,
model=panel_model_map.get(getattr(src.model, "id", None)),
signpanel_type=type_map.get(getattr(src.signpanel_type, "id", None)),
pole=pole_map.get(getattr(src.pole, "id", None)),
street=street_map.get(getattr(src.street, "id", None)),
# organization intentionally left None
) )
# Use pole's street if available, otherwise resolve from src.street or point geom
adr_street = (
dst_pole.street
if (dst_pole and dst_pole.street)
else get_adr_street(src.street, src.geom)
)
width = getattr(src, "width", None)
if not width and hasattr(src, "get_width"):
try:
width = src.get_width()
except Exception:
pass
height = getattr(src, "height", None)
if not height and hasattr(src, "get_height"):
try:
height = src.get_height()
except Exception:
pass
dst.name_fr = src.name_fr
dst.name_nl = src.name_nl
dst.status = src.status or "active"
dst.installation_date = src.installation_date
dst.uninstallation_date = src.uninstallation_date
dst.serial_number = src.serial_number
dst.warranty_duration = src.warranty_duration
dst.last_inspection_date = src.last_inspection_date
dst.last_preventive_date = src.last_preventive_date
dst.last_corrective_date = src.last_corrective_date
dst.last_survey_date = src.last_survey_date
dst.category = src.category
dst.brand = src.brand
dst.lon = src.lon
dst.lat = src.lat
dst.geom = src.geom
dst.geojson = src.geojson
dst.vertical_order = src.vertical_order
dst.signpanel_text = src.signpanel_text
dst.signpanel_value = src.signpanel_value
dst.orientation = src.orientation
dst.support_type = src.support_type
dst.is_temporary = src.is_temporary or False
dst.model = dst_model
dst.signpanel_type = dst_type
dst.pole = dst_pole
dst.street = adr_street
dst.organization = dst_org
dst.width = width
dst.height = height
dst.external_reference = getattr(src, "external_reference", None)
dst.funding_program = getattr(src, "funding_program", None)
dst.inspection_status = getattr(src, "inspection_status", "unknown") or "unknown"
dst.note = getattr(src, "note", None)
dst.validation_status = getattr(src, "validation_status", None)
dst.save()
panel_map[src.id] = dst panel_map[src.id] = dst
self.stdout.write(self.style.SUCCESS(f"Copied {len(panel_map)} panels")) self.stdout.write(self.style.SUCCESS(f"Copied/Updated {len(panel_map)} panels"))
# 7) Street Documents # 7) Street Documents
copied_docs = 0
for src in AssetsSignStreetDocument.objects.all(): for src in AssetsSignStreetDocument.objects.all():
dst = SignStreetDocument.objects.create( dst_street = street_map.get(getattr(src.street, "id", None))
street=street_map.get(getattr(src.street, "id", None)), if dst_street and src.file:
file=src.file, dst = SignStreetDocument.objects.filter(
description=src.description, street=dst_street, file=src.file
upload_date=src.upload_date, ).first()
status=src.status, if not dst:
thumbnail=src.thumbnail, dst = SignStreetDocument(street=dst_street, file=src.file)
document_type=src.document_type, dst.description = src.description
uploaded_by=src.uploaded_by, dst.upload_date = src.upload_date
managed_document=src.managed_document, dst.status = src.status
) dst.thumbnail = src.thumbnail
self.stdout.write(self.style.SUCCESS("Copied street documents")) dst.document_type = src.document_type
dst.uploaded_by = src.uploaded_by
dst.managed_document = getattr(src, "managed_document", None)
dst.organization = get_organization_for_street(src.street)
dst.save()
copied_docs += 1
self.stdout.write(self.style.SUCCESS(f"Copied/Updated {copied_docs} street documents"))
# 8) Complementary Police Regulations # 8) Complementary Police Regulations
copied_regs = 0
for src in AssetsComplementaryPoliceRegulation.objects.all(): for src in AssetsComplementaryPoliceRegulation.objects.all():
dst = ComplementaryPoliceRegulation.objects.create( ref = src.reference or f"REG-{src.id}"
title=src.title, dst = ComplementaryPoliceRegulation.objects.filter(reference=ref).first()
reference=src.reference, if not dst:
decision_date=src.decision_date, dst = ComplementaryPoliceRegulation(reference=ref)
file=src.file,
thumbnail=src.thumbnail,
uploaded_by=src.uploaded_by,
managed_document=src.managed_document,
)
# M2M panels mapping
if dst:
dst.panels.set([panel_map.get(p.id) for p in src.panels.all() if panel_map.get(p.id)])
self.stdout.write(self.style.SUCCESS("Copied complementary police regulations"))
self.stdout.write(self.style.SUCCESS("Copy completed successfully.")) dst.title = src.title or ref
dst.description = getattr(src, "description", None)
dst.decision_date = src.decision_date
dst.status = "approved" if src.decision_date else "in_preparation"
dst.file = src.file
dst.thumbnail = src.thumbnail
dst.uploaded_by = src.uploaded_by
dst.organization = default_org
dst.save()
# M2M panels mapping (excluding any panels not in panel_map)
dst_panels = [panel_map[p.id] for p in src.panels.all() if p.id in panel_map]
if dst_panels:
dst.panels.set(dst_panels)
copied_regs += 1
self.stdout.write(self.style.SUCCESS(f"Copied/Updated {copied_regs} complementary police regulations"))
self.stdout.write(self.style.SUCCESS("Copy of sign data completed successfully."))

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-09-08 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sign', '0002_signpanel_funding_program_and_more'),
]
operations = [
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='signpole',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
]

View file

@ -655,3 +655,132 @@ class ESignOrganizationRestrictionsTests(TestCase):
} }
) )
self.assertEqual(response_edit_other_rcp.status_code, 403) self.assertEqual(response_edit_other_rcp.status_code, 403)
class CopySignDataCommandTests(TestCase):
"""Tests for the copy_sign_data management command."""
def test_copy_sign_data_command(self):
from io import StringIO
from django.core.management import call_command
from django.contrib.gis.geos import Point
from common.models import Municipality, AdrStreet
from assets.models import AssetCategory
from assets.models.sign import (
SignPanelType as AssetsSignPanelType,
SignPoleModel as AssetsSignPoleModel,
SignPanelModel as AssetsSignPanelModel,
SignStreet as AssetsSignStreet,
SignPole as AssetsSignPole,
SignPanel as AssetsSignPanel,
ComplementaryPoliceRegulation as AssetsComplementaryPoliceRegulation,
)
from sign.models import (
SignPanelType,
SignPoleModel,
SignPanelModel,
SignStreet,
SignPole,
SignPanel,
ComplementaryPoliceRegulation,
Organization,
)
# 1. Setup Source Data in Assets
muni = Municipality.objects.create(fid="1000", name_fr="Bruxelles", name_nl="Brussel")
adr_street = AdrStreet.objects.create(
fid="STR-1",
name_fr="Rue Royale",
name_nl="Koningsstraat",
municipality=muni,
)
org = Organization.objects.create(
code="SPRB",
name_fr="Bruxelles Mobilité",
name_nl="Brussel Mobiliteit",
organization_type="region",
)
cat = AssetCategory.objects.create(code="sign_cat", name_fr="Sign Cat")
src_ptype = AssetsSignPanelType.objects.create(
code="C1",
name_fr="Sens interdit",
name_nl="Verboden richting",
default_width=60.0,
default_height=60.0,
)
src_pmodel = AssetsSignPoleModel.objects.create(
code="PM-1",
name_fr="Poteau Acier 3m",
category=cat,
color="Gris",
height=3.0,
)
src_panel_model = AssetsSignPanelModel.objects.create(
code="PANM-1",
name_fr="Modèle C1 60",
category=cat,
shape="rond",
)
src_street = AssetsSignStreet.objects.create(
code="STR-ROYALE",
name_fr="Rue Royale",
name_nl="Koningsstraat",
municipality=muni,
)
src_pole = AssetsSignPole.objects.create(
code="POL-001",
name_fr="Poteau 1",
street=src_street,
model=src_pmodel,
category=cat,
geom=Point(149000, 171000, srid=3812),
)
src_panel = AssetsSignPanel.objects.create(
code="PAN-001",
name_fr="Panneau C1",
street=src_street,
pole=src_pole,
model=src_panel_model,
signpanel_type=src_ptype,
category=cat,
geom=Point(149000, 171000, srid=3812),
)
src_reg = AssetsComplementaryPoliceRegulation.objects.create(
title="Règlement Test",
reference="RCP-TEST-001",
)
src_reg.panels.add(src_panel)
# 2. Execute command
out = StringIO()
call_command("copy_sign_data", stdout=out)
output = out.getvalue()
self.assertIn("Copy of sign data completed successfully", output)
# 3. Assertions on copied destination models in Sign app
dst_ptype = SignPanelType.objects.filter(code="C1").first()
self.assertIsNotNone(dst_ptype)
self.assertEqual(dst_ptype.default_width, 60.0)
dst_pole = SignPole.objects.filter(code="POL-001").first()
self.assertIsNotNone(dst_pole)
self.assertEqual(dst_pole.street, adr_street)
self.assertEqual(dst_pole.organization, org)
dst_panel = SignPanel.objects.filter(code="PAN-001").first()
self.assertIsNotNone(dst_panel)
self.assertEqual(dst_panel.pole, dst_pole)
self.assertEqual(dst_panel.street, adr_street)
self.assertEqual(dst_panel.organization, org)
dst_reg = ComplementaryPoliceRegulation.objects.filter(reference="RCP-TEST-001").first()
self.assertIsNotNone(dst_reg)
self.assertIn(dst_panel, dst_reg.panels.all())
# 4. Verify idempotence
out2 = StringIO()
call_command("copy_sign_data", stdout=out2)
self.assertEqual(SignPole.objects.filter(code="POL-001").count(), 1)
self.assertEqual(SignPanel.objects.filter(code="PAN-001").count(), 1)