# ----------------------------- # Outdoor Spaces & Assets Models # ----------------------------- from django.contrib.gis.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from assets.models import AbstractLocation, AbstractGeoAsset, ASSET_STATUS_CHOICES from common.models import Document from common.storages import PrivateMediaStorage private_storage = PrivateMediaStorage() SITE_TYPE_CHOICES = ( ('playground', _('Aire / Plaine de jeux')), ('sports_ground', _('Terrain de sport extérieur')), ('multisports_agora', _('Terrain multisports / City-stadium')), ('skatepark_pumptrack', _('Skatepark / Pumptrack / Bowl')), ('outdoor_fitness', _('Fitness plein air / Street Workout')), ('open_air_theater', _('Théâtre de verdure / Scène extérieure')), ('park_recreation', _('Parc & Espace récréatif')), ('dog_park', _('Espace canin / Canisite')), ('cemetery', _('Cimetière communal')), ('event_plaza', _('Place publique / Zone événementielle')), ('other', _('Autre espace plein air')), ) OUTDOOR_ASSET_TYPE_CHOICES = ( ('play_structure', _('Structure combinée / Tour de jeu')), ('swing', _('Balançoire / Portique')), ('spring_rider', _('Jeu à ressort / Bascule')), ('slide', _('Toboggan')), ('zip_line', _('Tyrolienne')), ('amortizing_surface', _('Sol amortissant / Réception')), ('sports_goal', _('But / Cage / Panneau de basket')), ('fitness_station', _('Agrès de fitness outdoor / Workout')), ('skate_module', _('Module de skate / Rampe / Rail')), ('stage_platform', _('Scène extérieure / Podium')), ('audience_seating', _('Gradins / Bancs de spectateurs')), ('event_power_box', _('Borne foraine / Armoire électrique événementielle')), ('outdoor_lighting', _('Éclairage plein air / Projecteur scénique')), ('bench_table', _('Banc / Table de pique-nique')), ('shade_shelter', _('Kiosque / Voile d\'ombrage / Abri')), ('drinking_fountain', _('Fontaine d\'eau potable')), ('fence_gate', _('Clôture / Portillon de sécurité')), ('info_panel', _('Panneau d\'information / Consignes')), ('other', _('Autre équipement plein air')), ) NORM_CHOICES = ( ('EN_1176', _('EN 1176 - Équipements & sols d\'aires de jeux')), ('EN_16630', _('EN 16630 - Modules de fitness d\'extérieur')), ('EN_14974', _('EN 14974 - Équipements de skatepark')), ('EN_15312', _('EN 15312 - Équipements multisports en accès libre')), ('other', _('Autre norme / Non spécifié')), ) class OutdoorLocation(AbstractLocation): """ Modèle représentant un Espace Public Extérieur (Plaine de jeux, Terrain de sport, Théâtre plein air, Parc, etc.). Peut comporter une arborescence (ex: Parc municipal > Aire de jeux > Zone toboggan). """ geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique")) parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations", verbose_name=_("Localisation parente")) site_type = models.CharField(max_length=50, choices=SITE_TYPE_CHOICES, default='playground', verbose_name=_("Type de site plein air")) is_main_location = models.BooleanField(default=False, verbose_name=_("Localisation principale")) address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse complète")) description = models.TextField(null=True, blank=True, verbose_name=_("Description")) # Propriété & Gestion owner = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Propriétaire")) manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_outdoor_locations", verbose_name=_("Gestionnaire référent")) occupant_entity = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Direction / Service gestionnaire")) # Caractéristiques physiques & aménagements surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface totale (m²)")) max_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Jauge / Capacité d'accueil")) pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR")) has_water_point = models.BooleanField(default=False, verbose_name=_("Point d'eau potable")) has_power_supply = models.BooleanField(default=False, verbose_name=_("Alimentation électrique disponible")) is_fenced = models.BooleanField(default=False, verbose_name=_("Site clôturé / Sécurisé")) class Meta: verbose_name = _("Espace public d'extérieur") verbose_name_plural = _("Espaces publics d'extérieur") ordering = ['code', 'name_fr'] def get_child_locations(self): return self.child_locations.all() def get_active_assets(self): def collect_assets(loc): assets = list(loc.location_assets.filter(status='active')) for child in loc.child_locations.all(): assets += collect_assets(child) return assets return collect_assets(self) def get_all_assets(self): def collect_assets(loc): if loc.parent_location is None: assets = list(loc.location_assets.select_related('location', 'parent_asset', 'site').all() | loc.all_outdoor_assets.select_related('location', 'parent_asset', 'site').filter(location__isnull=True)) else: assets = list(loc.location_assets.select_related('location', 'parent_asset', 'site').all()) for child in loc.child_locations.all(): assets += collect_assets(child) return assets seen = set() unique_assets = [] for a in collect_assets(self): if a.id not in seen: seen.add(a.id) unique_assets.append(a) return unique_assets def get_direct_child_assets(self): return self.location_assets.all() def get_absolute_url(self): return reverse('assets:outdoor_location_detail', kwargs={'pk': self.pk}) class OutdoorAsset(AbstractGeoAsset): """ Modèle représentant un asset ou équipement physique d'extérieur (Structure de jeu, Sol amortissant, Agrès, Cage, Scène...). Les assets peuvent former une arborescence parent-enfant. """ site = models.ForeignKey(OutdoorLocation, on_delete=models.CASCADE, related_name="all_outdoor_assets", verbose_name=_("Site / Espace principal")) location = models.ForeignKey(OutdoorLocation, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique")) parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent")) asset_type = models.CharField(max_length=50, choices=OUTDOOR_ASSET_TYPE_CHOICES, default='play_structure', verbose_name=_("Type d'asset / Équipement")) # Norme & Sécurité réglementaire norm = models.CharField(max_length=50, choices=NORM_CHOICES, blank=True, null=True, verbose_name=_("Norme de sécurité appliquée")) last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle visuel/opérationnel")) next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle requis")) last_annual_inspection = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle annuel certifié")) next_annual_inspection = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle annuel certifié")) certifying_body = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Organisme de contrôle certifié")) # Sols amortissants & hauteurs d'impact impact_surface_type = models.CharField(max_length=150, blank=True, null=True, verbose_name=_("Type de sol d'amortissement")) critical_fall_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur critique de chute (m)")) class Meta: verbose_name = _("Asset / Équipement plein air") verbose_name_plural = _("Assets / Équipements plein air") ordering = ['site', 'code'] def get_absolute_url(self): return reverse('assets:outdoor_assets_detail', kwargs={'asset_model': 'outdoorasset', 'asset_id': self.pk}) def get_child_assets(self): return self.child_assets.all() def upload_to_outdoor(instance, filename): return f"outdoor/{instance.site.code}/{filename}" class OutdoorLocationDocument(Document): """ Documents associés à un espace extérieur (certificats de conformité, registres de sécurité, plans). """ site = models.ForeignKey(OutdoorLocation, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Espace extérieur")) file = models.FileField(upload_to=upload_to_outdoor, storage=private_storage, verbose_name=_("Fichier"), max_length=255) document_type = models.CharField(max_length=50, default='other', verbose_name=_("Type de document")) class Meta: verbose_name = _("Document d'espace extérieur") verbose_name_plural = _("Documents d'espace extérieur")