loko/loko/sign/models.py

661 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}"
)