diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index e52a60f..331b9b1 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -90,6 +90,35 @@ def _get_symptom_autoclose_inline(): return SymptomAutoCloseInline +def _get_operation_template_posts_inline(): + from interventions.models import ContractOperationTemplatePost, OperationTemplate + from django.db.models import Q + + class ContractOperationTemplatePostInline(admin.TabularInline): + model = ContractOperationTemplatePost + extra = 0 + fields = ('operation_template', 'contract_post', 'default_quantity', 'comment') + verbose_name = "Poste par défaut (Template d'opérations)" + verbose_name_plural = "Postes par défaut (Templates d'opérations)" + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "contract_post": + contract_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None + if contract_id: + kwargs["queryset"] = ContractPost.objects.filter(contract_id=contract_id).order_by('order_number') + elif db_field.name == "operation_template": + contract_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None + if contract_id: + contract = Contract.objects.filter(pk=contract_id).first() + if contract and contract.thematics.exists(): + kwargs["queryset"] = OperationTemplate.objects.filter( + Q(thematic__in=contract.thematics.all()) | Q(thematic__isnull=True) + ).distinct().order_by('name') + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + return ContractOperationTemplatePostInline + + @admin.register(Company) class CompanyAdmin(admin.ModelAdmin): list_display = ('name', 'contact_email', 'phone_number') @@ -102,7 +131,7 @@ class ContractAdmin(admin.ModelAdmin): filter_horizontal = ('thematics',) def get_inlines(self, request, obj=None): - return [_get_pause_reason_inline(), _get_symptom_autoclose_inline()] + list(super().get_inlines(request, obj)) + return [_get_pause_reason_inline(), _get_symptom_autoclose_inline(), _get_operation_template_posts_inline()] + list(super().get_inlines(request, obj)) fieldsets = ( (None, { diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py index d4477c2..d58c428 100644 --- a/loko/interventions/admin.py +++ b/loko/interventions/admin.py @@ -31,6 +31,7 @@ from .models import ( InspectionConfiguration, InspectionConfigurationContract, OperationTemplate, OperationTemplateItem, OperationType, OperationTemplateAssetModelFilter, + ContractOperationTemplatePost, Operation, OperationPhoto ) from .services import generate_interventions_excel @@ -777,13 +778,23 @@ class OperationTemplateAdminForm(forms.ModelForm): js = ('interventions/admin_operation_template_dynamic.js',) +class OperationTemplateContractPostInline(admin.TabularInline): + model = ContractOperationTemplatePost + extra = 0 + fields = ('contract', 'contract_post', 'default_quantity', 'comment') + verbose_name = "Poste de contrat par défaut" + verbose_name_plural = "Postes de contrats par défaut" + autocomplete_fields = ('contract', 'contract_post') + + @admin.register(OperationTemplate) class OperationTemplateAdmin(admin.ModelAdmin): form = OperationTemplateAdminForm list_display = ('code', 'name', 'description', 'thematic', 'team', 'categories_list', 'models_list') + search_fields = ('name', 'code', 'description') list_filter = ('thematic', 'team', 'categories') filter_horizontal = ('categories',) - inlines = [OperationTemplateItemInline] + inlines = [OperationTemplateItemInline, OperationTemplateContractPostInline] fieldsets = ( (None, { 'fields': ('code', 'name', 'description', 'thematic', 'team', 'guided_template') @@ -813,6 +824,19 @@ class OperationTemplateAdmin(admin.ModelAdmin): return ', '.join(str(f) for f in filters) + ('...' if obj.asset_model_filters.count() > 3 else '') +@admin.register(ContractOperationTemplatePost) +class ContractOperationTemplatePostAdmin(admin.ModelAdmin): + list_display = ('contract', 'operation_template', 'contract_post', 'default_quantity', 'comment') + list_filter = ('contract', 'operation_template', 'contract__thematics') + search_fields = ( + 'contract__contract_number', + 'operation_template__name', + 'contract_post__code', + 'contract_post__description_fr', + ) + autocomplete_fields = ('contract', 'operation_template', 'contract_post') + + @admin.register(OperationTemplateItem) class OperationTemplateItemAdmin(admin.ModelAdmin): list_display = ('code', 'get_name', 'template', 'status_set', 'measurement_definition', 'is_generic', 'order') diff --git a/loko/interventions/migrations/0005_contractoperationtemplatepost.py b/loko/interventions/migrations/0005_contractoperationtemplatepost.py new file mode 100644 index 0000000..3b8e724 --- /dev/null +++ b/loko/interventions/migrations/0005_contractoperationtemplatepost.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.7 on 2026-08-19 13:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0003_contract_allow_team_members_edit'), + ('interventions', '0004_alter_intervention_type'), + ] + + operations = [ + migrations.CreateModel( + name='ContractOperationTemplatePost', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('default_quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Quantité par défaut')), + ('comment', models.CharField(blank=True, max_length=1024, null=True, verbose_name='Commentaire par défaut')), + ('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_template_posts', to='contracts.contract', verbose_name='Contrat')), + ('contract_post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_template_posts', to='contracts.contractpost', verbose_name='Poste de contrat')), + ('operation_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contract_posts', to='interventions.operationtemplate', verbose_name="Template d'opérations")), + ], + options={ + 'verbose_name': "Poste par défaut (Contrat × Template d'opérations)", + 'verbose_name_plural': "Postes par défaut (Contrats × Templates d'opérations)", + 'ordering': ['contract', 'operation_template', 'contract_post__order_number'], + 'unique_together': {('contract', 'operation_template', 'contract_post')}, + }, + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 7f1ffa1..2b776ec 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -2,6 +2,7 @@ from django.contrib.gis.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.conf import settings +from django.core.exceptions import ValidationError from django.utils.translation import get_language, gettext_lazy as _ from django.utils import timezone from django.db.models import Sum, F, Q @@ -1678,6 +1679,65 @@ class OperationTemplateItem(models.Model): return f"{self.template.name} - {self.name_fr}" +class ContractOperationTemplatePost(models.Model): + """ + Associe un poste de contrat à un template d'opérations avec une quantité par défaut. + Utilisé lors de la création d'interventions préventives pour pré-remplir les postes de contrat. + """ + contract = models.ForeignKey( + Contract, + on_delete=models.CASCADE, + related_name='operation_template_posts', + verbose_name=_("Contrat"), + ) + operation_template = models.ForeignKey( + OperationTemplate, + on_delete=models.CASCADE, + related_name='contract_posts', + verbose_name=_("Template d'opérations"), + ) + contract_post = models.ForeignKey( + ContractPost, + on_delete=models.CASCADE, + related_name='operation_template_posts', + verbose_name=_("Poste de contrat"), + ) + default_quantity = models.DecimalField( + max_digits=10, + decimal_places=2, + default=1, + verbose_name=_("Quantité par défaut"), + ) + comment = models.CharField( + max_length=1024, + blank=True, + null=True, + verbose_name=_("Commentaire par défaut"), + ) + + class Meta: + unique_together = [('contract', 'operation_template', 'contract_post')] + ordering = ['contract', 'operation_template', 'contract_post__order_number'] + verbose_name = _("Poste par défaut (Contrat × Template d'opérations)") + verbose_name_plural = _("Postes par défaut (Contrats × Templates d'opérations)") + + def __str__(self): + return f"{self.contract.contract_number} × {self.operation_template.name} → {self.contract_post.code} ({self.default_quantity})" + + def clean(self): + super().clean() + if self.contract_post_id and self.contract_id: + if self.contract_post.contract_id != self.contract_id: + raise ValidationError({ + 'contract_post': _("Ce poste n'appartient pas au contrat sélectionné.") + }) + + def save(self, *args, **kwargs): + if self.contract_post_id and not self.contract_id: + self.contract = self.contract_post.contract + super().save(*args, **kwargs) + + EMAIL_LOG_TYPE_CHOICES = { 'transmission': _('Envoi de la fiche'), 'reminder': _('Rappel'), diff --git a/loko/interventions/tests/test_preventive_templates.py b/loko/interventions/tests/test_preventive_templates.py index 01f51e4..82aa75d 100644 --- a/loko/interventions/tests/test_preventive_templates.py +++ b/loko/interventions/tests/test_preventive_templates.py @@ -15,6 +15,7 @@ from assets.models import AssetCategory from interventions.models import ( Intervention, InterventionContractPost, + ContractOperationTemplatePost, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, Operation, @@ -882,6 +883,287 @@ class PreventiveTemplateTeamAssignmentTests(TestCase): self.assertGreaterEqual(len(data['teams']), 2) +class ContractOperationTemplatePostTests(TestCase): + """Test associating ContractPosts with default quantities to OperationTemplates and preventive interventions.""" + + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username='test_user_posts', password='password123') + self.thematic, _ = Thematic.objects.get_or_create( + code='trafficlights', + defaults={'name_fr': 'Feux', 'name_nl': 'Verkeerslichten'} + ) + self.role, _ = Role.objects.get_or_create(name='manager') + self.user_config = UserConfig.objects.create(user=self.user, is_intern=True) + self.user_config.roles.add(self.role) + + self.company = Company.objects.create(name='Provider Co') + self.contract = Contract.objects.create( + contract_number='CTR-TEST-001', + company=self.company, + start_date=timezone.now().date(), + end_date=timezone.now().date() + timedelta(days=365), + is_active=True, + ) + self.contract.thematics.add(self.thematic) + self.user_config.contracts.add(self.contract) + UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic, can_edit_interventions=True) + + self.contract_other = Contract.objects.create( + contract_number='CTR-OTHER-002', + company=self.company, + start_date=timezone.now().date(), + end_date=timezone.now().date() + timedelta(days=365), + is_active=True, + ) + + self.post_1 = ContractPost.objects.create( + contract=self.contract, + code='P01', + description_fr='Poste 1 FR', + description_nl='Post 1 NL', + order_number=1, + unit='pc', + unit_price=Decimal('10.00'), + ) + self.post_2 = ContractPost.objects.create( + contract=self.contract, + code='P02', + description_fr='Poste 2 FR', + description_nl='Post 2 NL', + order_number=2, + unit='m', + unit_price=Decimal('25.50'), + ) + self.post_3 = ContractPost.objects.create( + contract=self.contract, + code='P03', + description_fr='Poste 3 FR', + description_nl='Post 3 NL', + order_number=3, + unit='h', + unit_price=Decimal('50.00'), + ) + self.post_other = ContractPost.objects.create( + contract=self.contract_other, + code='P_OTHER', + description_fr='Poste Autre Contrat', + description_nl='Post Andere', + order_number=1, + unit='pc', + unit_price=Decimal('100.00'), + ) + + self.template_a = OperationTemplate.objects.create( + name='Template A', + thematic=self.thematic + ) + self.template_b = OperationTemplate.objects.create( + name='Template B', + thematic=self.thematic + ) + + def test_model_str_and_save_clean(self): + """Test model validation, clean method, auto-contract and string representation.""" + from django.core.exceptions import ValidationError + + # Auto-populates contract if only contract_post is set + mapping = ContractOperationTemplatePost( + operation_template=self.template_a, + contract_post=self.post_1, + default_quantity=Decimal('2.00'), + comment='Commentaire test' + ) + mapping.save() + self.assertEqual(mapping.contract, self.contract) + self.assertIn('CTR-TEST-001', str(mapping)) + self.assertIn('Template A', str(mapping)) + self.assertIn('P01', str(mapping)) + + # Invalid: contract_post belongs to another contract + invalid_mapping = ContractOperationTemplatePost( + contract=self.contract, + operation_template=self.template_a, + contract_post=self.post_other, + default_quantity=Decimal('1.00') + ) + with self.assertRaises(ValidationError): + invalid_mapping.clean() + + def test_assign_contract_posts_from_templates_single(self): + """Test assigning default posts to an intervention from a single template.""" + from interventions.views.helpers import _assign_contract_posts_from_templates + + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_a, + contract_post=self.post_1, + default_quantity=Decimal('3.00'), + comment='Nettoyage standard' + ) + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_a, + contract_post=self.post_2, + default_quantity=Decimal('1.50'), + ) + + intervention = Intervention.objects.create( + title='Intervention Test', + contract=self.contract, + thematic=self.thematic, + maintain_type='preventive', + created_by=self.user + ) + + _assign_contract_posts_from_templates(intervention, [self.template_a]) + + posts = list(intervention.interv_contract_posts.all().order_by('contract_post__order_number')) + self.assertEqual(len(posts), 2) + self.assertEqual(posts[0].contract_post, self.post_1) + self.assertEqual(posts[0].quantity, Decimal('3.00')) + self.assertEqual(posts[0].comment, 'Nettoyage standard') + self.assertEqual(posts[1].contract_post, self.post_2) + self.assertEqual(posts[1].quantity, Decimal('1.50')) + + def test_assign_contract_posts_deduplication_first_found(self): + """ + When multiple templates are chosen and share a post, + the first one encountered must be kept, and subsequent duplicates ignored. + """ + from interventions.views.helpers import _assign_contract_posts_from_templates + + # Template A defines Post 1 (qty 2) and Post 2 (qty 5) + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_a, + contract_post=self.post_1, + default_quantity=Decimal('2.00'), + comment='From Template A' + ) + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_a, + contract_post=self.post_2, + default_quantity=Decimal('5.00'), + ) + + # Template B defines Post 1 (qty 10 with other comment) and Post 3 (qty 1) + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_b, + contract_post=self.post_1, + default_quantity=Decimal('10.00'), + comment='From Template B' + ) + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_b, + contract_post=self.post_3, + default_quantity=Decimal('1.00'), + ) + + intervention = Intervention.objects.create( + title='Intervention Multi-Templates', + contract=self.contract, + thematic=self.thematic, + maintain_type='preventive', + created_by=self.user + ) + + # Pass templates in order [A, B] + _assign_contract_posts_from_templates(intervention, [self.template_a, self.template_b]) + + posts = list(intervention.interv_contract_posts.all().order_by('contract_post__order_number')) + self.assertEqual(len(posts), 3) + + # Post 1 must come from Template A (qty 2, first found) + self.assertEqual(posts[0].contract_post, self.post_1) + self.assertEqual(posts[0].quantity, Decimal('2.00')) + self.assertEqual(posts[0].comment, 'From Template A') + + # Post 2 from Template A + self.assertEqual(posts[1].contract_post, self.post_2) + self.assertEqual(posts[1].quantity, Decimal('5.00')) + + # Post 3 from Template B + self.assertEqual(posts[2].contract_post, self.post_3) + self.assertEqual(posts[2].quantity, Decimal('1.00')) + + def test_assign_contract_posts_empty_or_no_contract(self): + """Test that helper handles empty templates or no contract gracefully.""" + from interventions.views.helpers import _assign_contract_posts_from_templates + + intervention_no_contract = Intervention.objects.create( + title='No Contract', + thematic=self.thematic, + created_by=self.user + ) + _assign_contract_posts_from_templates(intervention_no_contract, [self.template_a]) + self.assertEqual(intervention_no_contract.interv_contract_posts.count(), 0) + + intervention = Intervention.objects.create( + title='With Contract', + contract=self.contract, + thematic=self.thematic, + created_by=self.user + ) + _assign_contract_posts_from_templates(intervention, []) + self.assertEqual(intervention.interv_contract_posts.count(), 0) + + def test_preventive_creation_flow_auto_creates_posts(self): + """Test full preventive intervention creation flow automatically populates posts.""" + from assets.models import TrafficLightIntersection + loc = TrafficLightIntersection.objects.create(code='TL-POST-01', name_fr='Carrefour Test') + + ContractOperationTemplatePost.objects.create( + contract=self.contract, + operation_template=self.template_a, + contract_post=self.post_1, + default_quantity=Decimal('4.00'), + comment='Auto Post' + ) + + self.client.login(username='test_user_posts', password='password123') + url = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': 'trafficlights'}) + + session = self.client.session + session['intervention_drafts'] = { + 'draft-posts': { + 'locations': [loc.id], + 'assets': [], + 'form_data': {}, + 'timestamp': time.time(), + } + } + session.save() + + post_data = { + 'title': 'Preventive With Posts', + 'status': 'in_preparation', + 'type': 'care', + 'contract': self.contract.id, + 'operation_templates': [str(self.template_a.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-posts', + } + response = self.client.post(url, post_data) + self.assertEqual(response.status_code, 302) + + intervention = Intervention.objects.filter(location_code='TL-POST-01').first() + self.assertIsNotNone(intervention) + posts = list(intervention.interv_contract_posts.all()) + self.assertEqual(len(posts), 1) + self.assertEqual(posts[0].contract_post, self.post_1) + self.assertEqual(posts[0].quantity, Decimal('4.00')) + self.assertEqual(posts[0].comment, 'Auto Post') + + + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 3581c48..df5f051 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -1868,6 +1868,7 @@ def interventions_add(request, thematic_code): intervention.equipment_operation_templates.add(*templates) _assign_guided_template(intervention, templates) _assign_team_from_templates(intervention, templates) + _assign_contract_posts_from_templates(intervention, templates) _add_operations_from_templates(intervention, templates, only_generic=True) if asset_pairs: resolved_assets = _resolve_assets_from_pairs(asset_pairs) @@ -1884,6 +1885,8 @@ def interventions_add(request, thematic_code): ) if resolved_contract: intervention.contract = resolved_contract + if inspection_config and templates: + _assign_contract_posts_from_templates(intervention, templates) if resolved_provider and not intervention.assigned_provider: intervention.assigned_provider = resolved_provider if resolved_contract or resolved_provider: diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 618cd9d..cb538eb 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -60,6 +60,7 @@ from interventions.models import (InterventionSubscription, Intervention, Interv InterventionOccupation, Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition, GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia, + ContractOperationTemplatePost, InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment, DirectContractRouting) from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices @@ -145,6 +146,7 @@ __all__ = [ '_apply_realization_side_effects', '_asset_matches_filters', '_asset_matches_template_filters', + '_assign_contract_posts_from_templates', '_assign_guided_template', '_assign_team_from_templates', '_attach_equipment_templates', @@ -1123,6 +1125,49 @@ def _assign_team_from_templates(intervention, templates): +def _assign_contract_posts_from_templates(intervention, templates): + """ + Associe les postes de contrat par défaut configurés pour les OperationTemplates donnés + sur le contrat de l'intervention. + Si plusieurs templates ont des postes en doublon, le premier trouvé est conservé. + """ + if not templates: + return + + contract = intervention.contract or (intervention.order.contract if intervention.order else None) + if not contract: + return + + existing_post_ids = set( + intervention.interv_contract_posts.values_list('contract_post_id', flat=True) + ) + + posts_to_create = [] + for template in templates: + template_posts = ( + ContractOperationTemplatePost.objects + .filter(contract=contract, operation_template=template) + .select_related('contract_post') + .order_by('contract_post__order_number', 'id') + ) + for tp in template_posts: + if tp.contract_post_id not in existing_post_ids: + posts_to_create.append( + InterventionContractPost( + intervention=intervention, + contract_post=tp.contract_post, + quantity=tp.default_quantity, + comment=tp.comment or '', + ) + ) + existing_post_ids.add(tp.contract_post_id) + + if posts_to_create: + InterventionContractPost.objects.bulk_create(posts_to_create) + + + + 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 5ac08e9..4825a22 100644 --- a/loko/interventions/views/preventive.py +++ b/loko/interventions/views/preventive.py @@ -449,6 +449,7 @@ def intervention_add_preventive(request, thematic_code): _attach_equipment_templates(intervention, operation_templates) _assign_guided_template(intervention, operation_templates) _assign_team_from_templates(intervention, operation_templates) + _assign_contract_posts_from_templates(intervention, operation_templates) # 1) Lien générique intervention ↔ localisation ct = ContentType.objects.get_for_model(loc.__class__) @@ -576,6 +577,7 @@ def intervention_add_preventive(request, thematic_code): _attach_equipment_templates(intervention, operation_templates) _assign_guided_template(intervention, operation_templates) _assign_team_from_templates(intervention, operation_templates) + _assign_contract_posts_from_templates(intervention, operation_templates) # 1) Lien générique Asset ↔ Intervention InterventionAsset.objects.get_or_create( @@ -647,6 +649,7 @@ def intervention_add_preventive(request, thematic_code): _attach_equipment_templates(intervention, operation_templates) _assign_guided_template(intervention, operation_templates) _assign_team_from_templates(intervention, operation_templates) + _assign_contract_posts_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 2105a5f..84cb588 100644 --- a/loko/interventions/views/preventive_import.py +++ b/loko/interventions/views/preventive_import.py @@ -486,8 +486,9 @@ def process_worksheet( # Lien template d'opérations intervention.equipment_operation_templates.add(template) - from interventions.views.helpers import _assign_team_from_templates + from interventions.views.helpers import _assign_team_from_templates, _assign_contract_posts_from_templates _assign_team_from_templates(intervention, [template]) + _assign_contract_posts_from_templates(intervention, [template]) # Timelines InterventionTimeLine.objects.create(