From 1671d9e44941ef28a1f3ecbe9f372ed3b68dca82 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 19 Aug 2026 08:34:29 +0200 Subject: [PATCH] feat: implement intervention duplication functionality with mobile UI support and comprehensive unit tests --- loko/interventions/permissions.py | 22 +- .../interventions/detail/card_links.html | 13 +- .../intervention_detail_mobile.html | 21 +- loko/interventions/tests/test_general.py | 195 ++++++++++++++++++ loko/interventions/urls.py | 1 + loko/interventions/views/detail.py | 191 ++++++++++++++++- loko/interventions/views/mobile.py | 3 +- 7 files changed, 437 insertions(+), 9 deletions(-) diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index a00bcd5..834593c 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -1551,4 +1551,24 @@ def can_user_create_inspection_for_contract(user, contract): user_contract=uca, status__in=target_statuses, can_change_status_to=True, - ).exists() \ No newline at end of file + ).exists() + + +def can_duplicate_intervention(user, intervention): + """ + Vérifie si l'utilisateur peut dupliquer l'intervention donnée. + L'utilisateur doit pouvoir voir l'intervention source et pouvoir créer une nouvelle intervention + pour la thématique concernée. + """ + try: + user_config = UserConfig.objects.get(user=user) + except UserConfig.DoesNotExist: + return False + + if user_config.roles.filter(name__in=['admin', 'top_manager']).exists(): + return True + + if not can_view_intervention(user, intervention): + return False + + return can_add_intervention(user, intervention.thematic) \ No newline at end of file diff --git a/loko/interventions/templates/interventions/detail/card_links.html b/loko/interventions/templates/interventions/detail/card_links.html index b70d628..616df73 100644 --- a/loko/interventions/templates/interventions/detail/card_links.html +++ b/loko/interventions/templates/interventions/detail/card_links.html @@ -1,11 +1,20 @@ {% load i18n interventions_tags %} -{% if linked_groups or can_manage_links %} +{% if linked_groups or can_manage_links or can_duplicate_intervention %}
-

+

{% translate "Interventions liées" %}

+ {% if can_duplicate_intervention %} +
+ {% csrf_token %} + +
+ {% endif %}
{% if linked_groups %} diff --git a/loko/interventions/templates/interventions/intervention_detail_mobile.html b/loko/interventions/templates/interventions/intervention_detail_mobile.html index 9f16c1e..2012cad 100644 --- a/loko/interventions/templates/interventions/intervention_detail_mobile.html +++ b/loko/interventions/templates/interventions/intervention_detail_mobile.html @@ -1762,12 +1762,23 @@
{% endif %} -{% if linked_groups %} +{% if linked_groups or can_duplicate_intervention %}
-
- {% translate "Interventions liées" %} -
+
+
+ {% translate "Interventions liées" %} +
+ {% if can_duplicate_intervention %} +
+ {% csrf_token %} + + +
+ {% endif %} +
{% for group in linked_groups %}

{{ group.label }}

    @@ -1782,6 +1793,8 @@ {% endwith %} {% endfor %}
+ {% empty %} +

{% translate "Aucune intervention liée pour l'instant." %}

{% endfor %}
diff --git a/loko/interventions/tests/test_general.py b/loko/interventions/tests/test_general.py index 90c9623..4edad0b 100644 --- a/loko/interventions/tests/test_general.py +++ b/loko/interventions/tests/test_general.py @@ -16,6 +16,10 @@ from interventions.models import ( InterventionContractPost, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, + InterventionTimeLine, + InterventionLink, + InterventionNote, + Symptom, Operation, InterventionAsset, OperationTemplate, @@ -25,6 +29,7 @@ from interventions.models import ( STATUS_ORDERS, InterventionDocument, ) +from django.db.models import Q from django.contrib.contenttypes.models import ContentType from interventions.views import _group_operations_by_asset_and_template, _create_equipment_operations_for_assets from interventions.permissions import can_manage_occupations, can_delete_document @@ -1367,5 +1372,195 @@ class InterventionSubscriptionTests(TestCase): self.assertNotIn(itv2, filtered_qs) +class InterventionDuplicationTests(TestCase): + def setUp(self): + User = get_user_model() + self.admin_user = User.objects.create_superuser(username='dup-admin', password='pwd', email='admin@example.com') + self.admin_role = Role.objects.create(name='admin') + self.admin_config = UserConfig.objects.create(user=self.admin_user, is_intern=True) + self.admin_config.roles.add(self.admin_role) + + self.thematic = Thematic.objects.create(code='roads_dup', name_fr='Routes Dup', name_nl='Wegen Dup') + self.symptom = Symptom.objects.create(thematic=self.thematic, name_fr='Nid de poule', name_nl='Kuil', is_active=True) + + self.company = Company.objects.create(name='Provider Co') + self.team = CompanyTeam.objects.create(name='Team A', company=self.company) + self.member = CompanyMember.objects.create(user=self.admin_user, name='Member A') + self.member.teams.add(self.team) + + from datetime import date, timedelta + self.contract = Contract.objects.create( + company=self.company, + contract_number='C-DUP-01', + start_date=date.today() - timedelta(days=10), + end_date=date.today() + timedelta(days=100), + ) + + from django.utils import timezone + now = timezone.now() + + self.itv = Intervention.objects.create( + title='Intervention originale', + description='Description détaillée', + init_description='Origine signalement', + maintain_type='corrective', + type='repair', + origin_type='damage', + priority='1', + thematic=self.thematic, + symptom=self.symptom, + contract=self.contract, + assigned_provider=self.company, + assigned_team=self.team, + assigned_member=self.member, + status='in_progress', + status_order=21, + begin_time=now - timedelta(hours=2), + end_time=now + timedelta(hours=1), + planned_begin_time=now - timedelta(hours=3), + planned_end_time=now + timedelta(hours=2), + expected_begin_time=now - timedelta(hours=4), + expected_end_time=now + timedelta(hours=3), + transmission_time=now - timedelta(days=1), + address='Rue Royale 10, 1000 Bruxelles', + location_code='LOC-001', + lon=4.35, + lat=50.85, + created_by=self.admin_user, + ) + + # Notes + self.origin_note = InterventionNote.objects.create( + intervention=self.itv, + content='Note origine importante', + note_time=now, + note_type=InterventionNote.NoteType.ORIGIN, + note_author=self.admin_user, + ) + self.work_note = InterventionNote.objects.create( + intervention=self.itv, + content='Note travaux réalisés à ne pas copier', + note_time=now, + note_type=InterventionNote.NoteType.WORK_DONE, + note_author=self.admin_user, + ) + + # Document + self.doc = InterventionDocument.objects.create( + intervention=self.itv, + file=SimpleUploadedFile('test_dup_doc.txt', b'hello world duplicated file'), + uploaded_by=self.admin_user, + description='Doc description', + ) + + # Operation template & Operation + self.template = OperationTemplate.objects.create(name='Template Dup', thematic=self.thematic) + self.itv.equipment_operation_templates.add(self.template) + self.op = Operation.objects.create( + intervention=self.itv, + name_fr='Vérifier enrobé', + name_nl='Asfalt controleren', + template_group='Template Dup', + order=1, + is_custom=False, + ) + + def test_duplication_success(self): + self.client.force_login(self.admin_user) + url = reverse('interventions:duplicate_intervention', kwargs={'intervention_id': self.itv.id}) + response = self.client.post(url) + + # Should redirect to the new intervention detail + new_itv = Intervention.objects.exclude(id=self.itv.id).first() + self.assertIsNotNone(new_itv) + self.assertRedirects(response, reverse('interventions:intervention_detail', kwargs={'intervention_id': new_itv.id})) + + # General properties copied + self.assertEqual(new_itv.title, self.itv.title) + self.assertEqual(new_itv.description, self.itv.description) + self.assertEqual(new_itv.init_description, self.itv.init_description) + self.assertEqual(new_itv.thematic, self.itv.thematic) + self.assertEqual(new_itv.symptom, self.itv.symptom) + self.assertEqual(new_itv.priority, self.itv.priority) + self.assertEqual(new_itv.maintain_type, self.itv.maintain_type) + self.assertEqual(new_itv.type, self.itv.type) + self.assertEqual(new_itv.origin_type, self.itv.origin_type) + self.assertEqual(new_itv.address, self.itv.address) + self.assertEqual(new_itv.location_code, self.itv.location_code) + self.assertEqual(new_itv.lon, self.itv.lon) + self.assertEqual(new_itv.lat, self.itv.lat) + + # Status reset to in_preparation + self.assertEqual(new_itv.status, 'in_preparation') + self.assertEqual(new_itv.status_order, 1) + + # Contract, provider, team, members and dates NOT copied + self.assertIsNone(new_itv.contract) + self.assertIsNone(new_itv.order) + self.assertIsNone(new_itv.assigned_provider) + self.assertIsNone(new_itv.assigned_team) + self.assertIsNone(new_itv.assigned_member) + self.assertIsNone(new_itv.intervention_manager) + self.assertIsNone(new_itv.begin_time) + self.assertIsNone(new_itv.end_time) + self.assertIsNone(new_itv.planned_begin_time) + self.assertIsNone(new_itv.planned_end_time) + self.assertIsNone(new_itv.expected_begin_time) + self.assertIsNone(new_itv.expected_end_time) + self.assertIsNone(new_itv.transmission_time) + + # Template & operations copied + self.assertEqual(list(new_itv.equipment_operation_templates.all()), [self.template]) + self.assertEqual(new_itv.operations.count(), 1) + new_op = new_itv.operations.first() + self.assertEqual(new_op.name_fr, self.op.name_fr) + self.assertIsNone(new_op.status) + + # Notes: only origin note copied + self.assertEqual(new_itv.notes.count(), 1) + copied_note = new_itv.notes.first() + self.assertEqual(copied_note.note_type, InterventionNote.NoteType.ORIGIN) + self.assertEqual(copied_note.content, self.origin_note.content) + + # Documents copied + self.assertEqual(new_itv.documents.count(), 1) + copied_doc = new_itv.documents.first() + self.assertIn('test_dup_doc', copied_doc.file.name) + self.assertEqual(copied_doc.description, self.doc.description) + + # Link exists between original and new + link_exists = InterventionLink.objects.filter( + Q(first=self.itv, second=new_itv) | Q(first=new_itv, second=self.itv) + ).exists() + self.assertTrue(link_exists) + + # Timeline creation event exists + timeline_exists = InterventionTimeLine.objects.filter( + intervention=new_itv, + event_type='creation', + ).exists() + self.assertTrue(timeline_exists) + + def test_duplication_mobile_redirect(self): + self.client.force_login(self.admin_user) + url = reverse('interventions:duplicate_intervention', kwargs={'intervention_id': self.itv.id}) + response = self.client.post(url, {'variant': 'mobile'}) + + new_itv = Intervention.objects.exclude(id=self.itv.id).first() + self.assertIsNotNone(new_itv) + self.assertRedirects(response, reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_itv.id})) + + def test_duplication_unauthorized(self): + User = get_user_model() + unauth_user = User.objects.create_user(username='unauth', password='pwd') + UserConfig.objects.create(user=unauth_user, is_intern=False) + + self.client.force_login(unauth_user) + url = reverse('interventions:duplicate_intervention', kwargs={'intervention_id': self.itv.id}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + + + diff --git a/loko/interventions/urls.py b/loko/interventions/urls.py index b1b5d79..e314990 100644 --- a/loko/interventions/urls.py +++ b/loko/interventions/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ path('/notes//update/', views.intervention_update_note, name='intervention_update_note'), path('/notes//delete/', views.intervention_delete_note, name='intervention_delete_note'), path('/create-repair/', views.create_repair_intervention, name='create_repair_intervention'), + path('/duplicate/', views.duplicate_intervention, name='duplicate_intervention'), path('/link-existing/', views.link_existing_interventions, name='link_existing_interventions'), path('/occupations/add/', views.intervention_add_occupation, name='intervention_add_occupation'), path('/occupations//update/', views.intervention_update_occupation, name='intervention_update_occupation'), diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 454e0b8..692dd0a 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -72,7 +72,7 @@ from interventions.permissions import (intervention_permission_required, interve can_view_intervention_summary, get_accessible_interventions_for_technician, filter_viewable_interventions_for_user, can_edit_note, can_delete_document, can_manage_occupations, - can_add_intervention, can_add_repair_intervention, + can_add_intervention, can_add_repair_intervention, can_duplicate_intervention, intervention_manage_occupations_required, get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights, should_show_created_by_provider_badge, can_view_correction_messages) @@ -672,6 +672,7 @@ def intervention_detail(request, intervention_id): 'can_manage_links': can_manage_links, 'link_existing_form': LinkExistingInterventionsForm(), 'can_create_repair': can_create_repair, + 'can_duplicate_intervention': can_duplicate_intervention(request.user, intervention), 'can_send_reminder': can_send_reminder, # Historique des interventions aux mêmes localisations 'location_history_interventions': location_history_interventions, @@ -966,6 +967,194 @@ def create_repair_intervention(request, intervention_id): return redirect('interventions:intervention_detail', intervention_id=intervention.id) +def duplicate_intervention(request, intervention_id): + """ + Duplique une intervention existante: + - Recopie la plupart des éléments (titre, description, localisation, équipement, thématique, etc.). + - Recopie les opérations (statut réinitialisé). + - Recopie les notes de type 'origine' (note_type='origin'). + - Recopie les documents. + - Ne recopie pas : prestataire, contrat, équipe/membre assigné, dates. + - Statut par défaut : in_preparation (En préparation). + - Relie automatiquement la nouvelle intervention à la précédente (InterventionLink). + """ + intervention = get_object_or_404(Intervention, pk=intervention_id) + + if not can_duplicate_intervention(request.user, intervention): + return HttpResponseForbidden(_("Vous n'avez pas la permission de dupliquer cette intervention.")) + + variant = request.POST.get('variant') + if "/mobile/" in request.path: + variant = 'mobile' + + with transaction.atomic(): + new_intervention = Intervention( + title=intervention.title, + description=intervention.description, + init_description=intervention.init_description, + maintain_type=intervention.maintain_type, + type=intervention.type, + origin_type=intervention.origin_type, + priority=intervention.priority, + thematic=intervention.thematic, + asset_category=intervention.asset_category, + symptom=intervention.symptom, + source_category=intervention.source_category, + source_detail=intervention.source_detail, + source_ref=intervention.source_ref, + pmo_number=intervention.pmo_number, + osiris=intervention.osiris, + lon=intervention.lon, + lat=intervention.lat, + address=intervention.address, + location_code=intervention.location_code, + geom=intervention.geom, + guided_operation_template=intervention.guided_operation_template, + status='in_preparation', + status_order=STATUS_ORDERS.get('in_preparation', 1), + created_by=request.user, + last_modified_by=request.user, + contract=None, + order=None, + intervention_manager=None, + assigned_provider=None, + assigned_team=None, + assigned_member=None, + begin_time=None, + end_time=None, + planned_begin_time=None, + planned_end_time=None, + expected_begin_time=None, + expected_end_time=None, + transmission_time=None, + init_call_confirmed_time=None, + init_call_received=False, + pause_reason=None, + pause_reason_other=None, + cancellation_type=None, + period_validation_enabled=False, + period_number=1, + period_is_final=False, + processed_via_other_intervention=False, + reason_for_correction=None, + correction_details=None, + follow_up_performed=None, + is_checked=False, + ) + new_intervention.save() + auto_check_if_eligible(request.user, new_intervention) + + # Copier les templates d'opérations équipements (M2M) + templates = list(intervention.equipment_operation_templates.all()) + if templates: + new_intervention.equipment_operation_templates.set(templates) + + # Copier les localisations liées + for location in intervention.locations.all(): + InterventionLocation.objects.get_or_create( + intervention=new_intervention, + content_type=location.content_type, + object_id=location.object_id, + ) + + # Copier les équipements liés + for asset in intervention.interventionasset_set.all(): + InterventionAsset.objects.get_or_create( + intervention=new_intervention, + content_type=asset.content_type, + object_id=asset.object_id, + ) + + # Copier les liaisons aux projets + for project_link in ProjectIntervention.objects.filter(intervention=intervention): + ProjectIntervention.objects.create( + intervention=new_intervention, + project=project_link.project, + phase=project_link.phase, + row_number=project_link.row_number, + ) + + # Copier les opérations (avec statut réinitialisé) + for op in intervention.operations.all(): + Operation.objects.create( + intervention=new_intervention, + name_fr=op.name_fr, + name_nl=op.name_nl, + comment='', + status_set=op.status_set, + status=None, + measurement_definition=op.measurement_definition, + measured_value=None, + measured_duration=None, + measured_datetime=None, + is_not_applicable=False, + order=op.order, + template_group=op.template_group, + asset_content_type=op.asset_content_type, + asset_object_id=op.asset_object_id, + is_custom=op.is_custom, + is_generic=op.is_generic, + requires_photo=op.requires_photo, + ) + + # Copier uniquement les notes de type 'origine' + origin_notes = intervention.notes.filter(note_type=InterventionNote.NoteType.ORIGIN) + for note in origin_notes: + InterventionNote.objects.create( + intervention=new_intervention, + content=note.content, + note_time=note.note_time, + note_type=note.note_type, + note_author=note.note_author or request.user, + ) + + # Copier les documents + for doc in intervention.documents.all(): + if doc.file: + try: + doc.file.open('rb') + InterventionDocument.objects.create( + intervention=new_intervention, + file=ContentFile(doc.file.read(), name=os.path.basename(doc.file.name)), + uploaded_by=doc.uploaded_by or request.user, + description=doc.description, + ) + except Exception: + pass + finally: + try: + doc.file.close() + except Exception: + pass + + # Relier automatiquement à l'intervention précédente + InterventionLink.link(intervention, new_intervention, InterventionLink.LinkType.RELATED) + + # Timeline de création + InterventionTimeLine.objects.create( + intervention=new_intervention, + event_user=request.user, + event_time=now(), + event_type='creation', + from_status=None, + to_status=new_intervention.status, + ) + + messages.success( + request, + _("L'intervention %(code)s a été dupliquée avec succès à partir de %(origin)s.") % { + 'code': new_intervention.code, + 'origin': intervention.code, + } + ) + + if variant == 'mobile': + return redirect('mobile:intervention_detail_mobile', intervention_id=new_intervention.id) + + return redirect('interventions:intervention_detail', intervention_id=new_intervention.id) + + + def add_repair_intervention_modal(request): """ Vue AJAX pour créer une intervention de réparation via modale desktop. diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 0215429..1e13de8 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -74,7 +74,7 @@ from interventions.permissions import (intervention_permission_required, interve can_view_intervention_summary, get_accessible_interventions_for_technician, filter_viewable_interventions_for_user, can_edit_note, can_delete_document, can_manage_occupations, - can_add_intervention, can_add_repair_intervention, + can_add_intervention, can_add_repair_intervention, can_duplicate_intervention, intervention_manage_occupations_required, get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights, should_show_created_by_provider_badge, can_view_correction_messages) @@ -1091,6 +1091,7 @@ def intervention_detail_mobile(request, intervention_id): 'pause_reason_choices': PAUSE_REASON_CHOICES, 'linked_groups': linked_groups, 'can_create_repair': can_create_repair, + 'can_duplicate_intervention': can_duplicate_intervention(request.user, intervention), 'mobile_assets_enabled': can_edit_mobile, 'mobile_assets_config': mobile_assets_config, 'mobile_asset_location_labels': mobile_asset_location_labels,