feat: implement comprehensive associated intervention tracking and display for observations

This commit is contained in:
kdeterme 2026-08-19 08:44:57 +02:00
parent 1671d9e449
commit e93d875c70
8 changed files with 263 additions and 15 deletions

View file

@ -742,6 +742,61 @@ class Intervention(models.Model):
status__in=['to_process', 'pending']
).exists()
def get_associated_observations(self):
"""
Retourne le QuerySet de toutes les observations associées à cette intervention :
- Celles dont le champ intervention pointe directement vers cette intervention
- Celles correspondant au source_ref de cette intervention
- Celles associées aux interventions liées via InterventionLink (origine, duplication, réparation)
"""
if not self.pk:
from observations.models import Observation
return Observation.objects.none()
from observations.models import Observation
possible_obs_ids = set()
for obs_id in self.observations.values_list('id', flat=True):
possible_obs_ids.add(obs_id)
if self.source_category and self.source_ref:
sc_names = [getattr(self.source_category, a, '') or '' for a in ('name_fr', 'name_nl')]
if any(n.strip().lower() == 'observation' for n in sc_names):
try:
possible_obs_ids.add(int(self.source_ref))
except (ValueError, TypeError):
pass
if self.source_ref.startswith('OBS') and self.source_ref[3:].isdigit():
try:
possible_obs_ids.add(int(self.source_ref[3:]))
except (ValueError, TypeError):
pass
# Interventions liées (duplication, réparation, lien manuel)
linked_itv_ids = set()
for f, s in InterventionLink.objects.filter(
Q(first=self) | Q(second=self)
).values_list('first_id', 'second_id'):
linked_itv_ids.add(f if f != self.id else s)
if linked_itv_ids:
for obs_id in Observation.objects.filter(intervention_id__in=linked_itv_ids).values_list('id', flat=True):
possible_obs_ids.add(obs_id)
for itv_other in Intervention.objects.filter(id__in=linked_itv_ids, source_category__isnull=False).exclude(source_ref__isnull=True).exclude(source_ref=''):
sc_names = [getattr(itv_other.source_category, a, '') or '' for a in ('name_fr', 'name_nl')]
if any(n.strip().lower() == 'observation' for n in sc_names):
try:
possible_obs_ids.add(int(itv_other.source_ref))
except (ValueError, TypeError):
pass
if itv_other.source_ref.startswith('OBS') and itv_other.source_ref[3:].isdigit():
try:
possible_obs_ids.add(int(itv_other.source_ref[3:]))
except (ValueError, TypeError):
pass
return Observation.objects.filter(id__in=possible_obs_ids).order_by('-created_at')
class InterventionLink(models.Model):
class LinkType(models.TextChoices):

View file

@ -1550,15 +1550,56 @@ class InterventionDuplicationTests(TestCase):
self.assertIsNotNone(new_itv)
self.assertRedirects(response, reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_itv.id}))
def test_duplication_unauthorized(self):
User = get_user_model()
unauth_user = User.objects.create_user(username='unauth', password='pwd')
UserConfig.objects.create(user=unauth_user, is_intern=False)
def test_duplication_with_associated_observation(self):
from observations.models import Observation
from interventions.models import SourceCategory
source_cat, _ = SourceCategory.objects.get_or_create(name_fr='Observation', defaults={'name_nl': 'Observatie'})
# Create an observation linked to original intervention
obs = Observation.objects.create(
thematic=self.thematic,
status='in_progress',
created_by=self.admin_user,
description="Test Observation with Intervention",
intervention=self.itv,
latitude=50.85,
longitude=4.35,
)
self.itv.source_category = source_cat
self.itv.source_ref = str(obs.id)
self.itv.save()
self.client.force_login(unauth_user)
# Check initial associations
self.assertIn(self.itv, obs.get_associated_interventions())
self.assertIn(obs, self.itv.get_associated_observations())
# Duplicate intervention
self.client.force_login(self.admin_user)
url = reverse('interventions:duplicate_intervention', kwargs={'intervention_id': self.itv.id})
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302)
new_itv = Intervention.objects.exclude(id=self.itv.id).first()
self.assertIsNotNone(new_itv)
# Both interventions should be associated with the observation
obs_interventions = list(obs.get_associated_interventions())
self.assertIn(self.itv, obs_interventions)
self.assertIn(new_itv, obs_interventions)
# Both interventions should retrieve the observation
self.assertIn(obs, self.itv.get_associated_observations())
self.assertIn(obs, new_itv.get_associated_observations())
# Check observation detail view displays visible_interventions
obs_url = reverse('observations:observation_detail', kwargs={'observation_id': obs.id})
obs_response = self.client.get(obs_url)
self.assertEqual(obs_response.status_code, 200)
self.assertIn('visible_interventions', obs_response.context)
visible_itv_ids = [item['intervention'].id for item in obs_response.context['visible_interventions']]
self.assertIn(self.itv.id, visible_itv_ids)
self.assertIn(new_itv.id, visible_itv_ids)

View file

@ -545,7 +545,7 @@ def intervention_detail(request, intervention_id):
linked_observations = []
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
if obs_access_context:
all_observations = intervention.observations.select_related('thematic', 'category').order_by('-created_at')
all_observations = intervention.get_associated_observations().select_related('thematic', 'category')
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
# Observation source (si la source est une observation)

View file

@ -1043,7 +1043,7 @@ def intervention_detail_mobile(request, intervention_id):
linked_observations = []
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
if obs_access_context:
all_observations = intervention.observations.select_related('thematic', 'category').order_by('-created_at')
all_observations = intervention.get_associated_observations().select_related('thematic', 'category')
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
# Discussion liée à cette intervention (mobile)

View file

@ -460,6 +460,43 @@ class Observation(models.Model):
models.Q(observations=self) | models.Q(source_ref__in=possible_refs)
).exists()
def get_associated_interventions(self):
"""
Retourne le QuerySet de toutes les interventions associées à cette observation :
- L'intervention directement liée (self.intervention)
- Les interventions ayant cette observation en source (source_ref / source_category)
- Les interventions liées (InterventionLink) issues de ces interventions (duplications, réparations).
"""
if not self.pk:
from interventions.models import Intervention
return Intervention.objects.none()
from interventions.models import Intervention, InterventionLink
possible_refs = [str(self.pk)]
if self.code:
possible_refs.append(self.code)
possible_refs.append(f"OBS{self.pk:05d}")
direct_ids = set(
Intervention.objects.filter(
models.Q(observations=self) | models.Q(source_ref__in=possible_refs)
).values_list('id', flat=True)
)
if self.intervention_id:
direct_ids.add(self.intervention_id)
all_ids = set(direct_ids)
if direct_ids:
linked_ids = set()
for first_id, second_id in InterventionLink.objects.filter(
models.Q(first_id__in=direct_ids) | models.Q(second_id__in=direct_ids)
).values_list('first_id', 'second_id'):
linked_ids.add(first_id)
linked_ids.add(second_id)
all_ids.update(linked_ids)
return Intervention.objects.filter(id__in=all_ids).order_by('-creation_time', '-id')
def save(self, *args, **kwargs):
is_new = self.pk is None
old_status = None

View file

@ -304,8 +304,65 @@
</div>
{% endif %}
<!-- Linked intervention -->
{% if observation.intervention %}
<!-- Linked interventions -->
{% if visible_interventions %}
<div class="info-section">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">
<i class="bi bi-tools me-1"></i>
{% if visible_interventions|length > 1 %}
{% translate "Interventions associées" %}
{% else %}
{% translate "Intervention liée" %}
{% endif %}
<span class="badge bg-secondary ms-1">{{ visible_interventions|length }}</span>
</h5>
{% if can_link and observation.intervention %}
<form method="post" action="{% url 'observations:link_observation_to_intervention' observation.id %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="intervention_id" value="">
<button type="submit" class="btn btn-sm btn-outline-danger" title="{% translate 'Délier' %}">
<i class="bi bi-x-lg"></i>
</button>
</form>
{% endif %}
</div>
<div class="d-flex flex-column gap-2">
{% for item in visible_interventions %}
{% with itv=item.intervention %}
<div class="d-flex align-items-center justify-content-between p-2 rounded bg-light border">
<div class="d-flex align-items-center gap-2 text-truncate">
{% if item.can_view_detail %}
<a class="fw-semibold text-decoration-none" href="{% url 'interventions:intervention_detail' itv.id %}">
<i class="bi bi-link-45deg"></i> {{ itv.code|default:itv.id }}
</a>
{% else %}
<span class="fw-semibold text-muted">{{ itv.code|default:itv.id }}</span>
{% endif %}
<span class="text-truncate small text-muted" style="max-width: 250px;" title="{{ itv.title }}">– {{ itv.title }}</span>
</div>
<div class="d-flex align-items-center gap-2 flex-shrink-0 ms-2">
<span class="badge bg-{{ itv.get_status_color }} status-{{ itv.status }}">
{{ itv.get_status_display }}
</span>
{% if item.can_view_summary %}
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1"
data-bs-toggle="modal"
data-bs-target="#interventionSummaryModal"
data-summary-url="{% url 'interventions:intervention_summary_modal' itv.id %}"
data-summary-title="{{ itv.code }}"
title="{% translate 'Voir le résumé' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% elif observation.intervention %}
<div class="info-section">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 class="mb-0">{% translate "Intervention liée" %}</h5>

View file

@ -364,8 +364,54 @@
</div>
{% endif %}
<!-- Linked intervention -->
{% if observation.intervention %}
<!-- Linked interventions -->
{% if visible_interventions %}
<div class="info-card">
<h6 class="text-muted mb-2">
<i class="bi bi-tools me-1"></i>
{% if visible_interventions|length > 1 %}
{% translate "Interventions associées" %}
{% else %}
{% translate "Intervention liée" %}
{% endif %}
<span class="badge bg-secondary ms-1">{{ visible_interventions|length }}</span>
</h6>
<div class="d-flex flex-column gap-2">
{% for item in visible_interventions %}
{% with itv=item.intervention %}
<div class="d-flex align-items-center justify-content-between p-2 rounded bg-light border">
<div class="d-flex align-items-center gap-2 text-truncate">
{% if item.can_view_detail %}
<a class="btn btn-outline-primary btn-sm" href="{% url 'mobile:intervention_detail_mobile' itv.id %}">
<i class="bi bi-link-45deg"></i> {{ itv.code|default:itv.id }}
</a>
{% else %}
<span class="fw-semibold text-muted">{{ itv.code|default:itv.id }}</span>
{% endif %}
<span class="text-truncate small text-muted" style="max-width: 160px;">{{ itv.title }}</span>
</div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
<span class="badge bg-{{ itv.get_status_color }} status-{{ itv.status }}">
{{ itv.get_status_display }}
</span>
{% if item.can_view_summary %}
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1"
data-bs-toggle="modal"
data-bs-target="#interventionSummaryModal"
data-summary-url="{% url 'interventions:intervention_summary_modal' itv.id %}?variant=mobile"
data-summary-title="{{ itv.code }}"
title="{% translate 'Voir le résumé' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% elif observation.intervention %}
<div class="info-card">
<h6 class="text-muted mb-2">{% translate "Intervention liée" %}</h6>
<div class="d-flex align-items-center gap-2 flex-wrap">
@ -390,7 +436,6 @@
{% else %}
<span class="text-muted">{{ observation.intervention.code|default:observation.intervention.id }}</span>
{% endif %}
</div>
</div>
{% endif %}

View file

@ -747,12 +747,24 @@ def observation_detail(request, observation_id):
observation.status in ('in_preparation', 'to_process') and
can_edit
)
# Récupérer toutes les interventions associées (directes, sources, duplications/réparations)
from interventions.permissions import can_view_intervention, can_view_intervention_summary
associated_interventions = observation.get_associated_interventions()
visible_interventions = []
for itv in associated_interventions:
if can_view_intervention(request.user, itv):
visible_interventions.append({
'intervention': itv,
'can_view_detail': can_view_intervention(request.user, itv),
'can_view_summary': can_view_intervention_summary(request.user, itv),
'is_primary': (itv.id == observation.intervention_id),
})
# Vérifier les permissions pour l'intervention liée
# Vérifier les permissions pour l'intervention liée principale
can_view_intervention_detail = False
can_view_intervention_summary_modal = False
if observation.intervention:
from interventions.permissions import can_view_intervention, can_view_intervention_summary
can_view_intervention_detail = can_view_intervention(request.user, observation.intervention)
can_view_intervention_summary_modal = can_view_intervention_summary(request.user, observation.intervention)
@ -862,6 +874,7 @@ def observation_detail(request, observation_id):
'is_creator': is_creator,
'can_view_intervention_detail': can_view_intervention_detail,
'can_view_intervention_summary_modal': can_view_intervention_summary_modal,
'visible_interventions': visible_interventions,
'discussion': discussion,
'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message,