from django.contrib.gis import admin from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import get_language from django.utils.html import format_html from django import forms from django.urls import path from django.shortcuts import render from django.contrib import messages from django.http import HttpResponse from django.utils import timezone from datetime import date, datetime from dateutil.relativedelta import relativedelta import io import logging import traceback logger = logging.getLogger(__name__) try: import openpyxl except ImportError: openpyxl = None from .models import Intervention, InterventionDocument, InterventionNote, InterventionLocation, InterventionOccupation from. models import InterventionPlanificationTimeLine, InterventionRealizationTimeLine from .models import Symptom, SymptomRecommendation, ReflexSheet, SourceCategory, SymptomContractAutoClose from .models import ( OperationStatusSet, OperationStatus, MeasurementDefinition, GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia, InspectionConfiguration, InspectionConfigurationContract, OperationTemplate, OperationTemplateItem, OperationType, OperationTemplateAssetModelFilter, Operation, OperationPhoto ) from .services import generate_interventions_excel from common.models import Thematic from assets.models import AssetCategory from interventions.views.preventive_import import process_worksheet as _process_worksheet from interventions.models import STATUS_CHOICES, DirectContractRouting @admin.register(Intervention) class InterventionAdmin(admin.ModelAdmin): search_fields = ("code", "title") raw_id_fields = ("intervention_manager",) change_list_template = "admin/interventions/intervention_changelist.html" def get_urls(self): urls = super().get_urls() custom_urls = [ path('export-xlsx/', self.admin_site.admin_view(self.export_xlsx_view), name='interventions_intervention_export_xlsx'), path('import-preventive/', self.admin_site.admin_view(self.import_preventive_view), name='interventions_intervention_import_preventive'), ] return custom_urls + urls def export_xlsx_view(self, request): """Vue pour exporter les interventions en XLSX""" if request.method == 'POST': start_date_str = request.POST.get('start_date') end_date_str = request.POST.get('end_date') # Parser les dates start_date = None end_date = None if start_date_str: start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if end_date_str: end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() # Si aucune date fournie, utiliser le mois précédent if not start_date and not end_date: today = date.today() start_date = (today.replace(day=1) - relativedelta(months=1)) end_date = today.replace(day=1) - relativedelta(days=1) # Générer le nom du fichier file_year = start_date.year if start_date else date.today().year file_month = start_date.month if start_date else date.today().month output_filename = f'interventions_{file_year}_{file_month:02d}.xlsx' try: # Générer le fichier Excel en mémoire excel_file = self._generate_excel(start_date, end_date) # Retourner le fichier en téléchargement response = HttpResponse( excel_file.getvalue(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) response['Content-Disposition'] = f'attachment; filename="{output_filename}"' return response except Exception as e: tb = traceback.format_exc() logger.error('Erreur export interventions XLSX:\n%s', tb) error_detail = f'{str(e)}\n\n{tb}' messages.error(request, f'Erreur lors de l\'export : {str(e)}') return render(request, 'admin/interventions/export_xlsx_form.html', { 'title': 'Exporter les interventions en XLSX', 'opts': self.model._meta, 'has_view_permission': self.has_view_permission(request), 'site_url': '/', 'error': error_detail, }) # Afficher le formulaire today = date.today() first_day_prev_month = (today.replace(day=1) - relativedelta(months=1)) last_day_prev_month = today.replace(day=1) - relativedelta(days=1) context = { 'title': 'Exporter les interventions en XLSX', 'opts': self.model._meta, 'has_view_permission': self.has_view_permission(request), 'site_url': '/', 'default_start_date': first_day_prev_month.strftime('%Y-%m-%d'), 'default_end_date': last_day_prev_month.strftime('%Y-%m-%d'), } return render(request, 'admin/interventions/export_xlsx_form.html', context) def _generate_excel(self, start_date, end_date): """Génère le fichier Excel et le retourne comme BytesIO""" return generate_interventions_excel(start_date, end_date, output_format='file') # ------------------------------------------------------------------ # Vue d'import d'interventions préventives # ------------------------------------------------------------------ def import_preventive_view(self, request): """Vue d'administration pour importer des interventions préventives depuis un fichier Excel.""" BASE_CTX = { 'title': 'Importer des interventions préventives', 'opts': self.model._meta, 'has_view_permission': self.has_view_permission(request), 'site_url': '/', 'status_choices': [ (k, v) for k, v in STATUS_CHOICES.items() ], } if request.method != 'POST': return render(request, 'admin/interventions/import_preventive_form.html', BASE_CTX) # ---- Validation du formulaire ---- if openpyxl is None: messages.error(request, "Le module 'openpyxl' n'est pas installé sur le serveur.") return render(request, 'admin/interventions/import_preventive_form.html', BASE_CTX) uploaded_file = request.FILES.get('xlsx_file') if not uploaded_file: messages.error(request, "Veuillez sélectionner un fichier .xlsx.") return render(request, 'admin/interventions/import_preventive_form.html', BASE_CTX) begin_str = request.POST.get('begin_date', '').strip() end_str = request.POST.get('end_date', '').strip() status = request.POST.get('status', 'in_preparation').strip() dry_run = request.POST.get('dry_run') == '1' expected_begin = None expected_end = None try: if begin_str: expected_begin = timezone.make_aware(datetime.strptime(begin_str, '%Y-%m-%d')) if end_str: expected_end = timezone.make_aware(datetime.strptime(end_str, '%Y-%m-%d')) except ValueError as exc: messages.error(request, f"Format de date invalide : {exc}") return render(request, 'admin/interventions/import_preventive_form.html', BASE_CTX) if bool(expected_begin) != bool(expected_end): messages.error(request, "Les deux dates doivent être renseignées ensemble ou laissées vides.") return render(request, 'admin/interventions/import_preventive_form.html', BASE_CTX) # ---- Lecture du fichier ---- try: wb = openpyxl.load_workbook(io.BytesIO(uploaded_file.read()), data_only=True) ws = wb.worksheets[0] except Exception as exc: messages.error(request, f"Impossible de lire le fichier Excel : {exc}") return render(request, 'admin/interventions/import_preventive_form.html', BASE_CTX) # ---- Import ---- logs = [] result = _process_worksheet( ws=ws, user=request.user, expected_begin=expected_begin, expected_end=expected_end, status=status, dry_run=dry_run, log=logs.append, ) # ---- Résultat ---- for w in result.warnings: messages.warning(request, w) for e in result.errors: messages.error(request, e) if result.errors: pass # erreurs déjà affichées elif dry_run: messages.info( request, f"[Simulation] {result.created} intervention(s) auraient été créées. " "Aucune donnée écrite en base." ) else: messages.success( request, f"{result.created} intervention(s) préventive(s) créée(s) avec succès." ) if result.skipped: messages.info(request, f"{result.skipped} cellule(s) ignorée(s) (vides ou code introuvable).") ctx = {**BASE_CTX, 'import_logs': logs, 'result': result} return render(request, 'admin/interventions/import_preventive_form.html', ctx) @admin.register(InterventionDocument) class DocumentAdmin(admin.ModelAdmin): list_display = ("id", "file", "intervention", "uploaded_by", "upload_date") list_filter = ("uploaded_by", "upload_date") search_fields = ("file", "description", "intervention__title") def save_model(self, request, obj, form, change): if not obj.pk: # New object obj.uploaded_by = request.user super().save_model(request, obj, form, change) @admin.register(InterventionNote) class InterventionNoteAdmin(admin.ModelAdmin): list_display = ("intervention", "short_note", "note_type", "note_time", "note_author") list_filter = ("note_type", "note_time", "note_author") search_fields = ("content", "note_author__username", "intervention__id") autocomplete_fields = ["intervention", "note_author"] def short_note(self, obj): """Display a short code of the note.""" return obj.content[:50] + "..." if len(obj.content) > 50 else obj.content short_note.short_description = "Aperçu de la note" @admin.register(SourceCategory) class SourceCategoryAdmin(admin.ModelAdmin): list_display = ("id", "name_fr", "name_nl") search_fields = ("name_fr"," name_nl") list_filter = ("is_active",) @admin.register(InterventionLocation) class InterventionLocationAdmin(admin.ModelAdmin): list_display = ("id", "intervention", "content_type", "object_id", "location_preview") search_fields = ("intervention__code", "object_id") @admin.display(description="Localisation") def location_preview(self, obj): loc = getattr(obj, "location", None) if not loc: return format_html('[objet introuvable] {}', f"{obj.content_type_id}:{obj.object_id}") label = f"{_safe_get_name(loc)} ({getattr(loc, 'code', '')})" return format_html('{}', _admin_change_url(loc), label) def _admin_change_url(obj): return f"/admin/{obj._meta.app_label}/{obj._meta.model_name}/{obj.pk}/change/" def _safe_get_name(obj): # Tous héritent d'AbstractLocation → name_fr/name_nl lang = (get_language() or "fr").split("-")[0] name = getattr(obj, f"name_{lang}", None) or obj.name_fr or obj.name_nl return name or str(obj) admin.site.register(InterventionPlanificationTimeLine, admin.ModelAdmin) admin.site.register(InterventionRealizationTimeLine, admin.ModelAdmin) admin.site.register(InterventionOccupation, admin.ModelAdmin) @admin.register(SymptomRecommendation) class SymptomRecommendationAdmin(admin.ModelAdmin): list_display = ('symptom', 'short_text', 'order_number') list_filter = ('symptom__thematic',) search_fields = ('text_fr', 'text_nl', 'symptom__name_fr', 'symptom__name_nl') ordering = ('symptom__thematic', 'order_number') def short_text(self, obj): return (obj.text_fr or obj.text_nl)[:60] + "…" short_text.short_description = "Texte" class SymptomRecommendationInline(admin.TabularInline): model = SymptomRecommendation extra = 1 fields = ('icon', 'text_fr', 'text_nl', 'order_number', 'confirm_call', 'document') class SymptomContractAutoCloseInline(admin.TabularInline): model = SymptomContractAutoClose extra = 1 fields = ('contract', 'delay_days') verbose_name = "Règle de clôture automatique" verbose_name_plural = "Règles de clôture automatique (par contrat)" class SymptomAdminForm(forms.ModelForm): class Meta: model = Symptom fields = "__all__" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) thematic = None # 1) Cas "change": instance existante if self.instance and self.instance.pk: thematic = self.instance.thematic else: # 2) Cas "add": essayer de récupérer la thématique depuis le POST ou l'initial thematic_id = self.data.get('thematic') or self.initial.get('thematic') if thematic_id: try: thematic = Thematic.objects.get(pk=thematic_id) except Thematic.DoesNotExist: pass # Filtrer les catégories selon la thématique, sinon vider pour éviter le hors-sujet if thematic: self.fields['asset_categories'].queryset = AssetCategory.objects.filter(thematic=thematic) else: self.fields['asset_categories'].queryset = AssetCategory.objects.none() def clean_asset_categories(self): cats = self.cleaned_data['asset_categories'] thematic = self.cleaned_data.get('thematic') or (self.instance and self.instance.thematic) if thematic: invalid = cats.exclude(thematic=thematic) if invalid.exists(): raise forms.ValidationError( "Toutes les catégories doivent appartenir à la même thématique que le symptôme." ) return cats @admin.register(Symptom) class SymptomAdmin(admin.ModelAdmin): form = SymptomAdminForm change_list_template = "admin/interventions/symptom_changelist.html" list_display = ("id", "code", "thematic", "name_fr", "name_nl", "is_active", "auto_create_intervention", "auto_create_intervention_status", "require_equipment_selection", "asset_categories_list", "contract", "teams_list") list_filter = ("thematic", "asset_categories", "priority", "is_active", "auto_create_intervention", "require_equipment_selection", "teams") search_fields = ("name_fr", "name_nl", "code") filter_horizontal = ("teams",) inlines = [SymptomRecommendationInline, SymptomContractAutoCloseInline] def get_urls(self): urls = super().get_urls() custom_urls = [ path('export-xlsx/', self.admin_site.admin_view(self.export_xlsx_view), name='interventions_symptom_export_xlsx'), ] return custom_urls + urls def export_xlsx_view(self, request): """Exporte tous les symptômes en XLSX.""" if openpyxl is None: self.message_user(request, "La dépendance openpyxl est manquante.", level='error') from django.shortcuts import redirect return redirect('..') from .models import SYMPTOM_MAINTENANCE_TYPE_CHOICES, PRIORITY_CHOICES, ORIGIN_TYPE_CHOICES from assets.models import TrafficLightContract, StructureContract, CleanLocationContract, ITSGeoAssetContract from assets.models.its import ITSLocationContract priority_labels = dict(PRIORITY_CHOICES) if PRIORITY_CHOICES else {} origin_labels = dict(ORIGIN_TYPE_CHOICES) if hasattr(ORIGIN_TYPE_CHOICES, '__iter__') else {} maintenance_labels = dict(SYMPTOM_MAINTENANCE_TYPE_CHOICES) # Pré-calcul des contrats liés par maintenance_type (numéros distincts, statut actif) def _contracts_for_maintenance_type(mt): if not mt: return "—" pairs = set() # (contract_number, company_name) if mt in ('infra', 'controller'): pairs.update( TrafficLightContract.objects.filter(maintenance_type=mt, status='active') .values_list('contract__contract_number', 'contract__company__name') ) elif mt in ('electromeca', 'civil_engineering'): pairs.update( StructureContract.objects.filter(maintenance_type=mt, status='active') .values_list('contract__contract_number', 'contract__company__name') ) elif mt in ('clean', 'collection'): pairs.update( CleanLocationContract.objects.filter(maintenance_type=mt, status='active') .values_list('contract__contract_number', 'contract__company__name') ) elif mt == 'equipments': pairs.update( ITSLocationContract.objects.filter(maintenance_type=mt, status='active') .values_list('contract__contract_number', 'contract__company__name') ) pairs.update( ITSGeoAssetContract.objects.filter(maintenance_type=mt, status='active') .values_list('contract__contract_number', 'contract__company__name') ) entries = sorted( f"{num} ({company})" if company else num for num, company in pairs if num ) return ", ".join(entries) or "—" # Cache par maintenance_type pour éviter les requêtes répétées linked_contracts_cache: dict[str, str] = {} wb = openpyxl.Workbook() ws = wb.active ws.title = "Symptômes" headers = [ "ID", "Code", "Nom (FR)", "Nom (NL)", "Thématique", "Catégories d'assets", "Priorité", "Vérif. priorité", "Type d'origine", "Type de maintenance", "Numéro d'ordre", "Actif", "Statut recommandé", "Équipes", "Emails notifications contrat", "Emails CC contrat", "Société (prestataire)", "Numéro de contrat", "Contrat actif", "Contrats liés (type de maintenance)", ] # Style en-têtes from openpyxl.styles import Font, PatternFill, Alignment header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(fill_type="solid", fgColor="366092") header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) ws.append(headers) for col_idx, _ in enumerate(headers, start=1): cell = ws.cell(row=1, column=col_idx) cell.font = header_font cell.fill = header_fill cell.alignment = header_align qs = ( Symptom.objects .select_related("thematic", "provider", "contract") .prefetch_related("asset_categories", "teams") .order_by("thematic", "order_number") ) for symptom in qs: categories = ", ".join(ac.name_fr for ac in symptom.asset_categories.all()) or "—" teams = ", ".join(t.name for t in symptom.teams.all()) or "—" priority_label = str(priority_labels.get(symptom.priority, symptom.priority)) if symptom.priority else "—" origin_label = str(origin_labels.get(symptom.origin_type, symptom.origin_type)) if symptom.origin_type else "—" maintenance_label = str(maintenance_labels.get(symptom.maintenance_type, symptom.maintenance_type)) if symptom.maintenance_type else "—" recommended_status = str(dict(STATUS_CHOICES).get(symptom.recommended_next_status, symptom.recommended_next_status)) if symptom.recommended_next_status else "—" provider_name = symptom.provider.name if symptom.provider else "—" contract_number = symptom.contract.contract_number if symptom.contract else "—" contract_active = ("Oui" if symptom.contract.is_active else "Non") if symptom.contract else "—" contract_emails = symptom.contract.notification_emails or "—" if symptom.contract else "—" contract_cc_emails = symptom.contract.cc_notification_emails or "—" if symptom.contract else "—" mt = symptom.maintenance_type if mt not in linked_contracts_cache: linked_contracts_cache[mt] = _contracts_for_maintenance_type(mt) linked_contracts = linked_contracts_cache[mt] ws.append([ symptom.id, symptom.code or "—", symptom.name_fr, symptom.name_nl, symptom.thematic.name_fr if symptom.thematic else "—", categories, priority_label, "Oui" if symptom.check_priority else "Non", origin_label, maintenance_label, symptom.order_number if symptom.order_number is not None else "—", "Oui" if symptom.is_active else "Non", recommended_status, teams, contract_emails, contract_cc_emails, provider_name, contract_number, contract_active, linked_contracts, ]) # Largeurs de colonnes col_widths = [6, 14, 40, 40, 20, 30, 12, 16, 18, 25, 14, 8, 20, 40, 40, 40, 30, 20, 12, 50] for i, width in enumerate(col_widths, start=1): ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = width # Figer la première ligne ws.freeze_panes = "A2" output = io.BytesIO() wb.save(output) output.seek(0) response = HttpResponse( output.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) response['Content-Disposition'] = 'attachment; filename="symptomes_export.xlsx"' return response @admin.display(description="Catégories") def asset_categories_list(self, obj): qs = obj.asset_categories.all() return "—" if not qs else ", ".join(ac.name_fr for ac in qs) @admin.display(description="Équipes") def teams_list(self, obj): qs = obj.teams.all() return "—" if not qs else ", ".join(t.name for t in qs) def get_queryset(self, request): qs = super().get_queryset(request) return qs.prefetch_related("asset_categories", "teams") @admin.register(ReflexSheet) class ReflexSheetAdmin(admin.ModelAdmin): list_display = ('title_fr', 'asset_category', 'is_active', 'created_at') list_filter = ('asset_category', 'is_active') search_fields = ('title_fr', 'title_nl', 'comment_fr', 'comment_nl') filter_horizontal = ('symptoms', 'contact_persons') readonly_fields = ('created_at', 'updated_at') fieldsets = ( (None, { 'fields': ('title_fr', 'title_nl', 'asset_category', 'is_active') }), ('Contenu', { 'fields': ('symptoms', 'contact_persons', 'comment_fr', 'comment_nl') }), ('Métadonnées', { 'fields': ('created_at', 'updated_at') }), ) @admin.register(SymptomContractAutoClose) class SymptomContractAutoCloseAdmin(admin.ModelAdmin): list_display = ('symptom', 'contract', 'delay_days') list_filter = ('contract', 'symptom__thematic') search_fields = ('symptom__name_fr', 'symptom__code', 'contract__contract_number') autocomplete_fields = ('symptom', 'contract') ### OperationStatus admin ### class OperationStatusInline(admin.TabularInline): model = OperationStatus extra = 1 fields = ('label_fr', 'label_nl', 'value', 'order', 'bootstrap_icon', 'bootstrap_color', 'requires_comment') ordering = ('order',) @admin.register(OperationStatusSet) class OperationStatusSetAdmin(admin.ModelAdmin): list_display = ('name',) inlines = [OperationStatusInline] @admin.register(OperationStatus) class OperationStatusAdmin(admin.ModelAdmin): list_display = ('get_label', 'value', 'status_set', 'order', 'bootstrap_icon', 'bootstrap_color') list_filter = ('status_set',) ordering = ('status_set', 'order') fields = ('status_set', 'label_fr', 'label_nl', 'value', 'order', 'bootstrap_icon', 'bootstrap_color', 'requires_comment') def get_label(self, obj): lang = get_language() return obj.label_nl if lang == 'nl' else obj.label_fr get_label.short_description = 'Label' ### MeasurementDefinition admin ### @admin.register(MeasurementDefinition) class MeasurementDefinitionAdmin(admin.ModelAdmin): list_display = ('name', 'value_type', 'unit', 'min_value', 'max_value', 'min_duration', 'max_duration') search_fields = ('name', 'unit') list_filter = ('value_type',) fieldsets = ( (None, { 'fields': ('name', 'value_type'), }), ('Numérique', { 'fields': ('unit', 'min_value', 'max_value'), 'description': 'Applicable uniquement pour le type "Numérique".', }), ('Durée (hh:mm:ss)', { 'fields': ('min_duration', 'max_duration'), 'description': 'Applicable uniquement pour le type "Durée".', }), ('Date et heure', { 'fields': ('min_datetime', 'max_datetime'), 'description': 'Applicable uniquement pour le type "Date et heure".', }), ) ### OperationTemplate admin ### class OperationTemplateItemInline(admin.TabularInline): model = OperationTemplateItem extra = 1 fields = ('name_fr', 'name_nl', 'status_set', 'measurement_definition', 'is_generic', 'order') ordering = ('order',) class OperationTemplateAdminForm(forms.ModelForm): """Form for OperationTemplate with category filtering based on thematic.""" # Custom field for selecting asset models with FilteredSelectMultiple widget asset_models = forms.MultipleChoiceField( required=False, widget=FilteredSelectMultiple( verbose_name="Modèles d'équipements", is_stacked=False ), label="Modèles d'équipements concernés", help_text="Sélectionnez les modèles d'équipements pour lesquels les opérations seront créées." ) class Meta: model = OperationTemplate fields = ['code', 'name', 'description', 'thematic', 'categories'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from django.contrib.contenttypes.models import ContentType from django.apps import apps from assets.models.core import AbstractAssetModel thematic = None category_ids = [] if self.instance and self.instance.pk: thematic = self.instance.thematic category_ids = list(self.instance.categories.values_list('id', flat=True)) else: thematic_id = self.data.get('thematic') or self.initial.get('thematic') if thematic_id: try: thematic = Thematic.objects.get(pk=thematic_id) except Thematic.DoesNotExist: pass # Try to get categories from POST data if 'categories' in self.data: category_ids = [int(c) for c in self.data.getlist('categories') if c.isdigit()] # Filter categories by thematic if thematic and 'categories' in self.fields: self.fields['categories'].queryset = AssetCategory.objects.filter(thematic=thematic) elif 'categories' in self.fields: self.fields['categories'].queryset = AssetCategory.objects.none() # Build choices for asset models choices = [] thematic_id = thematic.id if thematic else None for model in apps.get_models(): if model._meta.abstract: continue try: if issubclass(model, AbstractAssetModel) and model != AbstractAssetModel: ct = ContentType.objects.get_for_model(model) # Filter instances by categories first (most specific), then by thematic if category_ids: instances = model.objects.filter( category_id__in=category_ids ).order_by('code') elif thematic_id: instances = model.objects.filter( category__thematic_id=thematic_id ).order_by('code') else: instances = model.objects.all().order_by('code') if instances.exists(): for instance in instances: # Value format: "content_type_id:object_id" value = f"{ct.id}:{instance.pk}" # Display with category info category_name = instance.category.get_name() if instance.category else '' display = f"{instance} ({category_name})" if category_name else str(instance) choices.append((value, display)) except (TypeError, Exception): continue self.fields['asset_models'].choices = choices # Pre-select existing asset model filters if self.instance and self.instance.pk: selected = [] for filter_obj in self.instance.asset_model_filters.all(): value = f"{filter_obj.asset_model_content_type_id}:{filter_obj.asset_model_object_id}" selected.append(value) self.fields['asset_models'].initial = selected def save(self, commit=True): # Don't commit yet so we can save asset model filters instance = super().save(commit=False) # Save the main instance if commit: instance.save() # Handle asset model filters (custom field not in model) if instance.pk: # Must have pk to save related objects # Clear existing filters instance.asset_model_filters.all().delete() # Create new filters from selected asset models selected_models = self.cleaned_data.get('asset_models', []) for value in selected_models: try: ct_id, obj_id = value.split(':') OperationTemplateAssetModelFilter.objects.create( template=instance, asset_model_content_type_id=int(ct_id), asset_model_object_id=int(obj_id) ) except (ValueError, AttributeError): continue return instance class Media: js = ('interventions/admin_operation_template_dynamic.js',) @admin.register(OperationTemplate) class OperationTemplateAdmin(admin.ModelAdmin): form = OperationTemplateAdminForm list_display = ('code', 'name', 'description', 'thematic', 'categories_list', 'models_list') list_filter = ('thematic', 'categories') filter_horizontal = ('categories',) inlines = [OperationTemplateItemInline] fieldsets = ( (None, { 'fields': ('code', 'name', 'description', 'thematic', 'guided_template') }), ('Catégories d\'équipements concernées (optionnel)', { 'fields': ('categories',), 'description': 'Sélectionnez les catégories d\'équipements pour lesquelles les opérations seront créées. Sauvegardez pour filtrer les modèles par catégorie.', }), ('Modèles d\'équipements concernés (optionnel)', { 'fields': ('asset_models',), 'description': 'Sélectionnez les modèles d\'équipements spécifiques. Si vide, tous les modèles des catégories sélectionnées seront concernés.', }), ) @admin.display(description='Catégories') def categories_list(self, obj): cats = obj.categories.all() if not cats: return '—' return ', '.join(c.get_name() for c in cats[:3]) + ('...' if cats.count() > 3 else '') @admin.display(description='Modèles') def models_list(self, obj): filters = obj.asset_model_filters.all()[:3] if not filters: return '—' return ', '.join(str(f) for f in filters) + ('...' if obj.asset_model_filters.count() > 3 else '') @admin.register(OperationTemplateItem) class OperationTemplateItemAdmin(admin.ModelAdmin): list_display = ('code', 'get_name', 'template', 'status_set', 'measurement_definition', 'is_generic', 'order') list_filter = ('template', 'status_set', 'is_generic') def get_name(self, obj): lang = get_language() return obj.name_nl if lang == 'nl' else obj.name_fr get_name.short_description = 'Nom' @admin.register(OperationType) class OperationTypeAdmin(admin.ModelAdmin): list_display = ('code', 'name_fr', 'name_nl') search_fields = ('code', 'name_fr', 'name_nl') ### Operation admin ### @admin.register(Operation) class OperationAdmin(admin.ModelAdmin): list_display = ('get_name', 'intervention', 'template_group', 'status_set', 'status', 'measurement_definition', 'measured_value', 'is_custom', 'is_generic', 'order') list_filter = ('status_set', 'measurement_definition', 'template_group', 'is_custom', 'is_generic') search_fields = ('name_fr', 'name_nl', 'comment', 'template_group') def get_name(self, obj): lang = get_language() return obj.name_nl if lang == 'nl' else obj.name_fr get_name.short_description = 'Nom' @admin.register(OperationPhoto) class OperationPhotoAdmin(admin.ModelAdmin): list_display = ('operation', 'uploaded_at', 'uploaded_by', 'description') list_filter = ('uploaded_at',) search_fields = ('description', 'operation__name_fr', 'operation__name_nl') readonly_fields = ('uploaded_at',) ### GuidedOperationTemplate admin ### @admin.register(GuidedOperationTemplate) class GuidedOperationTemplateAdmin(admin.ModelAdmin): list_display = ('slug', 'name_fr', 'name_nl', 'description') search_fields = ('slug', 'name_fr', 'name_nl') prepopulated_fields = {'slug': ('name_fr',)} class GuidedOperationMediaInline(admin.TabularInline): model = GuidedOperationMedia extra = 0 readonly_fields = ('uploaded_at', 'uploaded_by') @admin.register(GuidedOperationData) class GuidedOperationDataAdmin(admin.ModelAdmin): list_display = ('intervention', 'guided_template', 'updated_at') list_filter = ('guided_template',) search_fields = ('intervention__code', 'intervention__title') readonly_fields = ('created_at', 'updated_at') inlines = [GuidedOperationMediaInline] class InspectionConfigurationContractInline(admin.TabularInline): model = InspectionConfigurationContract extra = 1 @admin.register(InspectionConfiguration) class InspectionConfigurationAdmin(admin.ModelAdmin): list_display = ('name_fr', 'name_nl', 'thematic', 'maintain_type', 'show_equipment') list_filter = ('thematic', 'maintain_type', 'show_equipment') search_fields = ('name_fr', 'name_nl', 'description') filter_horizontal = ('operation_templates', 'displayed_location_types', 'displayed_asset_types', 'concerned_categories') inlines = [ InspectionConfigurationContractInline, ] @admin.register(DirectContractRouting) class DirectContractRoutingAdmin(admin.ModelAdmin): list_display = ('thematic', 'asset_code', 'symptom', 'contract') list_filter = ('thematic', 'contract') search_fields = ('asset_code', 'symptom__code', 'contract__contract_number')