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 %}
{% 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 %}
+
+ {% 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,