Files
smart-city-digital-twin-mar…/smart-app-city/backend/app/routes/reporting.py

571 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reporting routes — daily, weekly, custom reports and PDF export.
All endpoints require JWT authentication (Bearer token).
"""
from __future__ import annotations
import io
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user
from app.database import get_session
from app.models.models import Alert, Sensor, SensorReading, Zone
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/reports", tags=["Reporting"])
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _scalar_count(
session: AsyncSession,
expression,
*filters,
) -> int:
"""Shorthand: execute a COUNT query and return the integer scalar."""
stmt = select(expression)
if filters:
for f in filters:
stmt = stmt.where(f)
result = await session.execute(stmt)
return result.scalar_one()
async def _build_report_data(
session: AsyncSession,
from_dt: datetime,
to_dt: datetime,
period_label: str,
) -> dict:
"""Aggregate IoT data for the given time window and return a report dict."""
# ── Sensor overview ──────────────────────────────────────────────
total_sensors = await _scalar_count(session, func.count(Sensor.id))
active_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "active"
)
inactive_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "inactive"
)
maintenance_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "maintenance"
)
# ── Readings in period ───────────────────────────────────────────
readings_count = await _scalar_count(
session,
func.count(SensorReading.id),
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
avg_value_result = await session.execute(
select(func.avg(SensorReading.value)).where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
)
avg_value: Optional[float] = avg_value_result.scalar_one()
min_value_result = await session.execute(
select(func.min(SensorReading.value)).where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
)
min_value: Optional[float] = min_value_result.scalar_one()
max_value_result = await session.execute(
select(func.max(SensorReading.value)).where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
)
max_value: Optional[float] = max_value_result.scalar_one()
# ── Readings by sensor type ──────────────────────────────────────
type_avg_rows = await session.execute(
select(Sensor.type, func.avg(SensorReading.value), func.count(SensorReading.id))
.join(SensorReading, SensorReading.sensor_id == Sensor.id)
.where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
.group_by(Sensor.type)
)
readings_by_type = {
row[0]: {"avg_value": round(row[1], 4) if row[1] is not None else None, "count": row[2]}
for row in type_avg_rows.all()
}
# ── Alerts in period ─────────────────────────────────────────────
total_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
)
active_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
Alert.status == "active",
)
resolved_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
Alert.status == "resolved",
)
critical_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
Alert.severity == "critical",
)
# ── Alerts by severity ───────────────────────────────────────────
severity_rows = await session.execute(
select(Alert.severity, func.count(Alert.id))
.where(
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
)
.group_by(Alert.severity)
)
alerts_by_severity = {row[0]: row[1] for row in severity_rows.all()}
# ── Alerts by type ───────────────────────────────────────────────
type_rows = await session.execute(
select(Alert.type, func.count(Alert.id))
.where(
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
)
.group_by(Alert.type)
)
alerts_by_type = {row[0]: row[1] for row in type_rows.all()}
# ── Zones ────────────────────────────────────────────────────────
total_zones = await _scalar_count(session, func.count(Zone.id))
# ── Avg battery level ────────────────────────────────────────────
battery_result = await session.execute(
select(func.avg(Sensor.battery_level)).where(
Sensor.battery_level.is_not(None)
)
)
avg_battery: Optional[float] = battery_result.scalar_one()
# ── Low-battery sensors (< 20 %) ────────────────────────────────
low_battery_count = await _scalar_count(
session,
func.count(Sensor.id),
Sensor.battery_level.is_not(None),
Sensor.battery_level < 20,
)
now = datetime.now(timezone.utc)
return {
"period": period_label,
"from": from_dt.isoformat(),
"to": to_dt.isoformat(),
"generated_at": now.isoformat(),
"sensors": {
"total": total_sensors,
"active": active_sensors,
"inactive": inactive_sensors,
"maintenance": maintenance_sensors,
"avg_battery_level": round(avg_battery, 1) if avg_battery is not None else None,
"low_battery_count": low_battery_count,
},
"readings": {
"count": readings_count,
"avg_value": round(avg_value, 4) if avg_value is not None else None,
"min_value": round(min_value, 4) if min_value is not None else None,
"max_value": round(max_value, 4) if max_value is not None else None,
"by_type": readings_by_type,
},
"zones": {
"total": total_zones,
},
"alerts": {
"total": total_alerts,
"active": active_alerts,
"resolved": resolved_alerts,
"critical": critical_alerts,
"by_severity": alerts_by_severity,
"by_type": alerts_by_type,
},
}
def _generate_pdf_bytes(report: dict) -> bytes:
"""Generate a minimal PDF (text-based) from the report data.
Uses only stdlib — no external PDF dependency required.
Produces a valid PDF 1.4 document with the report content as text.
"""
lines: list[str] = []
lines.append("=" * 60)
lines.append(" SMART CITY DIGITAL TWIN — MARTINIQUE")
lines.append(f" RAPPORT {report['period'].upper()}")
lines.append("=" * 60)
lines.append("")
lines.append(f" Periode : {report['from']} -> {report['to']}")
lines.append(f" Genere le : {report['generated_at']}")
lines.append("")
lines.append("-" * 60)
lines.append(" CAPTEURS")
lines.append("-" * 60)
s = report["sensors"]
lines.append(f" Total : {s['total']}")
lines.append(f" Actifs : {s['active']}")
lines.append(f" Inactifs : {s['inactive']}")
lines.append(f" Maintenance : {s['maintenance']}")
lines.append(f" Batterie moy. : {s['avg_battery_level']}%")
lines.append(f" Batterie basse : {s['low_battery_count']} capteurs")
lines.append("")
lines.append("-" * 60)
lines.append(" LECTURES")
lines.append("-" * 60)
r = report["readings"]
lines.append(f" Nombre : {r['count']}")
lines.append(f" Valeur moy. : {r['avg_value']}")
lines.append(f" Valeur min. : {r['min_value']}")
lines.append(f" Valeur max. : {r['max_value']}")
if r["by_type"]:
lines.append("")
lines.append(" Par type de capteur:")
for sensor_type, data in r["by_type"].items():
lines.append(f" - {sensor_type}: avg={data['avg_value']}, n={data['count']}")
lines.append("")
lines.append("-" * 60)
lines.append(" ZONES")
lines.append("-" * 60)
lines.append(f" Total : {report['zones']['total']}")
lines.append("")
lines.append("-" * 60)
lines.append(" ALERTES")
lines.append("-" * 60)
a = report["alerts"]
lines.append(f" Total : {a['total']}")
lines.append(f" Actives : {a['active']}")
lines.append(f" Resolues : {a['resolved']}")
lines.append(f" Critiques : {a['critical']}")
if a["by_severity"]:
lines.append("")
lines.append(" Par severite:")
for sev, cnt in a["by_severity"].items():
lines.append(f" - {sev}: {cnt}")
if a["by_type"]:
lines.append("")
lines.append(" Par type:")
for atype, cnt in a["by_type"].items():
lines.append(f" - {atype}: {cnt}")
lines.append("")
lines.append("=" * 60)
lines.append(" FIN DU RAPPORT")
lines.append("=" * 60)
text = "\n".join(lines) + "\n"
# Build a minimal valid PDF 1.4 document
buf = io.BytesIO()
offsets: list[int] = []
def write(s: str) -> None:
buf.write(s.encode("latin-1"))
# Object 1: Catalog
offsets.append(buf.tell())
write("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\n")
# Object 2: Pages
offsets.append(buf.tell())
write("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\n")
# Object 3: Page
offsets.append(buf.tell())
write(
"3 0 obj\n"
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\n"
"endobj\n\n"
)
# Object 4: Content stream
escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
stream_lines = [
"BT",
"/F1 10 Tf",
"50 750 Td",
f"({escaped}) Tj",
"ET",
]
stream = "\n".join(stream_lines) + "\n"
offsets.append(buf.tell())
write(
f"4 0 obj\n<< /Length {len(stream)} >>\nstream\n"
f"{stream}"
"endstream\nendobj\n\n"
)
# Object 5: Font
offsets.append(buf.tell())
write(
"5 0 obj\n"
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\n"
"endobj\n\n"
)
# Cross-reference table
xref_offset = buf.tell()
write("xref\n")
write(f"0 {len(offsets) + 1}\n")
write("0000000000 65535 f \n")
for off in offsets:
write(f"{off:010d} 00000 n \n")
# Trailer
write(
"trailer\n"
f"<< /Size {len(offsets) + 1} /Root 1 0 R >>\n"
"startxref\n"
f"{xref_offset}\n"
"%%EOF\n"
)
return buf.getvalue()
# ===========================================================================
# GET /reports/daily
# ===========================================================================
@router.get(
"/daily",
summary="Daily report",
description="Return an aggregated IoT report for a specific date (defaults to today).",
)
async def daily_report(
date: Optional[str] = Query(
None,
description="Date in YYYY-MM-DD format. Defaults to today (UTC).",
pattern=r"^\d{4}-\d{2}-\d{2}$",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
if date is not None:
try:
day = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: '{date}'. Expected YYYY-MM-DD.",
)
else:
day = datetime.now(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
from_dt = day
to_dt = day + timedelta(days=1) - timedelta(microseconds=1)
return await _build_report_data(session, from_dt, to_dt, period_label="daily")
# ===========================================================================
# GET /reports/weekly
# ===========================================================================
@router.get(
"/weekly",
summary="Weekly report",
description="Return an aggregated IoT report for a specific week. "
"Defaults to the current ISO week (MondaySunday).",
)
async def weekly_report(
week: Optional[int] = Query(
None,
ge=1,
le=53,
description="ISO week number (1-53). Defaults to current week.",
),
year: Optional[int] = Query(
None,
ge=2020,
le=2100,
description="Year. Defaults to current year.",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
now = datetime.now(timezone.utc)
if week is not None and year is not None:
# Monday of the given ISO week
from_dt = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").replace(
tzinfo=timezone.utc
)
elif week is not None and year is None:
from_dt = datetime.strptime(
f"{now.isocalendar()[0]}-W{week:02d}-1", "%G-W%V-%u"
).replace(tzinfo=timezone.utc)
else:
# Current ISO week
iso_year, iso_week, _ = now.isocalendar()
from_dt = datetime.strptime(
f"{iso_year}-W{iso_week:02d}-1", "%G-W%V-%u"
).replace(tzinfo=timezone.utc)
to_dt = from_dt + timedelta(weeks=1) - timedelta(microseconds=1)
return await _build_report_data(session, from_dt, to_dt, period_label="weekly")
# ===========================================================================
# GET /reports/custom
# ===========================================================================
@router.get(
"/custom",
summary="Custom date-range report",
description="Return an aggregated IoT report for a custom time window.",
)
async def custom_report(
from_: datetime = Query(
...,
alias="from",
description="Start of time range (ISO 8601). Required.",
),
to: datetime = Query(
...,
description="End of time range (ISO 8601). Required.",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
# Ensure timezone-aware
if from_.tzinfo is None:
from_ = from_.replace(tzinfo=timezone.utc)
if to.tzinfo is None:
to = to.replace(tzinfo=timezone.utc)
if to <= from_:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="'to' must be after 'from'.",
)
max_range = timedelta(days=366)
if (to - from_) > max_range:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Date range cannot exceed 366 days.",
)
return await _build_report_data(
session, from_, to, period_label="custom"
)
# ===========================================================================
# GET /reports/export/pdf
# ===========================================================================
@router.get(
"/export/pdf",
summary="Export report as PDF",
description="Generate and download a PDF report for a given type and date.",
)
async def export_pdf(
type: str = Query(
...,
description="Report type: daily or weekly.",
pattern=r"^(daily|weekly)$",
),
date: Optional[str] = Query(
None,
description="Date in YYYY-MM-DD. For daily: that day. For weekly: any day in the week. Defaults to today.",
pattern=r"^\d{4}-\d{2}-\d{2}$",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> StreamingResponse:
now = datetime.now(timezone.utc)
if type == "daily":
if date is not None:
try:
day = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: '{date}'. Expected YYYY-MM-DD.",
)
else:
day = now.replace(hour=0, minute=0, second=0, microsecond=0)
from_dt = day
to_dt = day + timedelta(days=1) - timedelta(microseconds=1)
period_label = "daily"
filename_date = day.strftime("%Y-%m-%d")
else: # weekly
if date is not None:
try:
ref_day = datetime.strptime(date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: '{date}'. Expected YYYY-MM-DD.",
)
else:
ref_day = now
iso_year, iso_week, _ = ref_day.isocalendar()
from_dt = datetime.strptime(
f"{iso_year}-W{iso_week:02d}-1", "%G-W%V-%u"
).replace(tzinfo=timezone.utc)
to_dt = from_dt + timedelta(weeks=1) - timedelta(microseconds=1)
period_label = "weekly"
filename_date = f"W{iso_week:02d}-{iso_year}"
report = await _build_report_data(session, from_dt, to_dt, period_label)
pdf_bytes = _generate_pdf_bytes(report)
filename = f"smart-city-report-{type}-{filename_date}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)