from django.contrib.gis import admin from django.contrib.contenttypes.admin import GenericTabularInline from django import forms from documents.models import DocumentFolder, DocumentFolderAttachment from .models import TrafficLightIntersection, TrafficLightIntersectionDocument from .models import TrafficLightPoleModel, TrafficLightPole, TrafficLightCableModel, TrafficLightCable, TrafficLightElectricalCabinet, TrafficLightElectricalCabinetModel from .models import TrafficLightLanternModel, TrafficLightLantern, TrafficLightControllerModel, TrafficLightController, TrafficLightDetector, TrafficLightDetectorModel from .models import TrafficLightContract, TrafficLightRadarModel, TrafficLightRadar from .models import ( TrafficLightRadarPoleModel, TrafficLightRadarPole, TrafficLightElectricalCabinetContentModel, TrafficLightElectricalCabinetContent, TrafficLightAccessoryModel, TrafficLightAccessory, TrafficLightProgramming, TrafficLightControllerHardware, TrafficLightControllerLogFile, TrafficLightNetworkHardware, TrafficLightVoltageHardware ) from .models import SignStreet from .models import AssetCategory, AssetHistory from .models import GreenSurfacesComment, GreenSurfacesCommentPhoto from .models import CleanGlassContainer, CleanGlassContainerModel, CleanLitterBin, CleanLitterBinModel, CleanLocation, CleanLocationContract class AssetAdmin(admin.ModelAdmin): def get_search_fields(self, request): search_fields = list(super().get_search_fields(request) or []) model_fields = {f.name for f in self.model._meta.get_fields()} for field in ["code", "name_fr", "name_nl"]: if field in model_fields and field not in search_fields: search_fields.append(field) return search_fields def get_list_filter(self, request): list_filter = list(super().get_list_filter(request) or []) model_fields = {f.name for f in self.model._meta.get_fields()} if "model" in model_fields and "model" not in list_filter: list_filter.append("model") return list_filter class AssetGISAdmin(admin.GISModelAdmin): gis_widget_kwargs = { 'attrs': { "default_lon" : 4.3517, # Bruxelles "default_lat" : 50.8503, "default_zoom" : 12 }, } def get_search_fields(self, request): search_fields = list(super().get_search_fields(request) or []) model_fields = {f.name for f in self.model._meta.get_fields()} for field in ["code", "name_fr", "name_nl"]: if field in model_fields and field not in search_fields: search_fields.append(field) return search_fields def get_list_filter(self, request): list_filter = list(super().get_list_filter(request) or []) model_fields = {f.name for f in self.model._meta.get_fields()} if "model" in model_fields and "model" not in list_filter: list_filter.append("model") return list_filter @admin.register(TrafficLightContract) class TrafficLightContractAdmin(AssetAdmin): search_fields = ("intersection__code", "contract__contract_number") list_display = ("intersection", "contract") list_filter = ("contract",) class TrafficLightContractAdminInline(admin.TabularInline): model = TrafficLightContract extra = 1 @admin.register(TrafficLightIntersection) class TrafficLightIntersectionAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") list_display = ("code", "name_fr", "municipality") list_filter = ("municipality",) inlines = [TrafficLightContractAdminInline,] admin.site.register(TrafficLightIntersectionDocument, AssetAdmin) admin.site.register(TrafficLightPoleModel, AssetAdmin) admin.site.register(TrafficLightPole, AssetAdmin) admin.site.register(TrafficLightCableModel, AssetAdmin) admin.site.register(TrafficLightCable, AssetAdmin) admin.site.register(TrafficLightLanternModel, AssetAdmin) admin.site.register(TrafficLightLantern, AssetAdmin) admin.site.register(TrafficLightControllerModel, AssetAdmin) admin.site.register(TrafficLightDetector, AssetAdmin) admin.site.register(TrafficLightDetectorModel, AssetAdmin) admin.site.register(TrafficLightElectricalCabinetModel, AssetAdmin) admin.site.register(TrafficLightElectricalCabinet, AssetAdmin) admin.site.register(TrafficLightRadarModel, AssetAdmin) admin.site.register(TrafficLightRadar, AssetAdmin) admin.site.register(TrafficLightRadarPoleModel, AssetAdmin) admin.site.register(TrafficLightRadarPole, AssetAdmin) admin.site.register(TrafficLightElectricalCabinetContentModel, AssetAdmin) admin.site.register(TrafficLightElectricalCabinetContent, AssetAdmin) admin.site.register(TrafficLightAccessoryModel, AssetAdmin) admin.site.register(TrafficLightAccessory, AssetAdmin) admin.site.register(TrafficLightProgramming, AssetAdmin) admin.site.register(TrafficLightControllerHardware, AssetAdmin) admin.site.register(TrafficLightControllerLogFile, AssetAdmin) admin.site.register(TrafficLightNetworkHardware, AssetAdmin) admin.site.register(TrafficLightVoltageHardware, AssetAdmin) @admin.register(TrafficLightController) class TrafficLightControllerAdmin(AssetAdmin): search_fields = ("intersection__code",) list_display = ("intersection", "connected_to_control_center", "controller_maintenance_contract") list_filter = ("connected_to_control_center", "controller_maintenance_contract") @admin.register(AssetCategory) class AssetCategoryAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") list_display = ("code", "get_name", "thematic", "parent") list_filter = ("thematic", "parent") autocomplete_fields = ("parent",) filter_horizontal = ("allowed_models",) change_list_template = "admin/assets/assetcategory/change_list.html" def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) if "parent" in form.base_fields: # Limiter les choix du parent aux catégories sans parent (1 niveau max) form.base_fields["parent"].queryset = AssetCategory.objects.filter(parent__isnull=True) return form def get_urls(self): from django.urls import path as urlpath urls = super().get_urls() custom_urls = [ urlpath( "import-excel/", self.admin_site.admin_view(self.import_excel_view), name="assets_assetcategory_import_excel", ), urlpath( "download-template/", self.admin_site.admin_view(self.download_template_view), name="assets_assetcategory_download_template", ), ] return custom_urls + urls # ------------------------------------------------------------------ def import_excel_view(self, request): import io import tempfile import os from django.contrib import messages from django.shortcuts import render, redirect from django.urls import reverse from assets.management.commands.import_assets_from_excel import Command as ImportCommand from assets.management.commands.import_assets_from_excel import COLUMNS, COLUMN_HINTS column_guide = [ {"key": key, "desc": COLUMN_HINTS.get(key, "")} for key, _label in COLUMNS ] context = { **self.admin_site.each_context(request), "title": "Import d'assets depuis Excel", "opts": self.model._meta, "column_guide": column_guide, } if request.method == "POST": uploaded = request.FILES.get("excel_file") if not uploaded: messages.error(request, "Aucun fichier sélectionné.") return render(request, "admin/assets/assetcategory/import_excel.html", context) if not uploaded.name.endswith(".xlsx"): messages.error(request, "Seuls les fichiers .xlsx sont acceptés.") return render(request, "admin/assets/assetcategory/import_excel.html", context) dry_run = "dry_run" in request.POST update = "update" in request.POST # Save file to a temp path so the command can open it suffix = ".xlsx" with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: for chunk in uploaded.chunks(): tmp.write(chunk) tmp_path = tmp.name cmd = ImportCommand() # Capture stdout / stderr via StringIO-compatible wrapper class _Writer: def __init__(self): self.lines = [] def write(self, msg): self.lines.append(str(msg)) def flush(self): pass out = _Writer() err = _Writer() cmd.stdout = out cmd.stderr = err import_error = None try: cmd._import(tmp_path, dry_run=dry_run, update=update) except Exception as e: import_error = str(e) finally: try: os.unlink(tmp_path) except OSError: pass output_lines = out.lines + err.lines context["output_lines"] = output_lines context["dry_run"] = dry_run context["import_error"] = import_error if import_error: messages.error(request, f"Erreur lors de l'import : {import_error}") elif dry_run: messages.warning(request, "Dry-run terminé – aucune donnée modifiée.") else: messages.success(request, "Import terminé avec succès.") return render(request, "admin/assets/assetcategory/import_excel.html", context) return render(request, "admin/assets/assetcategory/import_excel.html", context) # ------------------------------------------------------------------ def download_template_view(self, request): import io from django.http import HttpResponse from assets.management.commands.import_assets_from_excel import generate_template buf = io.BytesIO() # generate_template expects a file path, so use a temp file import tempfile import os with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: tmp_path = tmp.name try: generate_template(tmp_path) with open(tmp_path, "rb") as f: data = f.read() finally: try: os.unlink(tmp_path) except OSError: pass response = HttpResponse( data, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) response["Content-Disposition"] = 'attachment; filename="assets_import_template.xlsx"' return response @admin.register(AssetHistory) class AssetHistoryAdmin(AssetAdmin): list_display = ("timestamp", "content_type", "object_id", "user", "field_name", "action_type") list_filter = ("action_type", "content_type", "timestamp") search_fields = ("user__username", "field_name", "object_id") readonly_fields = ("content_type", "object_id", "user", "timestamp", "field_name", "old_value", "new_value", "action_type") date_hierarchy = "timestamp" def has_add_permission(self, request): # Prevent manual creation return False def has_change_permission(self, request, obj=None): # Read-only return False # ============================================================================ # Public Lighting # ============================================================================ from .models import ( PublicLightingStreet, PublicLightingStreetDocument, PublicLightingPoleModel, PublicLightingPole, PublicLightingLightFixtureModel, PublicLightingLightFixture, PublicLightingElectricalCabinetModel, PublicLightingElectricalCabinet ) admin.site.register(PublicLightingStreet, AssetAdmin) admin.site.register(PublicLightingStreetDocument, AssetAdmin) admin.site.register(PublicLightingPoleModel, AssetAdmin) admin.site.register(PublicLightingPole, AssetAdmin) admin.site.register(PublicLightingLightFixtureModel, AssetAdmin) admin.site.register(PublicLightingLightFixture, AssetAdmin) admin.site.register(PublicLightingElectricalCabinetModel, AssetAdmin) admin.site.register(PublicLightingElectricalCabinet, AssetAdmin) # ============================================================================ # Artworks # ============================================================================ from .models import ( ArtworkStreet, ArtworkMonument, ArtworkFountain, ArtworkFountainDocument, ArtworkMonumentDocument ) admin.site.register(ArtworkStreet, AssetAdmin) admin.site.register(ArtworkMonument, AssetAdmin) admin.site.register(ArtworkFountain, AssetAdmin) admin.site.register(ArtworkFountainDocument, AssetAdmin) admin.site.register(ArtworkMonumentDocument, AssetAdmin) from .models import Structure, StructureLocation, StructureDocument, StructureAssetModel, StructureGeoAsset, StructureContract def make_folder_attachment_inline(thematic_code): """ Factory pour créer un inline DocumentFolderAttachment filtré par thématique. Args: thematic_code: Le code de la thématique (ex: 'structures', 'control_centers') """ class FolderAttachmentForm(forms.ModelForm): class Meta: model = DocumentFolderAttachment fields = ['folder', 'is_default'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Filtrer les dossiers par thématique self.fields['folder'].queryset = DocumentFolder.objects.filter( thematics__code=thematic_code ).distinct().order_by('name') class DocumentFolderAttachmentInline(GenericTabularInline): model = DocumentFolderAttachment form = FolderAttachmentForm extra = 1 fields = ('folder', 'is_default') verbose_name = "Dossier documentaire" verbose_name_plural = "Dossiers documentaires" return DocumentFolderAttachmentInline # Inlines spécifiques par thématique StructureFolderAttachmentInline = make_folder_attachment_inline('structures') ControlCenterFolderAttachmentInline = make_folder_attachment_inline('control_centers') TrafficLightFolderAttachmentInline = make_folder_attachment_inline('traffic_lights') @admin.register(Structure) class StructureAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") list_display = ("code", "name_fr", "structure_type") list_filter = ("structure_type",) inlines = [StructureFolderAttachmentInline] @admin.register(StructureLocation) class StructureLocationAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") admin.site.register(StructureDocument, AssetAdmin) admin.site.register(StructureAssetModel, AssetAdmin) admin.site.register(StructureGeoAsset, AssetAdmin) admin.site.register(StructureContract, AssetAdmin) from .models import ControlCenter, ControlCenterLocation, ControlCenterDocument, ControlCenterAssetModel, ControlCenterGeoAsset, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument @admin.register(ControlCenter) class ControlCenterAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") inlines = [ControlCenterFolderAttachmentInline] @admin.register(ControlCenterLocation) class ControlCenterLocationAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") admin.site.register(ControlCenterDocument, AssetAdmin) admin.site.register(ControlCenterAssetModel, AssetAdmin) admin.site.register(ControlCenterGeoAsset, AssetAdmin) admin.site.register(ControlCenterContract, AssetAdmin) admin.site.register(ControlCenterGeoAssetContract, AssetAdmin) admin.site.register(ControlCenterAssetDocument, AssetAdmin) @admin.register(SignStreet) class SignStreetAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") list_display = ("code", "name_fr", "name_nl", "municipality") list_filter = ("municipality",) # ============================================================================ # Sign # ============================================================================ from .models import ( SignStreetDocument, SignPoleModel, SignPole, SignPanelModel, SignPanelType, SignPanel, ComplementaryPoliceRegulation, SignParkingSpot ) admin.site.register(SignStreetDocument, AssetAdmin) admin.site.register(SignPoleModel, AssetAdmin) admin.site.register(SignPole, AssetAdmin) admin.site.register(SignPanelModel, AssetAdmin) admin.site.register(SignPanelType, AssetAdmin) admin.site.register(SignPanel, AssetAdmin) admin.site.register(ComplementaryPoliceRegulation, AssetAdmin) admin.site.register(SignParkingSpot, AssetAdmin) from .models import ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract admin.site.register(ITSAssetModel, AssetAdmin) @admin.register(ITSGeoAsset) class ITSGeoAssetAdmin(AssetAdmin): search_fields = ("code", "model__name_fr", "location__code") list_display = ("code", "model", "location") list_filter = ("model", "location") @admin.register(ITSGeoAssetContract) class ITSGeoAssetContractAdmin(AssetAdmin): search_fields = ("asset__code", "contract__contract_number") list_display = ("asset", "contract") list_filter = ("contract",) from .models import ITSLocation, ITSLocationDocument, ITSLocationContract admin.site.register(ITSLocation, AssetAdmin) admin.site.register(ITSLocationDocument, AssetAdmin) admin.site.register(ITSLocationContract, AssetAdmin) from .models import NatureAssetModel, NatureRWIAsset, NatureRWIAssetDocument from .models import ( NatureLocation, NatureGreenSurface, NatureGreenSurfaceDocument, NatureTree, NatureShrub, NatureBench, NatureRWIZ ) admin.site.register(NatureAssetModel, AssetAdmin) admin.site.register(NatureLocation, AssetAdmin) admin.site.register(NatureGreenSurface, AssetAdmin) admin.site.register(NatureGreenSurfaceDocument, AssetAdmin) admin.site.register(NatureTree, AssetAdmin) admin.site.register(NatureShrub, AssetAdmin) admin.site.register(NatureBench, AssetAdmin) admin.site.register(NatureRWIZ, AssetAdmin) class CustomNatureGeoAdmin(AssetGISAdmin): gis_widget_kwargs = { 'attrs': { "default_lon" : 4.3517, # Bruxelles "default_lat" : 50.8503, "default_zoom" : 12 }, } @admin.register(NatureRWIAsset) class NatureRWIAssetAdmin(CustomNatureGeoAdmin): list_display = ("code", "code_name", "structure_type", "geometry_type", "project_manager", "condition") list_filter = ("structure_type", "geometry_type", "project_manager", "condition", "construction_status") search_fields = ("code", "external_id", "administrator", "comment") change_list_template = "admin/assets/naturerwiasset/change_list.html" def get_fields(self, request, obj=None): fields = list(super().get_fields(request, obj)) if obj and obj.geometry_type != 'point': if 'has_filter' in fields: fields.remove('has_filter') return fields def get_urls(self): from django.urls import path as urlpath urls = super().get_urls() custom_urls = [ urlpath( "import-rwi/", self.admin_site.admin_view(self.import_rwi_view), name="assets_naturerwiasset_import_rwi", ), ] return custom_urls + urls def import_rwi_view(self, request): import io import os import tempfile from django.core.management import call_command from django.contrib import messages from django.shortcuts import render context = { **self.admin_site.each_context(request), "title": "Importation du GeoPackage RWI", "opts": self.model._meta, } if request.method == "POST": dry_run = "dry_run" in request.POST gpkg_file = request.FILES.get("gpkg_file") if not gpkg_file: messages.error(request, "Aucun fichier GeoPackage sélectionné.") return render(request, "admin/assets/naturerwiasset/import_rwi.html", context) if not gpkg_file.name.endswith(".gpkg"): messages.error(request, "Seuls les fichiers .gpkg sont acceptés.") return render(request, "admin/assets/naturerwiasset/import_rwi.html", context) # Save uploaded file to a temporary file with tempfile.NamedTemporaryFile(suffix=".gpkg", delete=False) as tmp: for chunk in gpkg_file.chunks(): tmp.write(chunk) tmp_path = tmp.name stdout_stream = io.StringIO() stderr_stream = io.StringIO() import_error = None try: if dry_run: call_command("import_rwi", tmp_path, "--dry-run", stdout=stdout_stream, stderr=stderr_stream) else: call_command("import_rwi", tmp_path, stdout=stdout_stream, stderr=stderr_stream) except Exception as e: import_error = str(e) finally: try: os.unlink(tmp_path) except OSError: pass output_lines = (stdout_stream.getvalue() + stderr_stream.getvalue()).split("\n") context["output_lines"] = output_lines context["dry_run"] = dry_run context["import_error"] = import_error if import_error: messages.error(request, f"Erreur lors de l'importation : {import_error}") elif dry_run: messages.warning(request, "Simulation d'importation (dry-run) terminée – aucune donnée n'a été modifiée.") else: messages.success(request, "Importation du GeoPackage RWI terminée avec succès !") return render(request, "admin/assets/naturerwiasset/import_rwi.html", context) return render(request, "admin/assets/naturerwiasset/import_rwi.html", context) admin.site.register(NatureRWIAssetDocument, AssetAdmin) # ============================================================================ # Green Surfaces Comments Admin # ============================================================================ class GreenSurfacesCommentPhotoInline(admin.TabularInline): model = GreenSurfacesCommentPhoto extra = 1 fields = ('photo', 'heading', 'order') readonly_fields = ('created_at',) @admin.register(GreenSurfacesComment) class GreenSurfacesCommentAdmin(AssetAdmin): list_display = ('id', 'comment_preview', 'created_by', 'created_at', 'address') list_filter = ('created_at', 'created_by') search_fields = ('comment', 'address', 'created_by__username') readonly_fields = ('created_at', 'updated_at', 'geom') inlines = [GreenSurfacesCommentPhotoInline] fieldsets = ( ('Commentaire', { 'fields': ('comment', 'created_by') }), ('Localisation', { 'fields': ('latitude', 'longitude', 'geom', 'address') }), ('Métadonnées', { 'fields': ('created_at', 'updated_at') }), ) def comment_preview(self, obj): return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment comment_preview.short_description = 'Commentaire' @admin.register(GreenSurfacesCommentPhoto) class GreenSurfacesCommentPhotoAdmin(AssetAdmin): list_display = ('id', 'comment', 'heading', 'order', 'created_at') list_filter = ('created_at',) search_fields = ('comment__comment',) readonly_fields = ('created_at',) # ============================================================================ # Roads # ============================================================================ from .models import ( RoadStreet, RoadStreetDocument, RoadwayModel, Roadway, RoadSidewalkModel, RoadSidewalk, RoadFurnitureModel, RoadFurniture, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain ) admin.site.register(RoadStreet, AssetAdmin) admin.site.register(RoadStreetDocument, AssetAdmin) admin.site.register(RoadwayModel, AssetAdmin) admin.site.register(Roadway, AssetAdmin) admin.site.register(RoadSidewalkModel, AssetAdmin) admin.site.register(RoadSidewalk, AssetAdmin) admin.site.register(RoadFurnitureModel, AssetAdmin) admin.site.register(RoadFurniture, AssetAdmin) admin.site.register(RoadwayFunction, AssetAdmin) admin.site.register(RoadConcreteBlockSet, AssetAdmin) admin.site.register(RoadDrainModel, AssetAdmin) admin.site.register(RoadDrain, AssetAdmin) @admin.register(CleanLocation) class CleanLocationAdmin(AssetAdmin): search_fields = ("code", "name_fr", "name_nl") list_display = ("code", "name_fr", "name_nl", "municipality") list_filter = ("municipality",) admin.site.register(CleanLitterBinModel, AssetAdmin) admin.site.register(CleanLitterBin, AssetAdmin) admin.site.register(CleanGlassContainerModel, AssetAdmin) admin.site.register(CleanGlassContainer, AssetAdmin) admin.site.register(CleanLocationContract, AssetAdmin) from .models import CleanLitterBinPhoto, CleanLitterBinObservation from .models import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument admin.site.register(CleanLitterBinPhoto, AssetAdmin) admin.site.register(CleanLitterBinObservation, AssetAdmin) @admin.register(PublicBuilding) class PublicBuildingAdmin(admin.GISModelAdmin): list_display = ("code", "name_fr", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating") list_filter = ("usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") search_fields = ("code", "name_fr", "name_nl", "address", "owner") admin.site.register(BuildingLevel, AssetAdmin) admin.site.register(BuildingRoom, AssetAdmin) admin.site.register(BuildingEquipment, AssetAdmin) admin.site.register(PublicBuildingDocument, AssetAdmin)