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')