diff --git a/loko/common/migrations/0018_setup_rbc_default_territory.py b/loko/common/migrations/0018_setup_rbc_default_territory.py new file mode 100644 index 0000000..68e9d0c --- /dev/null +++ b/loko/common/migrations/0018_setup_rbc_default_territory.py @@ -0,0 +1,43 @@ +import os +from django.db import migrations +from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon + + +def create_rbc_territory(apps, schema_editor): + Territory = apps.get_model('common', 'Territory') + if Territory.objects.filter(code='RBC').exists(): + return + + from django.conf import settings + fixture_path = os.path.join(settings.BASE_DIR, 'common', 'fixtures', 'rbc_territory_wkt.txt') + if os.path.exists(fixture_path): + with open(fixture_path, 'r', encoding='utf-8') as f: + wkt = f.read().strip() + geom = GEOSGeometry(wkt, srid=3812) + if isinstance(geom, Polygon): + geom = MultiPolygon(geom, srid=3812) + Territory.objects.create( + name_fr="Région de Bruxelles-Capitale", + name_nl="Brussels Hoofdstedelijk Gewest", + code="RBC", + description="Zone d'action régionale de Bruxelles Mobilité couvrant les 19 communes bruxelloises sans discontinuités territoriales.", + geom=geom, + is_default=True, + is_active=True, + ) + + +def remove_rbc_territory(apps, schema_editor): + Territory = apps.get_model('common', 'Territory') + Territory.objects.filter(code='RBC').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0017_territory'), + ] + + operations = [ + migrations.RunPython(create_rbc_territory, reverse_code=remove_rbc_territory), + ] diff --git a/loko/common/migrations/0019_userthematics_can_edit_management_zones.py b/loko/common/migrations/0019_userthematics_can_edit_management_zones.py new file mode 100644 index 0000000..713334b --- /dev/null +++ b/loko/common/migrations/0019_userthematics_can_edit_management_zones.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-10-05 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0018_setup_rbc_default_territory'), + ] + + operations = [ + migrations.AddField( + model_name='userthematics', + name='can_edit_management_zones', + field=models.BooleanField(default=False, verbose_name='Peut éditer les zones de gestion'), + ), + ] diff --git a/loko/home/views.py b/loko/home/views.py index 0f85272..5d826c6 100644 --- a/loko/home/views.py +++ b/loko/home/views.py @@ -1141,6 +1141,7 @@ def _get_recent_activity(user, allowed_contracts): for item in Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('thematic').order_by('-created_at')[:4]: status_classes = { 'to_process': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle', + 'needs_info': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle', 'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle', 'processed': 'bg-success-subtle text-success-emphasis border border-success-subtle', } diff --git a/loko/notifications/locale/en/LC_MESSAGES/django.mo b/loko/notifications/locale/en/LC_MESSAGES/django.mo index f2513c5..12c11b1 100644 Binary files a/loko/notifications/locale/en/LC_MESSAGES/django.mo and b/loko/notifications/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/notifications/locale/fr/LC_MESSAGES/django.mo b/loko/notifications/locale/fr/LC_MESSAGES/django.mo index 1b10e3f..3b3f37b 100644 Binary files a/loko/notifications/locale/fr/LC_MESSAGES/django.mo and b/loko/notifications/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/notifications/locale/nl/LC_MESSAGES/django.mo b/loko/notifications/locale/nl/LC_MESSAGES/django.mo index ef7f2bf..7ae26db 100644 Binary files a/loko/notifications/locale/nl/LC_MESSAGES/django.mo and b/loko/notifications/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/observations/locale/en/LC_MESSAGES/django.mo b/loko/observations/locale/en/LC_MESSAGES/django.mo index 112750d..048e4b7 100644 Binary files a/loko/observations/locale/en/LC_MESSAGES/django.mo and b/loko/observations/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/observations/locale/en/LC_MESSAGES/django.po b/loko/observations/locale/en/LC_MESSAGES/django.po index d83f2a6..841ac16 100644 --- a/loko/observations/locale/en/LC_MESSAGES/django.po +++ b/loko/observations/locale/en/LC_MESSAGES/django.po @@ -400,6 +400,12 @@ msgstr "In preparation" msgid "À traiter" msgstr "To be processed" +#: observations/models.py:428 +#: observations/templates/observations/_observations_list_mobile.html:71 +msgid "Informations demandées" +msgstr "Information requested" + + #: observations/models.py:428 #: observations/templates/observations/_observations_list_mobile.html:71 #: observations/templates/observations/observation_detail.html:876 diff --git a/loko/observations/locale/en/LC_MESSAGES/djangojs.mo b/loko/observations/locale/en/LC_MESSAGES/djangojs.mo index 77c240b..b86b5d5 100644 Binary files a/loko/observations/locale/en/LC_MESSAGES/djangojs.mo and b/loko/observations/locale/en/LC_MESSAGES/djangojs.mo differ diff --git a/loko/observations/locale/en/LC_MESSAGES/djangojs.po b/loko/observations/locale/en/LC_MESSAGES/djangojs.po index 3f220b2..f0aae24 100644 --- a/loko/observations/locale/en/LC_MESSAGES/djangojs.po +++ b/loko/observations/locale/en/LC_MESSAGES/djangojs.po @@ -24,6 +24,11 @@ msgstr "In preparation" msgid "À traiter" msgstr "To be processed" +#: loko/observations/static/observations/observations_index.js:16 +msgid "Informations demandées" +msgstr "Information requested" + + #: loko/observations/static/observations/observations_index.js:17 msgid "En cours" msgstr "In progress" diff --git a/loko/observations/locale/fr/LC_MESSAGES/django.mo b/loko/observations/locale/fr/LC_MESSAGES/django.mo index b45139c..a8c2969 100644 Binary files a/loko/observations/locale/fr/LC_MESSAGES/django.mo and b/loko/observations/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/observations/locale/fr/LC_MESSAGES/django.po b/loko/observations/locale/fr/LC_MESSAGES/django.po index a7c4bac..4aa5e6d 100644 --- a/loko/observations/locale/fr/LC_MESSAGES/django.po +++ b/loko/observations/locale/fr/LC_MESSAGES/django.po @@ -405,6 +405,12 @@ msgstr "En préparation" msgid "À traiter" msgstr "À traiter" +#: observations/models.py:428 +#: observations/templates/observations/_observations_list_mobile.html:71 +msgid "Informations demandées" +msgstr "Informations demandées" + + #: observations/models.py:428 #: observations/templates/observations/_observations_list_mobile.html:71 #: observations/templates/observations/observation_detail.html:876 diff --git a/loko/observations/locale/fr/LC_MESSAGES/djangojs.mo b/loko/observations/locale/fr/LC_MESSAGES/djangojs.mo index 46f19d4..fab1d27 100644 Binary files a/loko/observations/locale/fr/LC_MESSAGES/djangojs.mo and b/loko/observations/locale/fr/LC_MESSAGES/djangojs.mo differ diff --git a/loko/observations/locale/fr/LC_MESSAGES/djangojs.po b/loko/observations/locale/fr/LC_MESSAGES/djangojs.po index b732200..e0abaed 100644 --- a/loko/observations/locale/fr/LC_MESSAGES/djangojs.po +++ b/loko/observations/locale/fr/LC_MESSAGES/djangojs.po @@ -24,6 +24,11 @@ msgstr "En préparation" msgid "À traiter" msgstr "À traiter" +#: loko/observations/static/observations/observations_index.js:16 +msgid "Informations demandées" +msgstr "Informations demandées" + + #: loko/observations/static/observations/observations_index.js:17 msgid "En cours" msgstr "En cours" diff --git a/loko/observations/locale/nl/LC_MESSAGES/django.mo b/loko/observations/locale/nl/LC_MESSAGES/django.mo index bad8587..6655121 100644 Binary files a/loko/observations/locale/nl/LC_MESSAGES/django.mo and b/loko/observations/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/observations/locale/nl/LC_MESSAGES/django.po b/loko/observations/locale/nl/LC_MESSAGES/django.po index 7ea6e24..b32c2f3 100644 --- a/loko/observations/locale/nl/LC_MESSAGES/django.po +++ b/loko/observations/locale/nl/LC_MESSAGES/django.po @@ -404,6 +404,12 @@ msgstr "In voorbereiding" msgid "À traiter" msgstr "Te behandelen" +#: observations/models.py:428 +#: observations/templates/observations/_observations_list_mobile.html:71 +msgid "Informations demandées" +msgstr "Informatie gevraagd" + + #: observations/models.py:428 #: observations/templates/observations/_observations_list_mobile.html:71 #: observations/templates/observations/observation_detail.html:876 diff --git a/loko/observations/locale/nl/LC_MESSAGES/djangojs.mo b/loko/observations/locale/nl/LC_MESSAGES/djangojs.mo index e0995ae..91d3517 100644 Binary files a/loko/observations/locale/nl/LC_MESSAGES/djangojs.mo and b/loko/observations/locale/nl/LC_MESSAGES/djangojs.mo differ diff --git a/loko/observations/locale/nl/LC_MESSAGES/djangojs.po b/loko/observations/locale/nl/LC_MESSAGES/djangojs.po index 8846428..f9dde19 100644 --- a/loko/observations/locale/nl/LC_MESSAGES/djangojs.po +++ b/loko/observations/locale/nl/LC_MESSAGES/djangojs.po @@ -24,6 +24,11 @@ msgstr "In voorbereiding" msgid "À traiter" msgstr "Te behandelen" +#: loko/observations/static/observations/observations_index.js:16 +msgid "Informations demandées" +msgstr "Informatie gevraagd" + + #: loko/observations/static/observations/observations_index.js:17 msgid "En cours" msgstr "In behandeling" diff --git a/loko/observations/migrations/0003_alter_observation_status.py b/loko/observations/migrations/0003_alter_observation_status.py new file mode 100644 index 0000000..a9d22c4 --- /dev/null +++ b/loko/observations/migrations/0003_alter_observation_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-10-06 07:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='observation', + name='status', + field=models.CharField(choices=[('in_preparation', 'En préparation'), ('to_process', 'À traiter'), ('needs_info', 'Informations demandées'), ('in_progress', 'En cours'), ('processed', 'Traité'), ('not_relevant', 'Non relevant'), ('to_redirect', 'À rediriger'), ('duplicate', 'Doublon'), ('closed', 'Clôturé'), ('archived', 'Archivé')], default='in_preparation', max_length=20), + ), + ] diff --git a/loko/observations/models.py b/loko/observations/models.py index f1b67eb..c47fb33 100644 --- a/loko/observations/models.py +++ b/loko/observations/models.py @@ -425,6 +425,7 @@ class Observation(models.Model): STATUS_CHOICES = [ ('in_preparation', _('En préparation')), ('to_process', _('À traiter')), + ('needs_info', _('Informations demandées')), ('in_progress', _('En cours')), ('processed', _('Traité')), ('not_relevant', _('Non relevant')), diff --git a/loko/observations/static/observations/observations_index.js b/loko/observations/static/observations/observations_index.js index 9e4ad41..834ca8a 100644 --- a/loko/observations/static/observations/observations_index.js +++ b/loko/observations/static/observations/observations_index.js @@ -14,6 +14,7 @@ function truncateText(text, limit) { const STATUS_METADATA = { in_preparation: { label: gettext('En préparation'), badge: 'secondary', color: '#6c757d' }, to_process: { label: gettext('À traiter'), badge: 'danger', color: '#dc3545' }, + needs_info: { label: gettext('Informations demandées'), badge: 'warning', color: '#fd7e14' }, in_progress: { label: gettext('En cours'), badge: 'warning', color: '#ffc107' }, processed: { label: gettext('Traité'), badge: 'success', color: '#198754' }, not_relevant: { label: gettext('Non relevant'), badge: 'secondary', color: '#6c757d' }, @@ -59,6 +60,7 @@ const observations_layer_defs = [ ['get', 'status'], 'in_preparation', STATUS_METADATA.in_preparation.color, 'to_process', STATUS_METADATA.to_process.color, + 'needs_info', STATUS_METADATA.needs_info.color, 'in_progress', STATUS_METADATA.in_progress.color, 'processed', STATUS_METADATA.processed.color, 'closed', STATUS_METADATA.closed.color, diff --git a/loko/observations/templates/observations/_observations_list_mobile.html b/loko/observations/templates/observations/_observations_list_mobile.html index 9c0510d..981c320 100644 --- a/loko/observations/templates/observations/_observations_list_mobile.html +++ b/loko/observations/templates/observations/_observations_list_mobile.html @@ -66,6 +66,10 @@ {% translate "À traiter" %} + {% elif observation.status == 'needs_info' %} + + {% translate "Informations demandées" %} + {% elif observation.status == 'in_progress' %} {% translate "En cours" %} diff --git a/loko/observations/templates/observations/observation_detail.html b/loko/observations/templates/observations/observation_detail.html index a1d5e20..c6cdf3c 100644 --- a/loko/observations/templates/observations/observation_detail.html +++ b/loko/observations/templates/observations/observation_detail.html @@ -134,7 +134,7 @@ {% if can_edit or can_delete %}
{% if can_edit %} - {% if observation.status == 'in_preparation' or observation.status == 'to_process' or observation.status == 'to_redirect' %} + {% if observation.status == 'in_preparation' or observation.status == 'to_process' or observation.status == 'to_redirect' or observation.status == 'needs_info' %} {% translate "Modifier" %} @@ -161,6 +161,8 @@ {{ observation.get_status_display }} {% elif observation.status == 'to_process' %} {{ observation.get_status_display }} + {% elif observation.status == 'needs_info' %} + {{ observation.get_status_display }} {% elif observation.status == 'in_progress' %} {{ observation.get_status_display }} {% elif observation.status == 'processed' %} @@ -179,6 +181,34 @@
+ {% if observation.status == 'needs_info' %} + + {% endif %} +
{% translate "Description" %}
@@ -615,6 +645,13 @@ data-icon="bi-check2-circle" data-btn-class="btn-success"> {% translate "Marquer comme traitée" %} + + + {% if can_take_action %} + + {% endif %} +
+ {% endif %} + {% endif %} + {% if is_creator or 'admin' in user_roles %} {% if observation.status == 'in_preparation' or not observation.status %} @@ -657,7 +718,7 @@ {% if 'admin' in user_roles %} - {% if observation.status == 'processed' or observation.status == 'not_relevant' or observation.status == 'to_redirect' or observation.status == 'duplicate' %} + {% if observation.status == 'processed' or observation.status == 'not_relevant' or observation.status == 'to_redirect' or observation.status == 'duplicate' or observation.status == 'needs_info' %}
{% csrf_token %} @@ -1104,6 +1165,14 @@ document.addEventListener('DOMContentLoaded', function() { 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; + } } }); @@ -1125,6 +1194,22 @@ document.addEventListener('DOMContentLoaded', function() { 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'; @@ -1224,6 +1309,7 @@ document.addEventListener('DOMContentLoaded', function() { const _nearbyStatusColors = { 'in_preparation': '#6c757d', 'to_process': '#dc3545', + 'needs_info': '#fd7e14', 'in_progress': '#ffc107', 'processed': '#28a745', 'not_relevant': '#6c757d', @@ -1256,6 +1342,7 @@ document.addEventListener('DOMContentLoaded', function() { 'match', ['get', 'status'], 'in_preparation', '#6c757d', 'to_process', '#dc3545', + 'needs_info', '#fd7e14', 'in_progress', '#ffc107', 'processed', '#28a745', 'not_relevant', '#6c757d', diff --git a/loko/observations/templates/observations/observation_detail_mobile.html b/loko/observations/templates/observations/observation_detail_mobile.html index ef2c673..6000ee0 100644 --- a/loko/observations/templates/observations/observation_detail_mobile.html +++ b/loko/observations/templates/observations/observation_detail_mobile.html @@ -148,6 +148,13 @@ data-icon="bi-files" data-btn-class="btn-info"> {% translate "Doublon" %} + +
+ {% if can_take_action %} + + {% endif %} + {% endif %} + {% endif %} + - {% if observation.status == 'processed' or observation.status == 'not_relevant' or observation.status == 'to_redirect' or observation.status == 'duplicate' %} + {% if observation.status == 'processed' or observation.status == 'not_relevant' or observation.status == 'to_redirect' or observation.status == 'duplicate' or observation.status == 'needs_info' %} {% if 'admin' in user_roles or 'operator' in user_roles or 'manager' in user_roles or 'controller' in user_roles %}
{% csrf_token %} @@ -201,7 +230,7 @@ {% if can_edit or can_delete %} {% if can_edit %} - {% if observation.status == 'in_preparation' or observation.status == 'to_process' or observation.status == 'to_redirect' %} + {% if observation.status == 'in_preparation' or observation.status == 'to_process' or observation.status == 'to_redirect' or observation.status == 'needs_info' %} {% translate "Modifier" %} @@ -225,6 +254,8 @@ {{ observation.get_status_display }} {% elif observation.status == 'to_process' %} {{ observation.get_status_display }} + {% elif observation.status == 'needs_info' %} + {{ observation.get_status_display }} {% elif observation.status == 'in_progress' %} {{ observation.get_status_display }} {% elif observation.status == 'processed' %} @@ -243,6 +274,34 @@
+{% if observation.status == 'needs_info' %} + +{% endif %} +
@@ -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',