924 lines
No EOL
38 KiB
Python
924 lines
No EOL
38 KiB
Python
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,
|
|
ContractOperationTemplatePost,
|
|
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('<span style="color:#999">[objet introuvable] {}</span>',
|
|
f"{obj.content_type_id}:{obj.object_id}")
|
|
label = f"{_safe_get_name(loc)} ({getattr(loc, 'code', '')})"
|
|
return format_html('<a href="{}">{}</a>', _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',)
|
|
|
|
|
|
from django.db.models import Q
|
|
from contracts.models import CompanyTeam
|
|
|
|
|
|
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', 'team', 'guided_template', '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()
|
|
|
|
# Filter teams by thematic
|
|
if 'team' in self.fields:
|
|
if thematic:
|
|
self.fields['team'].queryset = CompanyTeam.objects.filter(
|
|
Q(thematics=thematic) | Q(thematics__isnull=True)
|
|
).distinct().select_related('company').order_by('name')
|
|
else:
|
|
self.fields['team'].queryset = CompanyTeam.objects.select_related('company').order_by('name')
|
|
|
|
# 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',)
|
|
|
|
|
|
class OperationTemplateContractPostInline(admin.TabularInline):
|
|
model = ContractOperationTemplatePost
|
|
extra = 0
|
|
fields = ('contract', 'contract_post', 'default_quantity', 'comment')
|
|
verbose_name = "Poste de contrat par défaut"
|
|
verbose_name_plural = "Postes de contrats par défaut"
|
|
autocomplete_fields = ('contract', 'contract_post')
|
|
|
|
|
|
@admin.register(OperationTemplate)
|
|
class OperationTemplateAdmin(admin.ModelAdmin):
|
|
form = OperationTemplateAdminForm
|
|
list_display = ('code', 'name', 'description', 'thematic', 'team', 'categories_list', 'models_list')
|
|
search_fields = ('name', 'code', 'description')
|
|
list_filter = ('thematic', 'team', 'categories')
|
|
filter_horizontal = ('categories',)
|
|
inlines = [OperationTemplateItemInline, OperationTemplateContractPostInline]
|
|
fieldsets = (
|
|
(None, {
|
|
'fields': ('code', 'name', 'description', 'thematic', 'team', '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(ContractOperationTemplatePost)
|
|
class ContractOperationTemplatePostAdmin(admin.ModelAdmin):
|
|
list_display = ('contract', 'operation_template', 'contract_post', 'default_quantity', 'comment')
|
|
list_filter = ('contract', 'operation_template', 'contract__thematics')
|
|
search_fields = (
|
|
'contract__contract_number',
|
|
'operation_template__name',
|
|
'contract_post__code',
|
|
'contract_post__description_fr',
|
|
)
|
|
autocomplete_fields = ('contract', 'operation_template', 'contract_post')
|
|
|
|
|
|
@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') |