feat: implement comprehensive associated intervention tracking and display for observations
This commit is contained in:
parent
1671d9e449
commit
e93d875c70
8 changed files with 263 additions and 15 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue