diff --git a/db.py b/db.py index 1f51d17..abae123 100644 --- a/db.py +++ b/db.py @@ -1,6 +1,9 @@ from sqlalchemy import create_engine from sqlmodel import SQLModel, Session +from models.station import Station +from models.measurement import IndoorMeasurement, OutdoorMeasurement + engine = create_engine("sqlite:///database.db") SQLModel.metadata.create_all(engine) diff --git a/main.py b/main.py index 0a620bd..2c1ba4f 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,14 @@ import functools +from datetime import datetime -from fastapi import FastAPI, Depends +from fastapi import FastAPI, Depends, Query from sqlmodel import Session from db import get_session +from models.measurement import IndoorMeasurementCreateRequest, MeasurementListResponse from models.station import StationCreateRequest, StationCreateResponse, Station, StationListResponse, \ StationUpdateResponse, StationUpdateRequest -from services import stationService +from services import stationService, measurementService app = FastAPI() @@ -30,6 +32,19 @@ async def update_station(station_data: StationUpdateRequest, session: Session = async def delete_station(station_id: int, session: Session = Depends(get_session)): stationService.delete_station(session, station_id) +@app.post("/measurements/indoor", status_code=204) +async def create_indoor_measurement(data: IndoorMeasurementCreateRequest, session: Session = Depends(get_session)): + measurementService.push_indoor_measurement(session, data) + +@app.get("/measurements", response_model=MeasurementListResponse, status_code=200) +async def get_measurements( + station_ids: list[int] | None = Query(default=None), + indoor: bool | None = Query(default=None), + from_timestamp: datetime | None = None, + to_timestamp: datetime | None = None, + limit: int = 100, + session: Session = Depends(get_session)): + return measurementService.get_measurements(session, station_ids, indoor, from_timestamp, to_timestamp, limit) @app.get("/hello/{name}") async def say_hello(name: str): return {"message": f"Hello {name}"} diff --git a/models/measurement.py b/models/measurement.py new file mode 100644 index 0000000..410d12a --- /dev/null +++ b/models/measurement.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone +from typing import List + +from sqlmodel import SQLModel, Field + +from models.station import StationListResponse + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class IndoorMeasurement(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + station_id: int = Field(foreign_key="station.id") + timestamp: datetime = Field(default_factory=utc_now) + temperature: float | None= Field(default=None) + humidity: float| None = Field(default=None) + +class OutdoorMeasurement(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + station_id : int= Field(foreign_key="station.id") + temperature: float| None = Field(default=None) + humidity: float | None = Field(default=None) + pressure: float| None = Field(default=None) + timestamp: datetime = Field(default_factory=utc_now) + +class IndoorMeasurementCreateRequest(SQLModel): + mac: str + temperature: float + humidity: float + + +class OutdoorMeasurementCreateRequest(SQLModel): + mac: str + temperature: float + humidity: float | None = None + pressure: float | None = None + +class MeasurementResponse(SQLModel): + temperature: float + humidity: float | None = None + pressure: float | None = None + timestamp: datetime + +class StationMeasurementResponse(SQLModel): + station: StationListResponse + measurements: list[MeasurementResponse] + indoor: bool + +class MeasurementListResponse(SQLModel): + stations: list[StationMeasurementResponse] \ No newline at end of file diff --git a/models/station.py b/models/station.py index 0a0e9b4..59089e4 100644 --- a/models/station.py +++ b/models/station.py @@ -1,14 +1,17 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from sqlmodel import SQLModel, Field +def utc_now() -> datetime: + return datetime.now(timezone.utc) + class Station(SQLModel, table=True): id: int = Field(default=None, primary_key=True) mac: str = Field(unique=True) name: Optional[str] = Field(default=None) - created_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=utc_now) class StationCreateRequest(SQLModel): name: str diff --git a/services/measurementService.py b/services/measurementService.py new file mode 100644 index 0000000..2d3a2d9 --- /dev/null +++ b/services/measurementService.py @@ -0,0 +1,116 @@ +from datetime import datetime + +from sqlmodel import Session, select + +from models.measurement import IndoorMeasurement, IndoorMeasurementCreateRequest, OutdoorMeasurementCreateRequest, \ + OutdoorMeasurement, MeasurementListResponse, StationMeasurementResponse, MeasurementResponse +from models.station import Station, StationCreateRequest, StationListResponse +from services import stationService +from coolname import generate_slug + + +def push_indoor_measurement(session: Session, raw_measurement: IndoorMeasurementCreateRequest): + statement = select(Station).where(Station.mac == raw_measurement.mac) + station = session.exec(statement).first() + + if not station: + station = stationService.create_station(session, StationCreateRequest( + mac=raw_measurement.mac, + name=generate_slug(2) + )) + + measurement = IndoorMeasurement( + station_id=station.id, + temperature=raw_measurement.temperature, + humidity=raw_measurement.humidity + ) + + session.add(IndoorMeasurement.model_validate(measurement)) + session.commit() + +def push_outdoor_measurement(session: Session, raw_measurement: OutdoorMeasurementCreateRequest): + statement = select(Station).where(Station.mac == raw_measurement.mac) + station = session.exec(statement).first() + + if not station: + station = stationService.create_station(session, StationCreateRequest( + mac=raw_measurement.mac, + name=generate_slug(2) + )) + + measurement = OutdoorMeasurement( + station_id=station.id, + temperature=raw_measurement.temperature, + humidity=raw_measurement.humidity, + pressure=raw_measurement.pressure + ) + + session.add(OutdoorMeasurement.model_validate(measurement)) + session.commit() +from typing import Type, Union + +def _query_measurements( + session: Session, + model: Type[Union[IndoorMeasurement, OutdoorMeasurement]], + indoor: bool, + station_ids: list[int] | None, + from_timestamp: datetime | None = None, + to_timestamp: datetime | None = None, + limit: int | None = None +) -> list[StationMeasurementResponse]: + statement = select(model) + if station_ids: + statement = statement.where(model.station_id.in_(station_ids)) + if from_timestamp: + statement = statement.where(model.timestamp >= from_timestamp) + if to_timestamp: + statement = statement.where(model.timestamp <= to_timestamp) + statement = statement.order_by(model.timestamp.desc()).limit(limit) + results = session.exec(statement).all() + + grouped: dict[int, list[MeasurementResponse]] = {} + for m in results: + if m.station_id not in grouped: + grouped[m.station_id] = [] + grouped[m.station_id].append(MeasurementResponse.model_validate(m)) + + return [ + StationMeasurementResponse( + station=StationListResponse.model_validate(session.get(Station, station_id)), + measurements=measurements, + indoor=indoor + ) + for station_id, measurements in grouped.items() + ] + +def get_indoor_measurements(session: Session, station_ids: list[int] | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None) -> list[StationMeasurementResponse]: + return _query_measurements(session, IndoorMeasurement, True, station_ids, from_timestamp, to_timestamp, limit) + +def get_outdoor_measurements(session: Session, station_ids: list[int] | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None) -> list[StationMeasurementResponse]: + return _query_measurements(session, OutdoorMeasurement, False, station_ids, from_timestamp, to_timestamp, limit) + +def get_measurements(session: Session, station_ids: list[int] | None, indoor: bool | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None): + if indoor is None: + indoor_results = get_indoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + outdoor_results = get_outdoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + return MeasurementListResponse( + stations=[ + *[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results], + *[StationMeasurementResponse(station=outdoor_result.station, measurements=outdoor_result.measurements, indoor=False) for outdoor_result in outdoor_results], + ] + ) + else: + if indoor: + indoor_results = get_indoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + return MeasurementListResponse( + stations=[ + *[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results], + ] + ) + else: + outdoor_results = get_outdoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + return MeasurementListResponse( + stations=[ + *[StationMeasurementResponse(station=outdoor_result.station, measurements=outdoor_result.measurements, indoor=False) for outdoor_result in outdoor_results], + ] + ) \ No newline at end of file