feat: implement preventive intervention templates and operation grouping system

This commit is contained in:
Karl Determe 2026-08-19 07:35:50 +02:00
parent ca6d09c6b1
commit b15326602b
11 changed files with 430 additions and 5 deletions

View file

@ -639,6 +639,10 @@ class OperationTemplateItemInline(admin.TabularInline):
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."""
@ -655,7 +659,7 @@ class OperationTemplateAdminForm(forms.ModelForm):
class Meta:
model = OperationTemplate
fields = ['code', 'name', 'description', 'thematic', 'categories']
fields = ['code', 'name', 'description', 'thematic', 'team', 'guided_template', 'categories']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -686,6 +690,15 @@ class OperationTemplateAdminForm(forms.ModelForm):
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 = []
@ -767,13 +780,13 @@ class OperationTemplateAdminForm(forms.ModelForm):
@admin.register(OperationTemplate)
class OperationTemplateAdmin(admin.ModelAdmin):
form = OperationTemplateAdminForm
list_display = ('code', 'name', 'description', 'thematic', 'categories_list', 'models_list')
list_filter = ('thematic', 'categories')
list_display = ('code', 'name', 'description', 'thematic', 'team', 'categories_list', 'models_list')
list_filter = ('thematic', 'team', 'categories')
filter_horizontal = ('categories',)
inlines = [OperationTemplateItemInline]
fieldsets = (
(None, {
'fields': ('code', 'name', 'description', 'thematic', 'guided_template')
'fields': ('code', 'name', 'description', 'thematic', 'team', 'guided_template')
}),
('Catégories d\'équipements concernées (optionnel)', {
'fields': ('categories',),

View file

@ -0,0 +1,20 @@
# Generated by Django 6.0.7 on 2026-08-19 05:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contracts', '0002_initial'),
('interventions', '0002_remove_intervention_origin_fms_nbr_and_more'),
]
operations = [
migrations.AddField(
model_name='operationtemplate',
name='team',
field=models.ForeignKey(blank=True, help_text="Si définie, cette équipe sera automatiquement assignée lors de la création d'interventions préventives utilisant cette gamme.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='operation_templates', to='contracts.companyteam', verbose_name='Équipe associée'),
),
]

View file

@ -1372,6 +1372,15 @@ class OperationTemplate(models.Model):
name = models.CharField(max_length=255, unique=True)
description = models.TextField(blank=True)
thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True)
team = models.ForeignKey(
CompanyTeam,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='operation_templates',
verbose_name=_("Équipe associée"),
help_text=_("Si définie, cette équipe sera automatiquement assignée lors de la création d'interventions préventives utilisant cette gamme.")
)
guided_template = models.ForeignKey(
GuidedOperationTemplate,
on_delete=models.SET_NULL,

View file

@ -17,6 +17,7 @@
$(document).ready(function() {
var $thematic = $('#id_thematic');
var $team = $('#id_team');
var $categoriesFrom = $('#id_categories_from');
var $categoriesTo = $('#id_categories_to');
var $assetModelsFrom = $('#id_asset_models_from');
@ -24,6 +25,41 @@
if (!$thematic.length) return;
/**
* Update teams based on selected thematic
*/
function updateTeams() {
if (!$team.length) return;
var thematicId = $thematic.val();
var currentTeamVal = $team.val();
$.ajax({
url: '/interventions/ajax/get-teams-by-thematic/',
data: { thematic_id: thematicId || '' },
success: function(data) {
$team.empty();
$team.append($('<option></option>').val('').text('---------'));
var hasCurrentVal = false;
data.teams.forEach(function(team) {
var option = $('<option></option>')
.val(team.id)
.text(team.name);
if (String(team.id) === String(currentTeamVal)) {
option.prop('selected', true);
hasCurrentVal = true;
}
$team.append(option);
});
if (!hasCurrentVal && currentTeamVal) {
$team.val('');
}
},
error: function() {
console.error('Error fetching teams');
}
});
}
/**
* Update categories based on selected thematic
*/
@ -131,6 +167,7 @@
// Listen to thematic changes
$thematic.on('change', function() {
updateCategories();
updateTeams();
});
// Listen to categories changes using MutationObserver

View file

@ -1,4 +1,5 @@
import json
import time
from datetime import timedelta
from unittest import mock
@ -603,6 +604,286 @@ class ExternalManagerPreventivePermissionsTests(TestCase):
self.assertTrue(should_show_created_by_provider_badge(self.internal_manager, intervention))
class PreventiveTemplateTeamAssignmentTests(TestCase):
"""Test automatic team assignment from OperationTemplate in preventive interventions."""
def setUp(self):
User = get_user_model()
self.thematic = Thematic.objects.create(code='trafficlights', name_fr='Signalisation', name_nl='Signalisatie')
self.company = Company.objects.create(name='Provider Corp')
self.team_a = CompanyTeam.objects.create(name='Equipe Alpha', company=self.company)
self.team_a.thematics.add(self.thematic)
self.team_b = CompanyTeam.objects.create(name='Equipe Beta', company=self.company)
self.team_b.thematics.add(self.thematic)
from datetime import date, timedelta
today = date.today()
self.contract = Contract.objects.create(
contract_number='C-TEST-001',
company=self.company,
is_active=True,
start_date=today - timedelta(days=30),
end_date=today + timedelta(days=365)
)
self.contract.thematics.add(self.thematic)
self.user = User.objects.create_user(username='manager_user', password='pwd')
self.role_manager = Role.objects.create(name='manager')
self.config = UserConfig.objects.create(user=self.user, is_intern=True)
self.config.roles.add(self.role_manager)
self.config.contracts.add(self.contract)
UserThematics.objects.create(user_config=self.config, thematic=self.thematic, can_edit_interventions=True)
self.status_set = OperationStatusSet.objects.create(name='Standard Status Set')
# Template with team A
self.template_with_team = OperationTemplate.objects.create(
name='Gamme Entretien A',
description='Gamme avec équipe A',
thematic=self.thematic,
team=self.team_a,
)
OperationTemplateItem.objects.create(
template=self.template_with_team,
name_fr='Vérification A',
name_nl='Controle A',
status_set=self.status_set,
is_generic=True,
order=1,
)
# Template with team B
self.template_with_team_b = OperationTemplate.objects.create(
name='Gamme Entretien B',
description='Gamme avec équipe B',
thematic=self.thematic,
team=self.team_b,
)
OperationTemplateItem.objects.create(
template=self.template_with_team_b,
name_fr='Vérification B',
name_nl='Controle B',
status_set=self.status_set,
is_generic=True,
order=1,
)
# Template without team
self.template_without_team = OperationTemplate.objects.create(
name='Gamme Sans Equipe',
description='Gamme sans équipe',
thematic=self.thematic,
team=None,
)
OperationTemplateItem.objects.create(
template=self.template_without_team,
name_fr='Vérification C',
name_nl='Controle C',
status_set=self.status_set,
is_generic=True,
order=1,
)
def test_assign_team_from_templates_helper(self):
"""Test _assign_team_from_templates helper function directly."""
from interventions.views.helpers import _assign_team_from_templates
itv = Intervention.objects.create(
title='Test ITV',
status='in_preparation',
maintain_type='preventive',
thematic=self.thematic,
)
# No templates -> no change
_assign_team_from_templates(itv, [])
self.assertIsNone(itv.assigned_team)
# Template without team -> no change
_assign_team_from_templates(itv, [self.template_without_team])
self.assertIsNone(itv.assigned_team)
# Template with team -> assigned
_assign_team_from_templates(itv, [self.template_with_team])
itv.refresh_from_db()
self.assertEqual(itv.assigned_team, self.team_a)
self.assertEqual(itv.assigned_provider, self.company)
def test_preventive_creation_auto_assigns_team_single_mode(self):
"""Test preventive intervention creation assigns team from template in single_for_all mode."""
self.client.login(username='manager_user', password='pwd')
url = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': 'trafficlights'})
session = self.client.session
session['intervention_drafts'] = {
'draft-team': {
'locations': [],
'assets': [],
'form_data': {},
'timestamp': time.time(),
}
}
session.save()
post_data = {
'title': 'Preventive with team',
'status': 'in_preparation',
'type': 'care',
'contract': self.contract.id,
'operation_templates': [str(self.template_with_team.id)],
'expected_begin_time': '2026-08-20T08:00',
'expected_end_time': '2026-08-20T17:00',
'recurrence_count': 0,
'type_of_selected_elements': 'locations',
'intervention_creation_mode': 'single_for_all',
'draft_id': 'draft-team',
}
response = self.client.post(url, post_data)
self.assertEqual(response.status_code, 302)
intervention = Intervention.objects.filter(title='Preventive with team').first()
self.assertIsNotNone(intervention)
self.assertEqual(intervention.assigned_team, self.team_a)
self.assertEqual(intervention.assigned_provider, self.company)
self.assertIn(self.template_with_team, intervention.equipment_operation_templates.all())
def test_preventive_creation_multiple_templates_first_team_assigned(self):
"""When multiple templates are selected, the first one with a team sets the assigned_team."""
self.client.login(username='manager_user', password='pwd')
url = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': 'trafficlights'})
session = self.client.session
session['intervention_drafts'] = {
'draft-multi': {
'locations': [],
'assets': [],
'form_data': {},
'timestamp': time.time(),
}
}
session.save()
# template_without_team first, then template_with_team_b
post_data = {
'title': 'Preventive multi templates',
'status': 'in_preparation',
'type': 'care',
'contract': self.contract.id,
'operation_templates': [str(self.template_without_team.id), str(self.template_with_team_b.id)],
'expected_begin_time': '2026-08-20T08:00',
'expected_end_time': '2026-08-20T17:00',
'recurrence_count': 0,
'type_of_selected_elements': 'locations',
'intervention_creation_mode': 'single_for_all',
'draft_id': 'draft-multi',
}
response = self.client.post(url, post_data)
self.assertEqual(response.status_code, 302)
intervention = Intervention.objects.filter(title='Preventive multi templates').first()
self.assertIsNotNone(intervention)
self.assertEqual(intervention.assigned_team, self.team_b)
def test_preventive_creation_without_team_template(self):
"""When template has no team, assigned_team remains None."""
self.client.login(username='manager_user', password='pwd')
url = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': 'trafficlights'})
session = self.client.session
session['intervention_drafts'] = {
'draft-noteam': {
'locations': [],
'assets': [],
'form_data': {},
'timestamp': time.time(),
}
}
session.save()
post_data = {
'title': 'Preventive without team',
'status': 'in_preparation',
'type': 'care',
'contract': self.contract.id,
'operation_templates': [str(self.template_without_team.id)],
'expected_begin_time': '2026-08-20T08:00',
'expected_end_time': '2026-08-20T17:00',
'recurrence_count': 0,
'type_of_selected_elements': 'locations',
'intervention_creation_mode': 'single_for_all',
'draft_id': 'draft-noteam',
}
response = self.client.post(url, post_data)
self.assertEqual(response.status_code, 302)
intervention = Intervention.objects.filter(title='Preventive without team').first()
self.assertIsNotNone(intervention)
self.assertIsNone(intervention.assigned_team)
def test_preventive_creation_auto_assigns_team_one_per_element_locations(self):
"""Test preventive creation in one_per_element mode auto-assigns team to each intervention."""
from assets.models import TrafficLightIntersection
loc = TrafficLightIntersection.objects.create(code='TL-001', name_fr='Carrefour 1')
self.client.login(username='manager_user', password='pwd')
url = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': 'trafficlights'})
session = self.client.session
session['intervention_drafts'] = {
'draft-loc': {
'locations': [loc.id],
'assets': [],
'form_data': {},
'timestamp': time.time(),
}
}
session.save()
post_data = {
'title': 'Preventive Location',
'status': 'in_preparation',
'type': 'care',
'contract': self.contract.id,
'operation_templates': [str(self.template_with_team.id)],
'expected_begin_time': '2026-08-20T08:00',
'expected_end_time': '2026-08-20T17:00',
'recurrence_count': 0,
'type_of_selected_elements': 'locations',
'intervention_creation_mode': 'one_per_element',
'draft_id': 'draft-loc',
}
response = self.client.post(url, post_data)
self.assertEqual(response.status_code, 302)
intervention = Intervention.objects.filter(location_code='TL-001').first()
self.assertIsNotNone(intervention)
self.assertEqual(intervention.assigned_team, self.team_a)
self.assertEqual(intervention.assigned_provider, self.company)
def test_ajax_get_teams_by_thematic(self):
"""Test the AJAX endpoint ajax_get_teams_by_thematic."""
self.client.login(username='manager_user', password='pwd')
url = reverse('interventions:ajax_get_teams_by_thematic')
# Filter by thematic
response = self.client.get(f"{url}?thematic_id={self.thematic.id}")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('teams', data)
team_ids = [t['id'] for t in data['teams']]
self.assertIn(self.team_a.id, team_ids)
self.assertIn(self.team_b.id, team_ids)
# Without thematic -> all teams
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('teams', data)
self.assertGreaterEqual(len(data['teams']), 2)

View file

@ -102,6 +102,7 @@ urlpatterns = [
path('<int:intervention_id>/mobile/transfer/', views.transfer_intervention, name='transfer_intervention'),
# Admin AJAX endpoints
path('ajax/get-categories-by-thematic/', views.ajax_get_categories_by_thematic, name='ajax_get_categories_by_thematic'),
path('ajax/get-teams-by-thematic/', views.ajax_get_teams_by_thematic, name='ajax_get_teams_by_thematic'),
path('ajax/get-subcategories/', views.ajax_get_subcategories, name='ajax_get_subcategories'),
path('ajax/get-asset-models/', views.ajax_get_asset_models, name='ajax_get_asset_models'),
path('ajax/get-replacement-models-by-category/', views.ajax_get_replacement_models_by_category, name='ajax_get_replacement_models_by_category'),

View file

@ -1677,6 +1677,7 @@ def interventions_add(request, thematic_code):
if templates:
intervention.equipment_operation_templates.add(*templates)
_assign_guided_template(intervention, templates)
_assign_team_from_templates(intervention, templates)
_add_operations_from_templates(intervention, templates, only_generic=True)
if asset_pairs:
resolved_assets = _resolve_assets_from_pairs(asset_pairs)

View file

@ -1145,6 +1145,32 @@ def ajax_get_categories_by_thematic(request):
return JsonResponse({'error': str(e)}, status=400)
def ajax_get_teams_by_thematic(request):
"""Get teams filtered by thematic for OperationTemplate admin."""
thematic_id = request.GET.get('thematic_id')
try:
from contracts.models import CompanyTeam
if thematic_id:
teams = CompanyTeam.objects.filter(
Q(thematics__id=thematic_id) | Q(thematics__isnull=True)
).distinct().select_related('company').order_by('name')
else:
teams = CompanyTeam.objects.select_related('company').order_by('name')
teams_data = [
{
'id': t.id,
'name': str(t),
}
for t in teams
]
return JsonResponse({'teams': teams_data})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
def ajax_get_subcategories(request):
"""AJAX endpoint to get subcategories (children) of a given category.
Used by intervention add/edit forms to populate the sub-category select.

View file

@ -146,6 +146,7 @@ __all__ = [
'_asset_matches_filters',
'_asset_matches_template_filters',
'_assign_guided_template',
'_assign_team_from_templates',
'_attach_equipment_templates',
'_build_guided_media_json',
'_build_link_groups',
@ -1102,6 +1103,26 @@ def _assign_guided_template(intervention, templates):
def _assign_team_from_templates(intervention, templates):
"""
If any selected OperationTemplate has a team FK set,
auto-assign the first one found to the intervention.
"""
if not templates:
return
for tmpl in templates:
if tmpl.team_id:
intervention.assigned_team = tmpl.team
fields_to_update = ['assigned_team']
if not intervention.assigned_provider_id and tmpl.team.company_id:
intervention.assigned_provider = tmpl.team.company
fields_to_update.append('assigned_provider')
intervention.save(update_fields=fields_to_update)
return
def _get_equipment_templates_for_intervention(intervention):
templates = list(intervention.equipment_operation_templates.all())
if templates:

View file

@ -319,17 +319,28 @@ def intervention_add_preventive(request, thematic_code):
# Utiliser la même commande pour toutes les répétitions
orders_list = [order] * recurrence_count
# --- Pré-déterminer l'équipe associée aux templates d'opérations
template_assigned_team = None
for tmpl in operation_templates:
if tmpl.team_id:
template_assigned_team = tmpl.team
break
# --- Fonction utilitaire pour créer une intervention
def create_base_intervention(i: int, order_for_intervention):
start_date = expected_start + i * recurrence_delta
end_date = expected_end + i * recurrence_delta
assigned_provider = contract.company if contract else None
if not assigned_provider and template_assigned_team and template_assigned_team.company_id:
assigned_provider = template_assigned_team.company
intervention = Intervention.objects.create(
title=title,
status=status,
status_order=STATUS_ORDERS[status],
contract=contract,
order=order_for_intervention,
assigned_provider=contract.company if contract else None,
assigned_provider=assigned_provider,
assigned_team=template_assigned_team,
expected_begin_time=start_date,
expected_end_time=end_date,
maintain_type="preventive",
@ -437,6 +448,7 @@ def intervention_add_preventive(request, thematic_code):
intervention = create_base_intervention(i, order_for_intervention)
_attach_equipment_templates(intervention, operation_templates)
_assign_guided_template(intervention, operation_templates)
_assign_team_from_templates(intervention, operation_templates)
# 1) Lien générique intervention ↔ localisation
ct = ContentType.objects.get_for_model(loc.__class__)
@ -563,6 +575,7 @@ def intervention_add_preventive(request, thematic_code):
intervention = create_base_intervention(i, order_for_intervention)
_attach_equipment_templates(intervention, operation_templates)
_assign_guided_template(intervention, operation_templates)
_assign_team_from_templates(intervention, operation_templates)
# 1) Lien générique Asset ↔ Intervention
InterventionAsset.objects.get_or_create(
@ -633,6 +646,7 @@ def intervention_add_preventive(request, thematic_code):
intervention = create_base_intervention(i, order_for_intervention)
_attach_equipment_templates(intervention, operation_templates)
_assign_guided_template(intervention, operation_templates)
_assign_team_from_templates(intervention, operation_templates)
# Utiliser le titre de base
intervention.title = title

View file

@ -486,6 +486,8 @@ def process_worksheet(
# Lien template d'opérations
intervention.equipment_operation_templates.add(template)
from interventions.views.helpers import _assign_team_from_templates
_assign_team_from_templates(intervention, [template])
# Timelines
InterventionTimeLine.objects.create(