from django import forms from django.db import models from django.utils.translation import gettext_lazy as _, get_language from django.db.models import F, CharField, Case, When, Q, Value, IntegerField from django.db.models.functions import Coalesce, Lower import bleach import json import re from interventions.models import Intervention, InterventionNote, InterventionDocument, Symptom, OperationTemplate, InterventionOccupation, SourceCategory, OperationStatusSet, MeasurementDefinition, TYPE_CHOICES from assets.models import AssetCategory from common.models import UserConfig, UserThematics from contracts.models import Contract, ContractOrder, CompanyMember from projects.models import Project class MultipleFileInput(forms.ClearableFileInput): allow_multiple_selected = True class MultipleFileField(forms.FileField): def __init__(self, *args, **kwargs): kwargs.setdefault("widget", MultipleFileInput()) super().__init__(*args, **kwargs) def clean(self, data, initial=None): single_file_clean = super().clean if isinstance(data, (list, tuple)): result = [single_file_clean(d, initial) for d in data] else: result = [single_file_clean(data, initial)] return [item for item in result if item] class InterventionForm(forms.ModelForm): documents = MultipleFileField(required=False, label=_("Documents")) project = forms.ModelChoiceField(required=False, label=_("Projet"), queryset = Project.get_active_projects()) def __init__(self, *args, thematic=None, user=None, **kwargs): super().__init__(*args, **kwargs) # Filtrer les querysets selon thématique if thematic: symptom_qs = Symptom.objects.filter(thematic=thematic, is_active=True) self.fields['asset_category'].queryset = AssetCategory.objects.filter(thematic=thematic, parent=None) self.fields['project'].queryset = Project.get_active_projects().filter(thematics=thematic) else: symptom_qs = Symptom.objects.all() self.fields['asset_category'].queryset = AssetCategory.objects.filter(parent=None) # Limiter les projets aux projets visibles par l'utilisateur if user is not None: from projects.permissions import filter_viewable_projects_for_user allowed_projects = filter_viewable_projects_for_user(user) self.fields['project'].queryset = self.fields['project'].queryset.filter(pk__in=allowed_projects) lang = (get_language() or "fr").lower() # Récupérer les symptômes et les trier en Python # (le tri sur annotations complexes ne fonctionne pas bien avec Django/PostgreSQL) symptoms_list = list(symptom_qs) def get_display_name(s): if lang.startswith("nl"): return s.name_nl if s.name_nl else s.name_fr return s.name_fr def sort_key(s): # Trier par: 1) has_order_number (0 si défini, 1 sinon), 2) order_number, 3) nom alphabétique has_order = 0 if s.order_number is not None else 1 order_num = s.order_number if s.order_number is not None else 999999 name = (get_display_name(s) or "").lower() return (has_order, order_num, name, s.pk) symptoms_list.sort(key=sort_key) # Créer un queryset ordonné par les IDs triés ordered_ids = [s.pk for s in symptoms_list] if ordered_ids: from django.db.models import Case, When preserved_order = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(ordered_ids)]) symptom_qs = symptom_qs.filter(pk__in=ordered_ids).order_by(preserved_order) self.fields['symptom'].queryset = symptom_qs self.fields['documents'].widget.attrs.update({'class': 'form-control'}) self.fields['project'].widget.attrs.update({'class': 'form-select'}) # Limit status choices to statuses up to 'pris en charge' (assigned) allowed_statuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'] self.fields['status'].choices = [ (val, lbl) for val, lbl in self.fields['status'].choices if val in allowed_statuses ] type_value = self.initial.get('type') or self.data.get('type') maintain_type_value = self.initial.get('maintain_type') or self.data.get('maintain_type') if type_value == 'inspection': if not self.initial.get('status') and not self.data.get('status'): self.fields['status'].initial = 'to_be_processed' self.fields['status'].widget = forms.Select(attrs={'class': 'form-select'}) self.fields['status'].widget.choices = self.fields['status'].choices else: self.fields['status'].initial = 'in_preparation' self.fields['status'].widget = forms.HiddenInput() if maintain_type_value == 'ameliorative': self.fields['origin_type'].initial = 'demand' self.fields['origin_type'].widget = forms.HiddenInput() if maintain_type_value == 'preventive': self.fields['priority'].required = False self.fields['origin_type'].required = False self.fields['priority'].widget = forms.HiddenInput() self.fields['origin_type'].widget = forms.HiddenInput() if type_value != 'inspection': self.fields['status'].required = False self.fields['status'].widget = forms.HiddenInput() self.fields['maintain_type'].widget = forms.HiddenInput() # --- Bonus UX : si un 'symptom' est déjà en initial (GET), # pré-remplir asset_category / priority / origin_type (sauf ameliorative) if not self.is_bound: symptom_pk = self.initial.get('symptom') if symptom_pk: s = Symptom.objects.filter(pk=symptom_pk).prefetch_related('asset_categories').only( 'priority', 'origin_type' ).first() if s: asset_category_ids = list(s.asset_categories.values_list('id', flat=True)) # Ne pré-remplir que si le symptôme a exactement une catégorie associée # Si plusieurs catégories, laisser vide pour forcer l'utilisateur à choisir if len(asset_category_ids) == 1 and not self.fields['asset_category'].initial: self.fields['asset_category'].initial = asset_category_ids[0] if s.priority: self.fields['priority'].initial = s.priority if (maintain_type_value != 'ameliorative' and s.origin_type): self.fields['origin_type'].initial = s.origin_type self.fields['title'].initial = s.get_name() # Set default type to 'fixing' for corrective interventions when no type is provided if maintain_type_value == 'corrective' and not self.initial.get('type'): self.fields['type'].initial = 'fixing' # If source category is OBSERVATION, make source_ref read-only (displayed but not editable) try: sc_val = self.initial.get('source_category') or (self.data.get('source_category') if self.is_bound else None) if sc_val: try: sc_obj = SourceCategory.objects.get(pk=int(sc_val)) except Exception: sc_obj = None if sc_obj: names = [getattr(sc_obj, a, '') or '' for a in ('name_fr', 'name_nl')] if any((n.strip().lower() == 'observation') for n in names): self.fields['source_ref'].widget.attrs['readonly'] = 'readonly' # subtle visual cue css = self.fields['source_ref'].widget.attrs.get('class', '') self.fields['source_ref'].widget.attrs['class'] = (css + ' bg-light').strip() self.fields['source_ref'].widget.attrs['title'] = _('Référence non modifiable') except Exception: pass class Meta: model = Intervention fields = [ 'code', 'title', 'status', 'priority', 'init_description', 'maintain_type', 'type', 'origin_type', 'begin_time', 'end_time', 'asset_category', 'symptom', 'source_category', 'source_detail', 'source_ref', 'lat', 'lon', 'geom', 'address', 'reason_for_correction', 'correction_details', 'documents', 'project' ] widgets = { 'title': forms.TextInput(attrs={'class': 'form-control'}), 'init_description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'status': forms.Select(attrs={'class': 'form-select'}), 'maintain_type': forms.Select(attrs={'class': 'form-select'}), 'source_category': forms.Select(attrs={'class': 'form-select'}), 'source_detail': forms.TextInput(attrs={'class': 'form-control'}), 'source_ref': forms.TextInput(attrs={'class': 'form-control'}), 'asset_category': forms.Select(attrs={'class': 'form-select'}), 'symptom': forms.Select(attrs={'class': 'form-select'}), 'priority': forms.Select(attrs={'class': 'form-select'}), 'type': forms.Select(attrs={'class': 'form-select'}), 'origin_type': forms.Select(attrs={'class': 'form-select'}), # 'project': forms.Select(attrs={'class': 'form-select'}), 'begin_time': forms.DateInput(attrs={ 'class': 'form-control', 'type': 'datetime-local' # Allows to select a date and time }), 'end_time': forms.DateInput(attrs={ 'class': 'form-control', 'type': 'datetime-local' }), 'lat': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any', 'placeholder': _('Latitude')}), 'lon': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any', 'placeholder': _('Longitude')}), 'reason_for_correction': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'correction_details': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), } def _is_observation_source(self, source_category): """Return True when the selected source category corresponds to OBSERVATION (FR/NL).""" if not source_category: return False possible_names = [ getattr(source_category, attr, "") or "" for attr in ("name_fr", "name_nl") ] return any(name.strip().lower() == "observation" for name in possible_names if isinstance(name, str)) def clean(self): cleaned_data = super().clean() # Force status to 'in_preparation' for non-inspection types type_value = cleaned_data.get('type') if type_value != 'inspection': cleaned_data['status'] = 'in_preparation' # If a sub-category was selected, override asset_category with it sub_cat_id = self.data.get('interv_sub_category_id') if sub_cat_id: try: sub_cat = AssetCategory.objects.get(pk=sub_cat_id) cleaned_data['asset_category'] = sub_cat # Clear any field-level error on asset_category that may have been raised self.errors.pop('asset_category', None) except (AssetCategory.DoesNotExist, ValueError): pass maintain_type = cleaned_data.get('maintain_type') if maintain_type == 'preventive': if not cleaned_data.get('status'): cleaned_data['status'] = 'to_be_processed' if not cleaned_data.get('priority'): cleaned_data['priority'] = '3' if not cleaned_data.get('origin_type'): cleaned_data['origin_type'] = 'demand' source_category = cleaned_data.get('source_category') documents = cleaned_data.get('documents') or [] # Ensure consistent list of uploaded files documents = [doc for doc in documents if doc] cleaned_data['documents'] = documents self.fms_filename_mismatch = [] self.fms_filename_reference = None if maintain_type == 'corrective': asset_category = cleaned_data.get('asset_category') if not asset_category: self.add_error( 'asset_category', _("Veuillez sélectionner une catégorie d'actif."), ) symptom = cleaned_data.get('symptom') if symptom and getattr(symptom, 'require_equipment_selection', False): equipment_id = (self.data.get('equipment_id') or '').strip() assets_raw = (self.data.get('assets') or '').strip() has_equipment = bool(equipment_id) if not has_equipment and assets_raw and assets_raw != '[]': try: parsed_assets = json.loads(assets_raw) has_equipment = isinstance(parsed_assets, list) and len(parsed_assets) > 0 except (TypeError, ValueError): has_equipment = True if not has_equipment: self.add_error( None, _("Un équipement est obligatoire pour ce symptôme."), ) # OBSERVATION: require a simple source reference (we use the observation id by convention) if maintain_type == 'corrective' and self._is_observation_source(source_category): source_ref = (cleaned_data.get('source_ref') or "").strip() if not source_ref: self.add_error( 'source_ref', _("Veuillez indiquer la référence de la source (ex.: l'identifiant de l'observation)."), ) else: # Be lenient but prefer a numeric id for consistency if not re.fullmatch(r"\d+", source_ref): self.add_error( 'source_ref', _("La référence doit être un identifiant numérique (ID d'observation)."), ) # Validation spécifique externe via les hooks from common.hooks import hooks cleaned_data = hooks.run_form_cleaners(self, cleaned_data) return cleaned_data class InterventionNoteForm(forms.ModelForm): note_type = forms.ChoiceField( label=_("Type de note"), choices=InterventionNote.NoteType.choices, initial=InterventionNote.NoteType.WORK_DONE, widget=forms.Select(attrs={'class': 'form-select'}), ) content = forms.CharField( label=_("Contenu"), widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), ) class Meta: model = InterventionNote fields = ['note_type', 'content'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) note_type_field = self.fields.get('note_type') if note_type_field: default_value = note_type_field.initial or InterventionNote.NoteType.WORK_DONE css_class = note_type_field.widget.attrs.get('class', '') classes = f"{css_class} form-select".strip().split() note_type_field.widget.attrs['class'] = ' '.join(dict.fromkeys(classes)) note_type_field.widget.attrs['data-default-value'] = default_value def clean_content(self): content = self.cleaned_data.get('content', '') # Utilisation de bleach pour nettoyer le contenu allowed_tags = ['p', 'br', 'strong', 'em', 'ul', 'li', 'ol', 'a'] allowed_attrs = {'a': ['href', 'title', 'target']} return bleach.clean(content, tags=allowed_tags, attributes=allowed_attrs) class InterventionDocumentForm(forms.ModelForm): documents = MultipleFileField(required=True, label=_("Documents")) class Meta: model = InterventionDocument fields = ['documents'] def __init__(self, *args, **kwargs): super(InterventionDocumentForm, self).__init__(*args, **kwargs) self.fields['documents'].widget.attrs.update({'class': 'form-control'}) class InterventionOccupationForm(forms.ModelForm): member = forms.ModelChoiceField( label=_('Technicien'), queryset=CompanyMember.objects.none(), widget=forms.Select(attrs={'class': 'form-select'}), ) minutes = forms.IntegerField( label=_('Durée (minutes)'), min_value=1, widget=forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'step': 1}), ) def __init__(self, *args, members_queryset=None, user=None, **kwargs): super().__init__(*args, **kwargs) if members_queryset is None: members_queryset = CompanyMember.objects.none() self.fields['member'].queryset = members_queryset if (not self.is_bound and not self.instance.pk and user is not None): default_member = members_queryset.filter(user=user).first() if default_member: self.fields['member'].initial = default_member.pk class Meta: model = InterventionOccupation fields = ['member', 'minutes'] class PreventiveInterventionForm(forms.Form): title = forms.CharField( label=_("Titre des interventions"), required=True, max_length=200, widget=forms.TextInput(attrs={ "class": "form-control", "placeholder": "" }) ) status = forms.ChoiceField( label=_("Statut"), choices=[ ('in_preparation', _('En préparation')), ('to_be_approved', _('À approuver')), ('to_be_planned', _('À planifier')), ], widget=forms.Select(attrs={"class": "form-select"}) ) type = forms.ChoiceField( label=_("Type d'intervention"), choices=TYPE_CHOICES, initial='care', widget=forms.Select(attrs={"class": "form-select"}) ) contract = forms.ModelChoiceField( label=_("Contrat"), queryset=Contract.objects.none(), widget=forms.Select(attrs={"class": "form-select"}) ) order = forms.ModelChoiceField( queryset=ContractOrder.objects.none(), label=_("Commande"), required=False, widget=forms.Select(attrs={"class": "form-select", "id": "id_order", "disabled": "disabled"}) ) operation_templates = forms.ModelMultipleChoiceField( label=_("Templates d'opérations"), queryset=OperationTemplate.objects.none(), widget=forms.CheckboxSelectMultiple(), required=False, help_text=_("Les opérations génériques seront ajoutées une fois par intervention. Les opérations liées aux équipements seront répétées pour chaque asset sélectionné.") ) expected_begin_time = forms.DateTimeField( label=_("Début prévisionnel"), widget=forms.DateTimeInput(attrs={ "type": "datetime-local", "class": "form-control" }) ) expected_end_time = forms.DateTimeField( label=_("Fin prévisionnelle"), widget=forms.DateTimeInput(attrs={ "type": "datetime-local", "class": "form-control" }) ) recurrence_count = forms.IntegerField( label=_("Nombre de répétitions"), min_value=0, initial=0, widget=forms.NumberInput(attrs={ "class": "form-control" }) ) recurrence_delta_months = forms.IntegerField( label=_("Délai entre les répétitions (mois)"), min_value=1, required=False, widget=forms.NumberInput(attrs={ "class": "form-control" }) ) create_order_per_recurrence = forms.BooleanField( label=_("Créer une commande par répétition"), required=False, initial=False, help_text=_("Si coché, une nouvelle commande sera créée pour chaque répétition. La commande sélectionnée sera renommée avec le suffixe '-1', et des commandes '-2', '-3', etc. seront créées automatiquement pour chaque répétition suivante."), widget=forms.CheckboxInput(attrs={ "class": "form-check-input" }) ) type_of_selected_elements = forms.ChoiceField( label=_("Type d'éléments à sélectionner"), choices=[ ('locations', 'Localisations'), ('assets', 'Assets') ], initial='locations', widget=forms.RadioSelect( attrs={ 'class': 'form-check-input' } ) ) intervention_creation_mode = forms.ChoiceField( label=_("Mode de création des interventions"), choices=[ ('one_per_element', _("Créer une intervention par élément (localisation ou asset)")), ('single_for_all', _("Créer une seule intervention pour tous les éléments")) ], initial='one_per_element', widget=forms.RadioSelect( attrs={ 'class': 'form-check-input' } ), help_text=_("Choisissez comment créer les interventions en fonction des éléments sélectionnés.") ) def clean(self): cleaned_data = super().clean() count = cleaned_data.get("recurrence_count") delta = cleaned_data.get("recurrence_delta_months") if count and count > 0 and not delta: self.add_error("recurrence_delta_months", _("Ce champ est requis si vous avez indiqué une répétition.")) def __init__(self, *args, user=None, thematic=None, **kwargs): super().__init__(*args, **kwargs) type_value = self.initial.get('type') or (self.data.get('type') if self.data else None) or 'care' if type_value == 'inspection': self.fields['status'].choices = [ ('in_preparation', _('En préparation')), ('to_be_approved', _('À approuver')), ('to_be_planned', _('À planifier')), ('to_be_processed', _('À traiter')), ('assigned', _('Pris en charge')), ] if user is not None: try: user_config = UserConfig.objects.get(user=user) # Récupérer les thématiques pour lesquelles l'utilisateur peut éditer des interventions user_thematics_ids = UserThematics.objects.filter( user_config=user_config, can_edit_interventions=True ).values_list('thematic_id', flat=True) # Filtrer les contrats : # 1. Contrats accessibles à l'utilisateur # 2. Contrats associés à au moins une thématique que l'utilisateur peut éditer # OU contrats sans thématiques (pour la rétrocompatibilité) base_contracts = user_config.contracts.filter(is_active=True) # Si une thématique spécifique est fournie, filtrer aussi par cette thématique if thematic: # Contrats liés à cette thématique spécifique contracts_qs = base_contracts.filter( Q(thematics=thematic) ).distinct() else: # Contrats liés aux thématiques de l'utilisateur contracts_qs = base_contracts.filter( Q(thematics__id__in=user_thematics_ids) ).distinct() self.fields['contract'].queryset = contracts_qs except UserConfig.DoesNotExist: self.fields['contract'].queryset = Contract.objects.none() # Récupérer contract_id proprement (initial ou data) contract_id = None # 1) Si on a un initial pour contract, il peut être un objet ou un id initial_contract = self.initial.get("contract") if initial_contract: if hasattr(initial_contract, "id"): contract_id = initial_contract.id else: try: contract_id = int(initial_contract) except (ValueError, TypeError): contract_id = None # 2) Si POST, la data peut définir/écraser le contrat raw_contract = self.data.get("contract") if raw_contract: try: contract_id = int(raw_contract) except (ValueError, TypeError): pass if contract_id: self.fields["order"].queryset = ContractOrder.objects.filter(contract_id=contract_id) # On enlève le disabled si présent, mais on ne touche pas à la valeur self.fields["order"].widget.attrs.pop("disabled", None) else: # Pas de contrat connu → pas de commandes proposées self.fields["order"].queryset = ContractOrder.objects.none() # Tri alphabétique des templates d'opération (OperationTemplate n'a qu'un champ 'name') if thematic: templates_qs = OperationTemplate.objects.filter(thematic=thematic).order_by(Lower('name')) else: templates_qs = OperationTemplate.objects.all().order_by(Lower('name')) self.fields['operation_templates'].queryset = templates_qs class AssetsLinkForm(forms.Form): assets = forms.JSONField(required=False) def clean_assets(self): data = self.cleaned_data.get("assets") or [] if not isinstance(data, list): raise forms.ValidationError(_("assets doit être une liste.")) norm = [] for i, a in enumerate(data): if not isinstance(a, dict): raise forms.ValidationError( _("Item #%(index)s doit être un objet JSON."), code="invalid_json", params={"index": i+1} ) try: model = str(a["model"]).lower().strip() object_id = int(a["object_id"]) norm.append({"model": model, "object_id": object_id}) except Exception: raise forms.ValidationError( _("Item #%(index)s invalide"), params={"index": i+1} ) return norm class LinkExistingInterventionsForm(forms.Form): codes = forms.CharField( label=_("Codes d'intervention"), required=True, help_text=_("Séparez les codes par des espaces, des virgules ou des retours à la ligne."), widget=forms.Textarea(attrs={"rows": 1}), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["codes"].widget.attrs.update({ "class": "form-control form-control-sm", "placeholder": _("Ex.: I000123 I000456"), }) def clean_codes(self): raw_value = self.cleaned_data.get("codes", "") tokens = re.split(r"[\s,;]+", raw_value) normalized = [] for token in tokens: token = token.strip().upper() if not token: continue if token not in normalized: normalized.append(token) if not normalized: raise forms.ValidationError(_("Veuillez indiquer au moins un code d'intervention.")) return normalized class CustomOperationForm(forms.Form): """Form for adding custom operations to an intervention""" OPERATION_TYPE_CHOICES = [ ('measurement', _('Mesure')), ('status', _('Choix de statut')), ] operation_type = forms.ChoiceField( choices=OPERATION_TYPE_CHOICES, label=_("Type d'opération"), widget=forms.RadioSelect, initial='status' ) name_fr = forms.CharField( label=_("Nom (FR)"), max_length=255, required=False, # Not individually required, but at least one name is required widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Nom de l\'opération en français')}) ) name_nl = forms.CharField( label=_("Nom (NL)"), max_length=255, required=False, # Not individually required, but at least one name is required widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Naam van de operatie in het Nederlands')}) ) # For status-based operations status_set = forms.ModelChoiceField( queryset=OperationStatusSet.objects.all(), label=_("Ensemble de statuts"), required=False, widget=forms.Select(attrs={'class': 'form-select'}) ) # For measurement operations measurement_definition = forms.ModelChoiceField( queryset=MeasurementDefinition.objects.all(), label=_("Définition de mesure"), required=False, widget=forms.Select(attrs={'class': 'form-select'}) ) template_group = forms.CharField( label=_("Groupe (optionnel)"), max_length=255, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Ex: Opérations personnalisées')}) ) def clean(self): cleaned_data = super().clean() operation_type = cleaned_data.get('operation_type') # Check that at least one name (FR or NL) is provided name_fr = cleaned_data.get('name_fr', '').strip() name_nl = cleaned_data.get('name_nl', '').strip() if not name_fr and not name_nl: raise forms.ValidationError(_("Vous devez fournir au moins un nom (FR ou NL).")) # If only one name is provided, copy it to the other if name_fr and not name_nl: cleaned_data['name_nl'] = name_fr elif name_nl and not name_fr: cleaned_data['name_fr'] = name_nl if operation_type == 'status': if not cleaned_data.get('status_set'): raise forms.ValidationError(_("Vous devez sélectionner un ensemble de statuts pour une opération de type 'Choix'.")) elif operation_type == 'measurement': if not cleaned_data.get('measurement_definition'): raise forms.ValidationError(_("Vous devez sélectionner une définition de mesure pour une opération de type 'Mesure'.")) return cleaned_data