loko/streetup/reporting/views.py
2026-07-22 14:48:40 +02:00

1394 lines
56 KiB
Python

"""
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
})