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

@ -0,0 +1 @@
Dockerfile

View file

@ -0,0 +1,6 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENTRYPOINT ["python"]

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

13
server/backend/api.py Normal file
View file

@ -0,0 +1,13 @@
import uvicorn
from core.logging import setup_logging
setup_logging()
if __name__ == "__main__":
uvicorn.run(
"adapters.http.main:app",
host="0.0.0.0",
port=8000,
reload=False,
)

View file

@ -0,0 +1,10 @@
from core.logging import setup_logging
from dependencies import get_uplink_service, get_mqtt_broker
setup_logging()
if __name__ == "__main__":
broker = get_mqtt_broker()
uplink = get_uplink_service()
broker.start(on_uplink=uplink.handle)

View file

@ -0,0 +1,13 @@
import logging
import sys
import os
def setup_logging() -> None:
level = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=getattr(logging, level, logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s%(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
stream=sys.stdout,
force=True,
)

View file

@ -0,0 +1,34 @@
import os
from adapters.postgres import PgDeviceRepository, PgReadingRepository, PgReadingQueryRepository
from adapters.mqtt import PahoMqttBroker
from services.uplink_service import UplinkService
from services.consumption_service import ConsumptionService
MQTT_HOST = os.getenv("MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.getenv("MQTT_PORT", 1883))
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "application/+/device/+/event/up")
## Repositories
def get_device_repo() -> PgDeviceRepository:
return PgDeviceRepository()
def get_reading_repo() -> PgReadingRepository:
return PgReadingRepository()
def get_query_repo() -> PgReadingQueryRepository:
return PgReadingQueryRepository()
## Services
def get_uplink_service() -> UplinkService:
return UplinkService(get_device_repo(), get_reading_repo())
def get_consumption_service() -> ConsumptionService:
return ConsumptionService(get_query_repo())
## Adapters
def get_mqtt_broker() -> PahoMqttBroker:
return PahoMqttBroker(MQTT_HOST, MQTT_PORT, MQTT_TOPIC)

View file

View file

@ -0,0 +1,4 @@
from .uplink_event import UplinkEvent
from .consumption_point import ConsumptionPoint, ConsumptionResponse
__all__ = ["UplinkEvent", "ConsumptionPoint", "ConsumptionResponse"]

View file

@ -0,0 +1,19 @@
from dataclasses import dataclass
from datetime import datetime
from domain.value_objects import Granularity
@dataclass
class ConsumptionPoint:
period: datetime
pulse_count_start: int
pulse_count_end: int
delta_pulses: int
delta_m3: float
@dataclass
class ConsumptionResponse:
dev_eui: str
start: datetime
end: datetime
granularity: Granularity
points: list[ConsumptionPoint]

View file

@ -0,0 +1,6 @@
from dataclasses import dataclass
@dataclass
class UplinkEvent:
dev_eui: str
pulse_count: int

View file

@ -0,0 +1,17 @@
class DomainError(Exception):
"""Base pour toutes les erreurs de domaine"""
class InfrastructureError(Exception):
"""Erreur technique levée par un adapter"""
class DatabaseConnectionError(InfrastructureError):
"""Impossible de se connecter à la db"""
class DatabaseError(InfrastructureError):
"""Erreur lors d'une opération en base de données."""
class MessageBrokerError(InfrastructureError):
"""Impossible de se connecter au broker MQTT"""
class ValidationError(DomainError):
"""Données d'entrée invalides"""

View file

@ -0,0 +1,3 @@
from typing import Literal
Granularity = Literal["hour", "day", "month"]

View file

@ -0,0 +1,57 @@
import os
import time
import logging
import psycopg2
from typing import Generator, Any
from psycopg2.pool import ThreadedConnectionPool
from psycopg2.extensions import connection
from contextlib import contextmanager
from functools import lru_cache
from domain.exceptions import DatabaseConnectionError
log = logging.getLogger(__name__)
_MIN_CONN = 1
_MAX_CONN = 10
DB_URI = os.getenv("DATABASE_URI", "postgresql://simugaz:simugaz@db/simugaz")
@lru_cache(maxsize=1)
def get_pool() -> ThreadedConnectionPool:
return _create_pool(DB_URI)
def _create_pool(
uri: str, retries: int = 10, base_delay: float = 1.0
) -> ThreadedConnectionPool:
for attempt in range(retries):
try:
pool = ThreadedConnectionPool(_MIN_CONN, _MAX_CONN, uri)
log.info(
"Pool PostgreSQL initialisé (%d%d connexions)", _MIN_CONN, _MAX_CONN
)
return pool
except psycopg2.OperationalError as e:
delay = min(base_delay * 2**attempt, 30.0)
log.warning(
"Attente PostgreSQL (tentative %d/%d) : %s", attempt + 1, retries, e
)
time.sleep(delay)
raise DatabaseConnectionError(
f"Impossible de se connecter après {retries} tentatives"
)
@contextmanager
def get_conn() -> Generator[connection, Any, None]:
pool = get_pool()
conn = pool.getconn() # type: ignore
try:
yield conn
except Exception:
conn.rollback() # type: ignore
raise
finally:
pool.putconn(conn) # type: ignore

View file

@ -0,0 +1,6 @@
from .device_repository import DeviceRepository
from .reading_repository import ReadingRepository
from .message_broker import MessageBroker
from .reading_query_repository import ReadingQueryRepository
__all__ = ["DeviceRepository", "ReadingRepository", "MessageBroker", "ReadingQueryRepository"]

View file

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
class DeviceRepository(ABC):
@abstractmethod
def get_or_create_device_id(self, dev_eui: str) -> str:
"""Retourne le device_id, crée le device s'il est inconnu"""
...

View file

@ -0,0 +1,10 @@
from abc import ABC, abstractmethod
from typing import Callable
from domain.entities import UplinkEvent
class MessageBroker(ABC):
@abstractmethod
def start(self, on_uplink: Callable[[UplinkEvent], None]) -> None:
"""Démarre l'écoute et appelle on_uplink(UplinkEvent) à chaque message"""
...

View file

@ -0,0 +1,16 @@
from abc import ABC, abstractmethod
from datetime import datetime
from domain.value_objects import Granularity
from domain.entities import ConsumptionPoint
class ReadingQueryRepository(ABC):
@abstractmethod
def get_consumption(
self,
dev_eui: str,
start: datetime,
end: datetime,
granularity: Granularity,
) -> list[ConsumptionPoint]:
...

View file

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
class ReadingRepository(ABC):
@abstractmethod
def insert_reading(self, device_id: str, pulse_count: int) -> None:
"""Persiste un relevé"""
...

View file

@ -0,0 +1,6 @@
paho-mqtt==v2.1.0
psycopg2-binary==2.9.12
pydantic==2.13.4
fastapi==0.136.1
uvicorn==0.46.0
python-dateutil==2.9.0

View file

View file

@ -0,0 +1,31 @@
from datetime import datetime
from domain.entities.consumption_point import ConsumptionResponse
from domain.exceptions import ValidationError
from domain.value_objects import Granularity
from ports import ReadingQueryRepository
class ConsumptionService:
def __init__(self, repo: ReadingQueryRepository) -> None:
self._repo = repo
def get_consumption(
self,
dev_eui: str,
start: datetime,
end: datetime,
granularity: Granularity,
) -> ConsumptionResponse:
if start > end:
raise ValidationError("'start' doit être inférieur ou égal à 'end'")
points = self._repo.get_consumption(dev_eui, start, end, granularity)
return ConsumptionResponse(
dev_eui=dev_eui,
start=start,
end=end,
granularity=granularity,
points=points,
)

View file

@ -0,0 +1,18 @@
import logging
from ports import DeviceRepository, ReadingRepository
from domain.entities import UplinkEvent
log = logging.getLogger(__name__)
class UplinkService:
def __init__(self, devices: DeviceRepository, readings: ReadingRepository):
self._devices = devices
self._readings = readings
def handle(self, event: UplinkEvent) -> None:
device_id = self._devices.get_or_create_device_id(event.dev_eui)
self._readings.insert_reading(device_id, event.pulse_count)
log.info(
"[UP] dev_eui=%s | device_id=%s | pulses=%d",
event.dev_eui, device_id, event.pulse_count
)