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
This commit is contained in:
kdeterme 2026-09-25 23:20:09 +02:00
parent 7911369740
commit 205a05cb5b
6 changed files with 282 additions and 38 deletions

View file

@ -1471,10 +1471,14 @@ def get_allowed_transitions(request, intervention):
def can_edit_note(user, user_config, note): def can_edit_note(user, user_config, note):
""" """
Règle: Règles de modification et suppression d'une note :
- Admin: peut tout modifier. - Admin : peut tout modifier et supprimer sans restriction.
- Thématique 'structures': l'auteur OU rôle manager/external_manager (pas de contrainte horaire). - Externes (non is_intern) :
- Sinon: uniquement l'auteur ET la note a été créée il y a < 1h. * Autorisés jusqu'au statut 'traité' (status_order <= STATUS_ORDERS['processed'], soit <= 31).
* Auteur de la note OU rôle 'external_manager' (hors notes rédigées par des internes).
- Internes (is_intern) :
* Autorisés jusqu'au statut 'validé' (status_order <= STATUS_ORDERS['validated'], soit <= 51).
* Auteur de la note OU rôle 'manager'.
""" """
if not user.is_authenticated or user_config is None: if not user.is_authenticated or user_config is None:
return False return False
@ -1483,29 +1487,42 @@ def can_edit_note(user, user_config, note):
if user_config.roles.filter(name='admin').exists(): if user_config.roles.filter(name='admin').exists():
return True return True
# 2) Thématique 'structures'
intervention = getattr(note, 'intervention', None) intervention = getattr(note, 'intervention', None)
thematic_code = getattr(getattr(intervention, 'thematic', None), 'code', None) if intervention is None:
if thematic_code == 'structures': return False
# Auteur OU rôle manager/external_manager
status_order = getattr(intervention, 'status_order', None)
if status_order is None:
status_order = STATUS_ORDERS.get(intervention.status, 0)
# 2) Utilisateurs internes (commune / régie / gestionnaires)
if user_config.is_intern:
# Autorisé jusqu'au statut 'validé' (inclus)
if status_order > STATUS_ORDERS.get('validated', 51):
return False
# Auteur OU rôle manager
if note.note_author_id == user.id: if note.note_author_id == user.id:
return True return True
if user_config.roles.filter(name__in=['manager', 'external_manager']).exists(): if user_config.roles.filter(name='manager').exists():
return True return True
return False return False
# 3) Auteur + moins d'une heure (autres thématiques) # 3) Utilisateurs externes (prestataires / soumissionnaires)
if note.note_author_id != user.id: # Autorisé jusqu'au statut 'traité' (inclus)
if status_order > STATUS_ORDERS.get('processed', 31):
return False return False
created_at = getattr(note, 'note_time', None) or getattr(note, 'created_at', None) # Auteur
if created_at is None: if note.note_author_id == user.id:
return False return True
if timezone.is_naive(created_at): # Rôle external_manager (hors notes rédigées par des agents internes)
created_at = timezone.make_aware(created_at, timezone.get_current_timezone()) if user_config.roles.filter(name='external_manager').exists():
if note.note_author and hasattr(note.note_author, 'config') and getattr(note.note_author.config, 'is_intern', False):
return False
return True
return (timezone.now() - created_at) <= timedelta(hours=1) return False

View file

@ -31,7 +31,7 @@
{% for note in notes %} {% for note in notes %}
<div id="note-container-{{ note.id }}" <div id="note-container-{{ note.id }}"
class="note-item py-3 {% if not forloop.last %}border-bottom{% endif %} {% if note.id in editable_note_ids and can_edit_notes %}editable-note{% endif %}" class="note-item py-3 {% if not forloop.last %}border-bottom{% endif %} {% if note.id in editable_note_ids %}editable-note{% endif %}"
data-note-id="{{ note.id }}" data-note-id="{{ note.id }}"
data-note-content="{{ note.content|urlencode }}" data-note-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}" data-note-author-id="{{ note.note_author_id }}"
@ -62,7 +62,7 @@
</div> </div>
<p class="mb-0 note-text">{{ note.content|safe|linebreaksbr }}</p> <p class="mb-0 note-text">{{ note.content|safe|linebreaksbr }}</p>
</div> </div>
{% if note.id in editable_note_ids and can_edit_notes %} {% if note.id in editable_note_ids %}
<div class="note-actions ms-auto"> <div class="note-actions ms-auto">
<button type="button" class="btn btn-link text-danger p-0 delete-note-btn" data-note-id="{{ note.id }}" aria-label="{% translate "Supprimer la note" %}" style="display: none;"> <button type="button" class="btn btn-link text-danger p-0 delete-note-btn" data-note-id="{{ note.id }}" aria-label="{% translate "Supprimer la note" %}" style="display: none;">
<i class="bi bi-trash fs-6"></i> <i class="bi bi-trash fs-6"></i>
@ -73,7 +73,7 @@
</div> </div>
<!-- Edit mode (shown only in CMS edit mode for editable notes) --> <!-- Edit mode (shown only in CMS edit mode for editable notes) -->
{% if note.id in editable_note_ids and can_edit_notes %} {% if note.id in editable_note_ids %}
<div class="note-edit" style="display: none;"> <div class="note-edit" style="display: none;">
<div class="mb-2"> <div class="mb-2">
<label class="form-label small fw-bold">{% translate "Type de note" %}</label> <label class="form-label small fw-bold">{% translate "Type de note" %}</label>

View file

@ -90,7 +90,7 @@
<div class="notes-list {% if block.documents %}border-top pt-3{% endif %}"> <div class="notes-list {% if block.documents %}border-top pt-3{% endif %}">
{% for note in block.notes %} {% for note in block.notes %}
<div id="note-container-{{ note.id }}" <div id="note-container-{{ note.id }}"
class="note-item py-2 {% if not forloop.last %}border-bottom{% endif %} {% if note.id in editable_note_ids and can_edit_notes %}editable-note{% endif %}" class="note-item py-2 {% if not forloop.last %}border-bottom{% endif %} {% if note.id in editable_note_ids %}editable-note{% endif %}"
data-note-id="{{ note.id }}" data-note-id="{{ note.id }}"
data-note-content="{{ note.content|urlencode }}" data-note-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}" data-note-author-id="{{ note.note_author_id }}"
@ -121,7 +121,7 @@
</div> </div>
<p class="mb-0">{{ note.content|linebreaksbr }}</p> <p class="mb-0">{{ note.content|linebreaksbr }}</p>
</div> </div>
{% if note.id in editable_note_ids and can_edit_notes %} {% if note.id in editable_note_ids %}
<div class="note-actions cms-note-actions" style="display: none;"> <div class="note-actions cms-note-actions" style="display: none;">
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-note me-1" title="{% translate 'Modifier la note' %}"> <button type="button" class="btn btn-sm btn-outline-primary btn-edit-note me-1" title="{% translate 'Modifier la note' %}">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
@ -135,7 +135,7 @@
</div> </div>
<!-- Edit mode (shown only in CMS edit mode for editable notes) --> <!-- Edit mode (shown only in CMS edit mode for editable notes) -->
{% if note.id in editable_note_ids and can_edit_notes %} {% if note.id in editable_note_ids %}
<div class="note-edit" style="display: none;"> <div class="note-edit" style="display: none;">
<div class="mb-2"> <div class="mb-2">
<label class="form-label small fw-bold">{% translate "Type de note" %}</label> <label class="form-label small fw-bold">{% translate "Type de note" %}</label>

View file

@ -727,7 +727,7 @@
</div> </div>
<p class="mb-0 small">{{ note.content|safe|linebreaksbr }}</p> <p class="mb-0 small">{{ note.content|safe|linebreaksbr }}</p>
</div> </div>
{% if note.id in editable_note_ids and can_edit_notes %} {% if note.id in editable_note_ids %}
<div class="note-actions d-flex flex-column align-items-center gap-2 ms-auto"> <div class="note-actions d-flex flex-column align-items-center gap-2 ms-auto">
<button type="button" class="btn btn-link text-primary p-0 edit-note-btn" <button type="button" class="btn btn-link text-primary p-0 edit-note-btn"
data-note-id="{{ note.id }}" aria-label="{% translate "Modifier la note" %}"> data-note-id="{{ note.id }}" aria-label="{% translate "Modifier la note" %}">

View file

@ -519,5 +519,192 @@ class TimelineHistoryTests(TestCase):
self.assertContains(response, 'history-status-circle') self.assertContains(response, 'history-status-circle')
self.assertContains(response, 'history-status-transition') 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)

View file

@ -53,7 +53,7 @@ from dateutil.relativedelta import relativedelta
from common.private_files.helpers import build_private_url from common.private_files.helpers import build_private_url
from common.utils import get_short_name from common.utils import get_short_name
from interventions.services.timeline_service import record_document_event from interventions.services.timeline_service import record_document_event, record_note_event
from interventions import views from interventions import views
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
InterventionLink, InterventionLink,
@ -491,12 +491,23 @@ def intervention_detail_mobile(request, intervention_id):
if note_id: if note_id:
try: try:
note = InterventionNote.objects.get(pk=note_id, intervention=intervention) note = InterventionNote.objects.get(pk=note_id, intervention=intervention)
# Only allow updating if user is the author user_cfg = getattr(request.user, 'config', None)
if note.note_author == request.user: if can_edit_note(request.user, user_cfg, note):
old_note_type = note.note_type
old_content = note.content
note.content = cleaned_data['content'] note.content = cleaned_data['content']
note.note_type = cleaned_data['note_type'] note.note_type = cleaned_data['note_type']
note.updated_by = request.user note.updated_by = request.user
note.save() note.save()
record_note_event(
intervention=intervention,
user=request.user,
action='update',
note_type=note.note_type,
content=note.content,
old_note_type=old_note_type,
old_content=old_content,
)
else: else:
messages.error(request, _("Vous n'êtes pas autorisé à modifier cette note")) messages.error(request, _("Vous n'êtes pas autorisé à modifier cette note"))
note = None note = None
@ -506,23 +517,40 @@ def intervention_detail_mobile(request, intervention_id):
# Create new note if no existing note was found/updated # Create new note if no existing note was found/updated
if not note: if not note:
InterventionNote.objects.create( new_note = InterventionNote.objects.create(
intervention=intervention, intervention=intervention,
content=cleaned_data['content'], content=cleaned_data['content'],
note_type=cleaned_data['note_type'], note_type=cleaned_data['note_type'],
note_author=request.user, note_author=request.user,
note_time=now() note_time=now()
) )
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=new_note.note_type,
content=new_note.content,
)
else: else:
messages.error(request, _("Erreur lors de l'enregistrement de la note.")) messages.error(request, _("Erreur lors de l'enregistrement de la note."))
# 2. Upload de documents (photos) # 2. Upload de documents (photos)
uploaded_photos = []
for file in request.FILES.getlist("photos"): for file in request.FILES.getlist("photos"):
InterventionDocument.objects.create( doc = InterventionDocument.objects.create(
intervention=intervention, intervention=intervention,
file=file, file=file,
uploaded_by=request.user uploaded_by=request.user
) )
filename = getattr(file, 'name', '') or getattr(doc, 'filename', None) or str(doc.id)
uploaded_photos.append(filename)
if uploaded_photos:
record_document_event(
intervention=intervention,
user=request.user,
action='upload',
filenames=uploaded_photos,
)
# 3. Traitement des opérations # 3. Traitement des opérations
for operation in intervention.operations.all(): for operation in intervention.operations.all():
@ -783,13 +811,7 @@ def intervention_detail_mobile(request, intervention_id):
.annotate(type_priority_order=note_priority_case) .annotate(type_priority_order=note_priority_case)
.order_by('type_priority_order', 'note_time') .order_by('type_priority_order', 'note_time')
) )
if is_admin: editable_note_ids = {n.id for n in notes if can_edit_note(request.user, user_config, n)}
editable_note_ids = set(notes.values_list('id', flat=True))
else:
editable_note_ids = set(
notes.filter(note_author=request.user, note_time__gte=one_hour_ago)
.values_list('id', flat=True)
)
documents_qs = intervention.documents.all() documents_qs = intervention.documents.all()
@ -1948,12 +1970,23 @@ def save_intervention_mobile_ajax(request, intervention_id):
if note_id: if note_id:
try: try:
note = InterventionNote.objects.get(pk=note_id, intervention=intervention) note = InterventionNote.objects.get(pk=note_id, intervention=intervention)
# Only allow updating if user is the author user_cfg = getattr(request.user, 'config', None)
if note.note_author == request.user: if can_edit_note(request.user, user_cfg, note):
old_note_type = note.note_type
old_content = note.content
note.content = note_content note.content = note_content
note.note_type = note_type or note.note_type note.note_type = note_type or note.note_type
note.updated_by = request.user note.updated_by = request.user
note.save() note.save()
record_note_event(
intervention=intervention,
user=request.user,
action='update',
note_type=note.note_type,
content=note.content,
old_note_type=old_note_type,
old_content=old_content,
)
result['note_saved'] = True result['note_saved'] = True
result['note_updated'] = True result['note_updated'] = True
else: else:
@ -1971,6 +2004,13 @@ def save_intervention_mobile_ajax(request, intervention_id):
note_author=request.user, note_author=request.user,
note_time=now() note_time=now()
) )
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=note.note_type,
content=note.content,
)
result['note_saved'] = True result['note_saved'] = True
result['note_updated'] = False result['note_updated'] = False