feat: extend support infrastructure for sign panels with traffic light pole association and custom model fields

This commit is contained in:
kdeterme 2026-09-11 18:08:20 +02:00
parent 48d7796e45
commit 1b38a8059e
11 changed files with 947 additions and 51 deletions

View file

@ -8,17 +8,24 @@ class SignPoleForm(forms.ModelForm):
class Meta: class Meta:
model = SignPole model = SignPole
fields = ['model', 'organization', 'orientation'] fields = ['category', 'model', 'organization', 'orientation', 'support_type']
widgets = { widgets = {
'category': forms.Select(attrs={'class': 'form-select'}), 'category': forms.Select(attrs={'class': 'form-select'}),
'model': forms.Select(attrs={'class': 'form-select'}), 'model': forms.Select(attrs={'class': 'form-select'}),
'organization': forms.Select(attrs={'class': 'form-select'}), 'organization': forms.Select(attrs={'class': 'form-select'}),
'support_type': forms.Select(attrs={'class': 'form-select'}),
'orientation': forms.HiddenInput(), 'orientation': forms.HiddenInput(),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['organization'].required = True self.fields['organization'].required = True
if 'support_type' in self.fields:
self.fields['support_type'].required = False
self.fields['support_type'].initial = 'pole'
def clean_support_type(self):
return self.cleaned_data.get('support_type') or 'pole'
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)
@ -40,13 +47,14 @@ class SignPanelForm(forms.ModelForm):
class Meta: class Meta:
model = SignPanel model = SignPanel
fields = ['signpanel_type', 'model', 'organization', 'orientation', 'support_type', 'is_temporary', 'signpanel_text', 'signpanel_value'] fields = ['signpanel_type', 'model', 'organization', 'orientation', 'support_type', 'support_model', 'is_temporary', 'signpanel_text', 'signpanel_value']
widgets = { widgets = {
'signpanel_type': forms.Select(attrs={'class': 'form-select'}), 'signpanel_type': forms.Select(attrs={'class': 'form-select'}),
'model': forms.Select(attrs={'class': 'form-select'}), 'model': forms.Select(attrs={'class': 'form-select'}),
'organization': forms.Select(attrs={'class': 'form-select'}), 'organization': forms.Select(attrs={'class': 'form-select'}),
'orientation': forms.NumberInput(attrs={'class': 'form-control'}), 'orientation': forms.NumberInput(attrs={'class': 'form-control'}),
'support_type': forms.Select(attrs={'class': 'form-select'}), 'support_type': forms.Select(attrs={'class': 'form-select'}),
'support_model': forms.TextInput(attrs={'class': 'form-control', 'placeholder': "Modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour...)"}),
'signpanel_text': forms.TextInput(attrs={'class': 'form-control'}), 'signpanel_text': forms.TextInput(attrs={'class': 'form-control'}),
'signpanel_value': forms.NumberInput(attrs={'class': 'form-control'}), 'signpanel_value': forms.NumberInput(attrs={'class': 'form-control'}),
'is_temporary': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_temporary': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
@ -55,6 +63,11 @@ class SignPanelForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['organization'].required = True self.fields['organization'].required = True
if 'support_type' in self.fields:
self.fields['support_type'].required = False
self.fields['support_type'].initial = 'pole'
if 'support_model' in self.fields:
self.fields['support_model'].required = False
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)

View file

@ -0,0 +1,35 @@
# Generated by Django 6.0.7 on 2026-09-11 14:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0025_artworkfountain_external_reference_and_more'),
('sign', '0003_signpanel_external_reference_and_more'),
]
operations = [
migrations.AddField(
model_name='signpanel',
name='support_model',
field=models.CharField(blank=True, help_text='Modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour, etc.)', max_length=255, null=True, verbose_name='Modèle du support'),
),
migrations.AddField(
model_name='signpanel',
name='trafficlight_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sign_app_panels', to='assets.trafficlightpole', verbose_name='Feu tricolore associé'),
),
migrations.AddField(
model_name='signpole',
name='support_type',
field=models.CharField(choices=[('pole', 'Poteau'), ('lightingpole', "Poteau d'éclairage"), ('trafficlightpole', 'Poteau de signalisation lumineuse'), ('facade', 'Façade'), ('mast', 'Mât'), ('portico', 'Portique'), ('gallows', 'Potence'), ('hopper', 'Trémie'), ('other', 'Autre')], default='pole', max_length=50, verbose_name='Type de support'),
),
migrations.AlterField(
model_name='signpanel',
name='signpanel_text',
field=models.CharField(blank=True, max_length=1000, null=True),
),
]

View file

@ -141,6 +141,19 @@ class SignPoleModel(AbstractAssetModel):
verbose_name_plural = "Modèles de poteaux (Sign)" verbose_name_plural = "Modèles de poteaux (Sign)"
SUPPORT_TYPE_CHOICES = (
("pole", "Poteau"),
("lightingpole", "Poteau d'éclairage"),
("trafficlightpole", "Poteau de signalisation lumineuse"),
("facade", "Façade"),
("mast", "Mât"),
("portico", "Portique"),
("gallows", "Potence"),
("hopper", "Trémie"),
("other", "Autre"),
)
class SignPole(AbstractGeoAsset): class SignPole(AbstractGeoAsset):
category = models.ForeignKey( category = models.ForeignKey(
'assets.AssetCategory', 'assets.AssetCategory',
@ -160,10 +173,21 @@ class SignPole(AbstractGeoAsset):
default=90, default=90,
help_text="Orientation en degrés (0-360)" help_text="Orientation en degrés (0-360)"
) )
support_type = models.CharField(
max_length=50,
default="pole",
blank=True,
choices=SUPPORT_TYPE_CHOICES,
verbose_name="Type de support",
)
class Meta: class Meta:
indexes = [models.Index(fields=["code"])] indexes = [models.Index(fields=["code"])]
@property
def support_type_display(self):
return dict(SUPPORT_TYPE_CHOICES).get(self.support_type, self.support_type or "Poteau")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.geom and hasattr(self, 'sign_app_panels'): if self.geom and hasattr(self, 'sign_app_panels'):
@ -227,19 +251,6 @@ class SignPanelType(models.Model):
return f"{self.code} - {self.get_name()}" return f"{self.code} - {self.get_name()}"
SUPPORT_TYPE_CHOICES = (
("pole", "Poteau"),
("lightingpole", "Poteau d'éclairage"),
("trafficlightpole", "Poteau de signalisation lumineuse"),
("facade", "Façade"),
("mast", "Mât"),
("portico", "Portique"),
("gallows", "Potence"),
("hopper", "Trémie"),
("other", "Autre"),
)
class SignPanel(AbstractGeoAsset): class SignPanel(AbstractGeoAsset):
category = models.ForeignKey( category = models.ForeignKey(
'assets.AssetCategory', 'assets.AssetCategory',
@ -251,11 +262,26 @@ class SignPanel(AbstractGeoAsset):
model = models.ForeignKey(SignPanelModel, on_delete=models.SET_NULL, null=True, blank=True) model = models.ForeignKey(SignPanelModel, on_delete=models.SET_NULL, null=True, blank=True)
signpanel_type = models.ForeignKey(SignPanelType, on_delete=models.SET_NULL, null=True, blank=True) signpanel_type = models.ForeignKey(SignPanelType, on_delete=models.SET_NULL, null=True, blank=True)
pole = models.ForeignKey(SignPole, related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True) pole = models.ForeignKey(SignPole, related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True)
trafficlight_pole = models.ForeignKey(
"assets.TrafficLightPole",
related_name="sign_app_panels",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Feu tricolore associé",
)
vertical_order = models.IntegerField(null=True, blank=True) vertical_order = models.IntegerField(null=True, blank=True)
signpanel_text = models.CharField(max_length=1000, null=True, blank=True, choices=SUPPORT_TYPE_CHOICES) signpanel_text = models.CharField(max_length=1000, null=True, blank=True)
signpanel_value = models.FloatField(null=True, blank=True) signpanel_value = models.FloatField(null=True, blank=True)
orientation = models.IntegerField(null=True, blank=True, help_text="Orientation en degrés (0-360)") orientation = models.IntegerField(null=True, blank=True, help_text="Orientation en degrés (0-360)")
support_type = models.CharField(max_length=200, null=True, blank=True, choices=SUPPORT_TYPE_CHOICES) support_type = models.CharField(max_length=200, null=True, blank=True, choices=SUPPORT_TYPE_CHOICES)
support_model = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name="Modèle du support",
help_text="Modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour, etc.)",
)
is_temporary = models.BooleanField(default=False) is_temporary = models.BooleanField(default=False)
street = models.ForeignKey("common.AdrStreet", related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True) street = models.ForeignKey("common.AdrStreet", related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True)
organization = models.ForeignKey( organization = models.ForeignKey(
@ -267,22 +293,49 @@ class SignPanel(AbstractGeoAsset):
class Meta: class Meta:
indexes = [models.Index(fields=["code"])] indexes = [models.Index(fields=["code"])]
@property
def effective_support_type(self):
if self.support_type:
return self.support_type
if self.pole:
return getattr(self.pole, 'support_type', 'pole') or 'pole'
if self.trafficlight_pole:
return 'trafficlightpole'
return 'pole'
@property
def effective_support_type_display(self):
st = self.effective_support_type
return dict(SUPPORT_TYPE_CHOICES).get(st, st or "Poteau")
@property
def support_model_display(self):
if self.support_model:
return self.support_model
if self.pole and self.pole.model:
return self.pole.model.get_name()
if self.trafficlight_pole and self.trafficlight_pole.model:
return self.trafficlight_pole.model.get_name()
return None
def clean(self): def clean(self):
super().clean() super().clean()
if self.pole and getattr(self.pole, 'geom', None): support = self.pole or self.trafficlight_pole
self.geom = self.pole.geom if support and getattr(support, 'geom', None):
self.lon = self.pole.lon self.geom = support.geom
self.lat = self.pole.lat self.lon = support.lon
self.geojson = self.pole.geojson self.lat = support.lat
self.geojson = support.geojson
if self.pole and self.pole.street and not self.street_id: if self.pole and self.pole.street and not self.street_id:
self.street = self.pole.street self.street = self.pole.street
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.pole and getattr(self.pole, 'geom', None): support = self.pole or self.trafficlight_pole
self.geom = self.pole.geom if support and getattr(support, 'geom', None):
self.lon = self.pole.lon self.geom = support.geom
self.lat = self.pole.lat self.lon = support.lon
self.geojson = self.pole.geojson self.lat = support.lat
self.geojson = support.geojson
if self.pole and self.pole.street and not self.street_id: if self.pole and self.pole.street and not self.street_id:
self.street = self.pole.street self.street = self.pole.street
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -348,13 +348,13 @@ function initMapSources() {
17, 5, 17, 5,
22, 6 22, 6
], ],
'circle-color': '#888888', 'circle-color': [
// 'circle-stroke-color': '#333333', 'case',
// 'circle-stroke-width': [ ['==', ['get', 'support_type'], 'trafficlightpole'], '#ef4444',
// 'interpolate', ['linear'], ['zoom'], ['==', ['get', 'support_type'], 'lightingpole'], '#f59e0b',
// 8, 0, ['==', ['get', 'support_type'], 'facade'], '#8b5cf6',
// 13, 1 '#888888'
// ], ],
'circle-opacity': 0.8 'circle-opacity': 0.8
} }
}); });
@ -387,7 +387,13 @@ function initMapSources() {
source: 'sign-assets', source: 'sign-assets',
filter: ['==', ['get', 'asset_type'], 'signpole'], filter: ['==', ['get', 'asset_type'], 'signpole'],
layout: { layout: {
'icon-image': 'sign-pole-vertical', 'icon-image': [
'case',
['==', ['get', 'support_type'], 'trafficlightpole'], 'sign-support-trafficlight',
['==', ['get', 'support_type'], 'lightingpole'], 'sign-support-lightingpole',
['==', ['get', 'support_type'], 'facade'], 'sign-support-facade',
'sign-pole-vertical'
],
'icon-anchor': 'top', 'icon-anchor': 'top',
'icon-offset': ['literal', [0, -64]], // Adjust for pole height 'icon-offset': ['literal', [0, -64]], // Adjust for pole height
'icon-allow-overlap': true, 'icon-allow-overlap': true,
@ -494,7 +500,12 @@ function setupMapInteractions() {
const feature = e.features[0]; const feature = e.features[0];
const coordinates = e.lngLat; const coordinates = e.lngLat;
const description = `<strong>${feature.properties.code || gettext('Poteau')}</strong>`; const supportType = feature.properties.support_type;
const supportLabel = feature.properties.support_type_label || (supportType === 'trafficlightpole' ? 'Feu tricolore' : (supportType === 'lightingpole' ? "Poteau d'éclairage" : (supportType === 'facade' ? 'Façade' : 'Poteau')));
const supportIcon = supportType === 'trafficlightpole' ? '🚦 ' : '';
const supportModelHtml = feature.properties.support_model ? `<div class="small text-muted">${feature.properties.support_model}</div>` : '';
const badgeClass = supportType === 'trafficlightpole' ? 'bg-danger text-white' : 'bg-secondary';
const description = `<strong>${feature.properties.code || supportLabel}</strong> <span class="badge ${badgeClass}">${supportIcon}${supportLabel}</span>${supportModelHtml}`;
signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap);
}); });
@ -509,7 +520,12 @@ function setupMapInteractions() {
signMap.getCanvas().style.cursor = 'pointer'; signMap.getCanvas().style.cursor = 'pointer';
const feature = e.features[0]; const feature = e.features[0];
const coordinates = e.lngLat; const coordinates = e.lngLat;
const description = `<strong>${feature.properties.code || gettext('Poteau')}</strong>`; const supportType = feature.properties.support_type;
const supportLabel = feature.properties.support_type_label || (supportType === 'trafficlightpole' ? 'Feu tricolore' : (supportType === 'lightingpole' ? "Poteau d'éclairage" : (supportType === 'facade' ? 'Façade' : 'Poteau')));
const supportIcon = supportType === 'trafficlightpole' ? '🚦 ' : '';
const supportModelHtml = feature.properties.support_model ? `<div class="small text-muted">${feature.properties.support_model}</div>` : '';
const badgeClass = supportType === 'trafficlightpole' ? 'bg-danger text-white' : 'bg-secondary';
const description = `<strong>${feature.properties.code || supportLabel}</strong> <span class="badge ${badgeClass}">${supportIcon}${supportLabel}</span>${supportModelHtml}`;
signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap);
}); });
@ -525,7 +541,8 @@ function setupMapInteractions() {
const feature = e.features[0]; const feature = e.features[0];
const coordinates = e.lngLat; const coordinates = e.lngLat;
const panelType = feature.properties.panel_type || ''; const panelType = feature.properties.panel_type || '';
const description = `<strong>${panelType || feature.properties.code || gettext('Panneau')}</strong>`; const supportModel = feature.properties.support_model ? `<div class="small text-muted">${feature.properties.support_type_label || 'Support'}: ${feature.properties.support_model}</div>` : (feature.properties.support_type === 'trafficlightpole' ? '<div class="small text-danger">🚦 Sur feu tricolore</div>' : '');
const description = `<strong>${panelType || feature.properties.code || gettext('Panneau')}</strong>${supportModel}`;
signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap);
}); });
@ -542,7 +559,8 @@ function setupMapInteractions() {
const feature = e.features[0]; const feature = e.features[0];
const coordinates = e.lngLat; const coordinates = e.lngLat;
const panelType = feature.properties.panel_type || ''; const panelType = feature.properties.panel_type || '';
const description = `<strong>${panelType || feature.properties.code || gettext('Panneau')}</strong>`; const supportModel = feature.properties.support_model ? `<div class="small text-muted">${feature.properties.support_type_label || 'Support'}: ${feature.properties.support_model}</div>` : (feature.properties.support_type === 'trafficlightpole' ? '<div class="small text-danger">🚦 Sur feu tricolore</div>' : '');
const description = `<strong>${panelType || feature.properties.code || gettext('Panneau')}</strong>${supportModel}`;
signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap);
}); });
@ -897,12 +915,37 @@ function showAssetDetail(feature, assetType) {
if (props.model) { if (props.model) {
detailHtml += ` detailHtml += `
<div class="detail-section"> <div class="detail-section">
<div class="detail-label">${gettext('Modèle')}</div> <div class="detail-label">${assetType === 'signpanel' ? gettext('Modèle du panneau') : gettext('Modèle')}</div>
<div class="detail-value">${props.model}</div> <div class="detail-value">${props.model}</div>
</div> </div>
`; `;
} }
// Support type (for both panels and poles)
if (props.support_type || props.support_type_label) {
const supportTypeLabel = props.support_type_label || props.support_type;
const supportIcon = props.support_type === 'trafficlightpole' ? '🚦' : (props.support_type === 'lightingpole' ? '💡' : (props.support_type === 'facade' ? '🏢' : '📍'));
const badgeClass = props.support_type === 'trafficlightpole' ? 'bg-danger text-white' : 'bg-secondary';
detailHtml += `
<div class="detail-section">
<div class="detail-label">${gettext('Type de support')}</div>
<div class="detail-value">
<span class="badge ${badgeClass}">${supportIcon} ${supportTypeLabel}</span>
</div>
</div>
`;
}
// Support model
if (props.support_model) {
detailHtml += `
<div class="detail-section">
<div class="detail-label">${gettext('Modèle du support')}</div>
<div class="detail-value fw-semibold">${props.support_model}</div>
</div>
`;
}
if (props.status) { if (props.status) {
detailHtml += ` detailHtml += `
<div class="detail-section"> <div class="detail-section">
@ -959,7 +1002,7 @@ function showAssetDetail(feature, assetType) {
})(); })();
// Edit Button for poles (after street view buttons) // Edit Button for poles (after street view buttons)
if (assetType === 'signpole' && typeof userPerms !== 'undefined' && userPerms.change_signpole) { if (assetType === 'signpole' && !props.is_standalone_support && typeof userPerms !== 'undefined' && userPerms.change_signpole) {
let lat = props.lat; let lat = props.lat;
let lon = props.lon; let lon = props.lon;
if ((lat == null || lon == null) && feature.geometry && feature.geometry.type === 'Point') { if ((lat == null || lon == null) && feature.geometry && feature.geometry.type === 'Point') {
@ -977,12 +1020,22 @@ function showAssetDetail(feature, assetType) {
`; `;
} }
if (props.is_standalone_support) {
detailHtml += `
<div class="detail-section">
<div class="alert alert-info py-2 px-3 small mb-0">
<i class="bi bi-info-circle me-1"></i>${gettext('Support autonome lié au panneau')} <strong>${props.code || ''}</strong>
</div>
</div>
`;
}
// For poles, list attached panels // For poles, list attached panels
if (assetType === 'signpole') { if (assetType === 'signpole') {
try { try {
const source = signMap.getSource('sign-assets'); const source = signMap.getSource('sign-assets');
const features = (source && source._data && source._data.features) ? source._data.features : []; const features = (source && source._data && source._data.features) ? source._data.features : [];
const panels = features.filter(f => f.properties && f.properties.asset_type === 'signpanel' && f.properties.pole_id === feature.id); const panels = features.filter(f => f.properties && f.properties.asset_type === 'signpanel' && (f.properties.pole_id === feature.id || (props.is_standalone_support && f.properties.code === props.code)));
// Sort panels by vertical order (if available) or id // Sort panels by vertical order (if available) or id
panels.sort((a, b) => { panels.sort((a, b) => {
@ -1201,6 +1254,182 @@ function loadRoadSignImages(map) {
data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data
}); });
// Create traffic light support image (mast + 3-light traffic signal box)
const tlCanvas = document.createElement('canvas');
tlCanvas.width = 24;
tlCanvas.height = 64;
const tlCtx = tlCanvas.getContext('2d');
tlCtx.clearRect(0, 0, tlCanvas.width, tlCanvas.height);
// Mast (poteau) from y = 28 to y = 64
const tlMastWidth = 4;
const tlMastX = Math.floor((tlCanvas.width - tlMastWidth) / 2); // 10
tlCtx.fillStyle = '#374151';
tlCtx.strokeStyle = '#111827';
tlCtx.lineWidth = 1;
tlCtx.fillRect(tlMastX, 28, tlMastWidth, 36);
tlCtx.strokeRect(tlMastX, 28, tlMastWidth, 36);
// Base plate at bottom
tlCtx.fillStyle = '#1f2937';
tlCtx.fillRect(tlMastX - 2, 61, tlMastWidth + 4, 3);
// Traffic light casing (boîtier)
const boxW = 14;
const boxH = 28;
const boxX = Math.floor((tlCanvas.width - boxW) / 2); // 5
const boxY = 2;
const radius = 3;
// Outer casing
tlCtx.fillStyle = '#111827';
tlCtx.strokeStyle = '#e5e7eb';
tlCtx.lineWidth = 1.2;
tlCtx.beginPath();
if (tlCtx.roundRect) {
tlCtx.roundRect(boxX, boxY, boxW, boxH, radius);
} else {
tlCtx.rect(boxX, boxY, boxW, boxH);
}
tlCtx.fill();
tlCtx.stroke();
// 3 lights: Red, Amber, Green
const lightRadius = 3;
const lightCenterX = 12;
// Red light (top)
tlCtx.beginPath();
tlCtx.arc(lightCenterX, 7, lightRadius, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ef4444';
tlCtx.fill();
tlCtx.strokeStyle = '#7f1d1d';
tlCtx.lineWidth = 0.8;
tlCtx.stroke();
tlCtx.beginPath();
tlCtx.arc(lightCenterX - 1, 6, 1, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ffffff';
tlCtx.fill();
// Amber / Yellow light (middle)
tlCtx.beginPath();
tlCtx.arc(lightCenterX, 16, lightRadius, 0, 2 * Math.PI);
tlCtx.fillStyle = '#f59e0b';
tlCtx.fill();
tlCtx.strokeStyle = '#78350f';
tlCtx.lineWidth = 0.8;
tlCtx.stroke();
tlCtx.beginPath();
tlCtx.arc(lightCenterX - 1, 15, 1, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ffffff';
tlCtx.fill();
// Green light (bottom)
tlCtx.beginPath();
tlCtx.arc(lightCenterX, 25, lightRadius, 0, 2 * Math.PI);
tlCtx.fillStyle = '#10b981';
tlCtx.fill();
tlCtx.strokeStyle = '#064e3b';
tlCtx.lineWidth = 0.8;
tlCtx.stroke();
tlCtx.beginPath();
tlCtx.arc(lightCenterX - 1, 24, 1, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ffffff';
tlCtx.fill();
map.addImage('sign-support-trafficlight', {
width: tlCanvas.width,
height: tlCanvas.height,
data: tlCtx.getImageData(0, 0, tlCanvas.width, tlCanvas.height).data
});
// Create lighting pole support image (mast with curved arm and lamp head)
const lpCanvas = document.createElement('canvas');
lpCanvas.width = 24;
lpCanvas.height = 64;
const lpCtx = lpCanvas.getContext('2d');
lpCtx.clearRect(0, 0, lpCanvas.width, lpCanvas.height);
const lpMastW = 4;
const lpMastX = Math.floor((lpCanvas.width - lpMastW) / 2); // 10
lpCtx.fillStyle = '#4b5563';
lpCtx.strokeStyle = '#1f2937';
lpCtx.lineWidth = 1;
lpCtx.fillRect(lpMastX, 10, lpMastW, 54);
lpCtx.strokeRect(lpMastX, 10, lpMastW, 54);
// Base plate
lpCtx.fillStyle = '#1f2937';
lpCtx.fillRect(lpMastX - 2, 61, lpMastW + 4, 3);
// Curved arm to the left
lpCtx.strokeStyle = '#4b5563';
lpCtx.lineWidth = 2.5;
lpCtx.beginPath();
lpCtx.moveTo(lpMastX + 2, 14);
lpCtx.quadraticCurveTo(lpMastX + 2, 3, 5, 5);
lpCtx.stroke();
// Luminaire / lamp head
lpCtx.fillStyle = '#fbbf24';
lpCtx.strokeStyle = '#b45309';
lpCtx.lineWidth = 1;
lpCtx.beginPath();
if (lpCtx.ellipse) {
lpCtx.ellipse(5, 5, 4, 2.5, -0.3, 0, 2 * Math.PI);
} else {
lpCtx.arc(5, 5, 3, 0, 2 * Math.PI);
}
lpCtx.fill();
lpCtx.stroke();
map.addImage('sign-support-lightingpole', {
width: lpCanvas.width,
height: lpCanvas.height,
data: lpCtx.getImageData(0, 0, lpCanvas.width, lpCanvas.height).data
});
// Create facade support image (wall bracket mount)
const fcCanvas = document.createElement('canvas');
fcCanvas.width = 24;
fcCanvas.height = 64;
const fcCtx = fcCanvas.getContext('2d');
fcCtx.clearRect(0, 0, fcCanvas.width, fcCanvas.height);
// Wall texture on left side
fcCtx.fillStyle = '#64748b';
fcCtx.strokeStyle = '#334155';
fcCtx.lineWidth = 1;
fcCtx.fillRect(2, 16, 6, 32);
fcCtx.strokeRect(2, 16, 6, 32);
// Mortar lines
fcCtx.strokeStyle = '#94a3b8';
fcCtx.lineWidth = 0.8;
fcCtx.beginPath();
fcCtx.moveTo(2, 24); fcCtx.lineTo(8, 24);
fcCtx.moveTo(2, 32); fcCtx.lineTo(8, 32);
fcCtx.moveTo(2, 40); fcCtx.lineTo(8, 40);
fcCtx.stroke();
// Horizontal bracket arm
fcCtx.strokeStyle = '#1e293b';
fcCtx.lineWidth = 3;
fcCtx.beginPath();
fcCtx.moveTo(8, 28);
fcCtx.lineTo(16, 28);
fcCtx.stroke();
// Vertical mounting sleeve
fcCtx.fillStyle = '#334155';
fcCtx.fillRect(14, 18, 4, 20);
map.addImage('sign-support-facade', {
width: fcCanvas.width,
height: fcCanvas.height,
data: fcCtx.getImageData(0, 0, fcCanvas.width, fcCanvas.height).data
});
map.addImage('road-sign-images-loaded', { map.addImage('road-sign-images-loaded', {
width: 1, width: 1,
height: 1, height: 1,

View file

@ -924,6 +924,165 @@ function loadRoadSignImages(map) {
data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data
}); });
// Create traffic light support image (mast + 3-light traffic signal box)
const tlCanvas = document.createElement('canvas');
tlCanvas.width = 24;
tlCanvas.height = 64;
const tlCtx = tlCanvas.getContext('2d');
tlCtx.clearRect(0, 0, tlCanvas.width, tlCanvas.height);
const tlMastWidth = 4;
const tlMastX = Math.floor((tlCanvas.width - tlMastWidth) / 2);
tlCtx.fillStyle = '#374151';
tlCtx.strokeStyle = '#111827';
tlCtx.lineWidth = 1;
tlCtx.fillRect(tlMastX, 28, tlMastWidth, 36);
tlCtx.strokeRect(tlMastX, 28, tlMastWidth, 36);
tlCtx.fillStyle = '#1f2937';
tlCtx.fillRect(tlMastX - 2, 61, tlMastWidth + 4, 3);
const boxW = 14;
const boxH = 28;
const boxX = Math.floor((tlCanvas.width - boxW) / 2);
const boxY = 2;
const radius = 3;
tlCtx.fillStyle = '#111827';
tlCtx.strokeStyle = '#e5e7eb';
tlCtx.lineWidth = 1.2;
tlCtx.beginPath();
if (tlCtx.roundRect) {
tlCtx.roundRect(boxX, boxY, boxW, boxH, radius);
} else {
tlCtx.rect(boxX, boxY, boxW, boxH);
}
tlCtx.fill();
tlCtx.stroke();
const lightRadius = 3;
const lightCenterX = 12;
tlCtx.beginPath();
tlCtx.arc(lightCenterX, 7, lightRadius, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ef4444';
tlCtx.fill();
tlCtx.strokeStyle = '#7f1d1d';
tlCtx.lineWidth = 0.8;
tlCtx.stroke();
tlCtx.beginPath();
tlCtx.arc(lightCenterX - 1, 6, 1, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ffffff';
tlCtx.fill();
tlCtx.beginPath();
tlCtx.arc(lightCenterX, 16, lightRadius, 0, 2 * Math.PI);
tlCtx.fillStyle = '#f59e0b';
tlCtx.fill();
tlCtx.strokeStyle = '#78350f';
tlCtx.lineWidth = 0.8;
tlCtx.stroke();
tlCtx.beginPath();
tlCtx.arc(lightCenterX - 1, 15, 1, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ffffff';
tlCtx.fill();
tlCtx.beginPath();
tlCtx.arc(lightCenterX, 25, lightRadius, 0, 2 * Math.PI);
tlCtx.fillStyle = '#10b981';
tlCtx.fill();
tlCtx.strokeStyle = '#064e3b';
tlCtx.lineWidth = 0.8;
tlCtx.stroke();
tlCtx.beginPath();
tlCtx.arc(lightCenterX - 1, 24, 1, 0, 2 * Math.PI);
tlCtx.fillStyle = '#ffffff';
tlCtx.fill();
map.addImage('sign-support-trafficlight', {
width: tlCanvas.width,
height: tlCanvas.height,
data: tlCtx.getImageData(0, 0, tlCanvas.width, tlCanvas.height).data
});
const lpCanvas = document.createElement('canvas');
lpCanvas.width = 24;
lpCanvas.height = 64;
const lpCtx = lpCanvas.getContext('2d');
lpCtx.clearRect(0, 0, lpCanvas.width, lpCanvas.height);
const lpMastW = 4;
const lpMastX = Math.floor((lpCanvas.width - lpMastW) / 2);
lpCtx.fillStyle = '#4b5563';
lpCtx.strokeStyle = '#1f2937';
lpCtx.lineWidth = 1;
lpCtx.fillRect(lpMastX, 10, lpMastW, 54);
lpCtx.strokeRect(lpMastX, 10, lpMastW, 54);
lpCtx.fillStyle = '#1f2937';
lpCtx.fillRect(lpMastX - 2, 61, lpMastW + 4, 3);
lpCtx.strokeStyle = '#4b5563';
lpCtx.lineWidth = 2.5;
lpCtx.beginPath();
lpCtx.moveTo(lpMastX + 2, 14);
lpCtx.quadraticCurveTo(lpMastX + 2, 3, 5, 5);
lpCtx.stroke();
lpCtx.fillStyle = '#fbbf24';
lpCtx.strokeStyle = '#b45309';
lpCtx.lineWidth = 1;
lpCtx.beginPath();
if (lpCtx.ellipse) {
lpCtx.ellipse(5, 5, 4, 2.5, -0.3, 0, 2 * Math.PI);
} else {
lpCtx.arc(5, 5, 3, 0, 2 * Math.PI);
}
lpCtx.fill();
lpCtx.stroke();
map.addImage('sign-support-lightingpole', {
width: lpCanvas.width,
height: lpCanvas.height,
data: lpCtx.getImageData(0, 0, lpCanvas.width, lpCanvas.height).data
});
const fcCanvas = document.createElement('canvas');
fcCanvas.width = 24;
fcCanvas.height = 64;
const fcCtx = fcCanvas.getContext('2d');
fcCtx.clearRect(0, 0, fcCanvas.width, fcCanvas.height);
fcCtx.fillStyle = '#64748b';
fcCtx.strokeStyle = '#334155';
fcCtx.lineWidth = 1;
fcCtx.fillRect(2, 16, 6, 32);
fcCtx.strokeRect(2, 16, 6, 32);
fcCtx.strokeStyle = '#94a3b8';
fcCtx.lineWidth = 0.8;
fcCtx.beginPath();
fcCtx.moveTo(2, 24); fcCtx.lineTo(8, 24);
fcCtx.moveTo(2, 32); fcCtx.lineTo(8, 32);
fcCtx.moveTo(2, 40); fcCtx.lineTo(8, 40);
fcCtx.stroke();
fcCtx.strokeStyle = '#1e293b';
fcCtx.lineWidth = 3;
fcCtx.beginPath();
fcCtx.moveTo(8, 28);
fcCtx.lineTo(16, 28);
fcCtx.stroke();
fcCtx.fillStyle = '#334155';
fcCtx.fillRect(14, 18, 4, 20);
map.addImage('sign-support-facade', {
width: fcCanvas.width,
height: fcCanvas.height,
data: fcCtx.getImageData(0, 0, fcCanvas.width, fcCanvas.height).data
});
map.addImage('road-sign-images-loaded', { map.addImage('road-sign-images-loaded', {
width: 1, width: 1,
height: 1, height: 1,
@ -1217,7 +1376,13 @@ function initMapSources() {
source: 'regulation-panels', source: 'regulation-panels',
filter: ['==', ['get', 'asset_type'], 'signpole'], filter: ['==', ['get', 'asset_type'], 'signpole'],
layout: { layout: {
'icon-image': 'sign-pole-vertical', 'icon-image': [
'case',
['==', ['get', 'support_type'], 'trafficlightpole'], 'sign-support-trafficlight',
['==', ['get', 'support_type'], 'lightingpole'], 'sign-support-lightingpole',
['==', ['get', 'support_type'], 'facade'], 'sign-support-facade',
'sign-pole-vertical'
],
'icon-anchor': 'top', 'icon-anchor': 'top',
'icon-offset': ['literal', [0, -5]], 'icon-offset': ['literal', [0, -5]],
'icon-allow-overlap': true, 'icon-allow-overlap': true,

View file

@ -17,12 +17,32 @@
<strong>Code:</strong> {{ asset.code|default:"-" }}<br> <strong>Code:</strong> {{ asset.code|default:"-" }}<br>
<strong>Organisation:</strong> {{ asset.organization|default:"-" }}<br> <strong>Organisation:</strong> {{ asset.organization|default:"-" }}<br>
{% if asset_model == 'signpole' %} {% if asset_model == 'signpole' %}
<strong>Modèle:</strong> {{ asset.model|default:"-" }}<br> <strong>{% translate "Type de support:" %}</strong>
<strong>Hauteur:</strong> {{ asset.height|default:"-" }} m<br> {% if asset.support_type == 'trafficlightpole' %}
<strong>Couleur:</strong> {{ asset.color|default:"-" }}<br> <span class="badge bg-danger-subtle text-danger border border-danger">🚦 {{ asset.support_type_display }}</span>
{% else %} {% else %}
<strong>Type:</strong> {{ asset.signpanel_type.code }} - {{ asset.signpanel_type.get_name }}<br> <span class="badge bg-light text-dark border">{{ asset.support_type_display|default:"Poteau" }}</span>
<strong>Texte:</strong> {{ asset.signpanel_text|default:"-" }}<br> {% endif %}<br>
<strong>{% translate "Modèle:" %}</strong> {{ asset.model|default:"-" }}<br>
<strong>{% translate "Hauteur:" %}</strong> {{ asset.height|default:"-" }} m<br>
<strong>{% translate "Couleur:" %}</strong> {{ asset.color|default:"-" }}<br>
{% else %}
<strong>{% translate "Type de panneau:" %}</strong> {{ asset.signpanel_type.code }} - {{ asset.signpanel_type.get_name }}<br>
<strong>{% translate "Support:" %}</strong>
{% if asset.effective_support_type == 'trafficlightpole' %}
<span class="badge bg-danger-subtle text-danger border border-danger">🚦 {{ asset.effective_support_type_display }}</span>
{% else %}
<span class="badge bg-light text-dark border">{{ asset.effective_support_type_display|default:"Poteau" }}</span>
{% endif %}
{% if asset.pole %}(<a href="{% url 'sign:sign_assets_detail' 'signpole' asset.pole.id %}">{{ asset.pole.code }}</a>){% endif %}
<br>
{% if asset.support_model_display %}
<strong>{% translate "Modèle du support:" %}</strong> <span class="fw-semibold">{{ asset.support_model_display }}</span><br>
{% endif %}
{% if asset.model %}
<strong>{% translate "Modèle du panneau:" %}</strong> {{ asset.model }}<br>
{% endif %}
<strong>{% translate "Texte:" %}</strong> {{ asset.signpanel_text|default:"-" }}<br>
{% endif %} {% endif %}
</div> </div>

View file

@ -162,6 +162,12 @@
{{ form.support_type }} {{ form.support_type }}
</div> </div>
<div class="mb-3">
<label for="{{ form.support_model.id_for_label }}" class="form-label">{{ form.support_model.label }}</label>
{{ form.support_model }}
<div class="form-text text-muted small">{% translate "Précisez le modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour, Candélabre...)" %}</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.orientation.id_for_label }}" class="form-label">{{ form.orientation.label }}</label> <label for="{{ form.orientation.id_for_label }}" class="form-label">{{ form.orientation.label }}</label>
{{ form.orientation }} {{ form.orientation }}

View file

@ -43,6 +43,14 @@
{% endif %} {% endif %}
</div> </div>
<div class="mb-3">
<label for="{{ form.support_type.id_for_label }}" class="form-label">{{ form.support_type.label }}</label>
{{ form.support_type }}
{% if form.support_type.errors %}
<div class="invalid-feedback d-block">{{ form.support_type.errors }}</div>
{% endif %}
</div>
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.organization.id_for_label }}" class="form-label">{{ form.organization.label }}</label> <label for="{{ form.organization.id_for_label }}" class="form-label">{{ form.organization.label }}</label>
{{ form.organization }} {{ form.organization }}

View file

@ -43,6 +43,14 @@
{% endif %} {% endif %}
</div> </div>
<div class="mb-3">
<label for="{{ form.support_type.id_for_label }}" class="form-label">{{ form.support_type.label }}</label>
{{ form.support_type }}
{% if form.support_type.errors %}
<div class="invalid-feedback d-block">{{ form.support_type.errors }}</div>
{% endif %}
</div>
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.organization.id_for_label }}" class="form-label">{{ form.organization.label }}</label> <label for="{{ form.organization.id_for_label }}" class="form-label">{{ form.organization.label }}</label>
{{ form.organization }} {{ form.organization }}

View file

@ -1000,3 +1000,205 @@ class SignStreetAssignmentTests(TestCase):
self.assertEqual(panel.vertical_order, 1) self.assertEqual(panel.vertical_order, 1)
class SupportRepresentationAndModelTests(TestCase):
def setUp(self):
from common.models import Municipality, AdrStreet, UserConfig, AppView
from sign.models import SignPanelType, SignPoleModel, SignPanelModel, SignPole, SignPanel, Organization
from assets.models import TrafficLightIntersection, TrafficLightPoleModel, TrafficLightPole
from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point
User = get_user_model()
self.user = User.objects.create_user(username="support_user", password="password", is_superuser=True)
sign_view, _ = AppView.objects.get_or_create(
code="sign",
defaults={
"name_fr": "Signalisation",
"name_nl": "Signalisatie",
"url_name": "sign:index",
}
)
self.config = UserConfig.objects.create(user=self.user, esign_can_edit=True)
self.config.accessible_views.add(sign_view)
self.muni = Municipality.objects.create(fid="1080", name_fr="Molenbeek", name_nl="Molenbeek")
self.adr_street = AdrStreet.objects.create(
fid="STR-100",
name_fr="Rue de Test",
name_nl="Teststraat",
municipality=self.muni,
)
self.org = Organization.objects.create(
code="TEST_ORG",
name_fr="Test Organisation",
name_nl="Test Organisatie",
organization_type="region",
)
self.pole_model = SignPoleModel.objects.create(
code="POL-MOD-1",
name_fr="Modèle Mât 4m",
)
self.panel_model = SignPanelModel.objects.create(
code="PAN-MOD-1",
name_fr="Modèle Type B",
)
self.panel_type = SignPanelType.objects.create(
code="B1",
name_fr="Céder le passage",
name_nl="Voorrang verlenen",
default_width=70.0,
default_height=70.0,
)
self.tl_intersection = TrafficLightIntersection.objects.create(
code="TL-INT-01",
name_fr="Carrefour Test",
)
self.tl_pole_model = TrafficLightPoleModel.objects.create(
code="TL-PM-01",
name_fr="Poteau Tricolore Acier 6m",
)
self.tl_pole = TrafficLightPole.objects.create(
code="TL-P-01",
intersection=self.tl_intersection,
model=self.tl_pole_model,
geom=Point(145000, 172000, srid=3812),
lon=4.32,
lat=50.85,
)
def test_signpole_support_type(self):
"""Test that SignPole supports support_type choices and defaults."""
from sign.models import SignPole
pole = SignPole.objects.create(
code="P-TEST-01",
street=self.adr_street,
organization=self.org,
support_type="trafficlightpole",
model=self.pole_model,
)
self.assertEqual(pole.support_type, "trafficlightpole")
self.assertEqual(pole.support_type_display, "Poteau de signalisation lumineuse")
def test_signpanel_effective_support_and_model(self):
"""Test effective_support_type and support_model_display on SignPanel."""
from sign.models import SignPole, SignPanel
# 1. Panel attached to pole with support_type
pole = SignPole.objects.create(
code="P-TEST-02",
street=self.adr_street,
organization=self.org,
support_type="lightingpole",
model=self.pole_model,
)
panel1 = SignPanel.objects.create(
code="PANEL-01",
pole=pole,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
)
self.assertEqual(panel1.effective_support_type, "lightingpole")
self.assertEqual(panel1.effective_support_type_display, "Poteau d'éclairage")
self.assertEqual(panel1.support_model_display, "Modèle Mât 4m")
# 2. Panel attached directly to trafficlight_pole
panel2 = SignPanel.objects.create(
code="PANEL-02",
trafficlight_pole=self.tl_pole,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
)
self.assertEqual(panel2.effective_support_type, "trafficlightpole")
self.assertEqual(panel2.effective_support_type_display, "Poteau de signalisation lumineuse")
self.assertEqual(panel2.support_model_display, "Poteau Tricolore Acier 6m")
# 3. Standalone panel with support_type and custom support_model
panel3 = SignPanel.objects.create(
code="PANEL-03",
support_type="trafficlightpole",
support_model="Feu Swarco Carrefour",
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
)
self.assertEqual(panel3.effective_support_type, "trafficlightpole")
self.assertEqual(panel3.effective_support_type_display, "Poteau de signalisation lumineuse")
self.assertEqual(panel3.support_model_display, "Feu Swarco Carrefour")
def test_sign_assets_geojson_includes_support_representation(self):
"""Test that sign_assets_geojson includes support_type, support_model and standalone support features."""
from django.contrib.gis.geos import Point
from sign.models import SignPanel
import json
pt = Point(145000, 172000, srid=3812)
panel = SignPanel.objects.create(
code="PANEL-TL-01",
street=self.adr_street,
support_type="trafficlightpole",
support_model="Feu Siemens 3 Feux",
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.32,
lat=50.85,
)
self.client.force_login(self.user)
response = self.client.post(
reverse('sign:sign_assets_geojson'),
data=json.dumps({'organization': [self.org.id]}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = response.json()
features = data.get('features', [])
self.assertTrue(len(features) >= 2)
panel_feat = next((f for f in features if f['properties'].get('asset_type') == 'signpanel'), None)
self.assertIsNotNone(panel_feat)
self.assertEqual(panel_feat['properties']['support_type'], 'trafficlightpole')
self.assertEqual(panel_feat['properties']['support_model'], 'Feu Siemens 3 Feux')
support_feat = next((f for f in features if f['properties'].get('asset_type') == 'signpole'), None)
self.assertIsNotNone(support_feat)
self.assertEqual(support_feat['properties']['support_type'], 'trafficlightpole')
self.assertEqual(support_feat['properties']['support_model'], 'Feu Siemens 3 Feux')
self.assertTrue(support_feat['properties'].get('is_standalone_support'))
def test_sign_poles_overview_geojson_includes_standalone_trafficlight(self):
"""Test sign_poles_overview_geojson includes standalone panel supports."""
from django.contrib.gis.geos import Point
from sign.models import SignPanel
import json
pt = Point(145000, 172000, srid=3812)
SignPanel.objects.create(
code="PANEL-TL-OV",
street=self.adr_street,
support_type="trafficlightpole",
support_model="Feu Carrefour 1",
organization=self.org,
geom=pt,
lon=4.32,
lat=50.85,
)
self.client.force_login(self.user)
response = self.client.post(
reverse('sign:sign_poles_overview_geojson'),
data=json.dumps({'organization': [self.org.id]}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = response.json()
features = data.get('features', [])
tl_support = next((f for f in features if f['properties'].get('support_type') == 'trafficlightpole'), None)
self.assertIsNotNone(tl_support)
self.assertEqual(tl_support['properties']['support_model'], 'Feu Carrefour 1')
self.assertTrue(tl_support['properties'].get('is_standalone_support'))

View file

@ -31,6 +31,7 @@ from .models import (
RegulationMeasurePanel, RegulationMeasurePanel,
SignPanelTypeArticle, SignPanelTypeArticle,
RegulationArticleVoirie, RegulationArticleVoirie,
SUPPORT_TYPE_CHOICES,
) )
from .permissions import user_can_manage_regulations, user_can_edit_regulation, user_can_submit_regulation, user_can_edit_esign_assets, user_can_view_regulation from .permissions import user_can_manage_regulations, user_can_edit_regulation, user_can_submit_regulation, user_can_edit_esign_assets, user_can_view_regulation
from .forms import SignPoleForm, SignPanelForm from .forms import SignPoleForm, SignPanelForm
@ -532,12 +533,16 @@ def sign_poles_overview_geojson(request):
# Use values() to fetch only the needed columns in a single query, # Use values() to fetch only the needed columns in a single query,
# and build the detail URL without get_absolute_url() to avoid instantiation. # and build the detail URL without get_absolute_url() to avoid instantiation.
poles = qs.values('id', 'code', 'lon', 'lat') poles = qs.values('id', 'code', 'lon', 'lat', 'support_type', 'model__name_fr')
features = [] features = []
seen_coords = set()
for pole in poles.iterator(chunk_size=2000): for pole in poles.iterator(chunk_size=2000):
lon, lat = pole['lon'], pole['lat'] lon, lat = pole['lon'], pole['lat']
if lon is not None and lat is not None:
seen_coords.add((round(float(lon), 6), round(float(lat), 6)))
geometry = {"type": "Point", "coordinates": [lon, lat]} if lon is not None and lat is not None else None geometry = {"type": "Point", "coordinates": [lon, lat]} if lon is not None and lat is not None else None
support_t = pole.get('support_type') or 'pole'
features.append({ features.append({
"type": "Feature", "type": "Feature",
"id": pole['id'], "id": pole['id'],
@ -545,12 +550,51 @@ def sign_poles_overview_geojson(request):
"properties": { "properties": {
"asset_type": "signpole", "asset_type": "signpole",
"code": pole['code'], "code": pole['code'],
"support_type": support_t,
"support_type_label": dict(SUPPORT_TYPE_CHOICES).get(support_t, support_t or "Poteau"),
"support_model": pole.get('model__name_fr') or None,
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"detail_url": f"/sign/assets/signpole/{pole['id']}/", "detail_url": f"/sign/assets/signpole/{pole['id']}/",
} }
}) })
# Include standalone panel supports (panels without pole, e.g. on traffic lights)
panel_supports = SignPanel.objects.filter(
Q(pole__isnull=True) & (Q(support_type__isnull=False) | Q(trafficlight_pole__isnull=False))
)
if organizations:
panel_supports = panel_supports.filter(organization_id__in=organizations)
if municipalities:
panel_supports = panel_supports.filter(street__municipality_id__in=municipalities)
if filter_geom_raw and filter_geom:
panel_supports = panel_supports.filter(geom__intersects=filter_geom)
for p in panel_supports.values('id', 'code', 'lon', 'lat', 'support_type', 'trafficlight_pole_id', 'support_model'):
lon, lat = p['lon'], p['lat']
if lon is not None and lat is not None:
coord_key = (round(float(lon), 6), round(float(lat), 6))
if coord_key not in seen_coords:
seen_coords.add(coord_key)
st = p['support_type'] or ('trafficlightpole' if p.get('trafficlight_pole_id') else 'pole')
st_label = dict(SUPPORT_TYPE_CHOICES).get(st, st or "Poteau")
features.append({
"type": "Feature",
"id": f"sp-{p['id']}",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"asset_type": "signpole",
"is_standalone_support": True,
"code": p['code'],
"support_type": st,
"support_type_label": st_label,
"support_model": p.get('support_model') or None,
"lat": lat,
"lon": lon,
"detail_url": f"/sign/assets/signpanel/{p['id']}/",
}
})
return JsonResponse({"type": "FeatureCollection", "features": features}) return JsonResponse({"type": "FeatureCollection", "features": features})
@ -665,9 +709,13 @@ def sign_assets_geojson(request):
else: else:
pole_orientation_offset = [0, pole_offset] pole_orientation_offset = [0, pole_offset]
support_t = getattr(obj, "support_type", "pole") or "pole"
feature["properties"].update({ feature["properties"].update({
"pole_orientation_offset": pole_orientation_offset, "pole_orientation_offset": pole_orientation_offset,
"show_orientation_arrow": show_orientation_arrow, "show_orientation_arrow": show_orientation_arrow,
"support_type": support_t,
"support_type_label": getattr(obj, "support_type_display", dict(SUPPORT_TYPE_CHOICES).get(support_t, "Poteau")),
"support_model": obj.model.get_name() if obj.model else None,
}) })
if asset_type == "signpanel": if asset_type == "signpanel":
panel_meta = panel_render_meta.get(obj.id, {}) panel_meta = panel_render_meta.get(obj.id, {})
@ -703,6 +751,9 @@ def sign_assets_geojson(request):
aspect_ratio = panel_meta.get("aspect_ratio", 1.0) aspect_ratio = panel_meta.get("aspect_ratio", 1.0)
feature["properties"].update({ feature["properties"].update({
"pole_id": getattr(obj, "pole_id", None), "pole_id": getattr(obj, "pole_id", None),
"support_type": obj.effective_support_type,
"support_type_label": obj.effective_support_type_display,
"support_model": obj.support_model_display,
"panel_text_can_render": can_render_text, "panel_text_can_render": can_render_text,
"panel_text_image_id": sprite_id, "panel_text_image_id": sprite_id,
"panel_hide_icon": can_render_text, "panel_hide_icon": can_render_text,
@ -725,7 +776,10 @@ def sign_assets_geojson(request):
return feature_count, False return feature_count, False
signpole_qs = SignPole.objects.select_related('street', 'category', 'model').all() signpole_qs = SignPole.objects.select_related('street', 'category', 'model').all()
signpanel_qs = SignPanel.objects.select_related('street', 'category', 'model', 'signpanel_type').all() signpanel_qs = SignPanel.objects.select_related(
'street', 'category', 'model', 'signpanel_type', 'pole', 'pole__model',
'trafficlight_pole', 'trafficlight_pole__model'
).all()
if municipalities: if municipalities:
signpole_qs = signpole_qs.filter(street__municipality_id__in=municipalities) signpole_qs = signpole_qs.filter(street__municipality_id__in=municipalities)
@ -819,6 +873,44 @@ def sign_assets_geojson(request):
return JsonResponse({ return JsonResponse({
"error": f"{_('Trop d\'objets à afficher')} (> {MAX_ASSET_FEATURES}). {_('Limitez votre sélection.')}" "error": f"{_('Trop d\'objets à afficher')} (> {MAX_ASSET_FEATURES}). {_('Limitez votre sélection.')}"
}, status=413) }, status=413)
# Add standalone supports for panels without a SignPole (e.g. traffic lights, facades, lighting poles)
seen_standalone_coords = set()
for f in features:
if f.get("properties", {}).get("asset_type") == "signpole":
geom = f.get("geometry")
if geom and geom.get("type") == "Point":
coords = geom.get("coordinates")
if coords and len(coords) >= 2:
seen_standalone_coords.add((round(float(coords[0]), 6), round(float(coords[1]), 6)))
for panel in signpanel_list:
if panel.pole_id is None and (panel.support_type or panel.trafficlight_pole_id):
if panel.lon is not None and panel.lat is not None:
coord_key = (round(float(panel.lon), 6), round(float(panel.lat), 6))
if coord_key not in seen_standalone_coords:
seen_standalone_coords.add(coord_key)
features.append({
"type": "Feature",
"id": f"sp-{panel.id}",
"geometry": json.loads(panel.geojson) if panel.geojson else {"type": "Point", "coordinates": [panel.lon, panel.lat]},
"properties": {
"code": panel.code,
"name": panel.code,
"category": panel.category.get_name() if panel.category else None,
"model": None,
"support_type": panel.effective_support_type,
"support_type_label": panel.effective_support_type_display,
"support_model": panel.support_model_display,
"status": panel.status,
"lon": panel.lon,
"lat": panel.lat,
"asset_type": "signpole",
"is_standalone_support": True,
"detail_url": panel.get_absolute_url() if hasattr(panel, 'get_absolute_url') else None,
}
})
features = sorted(features, key=lambda f: f["properties"]["code"]) features = sorted(features, key=lambda f: f["properties"]["code"])
geojson_data = {"type": "FeatureCollection", "features": features} geojson_data = {"type": "FeatureCollection", "features": features}
return JsonResponse(geojson_data, safe=False) return JsonResponse(geojson_data, safe=False)
@ -1289,7 +1381,10 @@ def regulation_panels_geojson(request, regulation_id: int):
pass pass
# Build queryset # Build queryset
panels_qs = SignPanel.objects.select_related('street', 'signpanel_type', 'pole', 'category', 'model') panels_qs = SignPanel.objects.select_related(
'street', 'signpanel_type', 'pole', 'pole__model', 'category', 'model',
'trafficlight_pole', 'trafficlight_pole__model'
)
if filter_geom: if filter_geom:
panels_qs = panels_qs.filter(geom__intersects=filter_geom) panels_qs = panels_qs.filter(geom__intersects=filter_geom)
@ -1424,6 +1519,9 @@ def regulation_panels_geojson(request, regulation_id: int):
"street": panel.street.get_name() if panel.street else "-", "street": panel.street.get_name() if panel.street else "-",
"status": panel.status, "status": panel.status,
"asset_type": "signpanel", "asset_type": "signpanel",
"support_type": panel.effective_support_type,
"support_type_label": panel.effective_support_type_display,
"support_model": panel.support_model_display,
"panel_icon_image": panel_icon_image, "panel_icon_image": panel_icon_image,
"panel_icon_image_scaled": panel_icon_image_scaled, "panel_icon_image_scaled": panel_icon_image_scaled,
"is_associated": is_associated, "is_associated": is_associated,
@ -1446,7 +1544,54 @@ def regulation_panels_geojson(request, regulation_id: int):
} }
} }
features = [obj_to_feature(panel) for panel in panels_list] # Also include support features (poles and standalone supports) so regulation map can render them
pole_features = []
seen_pole_ids = set()
seen_support_coords = set()
for panel in panels_list:
if panel.pole_id and panel.pole_id not in seen_pole_ids:
seen_pole_ids.add(panel.pole_id)
pole = panel.pole
if pole and pole.lon is not None and pole.lat is not None:
pole_geom = json.loads(pole.geojson) if pole.geojson else {"type": "Point", "coordinates": [pole.lon, pole.lat]}
support_t = getattr(pole, 'support_type', 'pole') or 'pole'
support_m = pole.model.get_name() if pole.model else None
pole_features.append({
"type": "Feature",
"id": f"pole-{pole.id}",
"geometry": pole_geom,
"properties": {
"asset_type": "signpole",
"code": pole.code,
"support_type": support_t,
"support_type_label": getattr(pole, 'support_type_display', dict(SUPPORT_TYPE_CHOICES).get(support_t, 'Poteau')),
"support_model": support_m,
"lon": pole.lon,
"lat": pole.lat,
}
})
elif not panel.pole_id and (panel.support_type or panel.trafficlight_pole_id):
if panel.lon is not None and panel.lat is not None:
coord_key = (round(float(panel.lon), 6), round(float(panel.lat), 6))
if coord_key not in seen_support_coords:
seen_support_coords.add(coord_key)
pole_features.append({
"type": "Feature",
"id": f"support-panel-{panel.id}",
"geometry": json.loads(panel.geojson) if panel.geojson else {"type": "Point", "coordinates": [panel.lon, panel.lat]},
"properties": {
"asset_type": "signpole",
"is_standalone_support": True,
"code": panel.code,
"support_type": panel.effective_support_type,
"support_type_label": panel.effective_support_type_display,
"support_model": panel.support_model_display,
"lon": panel.lon,
"lat": panel.lat,
}
})
features = [obj_to_feature(panel) for panel in panels_list] + pole_features
geojson_data = { geojson_data = {
"type": "FeatureCollection", "type": "FeatureCollection",
@ -2323,6 +2468,18 @@ def sign_panel_create(request):
panel.geojson = pole.geojson panel.geojson = pole.geojson
except SignPole.DoesNotExist: except SignPole.DoesNotExist:
pass pass
elif panel.support_type == 'trafficlightpole' and lat and lon:
from assets.models import TrafficLightPole
try:
tl_pt = Point(float(lon), float(lat), srid=4326)
tl_pt.transform(3812)
tl_pole = TrafficLightPole.objects.filter(geom__dwithin=(tl_pt, 5)).first()
if tl_pole:
panel.trafficlight_pole = tl_pole
if not panel.support_model and tl_pole.model:
panel.support_model = tl_pole.model.get_name()
except Exception:
pass
# If coordinates are provided and geom not yet set # If coordinates are provided and geom not yet set
if lat and lon and not panel.geom: if lat and lon and not panel.geom: