diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py
index f8ecf9c..d4477c2 100644
--- a/loko/interventions/admin.py
+++ b/loko/interventions/admin.py
@@ -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',),
diff --git a/loko/interventions/migrations/0003_operationtemplate_team.py b/loko/interventions/migrations/0003_operationtemplate_team.py
new file mode 100644
index 0000000..3dc89e2
--- /dev/null
+++ b/loko/interventions/migrations/0003_operationtemplate_team.py
@@ -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'),
+ ),
+ ]
diff --git a/loko/interventions/models.py b/loko/interventions/models.py
index 8e0d115..adb62a1 100644
--- a/loko/interventions/models.py
+++ b/loko/interventions/models.py
@@ -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,
diff --git a/loko/interventions/static/interventions/admin_operation_template_dynamic.js b/loko/interventions/static/interventions/admin_operation_template_dynamic.js
index 7ed5753..35d7b75 100644
--- a/loko/interventions/static/interventions/admin_operation_template_dynamic.js
+++ b/loko/interventions/static/interventions/admin_operation_template_dynamic.js
@@ -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($('').val('').text('---------'));
+ var hasCurrentVal = false;
+ data.teams.forEach(function(team) {
+ var 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
diff --git a/loko/interventions/tests/test_preventive_templates.py b/loko/interventions/tests/test_preventive_templates.py
index 9c765ec..0336a19 100644
--- a/loko/interventions/tests/test_preventive_templates.py
+++ b/loko/interventions/tests/test_preventive_templates.py
@@ -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)
+
+
+
+
diff --git a/loko/interventions/urls.py b/loko/interventions/urls.py
index 4d180b9..b1b5d79 100644
--- a/loko/interventions/urls.py
+++ b/loko/interventions/urls.py
@@ -102,6 +102,7 @@ urlpatterns = [
path('/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'),
diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py
index 1dda605..454e0b8 100644
--- a/loko/interventions/views/detail.py
+++ b/loko/interventions/views/detail.py
@@ -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)
diff --git a/loko/interventions/views/general.py b/loko/interventions/views/general.py
index f2dc3f5..b365c85 100644
--- a/loko/interventions/views/general.py
+++ b/loko/interventions/views/general.py
@@ -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.
diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py
index ed4ec82..596ea7d 100644
--- a/loko/interventions/views/helpers.py
+++ b/loko/interventions/views/helpers.py
@@ -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:
diff --git a/loko/interventions/views/preventive.py b/loko/interventions/views/preventive.py
index df6142b..5ac08e9 100644
--- a/loko/interventions/views/preventive.py
+++ b/loko/interventions/views/preventive.py
@@ -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
diff --git a/loko/interventions/views/preventive_import.py b/loko/interventions/views/preventive_import.py
index 578a79e..2105a5f 100644
--- a/loko/interventions/views/preventive_import.py
+++ b/loko/interventions/views/preventive_import.py
@@ -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(