diff --git a/server/app/Dockerfile b/server/app/Dockerfile index 090b299..4403a65 100644 --- a/server/app/Dockerfile +++ b/server/app/Dockerfile @@ -3,4 +3,4 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["python", "consumer.py"] \ No newline at end of file +ENTRYPOINT ["python"] \ No newline at end of file diff --git a/server/app/adapters/http/__init__.py b/server/app/adapters/http/__init__.py new file mode 100644 index 0000000..ae6c5b9 --- /dev/null +++ b/server/app/adapters/http/__init__.py @@ -0,0 +1,3 @@ +from .readings import readings_router + +__all__ = ["readings_router"] \ No newline at end of file diff --git a/server/app/adapters/http/_readings_schemas.py b/server/app/adapters/http/_readings_schemas.py new file mode 100644 index 0000000..58bc990 --- /dev/null +++ b/server/app/adapters/http/_readings_schemas.py @@ -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], + ) \ No newline at end of file diff --git a/server/app/adapters/http/main.py b/server/app/adapters/http/main.py new file mode 100644 index 0000000..f49aea0 --- /dev/null +++ b/server/app/adapters/http/main.py @@ -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"} \ No newline at end of file diff --git a/server/app/adapters/http/readings.py b/server/app/adapters/http/readings.py new file mode 100644 index 0000000..3807998 --- /dev/null +++ b/server/app/adapters/http/readings.py @@ -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)) diff --git a/server/app/adapters/postgres_query.py b/server/app/adapters/postgres_query.py new file mode 100644 index 0000000..31d534e --- /dev/null +++ b/server/app/adapters/postgres_query.py @@ -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 + ] diff --git a/server/app/api.py b/server/app/api.py new file mode 100644 index 0000000..91595ba --- /dev/null +++ b/server/app/api.py @@ -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, + ) \ No newline at end of file diff --git a/server/app/core/logging.py b/server/app/core/logging.py new file mode 100644 index 0000000..c1b10ae --- /dev/null +++ b/server/app/core/logging.py @@ -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, + ) \ No newline at end of file diff --git a/server/app/dependencies.py b/server/app/dependencies.py new file mode 100644 index 0000000..6f68a56 --- /dev/null +++ b/server/app/dependencies.py @@ -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 diff --git a/server/app/domain/entities/__init__.py b/server/app/domain/entities/__init__.py index 9b2115f..eb6f6cb 100644 --- a/server/app/domain/entities/__init__.py +++ b/server/app/domain/entities/__init__.py @@ -1,3 +1,4 @@ from .uplink_event import UplinkEvent +from .consumption_point import ConsumptionPoint, ConsumptionResponse -__all__ = ["UplinkEvent"] \ No newline at end of file +__all__ = ["UplinkEvent", "ConsumptionPoint", "ConsumptionResponse"] \ No newline at end of file diff --git a/server/app/domain/entities/consumption_point.py b/server/app/domain/entities/consumption_point.py new file mode 100644 index 0000000..4d9d23b --- /dev/null +++ b/server/app/domain/entities/consumption_point.py @@ -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] \ No newline at end of file diff --git a/server/app/domain/exceptions.py b/server/app/domain/exceptions.py index fd60569..181c5ac 100644 --- a/server/app/domain/exceptions.py +++ b/server/app/domain/exceptions.py @@ -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): """Erreur technique levée par un adapter""" @@ -10,4 +11,7 @@ class DatabaseError(InfrastructureError): """Erreur lors d'une opération en base de données.""" class MessageBrokerError(InfrastructureError): - """Impossible de se connecter au broker MQTT""" \ No newline at end of file + """Impossible de se connecter au broker MQTT""" + +class ValidationError(DomainError): + """Données d'entrée invalides""" diff --git a/server/app/domain/value_objects.py b/server/app/domain/value_objects.py new file mode 100644 index 0000000..7651996 --- /dev/null +++ b/server/app/domain/value_objects.py @@ -0,0 +1,3 @@ +from typing import Literal + +Granularity = Literal["hour", "day", "month"] \ No newline at end of file diff --git a/server/app/ports/__init__.py b/server/app/ports/__init__.py index 641a619..22dddec 100644 --- a/server/app/ports/__init__.py +++ b/server/app/ports/__init__.py @@ -1,5 +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"] +__all__ = ["DeviceRepository", "ReadingRepository", "MessageBroker", "ReadingQueryRepository"] diff --git a/server/app/ports/reading_query_repository.py b/server/app/ports/reading_query_repository.py new file mode 100644 index 0000000..c07b150 --- /dev/null +++ b/server/app/ports/reading_query_repository.py @@ -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]: + ... \ No newline at end of file diff --git a/server/app/requirements.txt b/server/app/requirements.txt index 8227b0a..256990d 100644 --- a/server/app/requirements.txt +++ b/server/app/requirements.txt @@ -1,2 +1,5 @@ paho-mqtt==v2.1.0 -psycopg2-binary \ No newline at end of file +psycopg2-binary==2.9.12 +pydantic==2.13.4 +fastapi==0.136.1 +uvicorn==0.46.0 \ No newline at end of file diff --git a/server/app/services/consumption_service.py b/server/app/services/consumption_service.py new file mode 100644 index 0000000..baacb9b --- /dev/null +++ b/server/app/services/consumption_service.py @@ -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, + ) diff --git a/server/docker-compose.yml b/server/docker-compose.yml index d02adbb..2871b04 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,11 +1,24 @@ services: consumer: build: ./app + image: simugaz/backend:latest + command: consumer.py restart: unless-stopped networks: - lora-gateway_mqtt - database + api: + build: ./app + image: simugaz/backend:latest + command: api.py + restart: unless-stopped + ports: + - 8000:8000 + networks: + - public + - database + db: image: postgres:18-alpine restart: unless-stopped