""" Endpoints REST pour le schéma reporting. Ces vues lisent les vues matérialisées du schéma "reporting" et les exposent en JSON paginé. Elles utilisent l'authentification HybridTokenAuthentication (session, JWT ou PAT) de l'app api. Droits requis : - Être authentifié (session Django ou token Bearer/PAT) - Disposer de la collection "reporting" dans son token (PAT/JWT), OU être connecté via session avec can_access_view('reporting'). Endpoints : GET /api/reporting/interventions/ → liste paginée mv_interventions GET /api/reporting/interventions/summary/ → agrégats par thématique/statut GET /api/reporting/locations/ → liste paginée mv_location_hierarchy GET /api/reporting/meta/ → dernière date de refresh """ from __future__ import annotations from collections import defaultdict from django.db import connection from django.db.models import Q from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from api.auth import HybridTokenAuthentication from common.models import ( UserConfig, UserThematicStatusPermission, UserContractStatusPermission, UserContractAccess, ) # ── Helpers ────────────────────────────────────────────────────────────────── def _has_reporting_access(request) -> bool: """ Vérifie l'accès au reporting : - Via token (PAT/JWT) : la collection 'reporting' doit être présente. - Via session : l'utilisateur doit avoir can_access_view('reporting'). """ # Token présent → on vérifie les collections assignées au token if hasattr(request, "collections"): return "reporting" in (request.collections or []) # Session → on vérifie la configuration utilisateur try: user_config = UserConfig.objects.get(user=request.user) return user_config.can_access_view("reporting") except UserConfig.DoesNotExist: return request.user.is_superuser def _dictfetchall(cursor) -> list[dict]: """Convertit les résultats d'un cursor en liste de dicts.""" columns = [col[0] for col in cursor.description] return [dict(zip(columns, row)) for row in cursor.fetchall()] def _parse_int(value, default: int, min_val: int = 1, max_val: int = 10000) -> int: try: v = int(value) return max(min_val, min(max_val, v)) except (TypeError, ValueError): return default def _get_permission_clause(user) -> tuple[str | None, list]: """ Construit une clause SQL WHERE reproduisant la logique de filter_viewable_interventions_for_user() pour les requêtes brutes sur reporting.mv_interventions. Retourne : (None, []) → pas de restriction (admin / superuser) ("FALSE", []) → aucun accès (aucune permission trouvée) (clause, params) → clause SQL à injecter dans la clause WHERE Note : le fallback prestataire assigné (CompanyMember) n'est pas appliqué ici car la vue matérialisée ne stocke pas assigned_provider_id. """ if user.is_superuser: return None, [] try: user_config = ( UserConfig.objects .select_related("user") .prefetch_related("roles") .get(user=user) ) except UserConfig.DoesNotExist: return "FALSE", [] if user_config.roles.filter(name__in=["admin", "top_manager", "operator"]).exists(): return None, [] # ── Permissions contrat-statut ──────────────────────────────────────────── cp_rows = list( UserContractStatusPermission.objects.filter( user_contract__user_config=user_config, ).values_list("user_contract__contract_id", "status", "can_view") ) explicit_cs_allowed: set[tuple] = set() denied_contracts_by_status: dict = defaultdict(set) for c_id, st, cv in cp_rows: if cv: explicit_cs_allowed.add((c_id, st)) else: denied_contracts_by_status[st].add(c_id) conditions: list[str] = [] params: list = [] # Cas (a) : contrat + can_view=True explicite for c_id, st in sorted(explicit_cs_allowed): conditions.append("(contract_id = %s AND status = %s)") params.extend([c_id, st]) # ── Fallback thématique (si pas limité aux contrats) ────────────────────── if not user_config.limit_interventions_to_contracts: thematic_ids_by_status: dict = defaultdict(set) for t_id, st in UserThematicStatusPermission.objects.filter( user_thematic__user_config=user_config, can_view=True, ).values_list("user_thematic__thematic_id", "status"): thematic_ids_by_status[st].add(t_id) for st, t_ids in thematic_ids_by_status.items(): if not t_ids: continue t_list = sorted(t_ids) t_ph = ", ".join(["%s"] * len(t_list)) denied_for_st = denied_contracts_by_status.get(st, set()) if denied_for_st: d_list = sorted(denied_for_st) d_ph = ", ".join(["%s"] * len(d_list)) conditions.append( f"(status = %s AND thematic_id IN ({t_ph})" f" AND (contract_id IS NULL OR contract_id NOT IN ({d_ph})))" ) params.extend([st] + t_list + d_list) else: conditions.append(f"(status = %s AND thematic_id IN ({t_ph}))") params.extend([st] + t_list) if not conditions: return "FALSE", [] return "(" + " OR ".join(conditions) + ")", params def _get_observations_permission_clause(user, table_prefix: str = "") -> tuple[str | None, list]: """ Construit une clause SQL WHERE reproduisant les permissions de filtrage des observations (thematic_id, created_by_id) pour l'utilisateur. """ if user.is_superuser: return None, [] try: user_config = ( UserConfig.objects .select_related("user") .prefetch_related("roles") .get(user=user) ) except UserConfig.DoesNotExist: return "FALSE", [] if user_config.roles.filter(name__in=["admin", "top_manager", "operator"]).exists(): return None, [] prefix = f"{table_prefix}." if table_prefix else "" from common.models import UserThematics # Get allowed thematic IDs ut_rows = list( UserThematics.objects.filter(user_config=user_config) .values('thematic_id', 'can_view_interventions') ) allowed_tids = [r['thematic_id'] for r in ut_rows if r['can_view_interventions']] if not allowed_tids: return "FALSE", [] conditions = [f"{prefix}thematic_id IN (" + ", ".join(["%s"] * len(allowed_tids)) + ")"] params = list(allowed_tids) # Restreindre l'accès pour les prestataires externes (non internes) if not user_config.is_intern: conditions.append( f"({prefix}created_by_id = %s OR {prefix}created_by_id IN (" " SELECT cm.user_id " " FROM contracts_companymember cm " " JOIN contracts_companyteam ct ON cm.team_id = ct.id " " WHERE ct.company_id IN (" " SELECT ct2.company_id " " FROM contracts_companymember cm2 " " JOIN contracts_companyteam ct2 ON cm2.team_id = ct2.id " " WHERE cm2.user_id = %s" " )" "))" ) params.extend([user.id, user.id]) return " AND ".join(conditions), params # ── Base view ──────────────────────────────────────────────────────────────── class ReportingBaseView(APIView): authentication_classes = [HybridTokenAuthentication] permission_classes = [IsAuthenticated] def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) if not _has_reporting_access(request): from rest_framework.exceptions import PermissionDenied raise PermissionDenied("Accès au reporting non autorisé.") # ── GET /api/reporting/meta/ ───────────────────────────────────────────────── class ReportingMetaView(ReportingBaseView): """ Retourne les métadonnées des vues matérialisées : date du dernier refresh pour chaque vue. """ def get(self, request): sql = """ SELECT schemaname, matviewname, -- pg_stat_user_tables ne suit pas les vues matérialisées ; -- on utilise pg_matviews pour savoir si elles existent. -- La date de dernière population n'est pas stockée nativement ; -- on renvoie l'heure courante du serveur comme proxy. NOW() AT TIME ZONE 'UTC' AS queried_at, ispopulated FROM pg_matviews WHERE schemaname = 'reporting' ORDER BY matviewname """ with connection.cursor() as cursor: cursor.execute(sql) rows = _dictfetchall(cursor) return Response({"views": rows}) # ── GET /api/reporting/interventions/ ──────────────────────────────────────── class ReportingInterventionsView(ReportingBaseView): """ Liste paginée des interventions depuis mv_interventions. Filtres disponibles (query params) : thematic_id, status, priority, contract_id, maintenance_type, origin_type, intervention_type, created_after (ISO 8601, ex: 2025-01-01) — filtre sur dt_to_be_processed created_before (ISO 8601) — filtre sur dt_to_be_processed Pagination : page (défaut : 1) per_page (défaut : 500, max : 2000) """ def get(self, request): # ── Filtres de base et de permission de reporting ──────────────────── reporting_contract_ids = _get_reporting_contract_ids(request.user) per_page = _parse_int(request.GET.get("per_page"), 500, 1, 10000) page = _parse_int(request.GET.get("page"), 1, 1) if reporting_contract_ids is not None and not reporting_contract_ids: return Response({"count": 0, "page": page, "per_page": per_page, "pages": 0, "results": []}) filters = [ "maintenance_type = 'corrective'", "dt_to_be_processed IS NOT NULL" ] params: list = [] # ── Filtres utilisateur ─────────────────────────────────────────────── for field in ("status", "priority", "origin_type", "intervention_type"): val = request.GET.get(field) if val: filters.append(f"{field} = %s") params.append(val) # Thematic filter raw_thematic_ids = request.GET.get("thematic_id", "") if raw_thematic_ids: try: thematic_ids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] except ValueError: thematic_ids = [] if thematic_ids: placeholders_t = ", ".join(["%s"] * len(thematic_ids)) filters.append(f"thematic_id IN ({placeholders_t})") params.extend(thematic_ids) # Contract filter (intersected with enabled contracts) raw_contract_ids = request.GET.get("contract_id", "") selected_ids: list[int] | None = None if raw_contract_ids: try: requested = [int(x) for x in raw_contract_ids.split(",") if x.strip()] except ValueError: requested = [] if reporting_contract_ids is not None: allowed = set(reporting_contract_ids) requested = [i for i in requested if i in allowed] selected_ids = requested effective_ids = selected_ids if selected_ids is not None else reporting_contract_ids if effective_ids is not None: if not effective_ids: return Response({"count": 0, "page": page, "per_page": per_page, "pages": 0, "results": []}) placeholders = ", ".join(["%s"] * len(effective_ids)) filters.append(f"contract_id IN ({placeholders})") params.extend(effective_ids) created_after = request.GET.get("created_after") if created_after: filters.append("dt_to_be_processed >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("dt_to_be_processed <= %s") params.append(created_before) # ── Filtrage par permissions ────────────────────────────────────────── perm_clause, perm_params = _get_permission_clause(request.user) if perm_clause == "FALSE": return Response({"count": 0, "page": page, "per_page": per_page, "pages": 0, "results": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = ("WHERE " + " AND ".join(filters)) if filters else "" # ── Pagination ─────────────────────────────────────────────────────── per_page = _parse_int(request.GET.get("per_page"), 500, 1, 10000) page = _parse_int(request.GET.get("page"), 1, 1) offset = (page - 1) * per_page # ── Requête count ───────────────────────────────────────────────────── count_sql = f"SELECT COUNT(*) FROM reporting.mv_interventions {where}" data_sql = f""" SELECT * FROM reporting.mv_interventions {where} ORDER BY dt_to_be_processed DESC LIMIT %s OFFSET %s """ with connection.cursor() as cursor: cursor.execute(count_sql, params) total = cursor.fetchone()[0] cursor.execute(data_sql, params + [per_page, offset]) rows = _dictfetchall(cursor) return Response({ "count": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page, "results": rows, }) # ── GET /api/reporting/interventions/summary/ ───────────────────────────────── class ReportingInterventionsSummaryView(ReportingBaseView): """ Agrégats pré-calculés sur mv_interventions. group_by (requis) : thematic_id | status | priority | maintenance_type | origin_type | intervention_type | contract_id Les filtres de date (created_after / created_before) filtrent sur dt_to_be_processed. Retourne pour chaque valeur du groupe : count, avg_response_time_min, avg_first_resolution_min, avg_final_resolution_min, avg_execution_time_min """ ALLOWED_GROUP_BY = { "thematic_id", "status", "priority", "maintenance_type", "origin_type", "intervention_type", "contract_id", } def get(self, request): group_by = request.GET.get("group_by", "status") if group_by not in self.ALLOWED_GROUP_BY: return Response( {"error": f"group_by invalide. Valeurs acceptées : {sorted(self.ALLOWED_GROUP_BY)}"}, status=400, ) # Colonnes de label associées (pour éviter un second appel côté client) label_cols = { "thematic_id": ", thematic_name_fr, thematic_name_nl", "contract_id": ", contract_number", }.get(group_by, "") filters = [] params: list = [] created_after = request.GET.get("created_after") if created_after: filters.append("dt_to_be_processed >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("dt_to_be_processed <= %s") params.append(created_before) # ── Filtrage par permissions ────────────────────────────────────────── perm_clause, perm_params = _get_permission_clause(request.user) if perm_clause == "FALSE": return Response({"group_by": group_by, "results": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = ("WHERE " + " AND ".join(filters)) if filters else "" sql = f""" SELECT {group_by}{label_cols}, COUNT(*) AS count, ROUND(AVG(response_time_min)) AS avg_response_time_min, ROUND(AVG(first_resolution_min)) AS avg_first_resolution_min, ROUND(AVG(final_resolution_min)) AS avg_final_resolution_min, ROUND(AVG(execution_time_min)) AS avg_execution_time_min FROM reporting.mv_interventions {where} GROUP BY {group_by}{label_cols} ORDER BY count DESC """ with connection.cursor() as cursor: cursor.execute(sql, params) rows = _dictfetchall(cursor) return Response({"group_by": group_by, "results": rows}) # ── GET /api/reporting/locations/ ──────────────────────────────────────────── class ReportingLocationsView(ReportingBaseView): """ Liste paginée de la hiérarchie de localisations (mv_location_hierarchy). Filtres : location_type ex: structurelocation, trafficlightintersection, ... root_code filtrage par code de la localisation racine Pagination : page, per_page (défaut 500, max 5000) """ def get(self, request): filters = [] params: list = [] location_type = request.GET.get("location_type") if location_type: filters.append("location_type = %s") params.append(location_type) root_code = request.GET.get("root_code") if root_code: filters.append("root_code ILIKE %s") params.append(f"%{root_code}%") where = ("WHERE " + " AND ".join(filters)) if filters else "" per_page = _parse_int(request.GET.get("per_page"), 500, 1, 5000) page = _parse_int(request.GET.get("page"), 1, 1) offset = (page - 1) * per_page count_sql = f"SELECT COUNT(*) FROM reporting.mv_location_hierarchy {where}" data_sql = f""" SELECT * FROM reporting.mv_location_hierarchy {where} ORDER BY location_type, code LIMIT %s OFFSET %s """ with connection.cursor() as cursor: cursor.execute(count_sql, params) total = cursor.fetchone()[0] cursor.execute(data_sql, params + [per_page, offset]) rows = _dictfetchall(cursor) return Response({ "count": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page, "results": rows, }) # ── GET /api/reporting/interventions/timeseries/ ───────────────────────────── def _get_reporting_contract_ids(user) -> list[int] | None: """ Retourne la liste des contract_id activés pour le reporting, filtrée selon le niveau d'accès de l'utilisateur. Retourne None si l'accès est complet (admin/top_manager/operator/superuser), ce qui signifie : voir tous les contrats enabled. Retourne une liste vide si aucun contrat accessible. """ from reporting.models import ReportingContractConfig enabled_ids = list( ReportingContractConfig.objects.filter(is_enabled=True) .values_list("contract_id", flat=True) ) if not enabled_ids: return [] if user.is_superuser: return enabled_ids try: user_config = ( UserConfig.objects.prefetch_related("roles").get(user=user) ) except UserConfig.DoesNotExist: return [] if user_config.roles.filter(name__in=["admin", "top_manager", "operator"]).exists(): return enabled_ids # Regular users: intersection with their contract access accessible_ids = set( UserContractAccess.objects.filter( user_config=user_config, contract_id__in=enabled_ids, ).values_list("contract_id", flat=True) ) return [cid for cid in enabled_ids if cid in accessible_ids] class ReportingInterventionsTimeseriesView(ReportingBaseView): """ Séries temporelles des temps de traitement des interventions correctives. Filtres disponibles (query params) : granularity : week (défaut) | month | day contract_id : IDs séparés par virgules (optionnel, sous-ensemble des contrats accessibles) thematic_id : IDs séparés par virgules (optionnel) created_after / created_before : ISO 8601 (filtre sur dt_to_be_processed) Filtre automatique : maintenance_type = 'corrective' Filtré sur les contrats activés pour le reporting ∩ droits utilisateur. """ ALLOWED_GRANULARITIES = {"day", "week", "month"} def get(self, request): granularity = request.GET.get("granularity", "week") if granularity not in self.ALLOWED_GRANULARITIES: return Response( {"error": f"granularity invalide. Valeurs acceptées : {sorted(self.ALLOWED_GRANULARITIES)}"}, status=400, ) # Reporting-enabled contract filter (resolved once) reporting_contract_ids = _get_reporting_contract_ids(request.user) if reporting_contract_ids is not None and not reporting_contract_ids: return Response({"granularity": granularity, "results": []}) # Optional caller-supplied contract_id narrowing raw_contract_ids = request.GET.get("contract_id", "") selected_ids: list[int] | None = None if raw_contract_ids: try: requested = [int(x) for x in raw_contract_ids.split(",") if x.strip()] except ValueError: return Response({"error": "contract_id invalide."}, status=400) if reporting_contract_ids is not None: allowed = set(reporting_contract_ids) requested = [i for i in requested if i in allowed] if not requested: return Response({"granularity": granularity, "results": []}) selected_ids = requested filters = ["maintenance_type = 'corrective'", "dt_to_be_processed IS NOT NULL"] params: list = [] # Contract filter effective_ids = selected_ids if selected_ids is not None else reporting_contract_ids if effective_ids is not None: placeholders = ", ".join(["%s"] * len(effective_ids)) filters.append(f"contract_id IN ({placeholders})") params.extend(effective_ids) # Thematic filter (optional, comma-separated IDs) raw_thematic_ids = request.GET.get("thematic_id", "") if raw_thematic_ids: try: thematic_ids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] except ValueError: return Response({"error": "thematic_id invalide."}, status=400) if thematic_ids: placeholders_t = ", ".join(["%s"] * len(thematic_ids)) filters.append(f"thematic_id IN ({placeholders_t})") params.extend(thematic_ids) # Date range (filtre sur dt_to_be_processed = passage au statut "à traiter") created_after = request.GET.get("created_after") if created_after: filters.append("dt_to_be_processed >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("dt_to_be_processed <= %s") params.append(created_before) # User permission clause perm_clause, perm_params = _get_permission_clause(request.user) if perm_clause == "FALSE": return Response({"granularity": granularity, "results": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = "WHERE " + " AND ".join(filters) sql = f""" SELECT DATE_TRUNC(%s, dt_to_be_processed) AS period, COUNT(*) AS count, ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_time_min)::numeric) AS median_response_time_min, ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY first_resolution_min)::numeric) AS median_first_resolution_min, ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY final_resolution_min)::numeric) AS median_final_resolution_min, ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY execution_time_min)::numeric) AS median_execution_time_min FROM reporting.mv_interventions {where} GROUP BY DATE_TRUNC(%s, dt_to_be_processed) ORDER BY period ASC """ sql_params = [granularity] + params + [granularity] with connection.cursor() as cursor: cursor.execute(sql, sql_params) rows = _dictfetchall(cursor) for row in rows: if row["period"] is not None: row["period"] = row["period"].isoformat() return Response({"granularity": granularity, "results": rows}) # ── GET /api/reporting/thematics/ ───────────────────────────────────────────────── class ReportingThematicsView(ReportingBaseView): """ Retourne les thématiques présentes dans mv_interventions pour les contrats accessibles, avec la liste des contract_id associés. Retourne : { results: [{ id, name_fr, name_nl, contract_ids: [int, …] }] } """ def get(self, request): reporting_contract_ids = _get_reporting_contract_ids(request.user) if reporting_contract_ids is not None and not reporting_contract_ids: return Response({"results": []}) filters = [ "maintenance_type = 'corrective'", "dt_to_be_processed IS NOT NULL", "thematic_id IS NOT NULL", ] params: list = [] if reporting_contract_ids is not None: placeholders = ", ".join(["%s"] * len(reporting_contract_ids)) filters.append(f"contract_id IN ({placeholders})") params.extend(reporting_contract_ids) perm_clause, perm_params = _get_permission_clause(request.user) if perm_clause == "FALSE": return Response({"results": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = "WHERE " + " AND ".join(filters) sql = f""" SELECT thematic_id, MAX(thematic_name_fr) AS name_fr, MAX(thematic_name_nl) AS name_nl, ARRAY_AGG(DISTINCT contract_id) AS contract_ids FROM reporting.mv_interventions {where} GROUP BY thematic_id ORDER BY MAX(thematic_name_fr) ASC """ with connection.cursor() as cursor: cursor.execute(sql, params) rows = _dictfetchall(cursor) # Convert contract_ids from PostgreSQL array to plain Python list of ints for row in rows: raw = row.get("contract_ids") or [] row["contract_ids"] = sorted(int(c) for c in raw if c is not None) return Response({"results": rows}) # ── GET/PATCH /api/reporting/preferences/ ──────────────────────────────────────── class ReportingPreferencesView(ReportingBaseView): """ Lit et met à jour les préférences de filtrage du reporting. Structure du JSON stocké dans UserConfig.reporting_preferences : { "dashboard": { "granularity": "week", "contract_ids": [], "thematic_ids": [], ... }, "autre_page": { ... } } GET /api/reporting/preferences/ → retourne le dict complet PATCH /api/reporting/preferences/ → body JSON : { "page": "dashboard", "prefs": {...} } """ def get(self, request): try: user_config = UserConfig.objects.get(user=request.user) return Response(user_config.reporting_preferences or {}) except UserConfig.DoesNotExist: return Response({}) def patch(self, request): page = request.data.get("page") page_prefs = request.data.get("prefs") if not page or not isinstance(page, str): return Response({"error": "\"page\" (string) requis."}, status=400) if page_prefs is None or not isinstance(page_prefs, dict): return Response({"error": "\"prefs\" (dict) requis."}, status=400) # Validation simple : les clés admises pour éviter des injections de données arbitraires ALLOWED_PAGES = {"dashboard", "volume_dashboard", "observations_dashboard"} # étendre au fur et à mesure if page not in ALLOWED_PAGES: return Response({"error": f"Page inconnue. Pages acceptées : {sorted(ALLOWED_PAGES)}"}, status=400) try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: return Response({"error": "Configuration utilisateur introuvable."}, status=404) prefs = dict(user_config.reporting_preferences or {}) prefs[page] = page_prefs user_config.reporting_preferences = prefs user_config.save(update_fields=["reporting_preferences"]) return Response(prefs) class ReportingInterventionsVolumeTimeseriesView(ReportingBaseView): """ Séries temporelles du volume d'interventions correctives. Filtres : granularity, contract_id, thematic_id, created_after, created_before Groupement par : group_by ('thematic' ou 'contract') """ ALLOWED_GRANULARITIES = {"day", "week", "month"} ALLOWED_GROUP_BY = {"thematic", "contract"} def get(self, request): granularity = request.GET.get("granularity", "week") if granularity not in self.ALLOWED_GRANULARITIES: return Response( {"error": f"granularity invalide. Valeurs acceptées : {sorted(self.ALLOWED_GRANULARITIES)}"}, status=400, ) group_by = request.GET.get("group_by", "thematic") if group_by not in self.ALLOWED_GROUP_BY: return Response( {"error": f"group_by invalide. Valeurs acceptées : {sorted(self.ALLOWED_GROUP_BY)}"}, status=400, ) # Reporting-enabled contract filter reporting_contract_ids = _get_reporting_contract_ids(request.user) if reporting_contract_ids is not None and not reporting_contract_ids: return Response({"granularity": granularity, "group_by": group_by, "results": []}) # Optional caller-supplied contract_id narrowing raw_contract_ids = request.GET.get("contract_id", "") selected_ids: list[int] | None = None if raw_contract_ids: try: requested = [int(x) for x in raw_contract_ids.split(",") if x.strip()] except ValueError: return Response({"error": "contract_id invalide."}, status=400) if reporting_contract_ids is not None: allowed = set(reporting_contract_ids) requested = [i for i in requested if i in allowed] if not requested: return Response({"granularity": granularity, "group_by": group_by, "results": []}) selected_ids = requested filters = ["maintenance_type = 'corrective'", "dt_to_be_processed IS NOT NULL"] params: list = [] # Contract filter effective_ids = selected_ids if selected_ids is not None else reporting_contract_ids if effective_ids is not None: placeholders = ", ".join(["%s"] * len(effective_ids)) filters.append(f"contract_id IN ({placeholders})") params.extend(effective_ids) # Thematic filter raw_thematic_ids = request.GET.get("thematic_id", "") if raw_thematic_ids: try: thematic_ids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] except ValueError: return Response({"error": "thematic_id invalide."}, status=400) if thematic_ids: placeholders_t = ", ".join(["%s"] * len(thematic_ids)) filters.append(f"thematic_id IN ({placeholders_t})") params.extend(thematic_ids) # Date range created_after = request.GET.get("created_after") if created_after: filters.append("dt_to_be_processed >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("dt_to_be_processed <= %s") params.append(created_before) # User permission clause perm_clause, perm_params = _get_permission_clause(request.user) if perm_clause == "FALSE": return Response({"granularity": granularity, "group_by": group_by, "results": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = "WHERE " + " AND ".join(filters) if group_by == "thematic": sql = f""" SELECT DATE_TRUNC(%s, dt_to_be_processed) AS period, thematic_id AS group_id, COALESCE(MAX(thematic_name_fr), 'Sans thématique') AS group_name_fr, COALESCE(MAX(thematic_name_nl), 'Zonder thematiek') AS group_name_nl, COUNT(*) AS count FROM reporting.mv_interventions {where} GROUP BY DATE_TRUNC(%s, dt_to_be_processed), thematic_id ORDER BY period ASC, count DESC """ else: # group_by == "contract" sql = f""" SELECT DATE_TRUNC(%s, dt_to_be_processed) AS period, contract_id AS group_id, COALESCE(MAX(contract_number), 'Sans contrat') AS group_name_fr, COALESCE(MAX(contract_number), 'Zonder contract') AS group_name_nl, COUNT(*) AS count FROM reporting.mv_interventions {where} GROUP BY DATE_TRUNC(%s, dt_to_be_processed), contract_id ORDER BY period ASC, count DESC """ sql_params = [granularity] + params + [granularity] with connection.cursor() as cursor: cursor.execute(sql, sql_params) rows = _dictfetchall(cursor) for row in rows: if row["period"] is not None: row["period"] = row["period"].isoformat() return Response({"granularity": granularity, "group_by": group_by, "results": rows}) class ReportingInterventionsMapLocationsView(ReportingBaseView): """ Retourne la liste des coordonnées géographiques (lon, lat) des interventions correctives. Format : GeoJSON FeatureCollection. """ def get(self, request): # Reporting-enabled contract filter reporting_contract_ids = _get_reporting_contract_ids(request.user) if reporting_contract_ids is not None and not reporting_contract_ids: return Response({"type": "FeatureCollection", "features": []}) # Optional caller-supplied contract_id narrowing raw_contract_ids = request.GET.get("contract_id", "") selected_ids: list[int] | None = None if raw_contract_ids: try: requested = [int(x) for x in raw_contract_ids.split(",") if x.strip()] except ValueError: return Response({"error": "contract_id invalide."}, status=400) if reporting_contract_ids is not None: allowed = set(reporting_contract_ids) requested = [i for i in requested if i in allowed] if not requested: return Response({"type": "FeatureCollection", "features": []}) selected_ids = requested filters = [ "maintenance_type = 'corrective'", "dt_to_be_processed IS NOT NULL", "lon IS NOT NULL", "lat IS NOT NULL" ] params: list = [] # Contract filter effective_ids = selected_ids if selected_ids is not None else reporting_contract_ids if effective_ids is not None: placeholders = ", ".join(["%s"] * len(effective_ids)) filters.append(f"contract_id IN ({placeholders})") params.extend(effective_ids) # Thematic filter raw_thematic_ids = request.GET.get("thematic_id", "") if raw_thematic_ids: try: thematic_ids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] except ValueError: return Response({"error": "thematic_id invalide."}, status=400) if thematic_ids: placeholders_t = ", ".join(["%s"] * len(thematic_ids)) filters.append(f"thematic_id IN ({placeholders_t})") params.extend(thematic_ids) # Date range created_after = request.GET.get("created_after") if created_after: filters.append("dt_to_be_processed >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("dt_to_be_processed <= %s") params.append(created_before) # User permission clause perm_clause, perm_params = _get_permission_clause(request.user) if perm_clause == "FALSE": return Response({"type": "FeatureCollection", "features": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = "WHERE " + " AND ".join(filters) sql = f""" SELECT id, code, title, lon, lat, thematic_id, thematic_name_fr, thematic_name_nl, contract_number, contract_id FROM reporting.mv_interventions {where} LIMIT 10000 """ with connection.cursor() as cursor: cursor.execute(sql, params) rows = _dictfetchall(cursor) features = [] for r in rows: features.append({ "type": "Feature", "geometry": { "type": "Point", "coordinates": [r["lon"], r["lat"]] }, "properties": { "id": r["id"], "code": r["code"], "title": r["title"], "thematic_id": r["thematic_id"], "thematic_name_fr": r["thematic_name_fr"], "thematic_name_nl": r["thematic_name_nl"], "contract_number": r["contract_number"], "contract_id": r["contract_id"] } }) return Response({ "type": "FeatureCollection", "features": features }) class ReportingObservationsView(ReportingBaseView): """ Liste paginée des observations. Filtres : status, observation_type, thematic_id, category_id, symptom_id, created_after, created_before, q """ def get(self, request): from observations.models import Observation from observations.permissions import get_observation_access_context access_context = get_observation_access_context(request.user) if access_context is None: return Response({"count": 0, "page": 1, "per_page": 50, "pages": 0, "results": []}) qs = Observation.objects.all().select_related("thematic", "category", "symptom", "created_by") qs = access_context.filter_queryset(qs) status = request.GET.get("status") if status: qs = qs.filter(status=status) obs_type = request.GET.get("observation_type") if obs_type: qs = qs.filter(observation_type=obs_type) raw_thematic_ids = request.GET.get("thematic_id") if raw_thematic_ids: try: tids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] if tids: qs = qs.filter(thematic_id__in=tids) except ValueError: pass raw_category_ids = request.GET.get("category_id") if raw_category_ids: try: cids = [int(x) for x in raw_category_ids.split(",") if x.strip()] if cids: qs = qs.filter(category_id__in=cids) except ValueError: pass raw_symptom_ids = request.GET.get("symptom_id") if raw_symptom_ids: try: sids = [int(x) for x in raw_symptom_ids.split(",") if x.strip()] if sids: qs = qs.filter(symptom_id__in=sids) except ValueError: pass created_after = request.GET.get("created_after") if created_after: qs = qs.filter(created_at__gte=created_after) created_before = request.GET.get("created_before") if created_before: qs = qs.filter(created_at__lte=created_before) q = request.GET.get("q") if q: qs = qs.filter( Q(code__icontains=q) | Q(description__icontains=q) | Q(address__icontains=q) ) per_page = _parse_int(request.GET.get("per_page"), 50, 1, 1000) page = _parse_int(request.GET.get("page"), 1, 1) total = qs.count() results_qs = qs.order_by("-created_at")[(page - 1) * per_page : page * per_page] results = [] for obs in results_qs: results.append({ "id": obs.id, "code": obs.code, "description": obs.description, "created_at": obs.created_at.isoformat(), "status": obs.status, "observation_type": obs.observation_type, "address": obs.address, "latitude": obs.latitude, "longitude": obs.longitude, "thematic_id": obs.thematic_id, "thematic_name_fr": obs.thematic.name_fr if obs.thematic else None, "thematic_name_nl": obs.thematic.name_nl if obs.thematic else None, "category_id": obs.category_id, "category_name_fr": obs.category.name_fr if obs.category else None, "category_name_nl": obs.category.name_nl if obs.category else None, "symptom_id": obs.symptom_id, "symptom_name_fr": obs.symptom.name_fr if obs.symptom else None, "symptom_name_nl": obs.symptom.name_nl if obs.symptom else None, }) return Response({ "count": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page, "results": results, }) class ReportingObservationsVolumeTimeseriesView(ReportingBaseView): """ Séries temporelles du volume d'observations. Filtres : granularity, thematic_id, category_id, symptom_id, created_after, created_before Groupement par : group_by ('thematic', 'category', 'symptom') """ ALLOWED_GRANULARITIES = {"day", "week", "month"} ALLOWED_GROUP_BY = {"thematic", "category", "symptom"} def get(self, request): granularity = request.GET.get("granularity", "week") if granularity not in self.ALLOWED_GRANULARITIES: return Response( {"error": f"granularity invalide. Valeurs acceptées : {sorted(self.ALLOWED_GRANULARITIES)}"}, status=400, ) group_by = request.GET.get("group_by", "thematic") if group_by not in self.ALLOWED_GROUP_BY: return Response( {"error": f"group_by invalide. Valeurs acceptées : {sorted(self.ALLOWED_GROUP_BY)}"}, status=400, ) filters = [] params = [] raw_thematic_ids = request.GET.get("thematic_id", "") if raw_thematic_ids: try: tids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] if tids: placeholders = ", ".join(["%s"] * len(tids)) filters.append(f"o.thematic_id IN ({placeholders})") params.extend(tids) except ValueError: return Response({"error": "thematic_id invalide."}, status=400) raw_category_ids = request.GET.get("category_id", "") if raw_category_ids: try: cids = [int(x) for x in raw_category_ids.split(",") if x.strip()] if cids: placeholders = ", ".join(["%s"] * len(cids)) filters.append(f"o.category_id IN ({placeholders})") params.extend(cids) except ValueError: return Response({"error": "category_id invalide."}, status=400) raw_symptom_ids = request.GET.get("symptom_id", "") if raw_symptom_ids: try: sids = [int(x) for x in raw_symptom_ids.split(",") if x.strip()] if sids: placeholders = ", ".join(["%s"] * len(sids)) filters.append(f"o.symptom_id IN ({placeholders})") params.extend(sids) except ValueError: return Response({"error": "symptom_id invalide."}, status=400) created_after = request.GET.get("created_after") if created_after: filters.append("o.created_at >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("o.created_at <= %s") params.append(created_before) # Permission filter perm_clause, perm_params = _get_observations_permission_clause(request.user, table_prefix="o") if perm_clause == "FALSE": return Response({"granularity": granularity, "group_by": group_by, "results": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = ("WHERE " + " AND ".join(filters)) if filters else "" if group_by == "thematic": sql = f""" SELECT DATE_TRUNC(%s, o.created_at) AS period, o.thematic_id AS group_id, COALESCE(MAX(t.name_fr), 'Sans thématique') AS group_name_fr, COALESCE(MAX(t.name_nl), 'Zonder thematiek') AS group_name_nl, COUNT(*) AS count FROM observations_observation o LEFT JOIN common_thematic t ON o.thematic_id = t.id {where} GROUP BY DATE_TRUNC(%s, o.created_at), o.thematic_id ORDER BY period ASC, count DESC """ elif group_by == "category": sql = f""" SELECT DATE_TRUNC(%s, o.created_at) AS period, o.category_id AS group_id, COALESCE(MAX(c.name_fr), 'Sans catégorie') AS group_name_fr, COALESCE(MAX(c.name_nl), 'Zonder categorie') AS group_name_nl, COUNT(*) AS count FROM observations_observation o LEFT JOIN assets_assetcategory c ON o.category_id = c.id {where} GROUP BY DATE_TRUNC(%s, o.created_at), o.category_id ORDER BY period ASC, count DESC """ else: # group_by == "symptom" sql = f""" SELECT DATE_TRUNC(%s, o.created_at) AS period, o.symptom_id AS group_id, COALESCE(MAX(s.name_fr), 'Sans symptôme') AS group_name_fr, COALESCE(MAX(s.name_nl), 'Zonder symptoom') AS group_name_nl, COUNT(*) AS count FROM observations_observation o LEFT JOIN interventions_symptom s ON o.symptom_id = s.id {where} GROUP BY DATE_TRUNC(%s, o.created_at), o.symptom_id ORDER BY period ASC, count DESC """ sql_params = [granularity] + params + [granularity] with connection.cursor() as cursor: cursor.execute(sql, sql_params) rows = _dictfetchall(cursor) for row in rows: if row["period"] is not None: row["period"] = row["period"].isoformat() return Response({"granularity": granularity, "group_by": group_by, "results": rows}) class ReportingObservationsMapLocationsView(ReportingBaseView): """ Retourne la liste des coordonnées géographiques (lon, lat) des observations filtrées. Format : GeoJSON FeatureCollection. """ def get(self, request): filters = [ "o.latitude IS NOT NULL", "o.longitude IS NOT NULL" ] params = [] raw_thematic_ids = request.GET.get("thematic_id", "") if raw_thematic_ids: try: tids = [int(x) for x in raw_thematic_ids.split(",") if x.strip()] if tids: placeholders = ", ".join(["%s"] * len(tids)) filters.append(f"o.thematic_id IN ({placeholders})") params.extend(tids) except ValueError: return Response({"error": "thematic_id invalide."}, status=400) raw_category_ids = request.GET.get("category_id", "") if raw_category_ids: try: cids = [int(x) for x in raw_category_ids.split(",") if x.strip()] if cids: placeholders = ", ".join(["%s"] * len(cids)) filters.append(f"o.category_id IN ({placeholders})") params.extend(cids) except ValueError: return Response({"error": "category_id invalide."}, status=400) raw_symptom_ids = request.GET.get("symptom_id", "") if raw_symptom_ids: try: sids = [int(x) for x in raw_symptom_ids.split(",") if x.strip()] if sids: placeholders = ", ".join(["%s"] * len(sids)) filters.append(f"o.symptom_id IN ({placeholders})") params.extend(sids) except ValueError: return Response({"error": "symptom_id invalide."}, status=400) created_after = request.GET.get("created_after") if created_after: filters.append("o.created_at >= %s") params.append(created_after) created_before = request.GET.get("created_before") if created_before: filters.append("o.created_at <= %s") params.append(created_before) # Permission filter perm_clause, perm_params = _get_observations_permission_clause(request.user, table_prefix="o") if perm_clause == "FALSE": return Response({"type": "FeatureCollection", "features": []}) if perm_clause is not None: filters.append(perm_clause) params.extend(perm_params) where = "WHERE " + " AND ".join(filters) sql = f""" SELECT o.id, o.code, o.description, o.latitude AS lat, o.longitude AS lon, o.thematic_id, t.name_fr AS thematic_name_fr, t.name_nl AS thematic_name_nl, o.category_id, c.name_fr AS category_name_fr, c.name_nl AS category_name_nl, o.symptom_id, s.name_fr AS symptom_name_fr, s.name_nl AS symptom_name_nl FROM observations_observation o LEFT JOIN common_thematic t ON o.thematic_id = t.id LEFT JOIN assets_assetcategory c ON o.category_id = c.id LEFT JOIN interventions_symptom s ON o.symptom_id = s.id {where} LIMIT 10000 """ with connection.cursor() as cursor: cursor.execute(sql, params) rows = _dictfetchall(cursor) features = [] for r in rows: features.append({ "type": "Feature", "geometry": { "type": "Point", "coordinates": [r["lon"], r["lat"]] }, "properties": { "id": r["id"], "code": r["code"], "description": r["description"], "thematic_id": r["thematic_id"], "thematic_name_fr": r["thematic_name_fr"], "thematic_name_nl": r["thematic_name_nl"], "category_id": r["category_id"], "category_name_fr": r["category_name_fr"], "category_name_nl": r["category_name_nl"], "symptom_id": r["symptom_id"], "symptom_name_fr": r["symptom_name_fr"], "symptom_name_nl": r["symptom_name_nl"] } }) return Response({ "type": "FeatureCollection", "features": features })