@@ -909,6 +968,14 @@ function lightboxNavMobile(dir) { _mobLightboxShow(_mobPhotoIdx + dir); }
if (!isSymptom) {
document.getElementById('actionStatusSymptomId').value = '';
}
+ } else if (status === 'needs_info') {
+ const comment = document.getElementById('actionStatusComment').value.trim();
+ if (!comment) {
+ e.preventDefault();
+ document.getElementById('actionStatusComment').classList.add('is-invalid');
+ document.getElementById('actionStatusComment').focus();
+ return;
+ }
}
});
@@ -930,6 +997,22 @@ function lightboxNavMobile(dir) { _mobLightboxShow(_mobPhotoIdx + dir); }
document.getElementById('redirectSymptomDisplay').style.display = 'none';
document.getElementById('redirectSymptomDisplay').textContent = '';
document.getElementById('redirectSymptomSearch').classList.remove('is-invalid');
+ document.getElementById('actionStatusComment').classList.remove('is-invalid');
+
+ // Adapter le label du commentaire si needs_info
+ const isNeedsInfo = status === 'needs_info';
+ const commentLabel = document.querySelector('label[for="actionStatusComment"]');
+ if (commentLabel) {
+ if (isNeedsInfo) {
+ commentLabel.innerHTML = '{% translate "Précisez les informations demandées" %} *';
+ } else {
+ commentLabel.innerHTML = '{% translate "Commentaire" %} ({% translate "optionnel" %})';
+ }
+ }
+ document.getElementById('actionStatusComment').placeholder = isNeedsInfo ?
+ '{% translate "Indiquez ici les détails ou photos attendus..." %}' :
+ '{% translate "Ajoutez un commentaire..." %}';
+
// Afficher/masquer la section choix selon le statut
const isToRedirect = status === 'to_redirect';
document.getElementById('redirectChoiceSection').style.display = isToRedirect ? '' : 'none';
diff --git a/loko/observations/templates/observations/observations_index_mobile.html b/loko/observations/templates/observations/observations_index_mobile.html
index f93431c..fb2ac6f 100644
--- a/loko/observations/templates/observations/observations_index_mobile.html
+++ b/loko/observations/templates/observations/observations_index_mobile.html
@@ -247,6 +247,23 @@
color: #fff;
}
+ .btn-outline-status-needs_info {
+ --bs-btn-color: #fd7e14;
+ --bs-btn-border-color: #fd7e14;
+ --bs-btn-hover-color: #fff;
+ --bs-btn-hover-bg: #fd7e14;
+ --bs-btn-hover-border-color: #fd7e14;
+ --bs-btn-active-color: #fff;
+ --bs-btn-active-bg: #fd7e14;
+ --bs-btn-active-border-color: #fd7e14;
+ }
+
+ .btn-check:checked + .btn-outline-status-needs_info {
+ background-color: #fd7e14;
+ border-color: #fd7e14;
+ color: #fff;
+ }
+
.btn-outline-status-to_redirect {
--bs-btn-color: #ffc107;
--bs-btn-border-color: #ffc107;
diff --git a/loko/observations/tests.py b/loko/observations/tests.py
index 1b6c27d..7411542 100644
--- a/loko/observations/tests.py
+++ b/loko/observations/tests.py
@@ -639,3 +639,153 @@ class ObservationViewerPermissionsTest(TestCase):
self.assertFalse(access_context.can_process_observation(self.obs_green))
+class ObservationNeedsInfoTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+
+ self.thematic = Thematic.objects.create(
+ code='THEME-OBS-INFO',
+ name_fr='Thématique Obs Info',
+ name_nl='Thema Obs Info',
+ )
+ self.category = AssetCategory.objects.create(
+ thematic=self.thematic,
+ code='CAT-OBS-INFO',
+ name_fr='Catégorie Obs Info',
+ name_nl='Categorie Obs Info',
+ )
+
+ self.creator = User.objects.create_user('creator_user', password='pass')
+ self.creator_config = UserConfig.objects.create(user=self.creator, is_intern=True)
+ UserThematics.objects.create(
+ user_config=self.creator_config,
+ thematic=self.thematic,
+ can_view_interventions=True,
+ )
+
+ self.manager = User.objects.create_user('manager_user', password='pass')
+ self.manager_config = UserConfig.objects.create(user=self.manager, is_intern=True)
+ manager_role, _ = Role.objects.get_or_create(name='manager')
+ self.manager_config.roles.add(manager_role)
+ UserThematics.objects.create(
+ user_config=self.manager_config,
+ thematic=self.thematic,
+ can_process_observations=True,
+ can_view_interventions=True,
+ )
+
+ self.unprivileged = User.objects.create_user('unprivileged_user', password='pass')
+ self.unprivileged_config = UserConfig.objects.create(user=self.unprivileged, is_intern=False)
+ UserThematics.objects.create(
+ user_config=self.unprivileged_config,
+ thematic=self.thematic,
+ )
+
+ self.obs = Observation.objects.create(
+ description='Observation to be clarified',
+ latitude=50.85,
+ longitude=4.35,
+ created_by=self.creator,
+ thematic=self.thematic,
+ category=self.category,
+ status='to_process',
+ )
+
+ def test_manager_can_request_info_with_mandatory_comment(self):
+ self.client.login(username='manager_user', password='pass')
+ url = reverse('observations:observation_change_status', args=[self.obs.id])
+
+ # Without comment: must fail/stay in to_process
+ resp = self.client.post(url, {'new_status': 'needs_info', 'comment': ''})
+ self.obs.refresh_from_db()
+ self.assertEqual(self.obs.status, 'to_process')
+
+ # With comment: transitions to needs_info, creates comment and discussion message
+ resp = self.client.post(url, {
+ 'new_status': 'needs_info',
+ 'comment': 'Pouvez-vous préciser la taille et joindre une photo ?',
+ })
+ self.assertEqual(resp.status_code, 302)
+ self.obs.refresh_from_db()
+ self.assertEqual(self.obs.status, 'needs_info')
+
+ # Check ObservationComment created
+ comment = self.obs.comments.last()
+ self.assertIsNotNone(comment)
+ self.assertEqual(comment.author, self.manager)
+ self.assertIn('Pouvez-vous préciser', comment.comment)
+
+ # Check Discussion & Message created
+ from notifications.services import get_or_create_observation_discussion
+ discussion, _ = get_or_create_observation_discussion(self.obs)
+ last_msg = discussion.messages.last()
+ self.assertIsNotNone(last_msg)
+ self.assertEqual(last_msg.author, self.manager)
+ self.assertIn('Pouvez-vous préciser', last_msg.content)
+
+ def test_unauthorized_user_cannot_request_info(self):
+ self.client.login(username='unprivileged_user', password='pass')
+ url = reverse('observations:observation_change_status', args=[self.obs.id])
+ resp = self.client.post(url, {
+ 'new_status': 'needs_info',
+ 'comment': 'Commentaire non autorisé',
+ })
+ self.assertEqual(resp.status_code, 403)
+ self.obs.refresh_from_db()
+ self.assertEqual(self.obs.status, 'to_process')
+
+ def test_creator_answering_in_discussion_auto_resubmits_observation(self):
+ self.obs.status = 'needs_info'
+ self.obs.save(update_fields=['status'])
+
+ self.client.login(username='creator_user', password='pass')
+ msg_url = reverse('observations:observation_add_message', args=[self.obs.id])
+
+ resp = self.client.post(msg_url, {
+ 'content': "Voici les informations : la fissure mesure 20 cm.",
+ })
+ self.assertEqual(resp.status_code, 302)
+
+ self.obs.refresh_from_db()
+ self.assertEqual(self.obs.status, 'to_process')
+
+ # Check auto-comment created
+ comment = self.obs.comments.last()
+ self.assertIsNotNone(comment)
+ self.assertEqual(comment.author, self.creator)
+ self.assertIn("Réponse apportée dans la discussion", comment.comment)
+
+ def test_creator_clicking_complements_provided_resubmits_observation(self):
+ self.obs.status = 'needs_info'
+ self.obs.save(update_fields=['status'])
+
+ self.client.login(username='creator_user', password='pass')
+ url = reverse('observations:observation_change_status', args=[self.obs.id])
+
+ resp = self.client.post(url, {
+ 'new_status': 'to_process',
+ 'comment': 'Compléments apportés',
+ })
+ self.assertEqual(resp.status_code, 302)
+
+ self.obs.refresh_from_db()
+ self.assertEqual(self.obs.status, 'to_process')
+
+ def test_manager_can_classify_not_relevant_from_needs_info(self):
+ self.obs.status = 'needs_info'
+ self.obs.save(update_fields=['status'])
+
+ self.client.login(username='manager_user', password='pass')
+ url = reverse('observations:observation_change_status', args=[self.obs.id])
+
+ resp = self.client.post(url, {
+ 'new_status': 'not_relevant',
+ 'comment': 'Sans suite après absence de réponse',
+ })
+ self.assertEqual(resp.status_code, 302)
+
+ self.obs.refresh_from_db()
+ self.assertEqual(self.obs.status, 'not_relevant')
+
+
+
diff --git a/loko/observations/views.py b/loko/observations/views.py
index fa85da6..3db93e2 100644
--- a/loko/observations/views.py
+++ b/loko/observations/views.py
@@ -103,6 +103,7 @@ def observations_index(request):
_status_badge_map = {
'in_preparation': 'secondary',
'to_process': 'danger',
+ 'needs_info': 'warning',
'in_progress': 'warning',
'processed': 'success',
'not_relevant': 'secondary',
@@ -205,6 +206,7 @@ def observations_index_mobile(request):
_OBS_STATUS_COLOR = {
'in_preparation': 'secondary',
'to_process': 'danger',
+ 'needs_info': 'warning',
'in_progress': 'warning',
'processed': 'success',
'not_relevant': 'secondary',
@@ -744,7 +746,7 @@ def observation_detail(request, observation_id):
# Vérifier si l'utilisateur peut supprimer l'observation
can_delete = (
- observation.status in ('in_preparation', 'to_process') and
+ observation.status in ('in_preparation', 'to_process', 'needs_info') and
can_edit
)
@@ -894,12 +896,30 @@ def observation_detail(request, observation_id):
def observation_change_status(request, observation_id):
observation = request.observation_obj
new_status = request.POST.get('new_status')
- allowed_statuses = ['to_process', 'processed', 'not_relevant', 'to_redirect', 'duplicate', 'closed', 'archived']
+ allowed_statuses = ['to_process', 'processed', 'not_relevant', 'to_redirect', 'duplicate', 'needs_info', 'closed', 'archived']
if new_status not in allowed_statuses:
return HttpResponseForbidden("Statut non autorisé.")
- # Règles simples de transition
- if new_status == 'to_process' and observation.status != 'in_preparation':
- return HttpResponseForbidden("Transition non autorisée.")
+
+ referer = request.META.get('HTTP_REFERER', '')
+ is_mobile = '/mobile/' in referer
+ redirect_target = 'mobile:observation_detail_mobile' if is_mobile else 'observations:observation_detail'
+
+ # Règles de transition vers 'to_process'
+ if new_status == 'to_process':
+ if observation.status == 'in_preparation':
+ pass
+ elif observation.status == 'needs_info':
+ # Autorisé pour le créateur ou les gestionnaires habilités
+ is_creator = (observation.created_by == request.user)
+ access_context = request.observation_access
+ can_manage = access_context.can_process_observation(observation) or any(
+ access_context.user_config.has_role(r) for r in ['admin', 'operator', 'manager', 'controller', 'top_manager']
+ )
+ if not (is_creator or can_manage):
+ return HttpResponseForbidden("Vous n'avez pas la permission d'effectuer cette action.")
+ else:
+ return HttpResponseForbidden("Transition non autorisée.")
+
# Contrôle des rôles/permissions pour 'processed' (tous types)
if new_status == 'processed':
access_context = request.observation_access
@@ -908,15 +928,28 @@ def observation_change_status(request, observation_id):
fallback_roles = ['manager', 'controller']
if not any(access_context.user_config.has_role(role) for role in fallback_roles):
return HttpResponseForbidden("Vous n'avez pas la permission de traiter cette observation.")
- # Contrôle des rôles pour 'not_relevant', 'to_redirect' et 'duplicate'
- if new_status in ('not_relevant', 'to_redirect', 'duplicate'):
- if observation.status != 'to_process':
+
+ # Contrôle des rôles pour 'not_relevant', 'to_redirect', 'duplicate' et 'needs_info'
+ if new_status in ('not_relevant', 'to_redirect', 'duplicate', 'needs_info'):
+ if new_status == 'not_relevant':
+ if observation.status not in ('to_process', 'needs_info'):
+ return HttpResponseForbidden("Transition non autorisée.")
+ elif observation.status != 'to_process':
return HttpResponseForbidden("Transition non autorisée.")
+
access_context = request.observation_access
if not access_context.can_process_observation(observation):
- fallback_roles = ['manager', 'controller']
+ fallback_roles = ['manager', 'controller', 'operator', 'admin']
if not any(access_context.user_config.has_role(role) for role in fallback_roles):
return HttpResponseForbidden("Vous n'avez pas la permission d'effectuer cette action.")
+
+ # Pour 'needs_info' : le motif / commentaire est obligatoire
+ if new_status == 'needs_info':
+ comment_text = (request.POST.get('comment') or '').strip()
+ if not comment_text:
+ messages.error(request, _("Veuillez préciser les informations complémentaires demandées."))
+ return redirect(redirect_target, observation_id=observation.id)
+
# Pour "à rediriger" avec choix d'un nouveau symptôme : mettre à jour symptôme/catégorie/thématique
# Dans ce cas, le statut n'est PAS modifié et un commentaire automatique est ajouté.
symptom_changed = False
@@ -952,6 +985,7 @@ def observation_change_status(request, observation_id):
new_status = observation.status # conserver le statut actuel
except Symptom.DoesNotExist:
pass
+
# Enregistrer un commentaire optionnel (pour tous les autres changements de statut)
if not symptom_changed:
comment_text = (request.POST.get('comment') or '').strip()
@@ -961,15 +995,41 @@ def observation_change_status(request, observation_id):
author=request.user if request.user.is_authenticated else None,
comment=comment_text,
)
- # Archiver seulement depuis 'processed', 'not_relevant', 'to_redirect' ou 'duplicate'
- if new_status == 'archived' and observation.status not in ('processed', 'not_relevant', 'to_redirect', 'duplicate'):
- return HttpResponseForbidden("Seules les observations traitées, non relevantes, à rediriger ou doublons peuvent être archivées.")
+
+ # Si passage en needs_info : publier aussi dans la discussion pour alerter l'auteur
+ if new_status == 'needs_info':
+ from notifications.services import get_or_create_observation_discussion, post_message
+ discussion, _ = get_or_create_observation_discussion(observation)
+ post_message(
+ discussion=discussion,
+ author=request.user,
+ content=_("Demande d'informations complémentaires :\n%(comment)s") % {'comment': comment_text}
+ )
+ messages.success(request, _("La demande d'informations a été envoyée à l'observateur."))
+
+ # Si retour à to_process depuis needs_info :
+ elif observation.status == 'needs_info' and new_status == 'to_process':
+ res_content = (
+ _("Compléments d'information fournis :\n%(comment)s") % {'comment': comment_text}
+ if comment_text else
+ _("Compléments d'information fournis. L'observation est à nouveau à traiter.")
+ )
+ from notifications.services import get_or_create_observation_discussion, post_message
+ discussion, _ = get_or_create_observation_discussion(observation)
+ post_message(
+ discussion=discussion,
+ author=request.user,
+ content=res_content
+ )
+ messages.success(request, _("L'observation a été renvoyée pour traitement."))
+
+ # Archiver seulement depuis 'processed', 'not_relevant', 'to_redirect', 'duplicate' ou 'needs_info'
+ if new_status == 'archived' and observation.status not in ('processed', 'not_relevant', 'to_redirect', 'duplicate', 'needs_info'):
+ return HttpResponseForbidden("Seules les observations traitées, non relevantes, à rediriger, en attente d'infos ou doublons peuvent être archivées.")
observation.status = new_status
observation.save()
# Rediriger vers la vue mobile ou desktop selon l'origine de la requête
- referer = request.META.get('HTTP_REFERER', '')
- is_mobile = '/mobile/' in referer
if is_mobile:
return redirect('mobile:observation_detail_mobile', observation_id=observation.id)
return redirect('observations:observation_detail', observation_id=observation.id)
@@ -1125,6 +1185,7 @@ def observation_add(request, observation_id=None):
observation.created_by = request.user
# Gestion du statut selon le bouton utilisé
+ previous_status = observation_to_edit.status if observation_to_edit else None
if 'save' in request.POST:
observation.status = 'in_preparation'
elif 'send' in request.POST:
@@ -1213,6 +1274,20 @@ def observation_add(request, observation_id=None):
except json.JSONDecodeError:
pass # Ignore invalid JSON
+ if observation_to_edit and previous_status == 'needs_info' and observation.status == 'to_process':
+ ObservationComment.objects.create(
+ observation=observation,
+ author=request.user,
+ comment=_("Observation modifiée et renvoyée par l'auteur."),
+ )
+ from notifications.services import get_or_create_observation_discussion, post_message
+ discussion, _ = get_or_create_observation_discussion(observation)
+ post_message(
+ discussion=discussion,
+ author=request.user,
+ content=_("L'observation a été modifiée et renvoyée par l'auteur.")
+ )
+
# After creation/update, redirect to detail
messages.success(request, "Observation enregistrée avec succès.")
@@ -1933,6 +2008,23 @@ def observation_add_message(request, observation_id):
files = request.FILES.getlist('attachments') or None
post_message(discussion=discussion, author=request.user, content=content, files=files)
+ # Si l'observation était en attente d'informations et que l'auteur répond,
+ # basculer automatiquement l'observation à 'to_process'.
+ if observation.status == 'needs_info' and request.user == observation.created_by:
+ observation.status = 'to_process'
+ observation.save(update_fields=['status'])
+ ObservationComment.objects.create(
+ observation=observation,
+ author=request.user,
+ comment=_("Réponse apportée dans la discussion : l'observation repasse à l'état 'À traiter'."),
+ )
+ messages.success(request, _("Votre réponse a été envoyée et l'observation a été renvoyée pour traitement."))
+
+ referer = request.META.get('HTTP_REFERER', '')
+ if '/mobile/' in referer:
+ return redirect(
+ reverse('mobile:observation_detail_mobile', args=[observation_id]) + '#discussion'
+ )
return redirect(
reverse('observations:observation_detail', args=[observation_id]) + '#discussion'
)
diff --git a/loko/reporting/templates/reporting/observations_dashboard.html b/loko/reporting/templates/reporting/observations_dashboard.html
index 7c9a6da..c585a5a 100644
--- a/loko/reporting/templates/reporting/observations_dashboard.html
+++ b/loko/reporting/templates/reporting/observations_dashboard.html
@@ -969,6 +969,7 @@
fr: {
'in_preparation': 'En préparation',
'to_process': 'À traiter',
+ 'needs_info': 'Informations demandées',
'in_progress': 'En cours',
'processed': 'Traité',
'not_relevant': 'Non relevant',
@@ -979,6 +980,7 @@
nl: {
'in_preparation': 'In voorbereiding',
'to_process': 'Te behandelen',
+ 'needs_info': 'Informatie gevraagd',
'in_progress': 'In behandeling',
'processed': 'Verwerkt',
'not_relevant': 'Niet relevant',
@@ -991,6 +993,7 @@
const STATUS_BADGES = {
'in_preparation': 'bg-secondary',
'to_process': 'bg-danger',
+ 'needs_info': 'bg-warning text-dark',
'in_progress': 'bg-primary',
'processed': 'bg-success',
'not_relevant': 'bg-secondary',