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): signpoles = self.signpoles.filter(status="active") signpanels = self.signpanels.filter(status="active") 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)" 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)" ) class Meta: indexes = [models.Index(fields=["code"])] 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()}" 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): 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) vertical_order = models.IntegerField(null=True, blank=True) signpanel_text = models.CharField(max_length=1000, null=True, blank=True, choices=SUPPORT_TYPE_CHOICES) 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) 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"])] 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= 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}" )