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 "Répartition par Statut d'Intervention" %} +
+
+
+
+
+
+
+ + +
+
+
+
+ {% translate "Pyramide des Âges" %} +
+
+
+
+
+
+
+ + +
+
+
+
+ {% translate "Motifs de Pause (on_pause)" %} +
+
+
+
+
+
+
+
+ + +
+
+
+
+ + {% translate "Tableau Croisé Dynamique : Analyse par Thématique" %} +
+ {% translate "Ventilation croisée des statuts, retards, motifs de pause et observations par domaine métier" %} +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% 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 + +