diff --git a/loko/interventions/services/pending_analysis_service.py b/loko/interventions/services/pending_analysis_service.py
index d0a4c40..82ee6a5 100644
--- a/loko/interventions/services/pending_analysis_service.py
+++ b/loko/interventions/services/pending_analysis_service.py
@@ -107,10 +107,10 @@ PAUSE_REASON_LABELS = {
}
-def get_pending_analysis_data(months=24, thematic_code=None, contract_number=None, provider_id=None):
+def get_pending_analysis_data(months=24, thematic_code=None, contract_number=None, provider_id=None, user=None):
"""
Extrait et structure toutes les données d'analyse pour les observations et interventions
- non terminées sur la période spécifiée.
+ non terminées sur la période spécifiée, avec filtrage optionnel par utilisateur.
Returns:
dict contenant:
@@ -123,6 +123,8 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
from interventions.models import Intervention, InterventionTimeLine, InterventionDocument
from observations.models import Observation, ObservationPhoto
from common.models import UserConfig
+ from interventions.permissions import filter_viewable_interventions_for_user
+ from observations.permissions import get_observation_access_context
now = timezone.now()
start_date = now - relativedelta(months=months)
@@ -130,7 +132,12 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
# -------------------------------------------------------------------------
# 1. Requête sur les Interventions non terminées
# -------------------------------------------------------------------------
- itv_qs = Intervention.objects.filter(
+ if user and not user.is_superuser:
+ base_itv_qs = filter_viewable_interventions_for_user(user)
+ else:
+ base_itv_qs = Intervention.objects.all()
+
+ itv_qs = base_itv_qs.filter(
status__in=PENDING_INTERVENTION_STATUSES,
creation_time__gte=start_date,
).select_related(
@@ -308,7 +315,13 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
# -------------------------------------------------------------------------
# 2. Requête sur les Observations non terminées
# -------------------------------------------------------------------------
- obs_qs = Observation.objects.filter(
+ if user and not user.is_superuser:
+ obs_ctx = get_observation_access_context(user)
+ base_obs_qs = obs_ctx.filter_queryset(Observation.objects.all()) if obs_ctx else Observation.objects.none()
+ else:
+ base_obs_qs = Observation.objects.all()
+
+ obs_qs = base_obs_qs.filter(
status__in=PENDING_OBSERVATION_STATUSES,
created_at__gte=start_date,
).select_related(
@@ -580,7 +593,7 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
}
-def generate_pending_analysis_excel(months=24, thematic_code=None, contract_number=None, provider_id=None, output_format='workbook'):
+def generate_pending_analysis_excel(months=24, thematic_code=None, contract_number=None, provider_id=None, user=None, output_format='workbook'):
"""
Génère le classeur Excel multi-onglets structuré et stylisé avec Tableaux Croisés Dynamiques.
"""
@@ -589,6 +602,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb
thematic_code=thematic_code,
contract_number=contract_number,
provider_id=provider_id,
+ user=user,
)
wb = openpyxl.Workbook()
diff --git a/loko/reporting/templates/reporting/base.html b/loko/reporting/templates/reporting/base.html
index 6548cda..4127743 100644
--- a/loko/reporting/templates/reporting/base.html
+++ b/loko/reporting/templates/reporting/base.html
@@ -59,5 +59,28 @@
{% translate "Nombre d'observations" %}
+
+
+
+
+ {% translate "Encours & Blocages" %}
+
+
+
+
+
+
+ {% translate "Tableau de bord encours" %}
+
+
{% endblock sidebar %}
diff --git a/loko/reporting/templates/reporting/pending_dashboard.html b/loko/reporting/templates/reporting/pending_dashboard.html
new file mode 100644
index 0000000..5f20bb2
--- /dev/null
+++ b/loko/reporting/templates/reporting/pending_dashboard.html
@@ -0,0 +1,887 @@
+{% extends "reporting/base.html" %}
+{% load i18n %}
+{% load static %}
+
+{% block head %}
+
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% translate "Tableau de bord des encours & blocages" %}
+
+
+ {% translate "Suivi des interventions non terminées (< 'finished') et observations ouvertes sur les 24 derniers mois" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% translate "Interventions en cours" %}
+
—
+
—j en moyenne
+
+
+
+
+
+
{% translate "Observations ouvertes" %}
+
—
+
—j en moyenne
+
+
+
+
+
+
{% translate "En retard SLA" %}
+
—
+
—% de l'encours
+
+
+
+
+
+
{% translate "En pause" %}
+
—
+
{% translate "Attente matériel / tiers" %}
+
+
+
+
+
+
{% translate "Obs. Orphelines" %}
+
—
+
{% translate "À traiter sans intervention" %}
+
+
+
+
+
+
{% translate "Obs. Désynchronisées" %}
+
—
+
{% translate "Intervention déjà close" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {% translate "Thématique" %} |
+ {% translate "En prép." %} |
+ {% translate "À approuver" %} |
+ {% translate "À planifier" %} |
+ {% translate "À traiter" %} |
+ {% translate "Pris en charge" %} |
+ {% translate "Démarré" %} |
+ {% translate "En pause" %} |
+ {% translate "À corriger" %} |
+ {% translate "Total Itv" %} |
+ {% translate "En Retard" %} |
+ {% translate "% Retard" %} |
+ {% translate "Obs. Ouvertes" %} |
+ {% translate "Obs. Orphelines" %} |
+ {% translate "Âge moy. (j)" %} |
+
+
+
+
+ |
+ {% translate "Chargement des données..." %}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {% translate "Code" %} |
+ {% translate "Titre / Objet" %} |
+ {% translate "Thématique" %} |
+ {% translate "Statut" %} |
+ {% translate "Prestataire / Contrat" %} |
+ {% translate "Retard SLA" %} |
+ {% translate "Diagnostic / Cause" %} |
+
+
+
+ | {% translate "Aucune intervention en retard." %} |
+
+
+
+
+
+
+
+
+
+
+
+ | {% translate "Code" %} |
+ {% translate "Titre / Objet" %} |
+ {% translate "Thématique" %} |
+ {% translate "Motif de Pause" %} |
+ {% translate "Prestataire" %} |
+ {% translate "Jours en Pause" %} |
+ {% translate "Diagnostic" %} |
+
+
+
+ | {% translate "Aucune intervention en pause." %} |
+
+
+
+
+
+
+
+
+
+
+
+ | {% translate "Code Obs." %} |
+ {% translate "Thématique" %} |
+ {% translate "Symptôme" %} |
+ {% translate "Statut" %} |
+ {% translate "Âge" %} |
+ {% translate "Itv Liée" %} |
+ {% translate "Anomalie / Diagnostic" %} |
+
+
+
+ | {% translate "Aucune observation problématique détectée." %} |
+
+
+
+
+
+
+
+
+
+
+
+ | {% translate "Prestataire" %} |
+ {% translate "Contrat" %} |
+ {% translate "Gestionnaire" %} |
+ {% translate "Total en cours" %} |
+ {% translate "En Retard" %} |
+ {% translate "% Retard" %} |
+ {% translate "Âge Moyen (j)" %} |
+
+
+
+ | {% translate "Aucune donnée de prestataire." %} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/loko/reporting/tests_pending_dashboard.py b/loko/reporting/tests_pending_dashboard.py
new file mode 100644
index 0000000..d6b4aa6
--- /dev/null
+++ b/loko/reporting/tests_pending_dashboard.py
@@ -0,0 +1,111 @@
+from django.test import TestCase, Client
+from django.contrib.auth.models import User
+from django.utils import timezone
+from datetime import timedelta
+
+from common.models import UserConfig, Thematic, Role
+from interventions.models import Intervention, Symptom
+from observations.models import Observation
+from contracts.models import Contract, Company
+
+
+class PendingDashboardTests(TestCase):
+ def setUp(self):
+ # Création de l'utilisateur avec accès reporting
+ self.user = User.objects.create_user(username="test_admin_pending", password="password123", is_superuser=True)
+ self.client = Client()
+ self.client.force_login(self.user)
+
+ self.thematic = Thematic.objects.create(code="lighting", name_fr="Éclairage public", name_nl="Openbare verlichting")
+ self.company = Company.objects.create(name="ProELEC SA")
+ self.contract = Contract.objects.create(
+ contract_number="CTR-2025-ELEC",
+ company=self.company,
+ start_date=timezone.now().date() - timedelta(days=365),
+ end_date=timezone.now().date() + timedelta(days=365)
+ )
+
+ self.itv1 = Intervention.objects.create(
+ code="ITV-P-001",
+ title="Luminaire en panne",
+ thematic=self.thematic,
+ contract=self.contract,
+ assigned_provider=self.company,
+ status="to_be_processed",
+ priority="2",
+ creation_time=timezone.now() - timedelta(days=10)
+ )
+
+ self.itv2 = Intervention.objects.create(
+ code="ITV-P-002",
+ title="Câble sectionné",
+ thematic=self.thematic,
+ contract=self.contract,
+ assigned_provider=self.company,
+ status="on_pause",
+ pause_reason="order_material",
+ priority="1",
+ creation_time=timezone.now() - timedelta(days=20)
+ )
+
+ self.obs1 = Observation.objects.create(
+ thematic=self.thematic,
+ status="to_process",
+ latitude=50.8503,
+ longitude=4.3517,
+ created_at=timezone.now() - timedelta(days=5)
+ )
+
+ def test_pending_dashboard_view(self):
+ response = self.client.get("/reporting/pending/")
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "reporting/pending_dashboard.html")
+ self.assertContains(response, "Tableau de bord des encours & blocages")
+
+ def test_pending_summary_api(self):
+ response = self.client.get("/api/reporting/pending/summary/?months=24")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+
+ self.assertIn("stats", data)
+ self.assertIn("thematic_matrix", data["stats"])
+ self.assertEqual(data["stats"]["total_interventions"], 2)
+ self.assertEqual(data["stats"]["total_observations"], 1)
+ self.assertEqual(data["stats"]["paused_itvs_count"], 1)
+ self.assertEqual(data["stats"]["orphan_obs_count"], 1)
+
+ # Vérification des listes d'action
+ self.assertIn("top_delayed_interventions", data)
+ self.assertIn("paused_interventions", data)
+ self.assertIn("problematic_observations", data)
+
+ def test_export_pending_excel_view(self):
+ response = self.client.get("/reporting/pending/export/?months=24")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response["Content-Type"],
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ )
+ self.assertIn("attachment; filename=", response["Content-Disposition"])
+ self.assertGreater(len(response.content), 5000)
+
+ def test_permission_denied_for_unauthorized_user(self):
+ # Utilisateur sans droit reporting
+ unauth_user = User.objects.create_user(username="unauth_user", password="password123")
+ u_cfg = UserConfig.objects.create(user=unauth_user, is_intern=True)
+ # Pas de can_access_view('reporting')
+
+ client_unauth = Client()
+ client_unauth.force_login(unauth_user)
+
+ # 1. Dashboard HTML -> 403
+ res_page = client_unauth.get("/reporting/pending/")
+ self.assertEqual(res_page.status_code, 403)
+
+ # 2. Export Excel -> 403
+ res_export = client_unauth.get("/reporting/pending/export/?months=24")
+ self.assertEqual(res_export.status_code, 403)
+
+ # 3. API Summary -> 403
+ res_api = client_unauth.get("/api/reporting/pending/summary/?months=24")
+ self.assertEqual(res_api.status_code, 403)
diff --git a/loko/reporting/urls.py b/loko/reporting/urls.py
index 5a157a0..90a6c59 100644
--- a/loko/reporting/urls.py
+++ b/loko/reporting/urls.py
@@ -13,6 +13,7 @@ from .views import (
ReportingObservationsView,
ReportingObservationsVolumeTimeseriesView,
ReportingObservationsMapLocationsView,
+ ReportingPendingSummaryView,
)
urlpatterns = [
@@ -28,5 +29,6 @@ urlpatterns = [
path("observations/", ReportingObservationsView.as_view(), name="reporting-observations"),
path("observations/volume-timeseries/", ReportingObservationsVolumeTimeseriesView.as_view(), name="reporting-observations-volume-timeseries"),
path("observations/map-locations/", ReportingObservationsMapLocationsView.as_view(), name="reporting-observations-map-locations"),
+ path("pending/summary/", ReportingPendingSummaryView.as_view(), name="reporting-pending-summary"),
]
diff --git a/loko/reporting/urls_frontend.py b/loko/reporting/urls_frontend.py
index fcac33e..5aa3033 100644
--- a/loko/reporting/urls_frontend.py
+++ b/loko/reporting/urls_frontend.py
@@ -8,5 +8,7 @@ urlpatterns = [
path("", views_dashboard.dashboard, name="dashboard"),
path("volume/", views_dashboard.volume_dashboard, name="volume-dashboard"),
path("observations/", views_dashboard.observations_dashboard, name="observations-dashboard"),
+ path("pending/", views_dashboard.pending_dashboard, name="pending-dashboard"),
+ path("pending/export/", views_dashboard.export_pending_excel, name="export-pending-excel"),
]
diff --git a/loko/reporting/views.py b/loko/reporting/views.py
index 2a33a86..3a66e57 100644
--- a/loko/reporting/views.py
+++ b/loko/reporting/views.py
@@ -1392,3 +1392,99 @@ class ReportingObservationsMapLocationsView(ReportingBaseView):
"type": "FeatureCollection",
"features": features
})
+
+
+class ReportingPendingSummaryView(APIView):
+ """
+ Retourne les agrégats, matrices thématiques et diagnostics des encours non terminés.
+ """
+ authentication_classes = [HybridTokenAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ if not _has_reporting_access(request):
+ return Response({"detail": "Accès refusé."}, status=403)
+
+ from interventions.services.pending_analysis_service import get_pending_analysis_data
+
+ try:
+ months = int(request.query_params.get("months", 24))
+ except (ValueError, TypeError):
+ months = 24
+
+ thematic_code = request.query_params.get("thematic") or None
+ contract_number = request.query_params.get("contract") or None
+ provider_id = request.query_params.get("provider") or None
+
+ data = get_pending_analysis_data(
+ months=months,
+ thematic_code=thematic_code,
+ contract_number=contract_number,
+ provider_id=provider_id,
+ user=request.user,
+ )
+
+ # Formattage et sérialisation des listes pour le frontend
+ serialized_itvs = []
+ for itv in data["interventions"]:
+ serialized_itvs.append({
+ "id": itv["id"],
+ "code": itv["code"],
+ "title": itv["title"],
+ "thematic_name": itv["thematic_name"],
+ "status": itv["status"],
+ "status_label": itv["status_label"],
+ "priority_label": itv["priority_label"],
+ "age_days": itv["age_days"],
+ "days_in_status": itv["days_in_status"],
+ "is_delayed": itv["is_delayed"],
+ "delay_days": itv["delay_days"],
+ "sla_status_label": itv["sla_status_label"],
+ "provider_name": itv["provider_name"],
+ "contract_number": itv["contract_number"],
+ "manager_name": itv["manager_name"],
+ "pause_reason_label": itv["pause_reason_label"],
+ "pause_reason_other": itv["pause_reason_other"],
+ "reason_for_correction": itv["reason_for_correction"],
+ "days_since_activity": itv["days_since_activity"],
+ "diagnostic": itv["diagnostic"],
+ })
+
+ serialized_obs = []
+ for obs in data["observations"]:
+ serialized_obs.append({
+ "id": obs["id"],
+ "code": obs["code"],
+ "thematic_name": obs["thematic_name"],
+ "symptom_name": obs["symptom_name"],
+ "status": obs["status"],
+ "status_label": obs["status_label"],
+ "age_days": obs["age_days"],
+ "created_by": obs["created_by"],
+ "intervention_code": obs["intervention_code"],
+ "intervention_status_label": obs["intervention_status_label"],
+ "is_orphan": obs["is_orphan"],
+ "is_desynced": obs["is_desynced"],
+ "diagnostic": obs["diagnostic"],
+ "address": obs["address"],
+ })
+
+ # Top éléments à actionner
+ delayed_itvs = [i for i in serialized_itvs if i["is_delayed"] or i["status"] == "to_be_corrected"]
+ delayed_itvs.sort(key=lambda x: (0 if x["is_delayed"] else 1, -x["delay_days"], -x["age_days"]))
+
+ paused_itvs = [i for i in serialized_itvs if i["status"] == "on_pause"]
+ paused_itvs.sort(key=lambda x: -x["days_in_status"])
+
+ problem_obs = [o for o in serialized_obs if o["is_orphan"] or o["is_desynced"]]
+ problem_obs.sort(key=lambda x: -x["age_days"])
+
+ return Response({
+ "months": months,
+ "start_date": data["start_date"].isoformat(),
+ "now": data["now"].isoformat(),
+ "stats": data["stats"],
+ "top_delayed_interventions": delayed_itvs[:50],
+ "paused_interventions": paused_itvs[:50],
+ "problematic_observations": problem_obs[:50],
+ })
diff --git a/loko/reporting/views_dashboard.py b/loko/reporting/views_dashboard.py
index 8a268b1..adefbad 100644
--- a/loko/reporting/views_dashboard.py
+++ b/loko/reporting/views_dashboard.py
@@ -336,3 +336,102 @@ def observations_dashboard(request):
})
+@login_required
+def pending_dashboard(request):
+ """
+ Tableau de bord interactif des encours et blocages (observations & interventions non terminées).
+ """
+ if not _can_access_reporting(request.user):
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied
+
+ try:
+ user_config = UserConfig.objects.prefetch_related("roles").get(user=request.user)
+ is_unrestricted = (
+ request.user.is_superuser
+ or user_config.roles.filter(name__in=["admin", "top_manager", "operator"]).exists()
+ )
+ except UserConfig.DoesNotExist:
+ user_config = None
+ is_unrestricted = request.user.is_superuser
+
+ lang = get_language() or "fr"
+ from common.models import Thematic
+ from contracts.models import Contract
+
+ # Thématiques accessibles
+ thematics_qs = Thematic.objects.all().order_by("name_fr")
+ accessible_thematics = [
+ {
+ "id": t.id,
+ "name": t.name_nl if lang == "nl" else t.name_fr,
+ "name_fr": t.name_fr,
+ "name_nl": t.name_nl,
+ "code": t.code,
+ }
+ for t in thematics_qs
+ ]
+
+ # Contrats accessibles
+ if is_unrestricted:
+ contracts_qs = Contract.objects.select_related("company").all().order_by("contract_number")
+ else:
+ user_contract_ids = set(user_config.contracts.values_list("id", flat=True)) if user_config else set()
+ contracts_qs = Contract.objects.filter(id__in=user_contract_ids).select_related("company").order_by("contract_number")
+
+ accessible_contracts = [
+ {
+ "id": c.id,
+ "number": c.contract_number,
+ "company_name": c.company.name if c.company else "",
+ }
+ for c in contracts_qs
+ ]
+
+ saved_prefs = {}
+ if user_config:
+ saved_prefs = (user_config.reporting_preferences or {}).get("pending_dashboard", {})
+
+ return render(request, "reporting/pending_dashboard.html", {
+ "accessible_thematics": accessible_thematics,
+ "accessible_contracts": accessible_contracts,
+ "saved_prefs_json": json.dumps(saved_prefs),
+ })
+
+
+@login_required
+def export_pending_excel(request):
+ """
+ Génère et télécharge le fichier Excel d'audit des encours avec les filtres demandés.
+ """
+ from django.http import HttpResponse
+ from django.utils import timezone
+ from interventions.services.pending_analysis_service import generate_pending_analysis_excel
+
+ if not _can_access_reporting(request.user):
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied
+
+ months = int(request.GET.get("months", 24))
+ thematic_code = request.GET.get("thematic") or None
+ contract_number = request.GET.get("contract") or None
+ provider_id = request.GET.get("provider") or None
+
+ output_stream = generate_pending_analysis_excel(
+ months=months,
+ thematic_code=thematic_code,
+ contract_number=contract_number,
+ provider_id=provider_id,
+ user=request.user,
+ output_format='file'
+ )
+
+ filename = f"audit_encours_loko_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx"
+ response = HttpResponse(
+ output_stream.read(),
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ )
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
+ return response
+
+