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']
|
status__in=['to_process', 'pending']
|
||||||
).exists()
|
).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 InterventionLink(models.Model):
|
||||||
class LinkType(models.TextChoices):
|
class LinkType(models.TextChoices):
|
||||||
|
|
|
||||||
|
|
@ -1550,15 +1550,56 @@ class InterventionDuplicationTests(TestCase):
|
||||||
self.assertIsNotNone(new_itv)
|
self.assertIsNotNone(new_itv)
|
||||||
self.assertRedirects(response, reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_itv.id}))
|
self.assertRedirects(response, reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_itv.id}))
|
||||||
|
|
||||||
def test_duplication_unauthorized(self):
|
def test_duplication_with_associated_observation(self):
|
||||||
User = get_user_model()
|
from observations.models import Observation
|
||||||
unauth_user = User.objects.create_user(username='unauth', password='pwd')
|
from interventions.models import SourceCategory
|
||||||
UserConfig.objects.create(user=unauth_user, is_intern=False)
|
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})
|
url = reverse('interventions:duplicate_intervention', kwargs={'intervention_id': self.itv.id})
|
||||||
response = self.client.post(url)
|
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 = []
|
linked_observations = []
|
||||||
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
|
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
|
||||||
if obs_access_context:
|
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)]
|
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
|
||||||
|
|
||||||
# Observation source (si la source est une observation)
|
# Observation source (si la source est une observation)
|
||||||
|
|
|
||||||
|
|
@ -1043,7 +1043,7 @@ def intervention_detail_mobile(request, intervention_id):
|
||||||
linked_observations = []
|
linked_observations = []
|
||||||
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
|
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
|
||||||
if obs_access_context:
|
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)]
|
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
|
||||||
|
|
||||||
# Discussion liée à cette intervention (mobile)
|
# 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)
|
models.Q(observations=self) | models.Q(source_ref__in=possible_refs)
|
||||||
).exists()
|
).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):
|
def save(self, *args, **kwargs):
|
||||||
is_new = self.pk is None
|
is_new = self.pk is None
|
||||||
old_status = None
|
old_status = None
|
||||||
|
|
|
||||||
|
|
@ -304,8 +304,65 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Linked intervention -->
|
<!-- Linked interventions -->
|
||||||
{% if observation.intervention %}
|
{% 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="info-section">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<h5 class="mb-0">{% translate "Intervention liée" %}</h5>
|
<h5 class="mb-0">{% translate "Intervention liée" %}</h5>
|
||||||
|
|
|
||||||
|
|
@ -364,8 +364,54 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Linked intervention -->
|
<!-- Linked interventions -->
|
||||||
{% if observation.intervention %}
|
{% 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">
|
<div class="info-card">
|
||||||
<h6 class="text-muted mb-2">{% translate "Intervention liée" %}</h6>
|
<h6 class="text-muted mb-2">{% translate "Intervention liée" %}</h6>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
|
@ -390,7 +436,6 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">{{ observation.intervention.code|default:observation.intervention.id }}</span>
|
<span class="text-muted">{{ observation.intervention.code|default:observation.intervention.id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -747,12 +747,24 @@ def observation_detail(request, observation_id):
|
||||||
observation.status in ('in_preparation', 'to_process') and
|
observation.status in ('in_preparation', 'to_process') and
|
||||||
can_edit
|
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_detail = False
|
||||||
can_view_intervention_summary_modal = False
|
can_view_intervention_summary_modal = False
|
||||||
if observation.intervention:
|
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_detail = can_view_intervention(request.user, observation.intervention)
|
||||||
can_view_intervention_summary_modal = can_view_intervention_summary(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,
|
'is_creator': is_creator,
|
||||||
'can_view_intervention_detail': can_view_intervention_detail,
|
'can_view_intervention_detail': can_view_intervention_detail,
|
||||||
'can_view_intervention_summary_modal': can_view_intervention_summary_modal,
|
'can_view_intervention_summary_modal': can_view_intervention_summary_modal,
|
||||||
|
'visible_interventions': visible_interventions,
|
||||||
'discussion': discussion,
|
'discussion': discussion,
|
||||||
'discussion_messages': discussion_messages,
|
'discussion_messages': discussion_messages,
|
||||||
'can_post_discussion_message': can_post_discussion_message,
|
'can_post_discussion_message': can_post_discussion_message,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue