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

View file

@ -31,7 +31,7 @@
{% for note in notes %}
<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-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}"
@ -62,7 +62,7 @@
</div>
<p class="mb-0 note-text">{{ note.content|safe|linebreaksbr }}</p>
</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">
<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>
@ -73,7 +73,7 @@
</div>
<!-- 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="mb-2">
<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 %}">
{% for note in block.notes %}
<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-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}"
@ -121,7 +121,7 @@
</div>
<p class="mb-0">{{ note.content|linebreaksbr }}</p>
</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;">
<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>
@ -135,7 +135,7 @@
</div>
<!-- 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="mb-2">
<label class="form-label small fw-bold">{% translate "Type de note" %}</label>

View file

@ -727,7 +727,7 @@
</div>
<p class="mb-0 small">{{ note.content|safe|linebreaksbr }}</p>
</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">
<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" %}">

View file

@ -519,5 +519,192 @@ class TimelineHistoryTests(TestCase):
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)

View file

@ -53,7 +53,7 @@ from dateutil.relativedelta import relativedelta
from common.private_files.helpers import build_private_url
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.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
InterventionLink,
@ -491,12 +491,23 @@ def intervention_detail_mobile(request, intervention_id):
if note_id:
try:
note = InterventionNote.objects.get(pk=note_id, intervention=intervention)
# Only allow updating if user is the author
if note.note_author == request.user:
user_cfg = getattr(request.user, 'config', None)
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.note_type = cleaned_data['note_type']
note.updated_by = request.user
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:
messages.error(request, _("Vous n'êtes pas autorisé à modifier cette note"))
note = None
@ -506,23 +517,40 @@ def intervention_detail_mobile(request, intervention_id):
# Create new note if no existing note was found/updated
if not note:
InterventionNote.objects.create(
new_note = InterventionNote.objects.create(
intervention=intervention,
content=cleaned_data['content'],
note_type=cleaned_data['note_type'],
note_author=request.user,
note_time=now()
)
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=new_note.note_type,
content=new_note.content,
)
else:
messages.error(request, _("Erreur lors de l'enregistrement de la note."))
# 2. Upload de documents (photos)
uploaded_photos = []
for file in request.FILES.getlist("photos"):
InterventionDocument.objects.create(
doc = InterventionDocument.objects.create(
intervention=intervention,
file=file,
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
for operation in intervention.operations.all():
@ -783,13 +811,7 @@ def intervention_detail_mobile(request, intervention_id):
.annotate(type_priority_order=note_priority_case)
.order_by('type_priority_order', 'note_time')
)
if is_admin:
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)
)
editable_note_ids = {n.id for n in notes if can_edit_note(request.user, user_config, n)}
documents_qs = intervention.documents.all()
@ -1948,12 +1970,23 @@ def save_intervention_mobile_ajax(request, intervention_id):
if note_id:
try:
note = InterventionNote.objects.get(pk=note_id, intervention=intervention)
# Only allow updating if user is the author
if note.note_author == request.user:
user_cfg = getattr(request.user, 'config', None)
if can_edit_note(request.user, user_cfg, note):
old_note_type = note.note_type
old_content = note.content
note.content = note_content
note.note_type = note_type or note.note_type
note.updated_by = request.user
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_updated'] = True
else:
@ -1971,6 +2004,13 @@ def save_intervention_mobile_ajax(request, intervention_id):
note_author=request.user,
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_updated'] = False