refacto: rename app into backend, to prepare for frontend dev

This commit is contained in:
Alexis Fourmaux 2026-05-11 21:09:49 +02:00
parent 017092040d
commit e605bf8603
32 changed files with 2 additions and 2 deletions

View file

View file

@ -0,0 +1,3 @@
from .readings import readings_router
__all__ = ["readings_router"]

View file

@ -0,0 +1,28 @@
from datetime import datetime
from pydantic import BaseModel
from domain.entities import ConsumptionResponse
class ConsumptionPointSchema(BaseModel):
period: datetime
pulse_count_start: int
pulse_count_end: int
delta_pulses: int
delta_m3: float
class ConsumptionResponseSchema(BaseModel):
dev_eui: str
start: datetime
end: datetime
granularity: str
points: list[ConsumptionPointSchema]
@classmethod
def from_domain(cls, r: ConsumptionResponse) -> "ConsumptionResponseSchema":
return cls(
dev_eui=r.dev_eui,
start=r.start,
end=r.end,
granularity=r.granularity,
points=[ConsumptionPointSchema(**p.__dict__) for p in r.points],
)

View file

@ -0,0 +1,18 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .readings import readings_router
app = FastAPI(title="SimuGazAPI", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
app.include_router(readings_router)
@app.get("/health")
def health():
return {"status": "ok"}

View file

@ -0,0 +1,30 @@
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Query, HTTPException
from domain.value_objects import Granularity
from domain.exceptions import ValidationError, DatabaseError
from services.consumption_service import ConsumptionService
from dependencies import get_consumption_service
from ._readings_schemas import ConsumptionResponseSchema
readings_router = APIRouter(prefix="/readings", tags=["readings"])
@readings_router.get("/{dev_eui}", response_model=ConsumptionResponseSchema)
def get_consumption(
dev_eui: str,
start: Annotated[datetime, Query()],
end: Annotated[datetime, Query()],
granularity: Annotated[Granularity, Query()] = "day",
service: ConsumptionService = Depends(get_consumption_service),
):
try:
result = service.get_consumption(dev_eui, start, end, granularity)
return ConsumptionResponseSchema.from_domain(result)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
except DatabaseError as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,65 @@
import json
import logging
from typing import Callable
import paho.mqtt.client as mqtt
from paho.mqtt.client import ConnectFlags
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
from domain.exceptions import MessageBrokerError, InfrastructureError
from domain.entities import UplinkEvent
from ports import MessageBroker
log = logging.getLogger(__name__)
class PahoMqttBroker(MessageBroker):
def __init__(self, host: str, port: int, topic: str):
self._host = host
self._port = port
self._topic = topic
def start(self, on_uplink: Callable[[UplinkEvent], None]) -> None:
_client = mqtt.Client(CallbackAPIVersion.VERSION2)
def _on_connect(
client: mqtt.Client,
userdata: None,
flags: ConnectFlags,
reason_code: ReasonCode,
props: Properties | None,
) -> None:
log.info("MQTT connecté (code=%s)", reason_code)
client.subscribe(self._topic, qos=1)
def _on_message(client: mqtt.Client, userdata: None, msg: mqtt.MQTTMessage) -> None:
try:
body = json.loads(msg.payload)
except json.JSONDecodeError as e:
log.error("Payload JSON invalide sur %s : %s", msg.topic, e)
return
try:
dev_eui = body["deviceInfo"]["devEui"]
pulse_count = int(body["object"]["pulse_count"])
except (KeyError, ValueError, TypeError) as e:
log.error("Champs manquants ou invalides : %s | body=%s", e, body)
return
try:
on_uplink(UplinkEvent(dev_eui=dev_eui, pulse_count=pulse_count))
except InfrastructureError as e:
log.error("Erreur infrastructure pour %s : %s", dev_eui, e)
_client.on_connect = _on_connect
_client.on_message = _on_message
_client.reconnect_delay_set(min_delay=1, max_delay=30)
try:
_client.connect(self._host, self._port, keepalive=60)
except OSError as e:
raise MessageBrokerError(f"Impossible de joindre {self._host}:{self._port} : {e}") from e
_client.loop_forever()

View file

@ -0,0 +1,4 @@
from .reading_repository import PgReadingQueryRepository, PgReadingRepository
from .device_repository import PgDeviceRepository
__all__ = ["PgReadingQueryRepository", "PgDeviceRepository", "PgReadingRepository"]

View file

@ -0,0 +1,30 @@
import logging
import psycopg2
from ports import DeviceRepository
from domain.exceptions import DatabaseError
from infrastructure.db import get_conn
log = logging.getLogger(__name__)
class PgDeviceRepository(DeviceRepository):
def get_or_create_device_id(self, dev_eui: str) -> str:
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO device (device_eui)
VALUES (%s)
ON CONFLICT (device_eui) DO NOTHING
""",
(dev_eui,),
)
cur.execute(
"SELECT device_id FROM device WHERE device_eui = %s", (dev_eui,)
)
return str(cur.fetchone()[0]) # type: ignore
except psycopg2.DatabaseError as e:
raise DatabaseError(f"Erreur de création du device {dev_eui}") from e

View file

@ -0,0 +1,91 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
import psycopg2
from domain.entities import ConsumptionPoint
from domain.exceptions import DatabaseError
from domain.value_objects import Granularity
from ports.reading_query_repository import ReadingQueryRepository
from ports.reading_repository import ReadingRepository
from infrastructure.db import get_conn
_GRANULARITY_DELTA = {
"hour": relativedelta(hours=1),
"day": relativedelta(days=1),
"week": relativedelta(weeks=1),
"month": relativedelta(months=1),
}
class PgReadingQueryRepository(ReadingQueryRepository):
def get_consumption(
self,
dev_eui: str,
start: datetime,
end: datetime,
granularity: Granularity,
) -> list[ConsumptionPoint]:
if start == end:
end = start + _GRANULARITY_DELTA[granularity]
adjusted_start = start - _GRANULARITY_DELTA[granularity]
date_trunc = granularity
query = """
WITH periods AS (
SELECT
DATE_TRUNC(%s, r.date) AS period,
MAX(r.pulses) AS pulse_end
FROM reading r
JOIN device d ON d.device_id = r.device_id
WHERE d.device_eui = %s
AND r.date >= %s
AND r.date < %s
GROUP BY period
)
SELECT
period,
LAG(pulse_end) OVER (ORDER BY period) AS pulse_start,
pulse_end,
pulse_end - LAG(pulse_end) OVER (ORDER BY period) AS delta_pulses
FROM periods
ORDER BY period ASC
"""
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(query, (date_trunc, dev_eui, adjusted_start, end))
rows = cur.fetchall()
except psycopg2.DatabaseError as e:
raise DatabaseError(f"Erreur requête consumption : {e}") from e
return [
ConsumptionPoint(
period=row[0],
pulse_count_start=row[1],
pulse_count_end=row[2],
delta_pulses=row[3],
delta_m3=round(row[3] * 0.010, 3),
)
for row in rows
if row[1] is not None
]
class PgReadingRepository(ReadingRepository):
def insert_reading(self, device_id: str, pulse_count: int) -> None:
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO reading (device_id, date, pulses)
VALUES (%s, NOW(), %s)
""",
(device_id, pulse_count),
)
except psycopg2.DatabaseError as e:
raise DatabaseError(
f"Erreur d'enregistrement de la télérelève sur le device {device_id}"
) from e