feat: add API to get daily consumption

This commit is contained in:
Alexis Fourmaux 2026-05-10 12:36:38 +02:00
parent 9c883a8eca
commit a0acb2950c
18 changed files with 278 additions and 6 deletions

View file

@ -3,4 +3,4 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
CMD ["python", "consumer.py"] ENTRYPOINT ["python"]

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,54 @@
from datetime import datetime
import psycopg2
from psycopg2.extensions import connection
from domain.entities import ConsumptionPoint
from domain.exceptions import DatabaseError
from domain.value_objects import Granularity
from ports.reading_query_repository import ReadingQueryRepository
class PgReadingQueryRepository(ReadingQueryRepository):
def __init__(self, conn: connection) -> None:
self._conn = conn
def get_consumption(
self,
dev_eui: str,
start: datetime,
end: datetime,
granularity: Granularity,
) -> list[ConsumptionPoint]:
date_trunc = granularity
query = """
SELECT
DATE_TRUNC(%s, r.date) AS period,
MIN(r.pulses) AS pulse_start,
MAX(r.pulses) AS pulse_end,
MAX(r.pulses) - MIN(r.pulses) AS delta_pulses
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
ORDER BY period ASC
"""
try:
with self._conn.cursor() as cur:
cur.execute(query, (date_trunc, dev_eui, 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
]

13
server/app/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,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,22 @@
import os
from functools import lru_cache
from adapters.postgres import connect
from adapters.postgres_query import PgReadingQueryRepository
from services.consumption_service import ConsumptionService
@lru_cache
def get_conn():
return connect(os.getenv("DATABASE_URL", "postgresql://simugaz:simugaz@db/simugaz"))
## Repositories
def get_query_repo() -> PgReadingQueryRepository:
return PgReadingQueryRepository(get_conn())
## Services
def get_consumption_service() -> ConsumptionService:
return ConsumptionService(get_query_repo())
## Adapters

View file

@ -1,3 +1,4 @@
from .uplink_event import UplinkEvent from .uplink_event import UplinkEvent
from .consumption_point import ConsumptionPoint, ConsumptionResponse
__all__ = ["UplinkEvent"] __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

@ -1,4 +1,5 @@
# domain/exceptions.py (ou dans domain.py directement) class DomainError(Exception):
"""Base pour toutes les erreurs de domaine"""
class InfrastructureError(Exception): class InfrastructureError(Exception):
"""Erreur technique levée par un adapter""" """Erreur technique levée par un adapter"""
@ -11,3 +12,6 @@ class DatabaseError(InfrastructureError):
class MessageBrokerError(InfrastructureError): class MessageBrokerError(InfrastructureError):
"""Impossible de se connecter au broker MQTT""" """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

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

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

@ -1,2 +1,5 @@
paho-mqtt==v2.1.0 paho-mqtt==v2.1.0
psycopg2-binary psycopg2-binary==2.9.12
pydantic==2.13.4
fastapi==0.136.1
uvicorn==0.46.0

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 antérieur à '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

@ -1,11 +1,24 @@
services: services:
consumer: consumer:
build: ./app build: ./app
image: simugaz/backend:latest
command: consumer.py
restart: unless-stopped restart: unless-stopped
networks: networks:
- lora-gateway_mqtt - lora-gateway_mqtt
- database - database
api:
build: ./app
image: simugaz/backend:latest
command: api.py
restart: unless-stopped
ports:
- 8000:8000
networks:
- public
- database
db: db:
image: postgres:18-alpine image: postgres:18-alpine
restart: unless-stopped restart: unless-stopped