feat: implement intervention duplication functionality with mobile UI support and comprehensive unit tests

This commit is contained in:
kdeterme 2026-08-19 08:34:29 +02:00
parent 86979307ae
commit 1671d9e449
7 changed files with 437 additions and 9 deletions

View file

@ -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()
).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)

View file

@ -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 %}
<div id="section-liens" class="card mt-3 mb-4">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h4>
<h4 class="mb-0">
<i class="bi bi-link-45deg"></i>
{% translate "Interventions liées" %}
</h4>
{% if can_duplicate_intervention %}
<form method="post" action="{% url 'interventions:duplicate_intervention' intervention.id %}" class="d-inline" onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir dupliquer cette intervention ?" %}');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-copy"></i>
{% translate "Dupliquer" %}
</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if linked_groups %}

View file

@ -1762,12 +1762,23 @@
</div>
{% endif %}
{% if linked_groups %}
{% if linked_groups or can_duplicate_intervention %}
<div class="card mb-3">
<div class="card-body">
<h6 class="text-uppercase text-muted small mb-3">
<i class="bi bi-link-45deg"></i> {% translate "Interventions liées" %}
</h6>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-uppercase text-muted small mb-0">
<i class="bi bi-link-45deg"></i> {% translate "Interventions liées" %}
</h6>
{% if can_duplicate_intervention %}
<form method="post" action="{% url 'interventions:duplicate_intervention' intervention.id %}" class="d-inline" onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir dupliquer cette intervention ?" %}');">
{% csrf_token %}
<input type="hidden" name="variant" value="mobile">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-copy"></i> {% translate "Dupliquer" %}
</button>
</form>
{% endif %}
</div>
{% for group in linked_groups %}
<p class="text-muted mb-1 fw-semibold">{{ group.label }}</p>
<ul class="list-unstyled {% if not forloop.last %}mb-3{% else %}mb-0{% endif %}">
@ -1782,6 +1793,8 @@
{% endwith %}
{% endfor %}
</ul>
{% empty %}
<p class="text-muted mb-0 small">{% translate "Aucune intervention liée pour l'instant." %}</p>
{% endfor %}
</div>
</div>

View file

@ -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)

View file

@ -29,6 +29,7 @@ urlpatterns = [
path('<int:intervention_id>/notes/<int:note_id>/update/', views.intervention_update_note, name='intervention_update_note'),
path('<int:intervention_id>/notes/<int:note_id>/delete/', views.intervention_delete_note, name='intervention_delete_note'),
path('<int:intervention_id>/create-repair/', views.create_repair_intervention, name='create_repair_intervention'),
path('<int:intervention_id>/duplicate/', views.duplicate_intervention, name='duplicate_intervention'),
path('<int:intervention_id>/link-existing/', views.link_existing_interventions, name='link_existing_interventions'),
path('<int:intervention_id>/occupations/add/', views.intervention_add_occupation, name='intervention_add_occupation'),
path('<int:intervention_id>/occupations/<int:occupation_id>/update/', views.intervention_update_occupation, name='intervention_update_occupation'),

View file

@ -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.

View file

@ -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,