735 lines
No EOL
30 KiB
Python
735 lines
No EOL
30 KiB
Python
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 |