- 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
710 lines
34 KiB
Python
710 lines
34 KiB
Python
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)
|