1394 lines
56 KiB
Python
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
|
|
})
|