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(