loko/loko/interventions/tests/test_timeline_history.py
kdeterme 205a05cb5b feat(interventions): aligner les permissions des notes par statut et historiser le mobile
- Autoriser les modifications/suppressions de notes jusqu'au statut 'traité' pour les externes
- Autoriser les modifications/suppressions de notes jusqu'au statut 'validé' pour les internes
- Supprimer la contrainte horaire de 1h sur la modification des notes
- Brancher l'historisation de la chronologie sur la création et modification de notes mobile
- Tracer l'upload des photos de formulaires mobiles dans la chronologie
- Ajouter les tests unitaires et d'intégration correspondants
2026-09-25 23:20:09 +02:00

710 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from django.core.files.uploadedfile import SimpleUploadedFile
from django.contrib.contenttypes.models import ContentType
from common.models import UserConfig, Role, Thematic
from contracts.models import Company, CompanyTeam, CompanyMember
from interventions.models import (
Intervention,
InterventionTimeLine,
InterventionNote,
InterventionDocument,
InterventionAsset,
InterventionLink,
)
from assets.models import TrafficLightIntersection
from interventions.services.timeline_service import (
record_timeline_event,
record_assignment_event,
record_note_event,
record_document_event,
record_asset_link_event,
record_relation_link_event,
InterventionChangeTracker,
)
from interventions.views.helpers import _sync_intervention_assets_data, _handle_notes_update
class TimelineHistoryTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username='agent_test',
password='secret_password',
first_name='Jean',
last_name='Dupont'
)
self.user2 = User.objects.create_user(
username='agent_two',
password='secret_password',
first_name='Marie',
last_name='Curie'
)
self.role_admin, _ = Role.objects.get_or_create(name='admin')
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
self.user_config.roles.add(self.role_admin)
self.thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name_fr': 'Signalisation', 'name_nl': 'Signalisatie'})
self.company = Company.objects.create(name='StreetUp Tech')
self.team = CompanyTeam.objects.create(company=self.company, name='Équipe Signalisation')
self.team.thematics.add(self.thematic)
self.team2 = CompanyTeam.objects.create(company=self.company, name='Équipe Éclairage')
self.team2.thematics.add(self.thematic)
self.member = CompanyMember.objects.create(name='Jean Dupont', user=self.user)
self.member.teams.add(self.team)
self.member2 = CompanyMember.objects.create(name='Marie Curie', user=self.user2)
self.member2.teams.add(self.team2)
self.intervention = Intervention.objects.create(
title='Test Intervention Timeline',
status='in_preparation',
priority='2',
thematic=self.thematic,
assigned_provider=self.company,
assigned_team=self.team,
assigned_member=self.member,
)
def test_timeline_model_properties_and_helpers(self):
"""Test InterventionTimeLine model properties, icons, badge classes and filters."""
# Status change event
event_status = InterventionTimeLine.objects.create(
intervention=self.intervention,
event_user=self.user,
event_time=timezone.now(),
event_type='status_change',
from_status='in_preparation',
to_status='to_be_planned',
event_description='Passage en planifié'
)
self.assertTrue(event_status.is_status_change)
self.assertTrue(event_status.is_default_visible)
self.assertFalse(event_status.is_modification)
self.assertFalse(event_status.is_assignment)
self.assertEqual(event_status.get_badge_class(), 'bg-purple')
self.assertEqual(event_status.get_icon(), 'bi-arrow-repeat')
self.assertEqual(event_status.from_status_number, 1)
self.assertEqual(event_status.to_status_number, 3)
self.assertEqual(event_status.from_status_hex, '#757575')
self.assertEqual(event_status.to_status_hex, '#ff8a80')
self.assertEqual(event_status.from_status_text_color, '#ffffff')
self.assertEqual(event_status.to_status_text_color, '#000000')
# Assignment event
event_assign = InterventionTimeLine.objects.create(
intervention=self.intervention,
event_user=self.user,
event_time=timezone.now(),
event_type='assignment',
event_description='Assigné à Jean Dupont'
)
self.assertTrue(event_assign.is_assignment)
self.assertTrue(event_assign.is_default_visible)
self.assertFalse(event_assign.is_modification)
self.assertIn('bg-info', event_assign.get_badge_class())
self.assertEqual(event_assign.get_icon(), 'bi-person-check-fill')
# Modification event
event_mod = InterventionTimeLine.objects.create(
intervention=self.intervention,
event_user=self.user,
event_time=timezone.now(),
event_type='modification',
event_description='Attributs modifiés',
field_changes={'priority': {'label': 'Priorité', 'old': 'Bas', 'new': 'Elevé'}}
)
self.assertTrue(event_mod.is_modification)
self.assertFalse(event_mod.is_default_visible)
self.assertEqual(event_mod.get_badge_class(), 'bg-secondary')
self.assertEqual(event_mod.get_icon(), 'bi-pencil-fill')
def test_change_tracker_records_modifications(self):
"""Test that InterventionChangeTracker records field modifications."""
tracker = InterventionChangeTracker(self.intervention, user=self.user)
self.intervention.priority = '4'
self.intervention.title = 'Nouveau titre'
self.intervention.save()
assign_event, mod_event = tracker.record_changes()
self.assertIsNone(assign_event)
self.assertIsNotNone(mod_event)
self.assertEqual(mod_event.event_type, 'modification')
self.assertEqual(mod_event.event_user, self.user)
self.assertIn('priority', mod_event.field_changes)
self.assertIn('title', mod_event.field_changes)
self.assertEqual(mod_event.field_changes['title']['old'], 'Test Intervention Timeline')
self.assertEqual(mod_event.field_changes['title']['new'], 'Nouveau titre')
self.assertEqual(self.intervention.last_modified_by, self.user)
def test_change_tracker_records_assignment_change(self):
"""Test that changing assigned_member or assigned_team generates an assignment event."""
tracker = InterventionChangeTracker(self.intervention, user=self.user)
self.intervention.assigned_member = self.member2
self.intervention.assigned_team = self.team2
self.intervention.save()
assign_event, mod_event = tracker.record_changes()
self.assertIsNotNone(assign_event)
self.assertEqual(assign_event.event_type, 'assignment')
self.assertEqual(assign_event.event_user, self.user)
self.assertIn('assigned_member', assign_event.field_changes)
self.assertIn('assigned_team', assign_event.field_changes)
def test_change_tracker_no_changes(self):
"""Test that if nothing changed, no timeline event is generated."""
tracker = InterventionChangeTracker(self.intervention, user=self.user)
# No modification to fields
self.intervention.save()
assign_event, mod_event = tracker.record_changes()
self.assertIsNone(assign_event)
self.assertIsNone(mod_event)
def test_update_intervention_view_tracks_modification(self):
"""Test update_intervention view logs attribute changes into InterventionTimeLine."""
self.client.login(username='agent_test', password='secret_password')
url = reverse('interventions:update_intervention', args=[self.intervention.id])
payload = {
'title': 'Titre via AJAX',
'priority': '1'
}
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.intervention.refresh_from_db()
self.assertEqual(self.intervention.title, 'Titre via AJAX')
self.assertEqual(self.intervention.priority, '1')
self.assertEqual(self.intervention.last_modified_by, self.user)
mod_event = self.intervention.events.filter(event_type='modification').last()
self.assertIsNotNone(mod_event)
self.assertEqual(mod_event.event_user, self.user)
self.assertIn('title', mod_event.field_changes)
self.assertIn('priority', mod_event.field_changes)
def test_update_intervention_view_tracks_assignment(self):
"""Test update_intervention view logs assignment changes."""
self.client.login(username='agent_test', password='secret_password')
url = reverse('interventions:update_intervention', args=[self.intervention.id])
payload = {
'member_assignment': f'member-{self.member2.id}',
}
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.intervention.refresh_from_db()
self.assertEqual(self.intervention.assigned_member, self.member2)
assign_event = self.intervention.events.filter(event_type='assignment').last()
self.assertIsNotNone(assign_event)
self.assertEqual(assign_event.event_user, self.user)
self.assertIn('assigned_member', assign_event.field_changes)
def test_intervention_detail_view_context(self):
"""Test intervention_detail provides timeline counts and events in context."""
# Create a creation event, an assignment event and a modification event
InterventionTimeLine.objects.create(
intervention=self.intervention,
event_user=self.user,
event_time=timezone.now(),
event_type='creation',
to_status='in_preparation'
)
InterventionTimeLine.objects.create(
intervention=self.intervention,
event_user=self.user,
event_time=timezone.now(),
event_type='assignment',
event_description='Assigné à l’équipe'
)
InterventionTimeLine.objects.create(
intervention=self.intervention,
event_user=self.user,
event_time=timezone.now(),
event_type='modification',
event_description='Titre modifié'
)
self.client.login(username='agent_test', password='secret_password')
url = reverse('interventions:intervention_detail', args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn('timeline_events', response.context)
self.assertIn('timeline_default_count', response.context)
self.assertIn('timeline_modification_count', response.context)
self.assertIn('timeline_total_count', response.context)
self.assertGreaterEqual(response.context['timeline_default_count'], 2)
self.assertGreaterEqual(response.context['timeline_modification_count'], 1)
self.assertGreaterEqual(response.context['timeline_total_count'], 3)
# Check that response content contains IDs, classes and shortname
self.assertContains(response, 'id="section-chronologie"')
self.assertContains(response, 'id="btnHistoryDefault"')
self.assertContains(response, 'id="btnHistoryFull"')
self.assertContains(response, 'history-timeline')
self.assertContains(response, 'Jean D.')
from django.utils import translation
with translation.override('fr'):
response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr')
self.assertContains(response_fr, 'Historique complet')
def test_timeline_model_new_event_types(self):
"""Test properties, badge classes, and icons for note, document, and link event types."""
# Note event
event_note = record_note_event(
intervention=self.intervention,
user=self.user,
action='create',
note_type='work_done',
content='Remplacement du contrôleur effectué.',
)
self.assertTrue(event_note.is_note)
self.assertFalse(event_note.is_default_visible)
self.assertEqual(event_note.get_badge_class(), 'bg-info text-dark')
self.assertEqual(event_note.get_icon(), 'bi-chat-left-text-fill')
self.assertIn('note_type', event_note.field_changes)
self.assertIn('content', event_note.field_changes)
# Document event
event_doc = record_document_event(
intervention=self.intervention,
user=self.user,
action='upload',
filenames=['rapport_technique.pdf', 'photo_avant.jpg'],
)
self.assertTrue(event_doc.is_document)
self.assertFalse(event_doc.is_default_visible)
self.assertEqual(event_doc.get_badge_class(), 'bg-secondary')
self.assertEqual(event_doc.get_icon(), 'bi-paperclip')
self.assertIn('rapport_technique.pdf', event_doc.event_description)
# Link event
event_link = record_asset_link_event(
intervention=self.intervention,
user=self.user,
added_asset_names=['Carrefour Louise (Carrefour)'],
removed_asset_names=['Carrefour Trône (Carrefour)'],
)
self.assertTrue(event_link.is_link)
self.assertFalse(event_link.is_default_visible)
self.assertEqual(event_link.get_badge_class(), 'bg-dark')
self.assertEqual(event_link.get_icon(), 'bi-link-45deg')
self.assertIn('linked_assets', event_link.field_changes)
self.assertIn('unlinked_assets', event_link.field_changes)
def test_note_views_timeline_recording(self):
"""Test that adding, updating, and deleting notes via views records timeline events."""
self.client.login(username='agent_test', password='secret_password')
# 1. Add Note
add_url = reverse('interventions:intervention_add_note', args=[self.intervention.id])
resp = self.client.post(add_url, {
'note_type': 'important_info',
'content': 'Attention au câble sous tension',
})
self.assertEqual(resp.status_code, 200)
note_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(note_event)
self.assertEqual(note_event.event_user, self.user)
self.assertIn('Attention au câble', note_event.field_changes['content']['new'])
created_note = InterventionNote.objects.filter(intervention=self.intervention).last()
# 2. Update Note
update_url = reverse('interventions:intervention_update_note', args=[self.intervention.id, created_note.id])
resp_update = self.client.post(update_url, {
'note_type': 'important_info',
'content': 'Attention au câble sous tension (mis à jour)',
})
self.assertEqual(resp_update.status_code, 200)
update_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(update_event)
self.assertIn('old', update_event.field_changes['content'])
self.assertIn('mis à jour', update_event.field_changes['content']['new'])
# 3. Delete Note
delete_url = reverse('interventions:intervention_delete_note', args=[self.intervention.id, created_note.id])
resp_del = self.client.post(delete_url)
self.assertEqual(resp_del.status_code, 200)
del_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(del_event)
self.assertIn(del_event.field_changes['note_action']['old'], ['Suppression', 'Deletion'])
def test_batch_notes_update_timeline_recording(self):
"""Test _handle_notes_update records timeline events for notes in batch mode."""
from django.test.client import RequestFactory
factory = RequestFactory()
request = factory.post('/fake-url')
request.user = self.user
# Create note directly first
existing_note = InterventionNote.objects.create(
intervention=self.intervention,
note_type='work_done',
content='Note initiale',
note_author=self.user,
note_time=timezone.now(),
)
notes_data = {
'new_notes': [{'note_type': 'comment', 'content': 'Nouvelle note en batch'}],
'updated_notes': [{'id': existing_note.id, 'note_type': 'comment', 'content': 'Note modifiée en batch'}],
'deleted_notes': [],
}
success, errors = _handle_notes_update(request, self.intervention, notes_data)
self.assertTrue(success)
self.assertEqual(len(errors), 0)
note_events = list(self.intervention.events.filter(event_type='note'))
self.assertGreaterEqual(len(note_events), 2)
descriptions = [e.event_description for e in note_events]
self.assertTrue(any('Note ajoutée' in d or 'Note added' in d for d in descriptions))
self.assertTrue(any('Note modifiée' in d or 'Note updated' in d for d in descriptions))
def test_document_views_timeline_recording(self):
"""Test adding and deleting a document logs document events in timeline."""
self.client.login(username='agent_test', password='secret_password')
# 1. Add document
add_url = reverse('interventions:intervention_add_document', args=[self.intervention.id])
test_file = SimpleUploadedFile("schema_test.pdf", b"file_content_binary", content_type="application/pdf")
resp = self.client.post(add_url, {'documents': [test_file]})
self.assertEqual(resp.status_code, 302)
doc_event = self.intervention.events.filter(event_type='document').last()
self.assertIsNotNone(doc_event)
self.assertEqual(doc_event.event_user, self.user)
self.assertIn('schema_test.pdf', doc_event.event_description)
created_doc = InterventionDocument.objects.filter(intervention=self.intervention).last()
# 2. Delete document
del_url = reverse('interventions:intervention_delete_document', args=[self.intervention.id, created_doc.id])
resp_del = self.client.post(del_url)
self.assertEqual(resp_del.status_code, 200)
del_event = self.intervention.events.filter(event_type='document').last()
self.assertIsNotNone(del_event)
self.assertTrue('Document supprimé' in del_event.event_description or 'Document deleted' in del_event.event_description)
def test_asset_link_timeline_recording(self):
"""Test linking and unlinking an asset records link events."""
asset = TrafficLightIntersection.objects.create(code='TL-001', name_fr='Carrefour Arts-Loi')
ct = ContentType.objects.get_for_model(TrafficLightIntersection)
# Sync asset to intervention
_sync_intervention_assets_data(
intervention=self.intervention,
normalized_assets=[{'model': 'trafficlightintersection', 'object_id': asset.id}],
user=self.user,
)
link_event = self.intervention.events.filter(event_type='link').last()
self.assertIsNotNone(link_event)
self.assertEqual(link_event.event_user, self.user)
self.assertIn('Carrefour Arts-Loi', link_event.event_description)
self.assertIn('linked_assets', link_event.field_changes)
# Unlink asset
_sync_intervention_assets_data(
intervention=self.intervention,
normalized_assets=[],
user=self.user,
)
unlink_event = self.intervention.events.filter(event_type='link').last()
self.assertIsNotNone(unlink_event)
self.assertTrue('délié' in unlink_event.event_description or 'unlinked' in unlink_event.event_description.lower())
self.assertIn('unlinked_assets', unlink_event.field_changes)
def test_intervention_link_timeline_recording(self):
"""Test linking two interventions records link events on both sides."""
target_intervention = Intervention.objects.create(
title='Target Linked Intervention',
status='in_preparation',
thematic=self.thematic,
)
self.client.login(username='agent_test', password='secret_password')
url = reverse('interventions:link_existing_interventions', args=[self.intervention.id])
resp = self.client.post(url, {'codes': target_intervention.code})
self.assertEqual(resp.status_code, 302)
# Event on origin intervention
origin_link_event = self.intervention.events.filter(event_type='link').last()
self.assertIsNotNone(origin_link_event)
self.assertEqual(origin_link_event.event_user, self.user)
self.assertIn(target_intervention.code, origin_link_event.event_description)
# Event on target intervention
target_link_event = target_intervention.events.filter(event_type='link').last()
self.assertIsNotNone(target_link_event)
self.assertEqual(target_link_event.event_user, self.user)
self.assertIn(self.intervention.code, target_link_event.event_description)
def test_detail_view_shows_all_in_full_history(self):
"""Test that notes, documents, and links appear as detailed events in timeline."""
record_timeline_event(self.intervention, self.user, event_type='status_change', from_status='in_preparation', to_status='to_be_processed')
record_note_event(self.intervention, self.user, action='create', note_type='comment', content='Note test')
record_document_event(self.intervention, self.user, action='upload', filenames=['doc_test.png'])
record_relation_link_event(self.intervention, self.user, relation_label='Intervention', target_labels=['I999999'])
self.client.login(username='agent_test', password='secret_password')
url = reverse('interventions:intervention_detail', args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn('timeline_detailed_count', response.context)
# Note, document, link are all non-default-visible (detailed)
self.assertGreaterEqual(response.context['timeline_detailed_count'], 3)
self.assertContains(response, 'history-item-detailed')
self.assertContains(response, 'data-event-type="note"')
self.assertContains(response, 'data-event-type="document"')
self.assertContains(response, 'data-event-type="link"')
self.assertContains(response, 'history-event-badge')
self.assertContains(response, 'history-status-circle')
self.assertContains(response, 'history-status-transition')
self.assertContains(response, 'bg-purple')
self.assertContains(response, 'id="btnHistoryFull"')
def test_detail_view_hides_full_history_for_external_user(self):
"""Test that external users (is_intern=False) do NOT see full history or detailed modification/note events."""
User = get_user_model()
ext_user = User.objects.create_user(
username='prestataire_ext',
password='secret_password',
first_name='Pierre',
last_name='Martin'
)
ext_config = UserConfig.objects.create(user=ext_user, is_intern=False)
ext_role, _ = Role.objects.get_or_create(name='external_manager')
ext_config.roles.add(ext_role)
self.intervention.assigned_provider = self.company
self.intervention.save()
record_timeline_event(self.intervention, self.user, event_type='status_change', from_status='in_preparation', to_status='to_be_processed')
record_note_event(self.intervention, self.user, action='create', note_type='comment', content='Note interne confidentielle')
record_document_event(self.intervention, self.user, action='upload', filenames=['secret_doc.pdf'])
self.client.login(username='prestataire_ext', password='secret_password')
url = reverse('interventions:intervention_detail', args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# Le filtre "Historique complet" ne doit PAS être affiché
self.assertNotContains(response, 'id="btnHistoryFull"')
self.assertNotContains(response, 'id="btnHistoryDefault"')
# Aucun événement détaillé n'est dans le contexte
self.assertEqual(response.context['timeline_detailed_count'], 0)
self.assertTrue(all(e.is_default_visible for e in response.context['timeline_events']))
# Le contenu confidentiel des notes et documents n'est pas transmis dans l'historique
self.assertNotContains(response, 'Note interne confidentielle')
self.assertNotContains(response, 'secret_doc.pdf')
# Les statuts et assignations restent parfaitement visibles
self.assertContains(response, 'history-status-circle')
self.assertContains(response, 'history-status-transition')
def test_can_edit_note_status_flexibility_for_internals_and_externals(self):
"""Test note edit/delete flexibility:
- Externals can edit/delete up to 'processed' (status_order <= 31)
- Internals can edit/delete up to 'validated' (status_order <= 51)
- Externals cannot edit internal notes
- Admin can always edit
"""
from interventions.permissions import can_edit_note
User = get_user_model()
ext_user = User.objects.create_user(username='ext_author', password='pwd')
ext_cfg = UserConfig.objects.create(user=ext_user, is_intern=False)
role_tech, _ = Role.objects.get_or_create(name='technician')
ext_cfg.roles.add(role_tech)
int_user = self.user # is_intern=True, admin
int_agent = User.objects.create_user(username='int_agent', password='pwd')
int_cfg = UserConfig.objects.create(user=int_agent, is_intern=True)
int_manager = User.objects.create_user(username='int_manager', password='pwd')
mgr_cfg = UserConfig.objects.create(user=int_manager, is_intern=True)
role_mgr, _ = Role.objects.get_or_create(name='manager')
mgr_cfg.roles.add(role_mgr)
# Note créée par l'externe
ext_note = InterventionNote.objects.create(
intervention=self.intervention,
content='Note de l\'externe',
note_type='comment',
note_author=ext_user,
note_time=timezone.now()
)
# Note créée par l'interne
int_note = InterventionNote.objects.create(
intervention=self.intervention,
content='Note de l\'interne',
note_type='comment',
note_author=int_agent,
note_time=timezone.now()
)
# 1. Statut initial (in_preparation, order=1 <= 31)
self.intervention.status = 'in_preparation'
self.intervention.status_order = 1
self.intervention.save()
# L'externe peut modifier sa propre note
self.assertTrue(can_edit_note(ext_user, ext_cfg, ext_note))
# L'externe NE PEUT PAS modifier la note de l'agent interne
self.assertFalse(can_edit_note(ext_user, ext_cfg, int_note))
# L'agent interne peut modifier sa propre note
self.assertTrue(can_edit_note(int_agent, int_cfg, int_note))
# Le manager interne peut modifier les notes
self.assertTrue(can_edit_note(int_manager, mgr_cfg, ext_note))
# 2. Statut 'processed' (Traité, order=31)
self.intervention.status = 'processed'
self.intervention.status_order = 31
self.intervention.save()
# Jusqu'à 'traité', l'externe peut toujours modifier sa note
self.assertTrue(can_edit_note(ext_user, ext_cfg, ext_note))
# L'interne peut modifier sa note
self.assertTrue(can_edit_note(int_agent, int_cfg, int_note))
# 3. Statut 'to_be_corrected' (A corriger, order=41 > 31)
self.intervention.status = 'to_be_corrected'
self.intervention.status_order = 41
self.intervention.save()
# Après 'traité', l'externe NE PEUT PLUS modifier sa note
self.assertFalse(can_edit_note(ext_user, ext_cfg, ext_note))
# Mais l'interne PEUT TOUJOURS modifier sa note (car <= 51)
self.assertTrue(can_edit_note(int_agent, int_cfg, int_note))
# 4. Statut 'validated' (Validé, order=51)
self.intervention.status = 'validated'
self.intervention.status_order = 51
self.intervention.save()
# L'externe ne peut pas
self.assertFalse(can_edit_note(ext_user, ext_cfg, ext_note))
# Jusqu'à 'validé', l'interne peut toujours modifier
self.assertTrue(can_edit_note(int_agent, int_cfg, int_note))
self.assertTrue(can_edit_note(int_manager, mgr_cfg, int_note))
# 5. Statut 'closed' (Finalisé, order=71 > 51)
self.intervention.status = 'closed'
self.intervention.status_order = 71
self.intervention.save()
# Ni l'externe ni l'interne ne peuvent modifier
self.assertFalse(can_edit_note(ext_user, ext_cfg, ext_note))
self.assertFalse(can_edit_note(int_agent, int_cfg, int_note))
self.assertFalse(can_edit_note(int_manager, mgr_cfg, int_note))
# Sauf l'admin
self.assertTrue(can_edit_note(int_user, self.user_config, int_note))
# 6. Test direct des endpoints AJAX de modification et suppression
# Au statut 'processed' (31) : l'externe peut supprimer sa note
self.intervention.status = 'processed'
self.intervention.status_order = 31
self.intervention.save()
self.client.login(username='ext_author', password='pwd')
del_url = reverse('interventions:intervention_delete_note', args=[self.intervention.id, ext_note.id])
res = self.client.post(del_url)
self.assertEqual(res.status_code, 200)
self.assertFalse(InterventionNote.objects.filter(id=ext_note.id).exists())
# Création d'une nouvelle note externe
new_ext_note = InterventionNote.objects.create(
intervention=self.intervention,
content='Nouvelle note externe',
note_type='comment',
note_author=ext_user,
note_time=timezone.now()
)
# Au statut 'to_be_corrected' (41 > 31) : l'externe ne peut plus modifier ni supprimer
self.intervention.status = 'to_be_corrected'
self.intervention.status_order = 41
self.intervention.save()
upd_url = reverse('interventions:intervention_update_note', args=[self.intervention.id, new_ext_note.id])
res = self.client.post(upd_url, {'content': 'Modification interdite', 'note_type': 'comment'})
self.assertEqual(res.status_code, 403)
del_url = reverse('interventions:intervention_delete_note', args=[self.intervention.id, new_ext_note.id])
res = self.client.post(del_url)
self.assertEqual(res.status_code, 403)
self.assertTrue(InterventionNote.objects.filter(id=new_ext_note.id).exists())
# Au statut 'validated' (51) : l'interne peut toujours modifier sa note
self.intervention.status = 'validated'
self.intervention.status_order = 51
self.intervention.save()
self.client.login(username='int_agent', password='pwd')
upd_url = reverse('interventions:intervention_update_note', args=[self.intervention.id, int_note.id])
res = self.client.post(upd_url, {'content': 'Modification autorisée interne', 'note_type': 'comment'})
self.assertEqual(res.status_code, 200)
int_note.refresh_from_db()
self.assertEqual(int_note.content, 'Modification autorisée interne')
def test_mobile_note_events_recorded_in_timeline(self):
"""Test that notes saved or updated via mobile endpoints create timeline events."""
self.client.login(username='agent_test', password='secret_password')
# 1. Création d'une note via save_intervention_mobile_ajax
url = reverse('interventions:save_operations_ajax', args=[self.intervention.id])
# Note: on utilise la vue mobile AJAX save_intervention_mobile_ajax
# Vérifions son url
mobile_ajax_url = f"/interventions/{self.intervention.id}/mobile/"
post_data = {
'content': 'Note créée depuis mobile',
'note_type': 'comment',
'note_id': '',
}
response = self.client.post(f"/interventions/{self.intervention.id}/mobile/", post_data)
# Vérifier qu'un événement note a été consigné
note_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(note_event)
self.assertTrue('Note ajoutée' in note_event.event_description or 'Note added' in note_event.event_description)
self.assertEqual(note_event.field_changes.get('content', {}).get('new'), 'Note créée depuis mobile')
# 2. Modification de la note créée via save_intervention_mobile_ajax
created_note = InterventionNote.objects.filter(content='Note créée depuis mobile').first()
self.assertIsNotNone(created_note)
update_data = {
'content': 'Note mise à jour depuis mobile',
'note_type': 'comment',
'note_id': str(created_note.id),
}
self.client.post(f"/interventions/{self.intervention.id}/mobile/", update_data)
update_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(update_event)
self.assertTrue('Note modifiée' in update_event.event_description or 'Note updated' in update_event.event_description)
self.assertEqual(update_event.field_changes.get('content', {}).get('old'), 'Note créée depuis mobile')
self.assertEqual(update_event.field_changes.get('content', {}).get('new'), 'Note mise à jour depuis mobile')
# 3. Upload photo depuis intervention_detail_mobile POST
photo_file = SimpleUploadedFile("test_mobile_photo.jpg", b"image_data", content_type="image/jpeg")
detail_url = reverse('interventions:intervention_detail_mobile', args=[self.intervention.id])
self.client.post(detail_url, {'photos': [photo_file]})
doc_event = self.intervention.events.filter(event_type='document').last()
self.assertIsNotNone(doc_event)
self.assertTrue('Document ajouté' in doc_event.event_description or 'Document added' in doc_event.event_description)