661 lines
24 KiB
Python
661 lines
24 KiB
Python
from django.contrib.gis.db import models
|
||
from django.urls import reverse
|
||
from django.utils.text import slugify
|
||
from django.utils.translation import get_language
|
||
from django.conf import settings
|
||
|
||
import os
|
||
|
||
from assets.models import AbstractLocation, AbstractGeoAsset, AbstractAssetModel
|
||
from common.models import Municipality, Document
|
||
|
||
from common.storages import PrivateMediaStorage
|
||
private_storage = PrivateMediaStorage()
|
||
|
||
class Organization(models.Model):
|
||
"""
|
||
Represents an organization/entity that can synchronize sign data.
|
||
This can be a municipality (commune), region, administration, or any other entity.
|
||
"""
|
||
name_fr = models.CharField(max_length=255, verbose_name="Nom (FR)")
|
||
name_nl = models.CharField(max_length=255, verbose_name="Nom (NL)")
|
||
code = models.CharField(max_length=100, unique=True, verbose_name="Code")
|
||
organization_type = models.CharField(
|
||
max_length=50,
|
||
choices=[
|
||
('municipality', 'Commune'),
|
||
('region', 'Région'),
|
||
('administration', 'Administration'),
|
||
('other', 'Autre'),
|
||
],
|
||
default='municipality',
|
||
verbose_name="Type d'organisation"
|
||
)
|
||
description = models.TextField(blank=True, null=True, verbose_name="Description")
|
||
contact_email = models.EmailField(blank=True, null=True, verbose_name="Email de contact")
|
||
is_active = models.BooleanField(default=True, verbose_name="Actif")
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Organisation"
|
||
verbose_name_plural = "Organisations"
|
||
ordering = ['name_fr']
|
||
|
||
@property
|
||
def name(self):
|
||
"""Return name in current language"""
|
||
lang = get_language()
|
||
if lang == 'nl':
|
||
return self.name_nl
|
||
return self.name_fr
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
|
||
# ----------------------
|
||
# Road Sign Models
|
||
# ----------------------
|
||
|
||
|
||
class SignStreet(AbstractLocation):
|
||
geom = models.MultiPolygonField(srid=3812, null=True, blank=True)
|
||
municipality = models.ForeignKey(
|
||
Municipality, on_delete=models.SET_NULL, null=True, blank=True, related_name="sign_app_streets"
|
||
)
|
||
road_code = models.CharField(max_length=50, null=True, blank=True)
|
||
section_code = models.CharField(max_length=50, null=True, blank=True)
|
||
street_level = models.CharField(max_length=50, null=True, blank=True)
|
||
|
||
def get_active_assets(self):
|
||
from django.db.models import Q
|
||
if hasattr(self, 'signpoles'):
|
||
signpoles = self.signpoles.filter(status="active")
|
||
signpanels = self.signpanels.filter(status="active")
|
||
else:
|
||
poles_qs = SignPole.objects.filter(status="active")
|
||
panels_qs = SignPanel.objects.filter(status="active")
|
||
if self.geom:
|
||
signpoles = poles_qs.filter(Q(geom__intersects=self.geom) | Q(street__name_fr__iexact=self.name_fr)).distinct()
|
||
signpanels = panels_qs.filter(Q(geom__intersects=self.geom) | Q(street__name_fr__iexact=self.name_fr)).distinct()
|
||
else:
|
||
signpoles = poles_qs.filter(street__name_fr__iexact=self.name_fr)
|
||
signpanels = panels_qs.filter(street__name_fr__iexact=self.name_fr)
|
||
return {"signpoles": signpoles, "signpanels": signpanels}
|
||
|
||
def get_absolute_url(self):
|
||
return reverse("sign:sign_streets_detail", kwargs={"street_id": self.id})
|
||
|
||
|
||
def upload_to_signstreet(instance, filename):
|
||
location = instance.street
|
||
code = slugify(location.code or "unknown")
|
||
return os.path.join("sign_documents", "signstreet", code, filename)
|
||
|
||
|
||
SIGN_DOCUMENT_TYPE_CHOICES = (
|
||
("as_built", "As Built"),
|
||
("other", "Autre"),
|
||
)
|
||
|
||
|
||
class SignStreetDocument(Document):
|
||
street = models.ForeignKey(
|
||
SignStreet, on_delete=models.CASCADE, related_name="documents", verbose_name="Associated Street"
|
||
)
|
||
file = models.FileField(upload_to=upload_to_signstreet, storage=private_storage, verbose_name="Fichier", max_length=255)
|
||
thumbnail = models.ImageField(upload_to=upload_to_signstreet, storage=private_storage, null=True, blank=True, max_length=255)
|
||
document_type = models.CharField(max_length=50, choices=SIGN_DOCUMENT_TYPE_CHOICES, default="other", verbose_name="Type de document")
|
||
uploaded_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Ajouté par",
|
||
related_name="uploaded_sign_street_documents_sign",
|
||
)
|
||
organization = models.ForeignKey(
|
||
Organization, on_delete=models.PROTECT, null=True, blank=True, related_name="sign_street_documents"
|
||
)
|
||
|
||
|
||
class SignPoleModel(AbstractAssetModel):
|
||
category = models.ForeignKey(
|
||
'assets.AssetCategory',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='sign_app_pole_models'
|
||
)
|
||
color = models.CharField(max_length=200, null=True, blank=True)
|
||
ral = models.CharField(max_length=200, null=True, blank=True)
|
||
height = models.FloatField(null=True, blank=True)
|
||
diameter = models.FloatField(null=True, blank=True)
|
||
material = models.CharField(max_length=200, null=True, blank=True)
|
||
galvanized = models.BooleanField(default=False)
|
||
|
||
class Meta:
|
||
verbose_name = "Modèle de poteau (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):
|
||
category = models.ForeignKey(
|
||
'assets.AssetCategory',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='sign_app_poles'
|
||
)
|
||
model = models.ForeignKey(SignPoleModel, on_delete=models.PROTECT, null=True, blank=True)
|
||
street = models.ForeignKey("common.AdrStreet", related_name="sign_app_poles", on_delete=models.SET_NULL, null=True, blank=True)
|
||
organization = models.ForeignKey(
|
||
Organization, on_delete=models.PROTECT, null=True, blank=True, related_name="sign_app_poles"
|
||
)
|
||
orientation = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
default=90,
|
||
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:
|
||
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):
|
||
super().save(*args, **kwargs)
|
||
if self.geom and hasattr(self, 'sign_app_panels'):
|
||
for panel in self.sign_app_panels.all():
|
||
needs_save = False
|
||
if panel.geom != self.geom or panel.lon != self.lon or panel.lat != self.lat:
|
||
panel.geom = self.geom
|
||
panel.lon = self.lon
|
||
panel.lat = self.lat
|
||
panel.geojson = self.geojson
|
||
needs_save = True
|
||
if panel.street_id != self.street_id and self.street_id:
|
||
panel.street = self.street
|
||
needs_save = True
|
||
if needs_save:
|
||
panel.save()
|
||
|
||
def get_absolute_url(self):
|
||
return reverse("sign:sign_assets_detail", kwargs={"asset_model": "signpole", "asset_id": self.id})
|
||
|
||
|
||
class SignPanelModel(AbstractAssetModel):
|
||
category = models.ForeignKey(
|
||
'assets.AssetCategory',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='sign_app_panel_models'
|
||
)
|
||
shape = models.CharField(max_length=200, null=True, blank=True)
|
||
color = models.CharField(max_length=200, null=True, blank=True)
|
||
ral = models.CharField(max_length=200, null=True, blank=True)
|
||
height = models.FloatField(null=True, blank=True)
|
||
width = models.FloatField(null=True, blank=True)
|
||
diameter = models.FloatField(null=True, blank=True)
|
||
material = models.CharField(max_length=200, null=True, blank=True)
|
||
reflective = models.BooleanField(default=False)
|
||
illuminated = models.BooleanField(default=False)
|
||
dynamic = models.BooleanField(default=False)
|
||
|
||
class Meta:
|
||
verbose_name = "Modèle de panneau (Sign)"
|
||
verbose_name_plural = "Modèles de panneaux (Sign)"
|
||
|
||
|
||
class SignPanelType(models.Model):
|
||
code = models.CharField(max_length=100, unique=True)
|
||
name_fr = models.CharField(max_length=200)
|
||
name_nl = models.CharField(max_length=200)
|
||
description = models.TextField(null=True, blank=True)
|
||
sgv_picture = models.ImageField(null=True, blank=True, upload_to="sign/signpanels_types/")
|
||
default_width = models.FloatField(null=True, blank=True, verbose_name="Largeur par défaut (cm)")
|
||
default_height = models.FloatField(null=True, blank=True, verbose_name="Hauteur par défaut (cm)")
|
||
default_diameter = models.FloatField(null=True, blank=True, verbose_name="Diamètre par défaut (cm)")
|
||
|
||
def get_name(self):
|
||
lang = get_language()
|
||
return getattr(self, f"name_{lang}", self.name_fr) or ""
|
||
|
||
def __str__(self):
|
||
return f"{self.code} - {self.get_name()}"
|
||
|
||
|
||
class SignPanel(AbstractGeoAsset):
|
||
category = models.ForeignKey(
|
||
'assets.AssetCategory',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='sign_app_panels'
|
||
)
|
||
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)
|
||
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)
|
||
signpanel_text = models.CharField(max_length=1000, 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)")
|
||
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)
|
||
street = models.ForeignKey("common.AdrStreet", related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True)
|
||
organization = models.ForeignKey(
|
||
Organization, on_delete=models.PROTECT, null=True, blank=True, related_name="sign_app_panels"
|
||
)
|
||
width = models.FloatField(null=True, blank=True, verbose_name="Largeur du panneau (cm)", help_text="Largeur ou diamètre du panneau en centimètres. Par défaut: 50 cm")
|
||
height = models.FloatField(null=True, blank=True, verbose_name="Hauteur du panneau (cm)", help_text="Hauteur du panneau en centimètres. Par défaut: égal à la largeur")
|
||
|
||
class Meta:
|
||
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):
|
||
super().clean()
|
||
support = self.pole or self.trafficlight_pole
|
||
if support and getattr(support, 'geom', None):
|
||
self.geom = support.geom
|
||
self.lon = support.lon
|
||
self.lat = support.lat
|
||
self.geojson = support.geojson
|
||
if self.pole and self.pole.street and not self.street_id:
|
||
self.street = self.pole.street
|
||
|
||
def save(self, *args, **kwargs):
|
||
support = self.pole or self.trafficlight_pole
|
||
if support and getattr(support, 'geom', None):
|
||
self.geom = support.geom
|
||
self.lon = support.lon
|
||
self.lat = support.lat
|
||
self.geojson = support.geojson
|
||
if self.pole and self.pole.street and not self.street_id:
|
||
self.street = self.pole.street
|
||
super().save(*args, **kwargs)
|
||
|
||
def get_absolute_url(self):
|
||
return reverse("sign:sign_assets_detail", kwargs={"asset_model": "signpanel", "asset_id": self.id})
|
||
|
||
def get_width(self):
|
||
"""Retourne la largeur du panneau en cm (50 cm par défaut ou valeur par défaut du type de panneau)"""
|
||
if self.width:
|
||
return self.width
|
||
if self.signpanel_type:
|
||
if self.signpanel_type.default_width:
|
||
return self.signpanel_type.default_width
|
||
if self.signpanel_type.default_diameter:
|
||
return self.signpanel_type.default_diameter
|
||
return 50.0 # Valeur par défaut
|
||
|
||
def get_height(self):
|
||
"""Retourne la hauteur du panneau en cm (égale à la largeur par défaut ou valeur par défaut du type de panneau)"""
|
||
if self.height:
|
||
return self.height
|
||
if self.signpanel_type:
|
||
if self.signpanel_type.default_height:
|
||
return self.signpanel_type.default_height
|
||
if self.signpanel_type.default_diameter:
|
||
return self.signpanel_type.default_diameter
|
||
return self.get_width() # Hauteur = largeur par défaut
|
||
|
||
def get_scale_factor(self):
|
||
"""Retourne le facteur d'échelle par rapport à la taille de référence (50 cm)"""
|
||
return self.get_width() / 50.0
|
||
|
||
|
||
# ----------------------
|
||
# Regulation Article Models
|
||
# ----------------------
|
||
|
||
class RegulationArticle(models.Model):
|
||
"""
|
||
Article d'un règlement complémentaire de police.
|
||
Chargé depuis le référentiel RC (20200427 Référentiel RC.xlsx).
|
||
La structure est hiérarchique : un article peut avoir un parent.
|
||
|
||
Les articles avec regulation=None sont des articles "globaux" (référentiel).
|
||
Les articles avec regulation=<reg> sont des sous-articles spécifiques à un règlement
|
||
(créés automatiquement lors de la sélection de panneaux, un par rue/zone).
|
||
"""
|
||
article_id = models.CharField(
|
||
max_length=50,
|
||
verbose_name="Identifiant article",
|
||
help_text="Ex: Art.1.1.1",
|
||
db_index=True,
|
||
)
|
||
article_number = models.CharField(
|
||
max_length=20,
|
||
verbose_name="Numéro d'article",
|
||
blank=True
|
||
)
|
||
text_fr = models.TextField(verbose_name="Texte FR")
|
||
text_nl = models.TextField(verbose_name="Texte NL", blank=True)
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="children",
|
||
verbose_name="Article parent",
|
||
)
|
||
# Non-null = sous-article spécifique à ce règlement; null = article global du référentiel
|
||
regulation = models.ForeignKey(
|
||
"ComplementaryPoliceRegulation",
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name="custom_articles",
|
||
verbose_name="Règlement (sous-article spécifique)",
|
||
)
|
||
level = models.PositiveSmallIntegerField(default=1, verbose_name="Niveau")
|
||
ordering = models.PositiveIntegerField(default=0, verbose_name="Ordre", db_index=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Article de règlement"
|
||
verbose_name_plural = "Articles de règlement"
|
||
ordering = ["ordering", "article_id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["article_id"],
|
||
condition=models.Q(regulation=None),
|
||
name="sign_regulationarticle_global_unique_article_id",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.article_id} – {self.text_fr[:80]}"
|
||
|
||
@property
|
||
def text(self):
|
||
lang = get_language()
|
||
return self.text_nl if lang == "nl" and self.text_nl else self.text_fr
|
||
|
||
def get_ancestors(self):
|
||
"""Return list of ancestors from root to direct parent."""
|
||
ancestors = []
|
||
current = self.parent
|
||
while current is not None:
|
||
ancestors.insert(0, current)
|
||
current = current.parent
|
||
return ancestors
|
||
|
||
|
||
class SignPanelTypeArticle(models.Model):
|
||
"""
|
||
Liaison entre un type de panneau et les articles de règlement applicables.
|
||
Chargé depuis le référentiel panneaux (20210422 - SPRB - Référentiel panneaux.xlsm).
|
||
"""
|
||
panel_type = models.ForeignKey(
|
||
SignPanelType,
|
||
on_delete=models.CASCADE,
|
||
related_name="regulation_articles",
|
||
verbose_name="Type de panneau",
|
||
)
|
||
article = models.ForeignKey(
|
||
RegulationArticle,
|
||
on_delete=models.CASCADE,
|
||
related_name="panel_types",
|
||
verbose_name="Article de règlement",
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Article applicable à un type de panneau"
|
||
verbose_name_plural = "Articles applicables aux types de panneaux"
|
||
unique_together = [("panel_type", "article")]
|
||
ordering = ["panel_type__code", "article__ordering"]
|
||
|
||
def __str__(self):
|
||
return f"{self.panel_type.code} → {self.article.article_id}"
|
||
|
||
|
||
# ----------------------
|
||
# Police Regulation Document
|
||
# ----------------------
|
||
|
||
def upload_to_police_regulation(instance, filename):
|
||
code = slugify(instance.reference or instance.title or "regulation")
|
||
return os.path.join("sign_documents", "police_regulations", code, filename)
|
||
|
||
|
||
CPL_STATUS_CHOICES = {
|
||
'in_preparation': 'En préparation',
|
||
'to_be_approved': 'À approuver',
|
||
'approved': 'Approuvé',
|
||
'expired': 'Expiré',
|
||
'archived': 'Archivé',
|
||
}
|
||
|
||
CPL_STATUS_BADGES = {
|
||
'in_preparation': 'warning',
|
||
'to_be_approved': 'info',
|
||
'approved': 'success',
|
||
'expired': 'secondary',
|
||
'archived': 'dark',
|
||
}
|
||
|
||
class ComplementaryPoliceRegulation(models.Model):
|
||
title = models.CharField(max_length=255)
|
||
description = models.TextField(null=True, blank=True)
|
||
reference = models.CharField(max_length=100)
|
||
status = models.CharField(max_length=50, null=True, blank=True, choices=CPL_STATUS_CHOICES.items(), default='in_preparation')
|
||
decision_date = models.DateField(null=True, blank=True)
|
||
file = models.FileField(upload_to=upload_to_police_regulation, storage=private_storage, verbose_name="Fichier", max_length=255)
|
||
thumbnail = models.ImageField(upload_to=upload_to_police_regulation, storage=private_storage, null=True, blank=True, max_length=255)
|
||
panels = models.ManyToManyField(SignPanel, related_name="sign_app_regulations", blank=True)
|
||
organization = models.ForeignKey(
|
||
Organization, on_delete=models.PROTECT, null=True, blank=True, related_name="sign_app_regulations"
|
||
)
|
||
# Editable text sections for the regulation document (bilingual FR/NL)
|
||
text_introduction_fr = models.TextField(null=True, blank=True, verbose_name="Introduction (FR)")
|
||
text_introduction_nl = models.TextField(null=True, blank=True, verbose_name="Introduction (NL)")
|
||
text_abrogations_fr = models.TextField(null=True, blank=True, verbose_name="Abrogations (FR)")
|
||
text_abrogations_nl = models.TextField(null=True, blank=True, verbose_name="Abrogations (NL)")
|
||
text_dispositions_finales_fr = models.TextField(null=True, blank=True, verbose_name="Dispositions finales (FR)")
|
||
text_dispositions_finales_nl = models.TextField(null=True, blank=True, verbose_name="Dispositions finales (NL)")
|
||
# Supplementary free-text for "Nouvelles Dispositions" (after selected articles)
|
||
text_nouvelles_dispositions_fr = models.TextField(null=True, blank=True, verbose_name="Nouvelles dispositions – notes (FR)")
|
||
text_nouvelles_dispositions_nl = models.TextField(null=True, blank=True, verbose_name="Nouvelles dispositions – notes (NL)")
|
||
# Articles selected for "Nouvelles Dispositions" (ordered by RegulationArticle.ordering)
|
||
selected_articles = models.ManyToManyField(
|
||
RegulationArticle, blank=True, related_name='regulations', verbose_name="Articles sélectionnés"
|
||
)
|
||
# Articles selected for "Abrogations" (ordered by RegulationArticle.ordering)
|
||
selected_abrogation_articles = models.ManyToManyField(
|
||
RegulationArticle, blank=True, related_name='abrogation_article_regulations', verbose_name="Articles abrogés sélectionnés"
|
||
)
|
||
# Panels marked as abrogated (removed) in this regulation
|
||
abrogated_panels = models.ManyToManyField(
|
||
'SignPanel', blank=True, related_name='abrogation_regulations', verbose_name="Panneaux abrogés"
|
||
)
|
||
uploaded_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Ajouté par",
|
||
related_name="uploaded_complementary_police_regulations_sign",
|
||
)
|
||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ["-decision_date", "reference", "title"]
|
||
|
||
def __str__(self):
|
||
ref = f"{self.reference} - " if self.reference else ""
|
||
return f"{ref}{self.title}"
|
||
|
||
@property
|
||
def status_label(self):
|
||
return CPL_STATUS_CHOICES.get(self.status, self.status or "")
|
||
|
||
@property
|
||
def status_badge_class(self):
|
||
return CPL_STATUS_BADGES.get(self.status, "secondary")
|
||
|
||
|
||
VOIRIE_SECTION_CHOICES = [
|
||
('nouvelle_disposition', 'Nouvelle disposition'),
|
||
('abrogation', 'Abrogation'),
|
||
]
|
||
|
||
|
||
class RegulationArticleVoirie(models.Model):
|
||
"""
|
||
Voirie (rue / tronçon) associée à un article dans le cadre d'un règlement
|
||
complémentaire de police précis. Bilingue FR/NL.
|
||
"""
|
||
regulation = models.ForeignKey(
|
||
ComplementaryPoliceRegulation,
|
||
on_delete=models.CASCADE,
|
||
related_name='article_voiries',
|
||
verbose_name="Règlement",
|
||
)
|
||
article = models.ForeignKey(
|
||
RegulationArticle,
|
||
on_delete=models.CASCADE,
|
||
related_name='voiries',
|
||
verbose_name="Article",
|
||
)
|
||
section = models.CharField(
|
||
max_length=25,
|
||
choices=VOIRIE_SECTION_CHOICES,
|
||
default='nouvelle_disposition',
|
||
verbose_name="Section",
|
||
)
|
||
text_fr = models.TextField(verbose_name="Description voirie (FR)", blank=True)
|
||
text_nl = models.TextField(verbose_name="Description voirie (NL)", blank=True)
|
||
ordering = models.PositiveIntegerField(default=0, db_index=True, verbose_name="Ordre")
|
||
|
||
class Meta:
|
||
ordering = ['ordering', 'id']
|
||
verbose_name = "Voirie d'article de règlement"
|
||
verbose_name_plural = "Voiries d'articles de règlement"
|
||
|
||
def __str__(self):
|
||
return f"{self.regulation} / {self.article.article_id} [{self.section}] – {self.text_fr[:60]}"
|
||
|
||
|
||
# ----------------------
|
||
# Measure ↔ Panel associations
|
||
# ----------------------
|
||
|
||
class RegulationMeasurePanel(models.Model):
|
||
"""
|
||
Association entre un article (mesure) sélectionné dans un RCP et un ou plusieurs
|
||
panneaux signalétiques.
|
||
|
||
Permet deux flux :
|
||
- Sélectionner d'abord la mesure (article) puis choisir les panneaux,
|
||
- Sélectionner d'abord les panneaux puis générer/choisir la mesure applicable.
|
||
|
||
Un enregistrement par (regulation, article, section, panel).
|
||
"""
|
||
|
||
SECTION_CHOICES = [
|
||
('nouvelle_disposition', 'Nouvelle disposition'),
|
||
('abrogation', 'Abrogation'),
|
||
]
|
||
|
||
regulation = models.ForeignKey(
|
||
ComplementaryPoliceRegulation,
|
||
on_delete=models.CASCADE,
|
||
related_name='measure_panels',
|
||
verbose_name="Règlement",
|
||
)
|
||
article = models.ForeignKey(
|
||
RegulationArticle,
|
||
on_delete=models.CASCADE,
|
||
related_name='measure_panels',
|
||
verbose_name="Article / Mesure",
|
||
)
|
||
section = models.CharField(
|
||
max_length=25,
|
||
choices=SECTION_CHOICES,
|
||
default='nouvelle_disposition',
|
||
verbose_name="Section",
|
||
)
|
||
panel = models.ForeignKey(
|
||
SignPanel,
|
||
on_delete=models.CASCADE,
|
||
related_name='regulation_measures',
|
||
verbose_name="Panneau",
|
||
)
|
||
|
||
class Meta:
|
||
unique_together = [('regulation', 'article', 'section', 'panel')]
|
||
verbose_name = "Panneau d'une mesure de règlement"
|
||
verbose_name_plural = "Panneaux de mesures de règlement"
|
||
ordering = ['regulation', 'article__ordering', 'panel__code']
|
||
|
||
def __str__(self):
|
||
return (
|
||
f"{self.regulation} / {self.article.article_id}"
|
||
f" [{self.section}] → {self.panel.code}"
|
||
)
|