Compare commits

...

2 commits

28 changed files with 554 additions and 20 deletions

View file

@ -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),
]

View file

@ -1,4 +1,4 @@
# Generated by Django 6.0.7 on 2026-10-05 13:22
# Generated by Django 6.0.7 on 2026-10-05 13:22
from django.db import migrations, models
@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0017_territory'),
('common', '0018_setup_rbc_default_territory'),
]
operations = [

View file

@ -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',
}

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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),
),
]

View file

@ -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')),

View file

@ -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,

View file

@ -66,6 +66,10 @@
<span class="badge bg-danger">
<i class="bi bi-clock-history me-1"></i>{% translate "À traiter" %}
</span>
{% elif observation.status == 'needs_info' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-question-circle me-1"></i>{% translate "Informations demandées" %}
</span>
{% elif observation.status == 'in_progress' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-hourglass-split me-1"></i>{% translate "En cours" %}

View file

@ -134,7 +134,7 @@
{% if can_edit or can_delete %}
<div class="d-flex flex-wrap gap-2 pt-1">
{% 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' %}
<a href="{% url 'observations:observation_edit' observation.id %}" class="btn btn-outline-secondary btn-sm" style="width:150px">
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
</a>
@ -161,6 +161,8 @@
<span class="badge bg-secondary"><i class="bi bi-hourglass-split me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'to_process' %}
<span class="badge bg-danger"><i class="bi bi-clock-history me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'needs_info' %}
<span class="badge bg-warning text-dark"><i class="bi bi-question-circle me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'in_progress' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'processed' %}
@ -179,6 +181,34 @@
</div>
</div>
{% if observation.status == 'needs_info' %}
<div class="alert alert-warning d-flex align-items-start gap-2 mb-3" role="alert">
<i class="bi bi-question-circle-fill fs-5 flex-shrink-0 mt-1"></i>
<div class="w-100">
<h6 class="alert-heading mb-1">{% translate "Informations complémentaires demandées" %}</h6>
<p class="mb-2 small">
{% if is_creator %}
{% translate "Des informations complémentaires sont nécessaires pour traiter votre observation. Vous pouvez modifier la fiche pour corriger/compléter les données (photos, checklist, description) ou répondre directement ci-dessous dans la discussion." %}
{% else %}
{% translate "Cette observation est en attente d'informations complémentaires de la part de l'observateur." %}
{% endif %}
</p>
{% if is_creator %}
<div class="d-flex gap-2">
{% if can_edit %}
<a href="{% url 'observations:observation_edit' observation.id %}" class="btn btn-warning btn-sm">
<i class="bi bi-pencil"></i> {% translate "Modifier la fiche" %}
</a>
{% endif %}
<a href="#discussion" class="btn btn-outline-dark btn-sm">
<i class="bi bi-chat-dots"></i> {% translate "Répondre dans la discussion" %}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Description -->
<div class="info-section">
<h5>{% translate "Description" %}</h5>
@ -615,6 +645,13 @@
data-icon="bi-check2-circle" data-btn-class="btn-success">
<i class="bi bi-check2-circle"></i> {% translate "Marquer comme traitée" %}
</button>
<button type="button" class="btn btn-outline-warning btn-sm" style="width:200px"
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
data-status="needs_info"
data-label="{% translate 'Demander un complément' %}"
data-icon="bi-question-circle" data-btn-class="btn-warning">
<i class="bi bi-question-circle"></i> {% translate "Demander un complément" %}
</button>
<button type="button" class="btn btn-outline-info btn-sm" style="width:200px"
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
data-status="duplicate"
@ -640,6 +677,30 @@
</div>
{% endif %}
<!-- Renvoyer / Compléments fournis (needs_info → to_process) -->
{% if is_creator or can_take_action %}
{% if observation.status == 'needs_info' %}
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="new_status" value="to_process">
<button type="submit" class="btn btn-success btn-sm" style="width:200px">
<i class="bi bi-send-check"></i> {% translate "Compléments fournis" %}
</button>
</form>
{% if can_take_action %}
<button type="button" class="btn btn-outline-secondary btn-sm" style="width:200px"
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
data-status="not_relevant"
data-label="{% translate 'Non relevant / Sans suite' %}"
data-icon="bi-slash-circle" data-btn-class="btn-secondary">
<i class="bi bi-slash-circle"></i> {% translate "Classer sans suite" %}
</button>
{% endif %}
</div>
{% endif %}
{% endif %}
<!-- Envoyer (in_preparation → to_process) -->
{% if is_creator or 'admin' in user_roles %}
{% if observation.status == 'in_preparation' or not observation.status %}
@ -657,7 +718,7 @@
<!-- Archiver -->
{% 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' %}
<div>
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}" class="d-inline">
{% 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" %} <span class="text-danger">*</span>';
} else {
commentLabel.innerHTML = '{% translate "Commentaire" %} <span class="text-muted fst-italic">({% translate "optionnel" %})</span>';
}
}
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',

View file

@ -148,6 +148,13 @@
data-icon="bi-files" data-btn-class="btn-info">
<i class="bi bi-files"></i> {% translate "Doublon" %}
</button>
<button type="button" class="btn btn-outline-warning btn-sm w-100"
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
data-status="needs_info"
data-label="{% translate 'Demander un complément' %}"
data-icon="bi-question-circle" data-btn-class="btn-warning">
<i class="bi bi-question-circle"></i> {% translate "Demander un complément" %}
</button>
<button type="button" class="btn btn-outline-warning btn-sm w-100"
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
data-status="to_redirect"
@ -178,8 +185,30 @@
</form>
{% endif %}
<!-- Renvoyer / Compléments fournis (needs_info → to_process) -->
{% if is_creator or can_take_action %}
{% if observation.status == 'needs_info' %}
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}">
{% csrf_token %}
<input type="hidden" name="new_status" value="to_process">
<button type="submit" class="btn btn-success btn-sm" style="min-width:120px">
<i class="bi bi-send-check"></i> {% translate "Compléments fournis" %}
</button>
</form>
{% if can_take_action %}
<button type="button" class="btn btn-outline-secondary btn-sm" style="min-width:120px"
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
data-status="not_relevant"
data-label="{% translate 'Non relevant / Sans suite' %}"
data-icon="bi-slash-circle" data-btn-class="btn-secondary">
<i class="bi bi-slash-circle"></i> {% translate "Classer sans suite" %}
</button>
{% endif %}
{% endif %}
{% endif %}
<!-- Archiver -->
{% 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 %}
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}">
{% csrf_token %}
@ -201,7 +230,7 @@
{% if can_edit or can_delete %}
<span class="ms-1">
{% 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' %}
<a href="{% url 'mobile:observation_edit_mobile' observation.id %}" class="btn btn-outline-secondary btn-sm" style="font-size:small">
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
</a>
@ -225,6 +254,8 @@
<span class="badge bg-secondary text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'to_process' %}
<span class="badge bg-danger"><i class="bi bi-clock-history me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'needs_info' %}
<span class="badge bg-warning text-dark"><i class="bi bi-question-circle me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'in_progress' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ observation.get_status_display }}</span>
{% elif observation.status == 'processed' %}
@ -243,6 +274,34 @@
</div>
</div>
{% if observation.status == 'needs_info' %}
<div class="alert alert-warning d-flex align-items-start gap-2 mb-3" role="alert">
<i class="bi bi-question-circle-fill fs-5 flex-shrink-0 mt-1"></i>
<div class="w-100">
<h6 class="alert-heading mb-1">{% translate "Informations complémentaires demandées" %}</h6>
<p class="mb-2 small">
{% if is_creator %}
{% translate "Des informations complémentaires sont nécessaires pour traiter votre observation. Vous pouvez modifier la fiche pour corriger/compléter les données (photos, checklist, description) ou répondre directement ci-dessous dans la discussion." %}
{% else %}
{% translate "Cette observation est en attente d'informations complémentaires de la part de l'observateur." %}
{% endif %}
</p>
{% if is_creator %}
<div class="d-flex gap-2">
{% if can_edit %}
<a href="{% url 'mobile:observation_edit_mobile' observation.id %}" class="btn btn-warning btn-sm">
<i class="bi bi-pencil"></i> {% translate "Modifier la fiche" %}
</a>
{% endif %}
<a href="#discussion" class="btn btn-outline-dark btn-sm">
<i class="bi bi-chat-dots"></i> {% translate "Discussion" %}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Thematic and Category -->
<div class="info-card">
<div class="row">
@ -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" %} <span class="text-danger">*</span>';
} else {
commentLabel.innerHTML = '{% translate "Commentaire" %} <span class="text-muted fst-italic">({% translate "optionnel" %})</span>';
}
}
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';

View file

@ -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;

View file

@ -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')

View file

@ -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':
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'
)

View file

@ -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',