Skip to content

Commit

Permalink
Feature/latest endpoints (#372)
Browse files Browse the repository at this point in the history
* add latest endpoints and test

* fix datetime_from datetime_to date_from and date_to docstring about tz

* Added some tests

---------

Co-authored-by: Christian Parker <[email protected]>
  • Loading branch information
russbiggs and caparker authored Aug 28, 2024
1 parent 317de37 commit 8347e35
Show file tree
Hide file tree
Showing 7 changed files with 336 additions and 21 deletions.
13 changes: 8 additions & 5 deletions openaq_api/openaq_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
UnprocessableEntityLog,
WarnLog,
)
#from openaq_api.routers.auth import router as auth_router

# from openaq_api.routers.auth import router as auth_router
from openaq_api.routers.averages import router as averages_router
from openaq_api.routers.cities import router as cities_router
from openaq_api.routers.countries import router as countries_router
Expand Down Expand Up @@ -61,6 +62,7 @@
tiles,
trends,
licenses,
latest,
)

logging.basicConfig(
Expand Down Expand Up @@ -193,13 +195,12 @@ class OpenAQValidationResponse(BaseModel):
detail: list[OpenAQValidationResponseDetail] | None = None



@app.exception_handler(RequestValidationError)
async def openaq_request_validation_exception_handler(
request: Request, exc: RequestValidationError
):
return ORJSONResponse(status_code=422, content=jsonable_encoder(str(exc)))
#return PlainTextResponse(str(exc))
# return PlainTextResponse(str(exc))
# print("\n\n\n\n\n")
# print(str(exc))
# print("\n\n\n\n\n")
Expand All @@ -209,7 +210,7 @@ async def openaq_request_validation_exception_handler(
# UnprocessableEntityLog(request=request, detail=str(exc)).model_dump_json()
# )
# detail = OpenAQValidationResponse(detail=detail)
#return ORJSONResponse(status_code=422, content=jsonable_encoder(detail))
# return ORJSONResponse(status_code=422, content=jsonable_encoder(detail))


@app.exception_handler(ValidationError)
Expand All @@ -222,7 +223,7 @@ async def openaq_exception_handler(request: Request, exc: ValidationError):
# request=request, detail=exc.jsmodel_dump_jsonon()
# ).model_dump_json()
# )
#return ORJSONResponse(status_code=422, content=jsonable_encoder(detail))
# return ORJSONResponse(status_code=422, content=jsonable_encoder(detail))
# return ORJSONResponse(status_code=500, content={"message": "internal server error"})


Expand Down Expand Up @@ -256,6 +257,8 @@ def favico():
app.include_router(trends.router)
app.include_router(providers.router)
app.include_router(sensors.router)
app.include_router(latest.router)


# app.include_router(auth_router)
app.include_router(averages_router)
Expand Down
16 changes: 8 additions & 8 deletions openaq_api/openaq_api/v3/models/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ def where(self) -> str:
Overrides the base QueryBaseModel `where` method
If `datetime_from` is a `date` or `datetime` without a timezone a timezone
is added as UTC.
is added as local timezone.
Returns:
string of WHERE clause if `datetime_from` is set
Expand Down Expand Up @@ -544,13 +544,13 @@ class DatetimeToQuery(QueryBaseModel):
def where(self) -> str:
"""Generates SQL condition for filtering to datetime.
Overrides the base QueryBaseModel `where` method
Overrides the base QueryBaseModel `where` method
If `datetime_to` is a `date` or `datetime` without a timezone a timezone
is added as UTC.
If `datetime_to` is a `date` or `datetime` without a timezone a timezone
is added as local timezone.
Returns:
string of WHERE clause if `datetime_to` is set
Returns:
string of WHERE clause if `datetime_to` is set
"""
tz = self.map("timezone", "timezone")
dt = self.map("datetime", "datetime")
Expand Down Expand Up @@ -587,7 +587,7 @@ def where(self) -> str:
Overrides the base QueryBaseModel `where` method
If `date_from` is a `date` or `datetime` without a timezone a timezone
is added as UTC.
is added as local timezone.
Returns:
string of WHERE clause if `date_from` is set
Expand Down Expand Up @@ -624,7 +624,7 @@ def where(self) -> str:
Overrides the base QueryBaseModel `where` method
If `date_to` is a `date` or `datetime` without a timezone a timezone
is added as UTC.
is added as local timezone.
Returns:
string of WHERE clause if `date_to` is set
Expand Down
24 changes: 18 additions & 6 deletions openaq_api/openaq_api/v3/models/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class License(JsonBase):
source_url: str


class Latest(JsonBase):
class LatestBase(JsonBase):
datetime: DatetimeObject
value: float
coordinates: Coordinates
Expand Down Expand Up @@ -213,10 +213,15 @@ class Sensor(SensorBase):
datetime_first: DatetimeObject | None = None
datetime_last: DatetimeObject | None = None
coverage: Coverage
latest: Latest
latest: LatestBase
summary: Summary


class Latest(LatestBase):
sensors_id: int
locations_id: int


class Location(JsonBase):
id: int
name: str | None = None
Expand Down Expand Up @@ -248,8 +253,8 @@ class Measurement(JsonBase):


class HourlyData(JsonBase):
#datetime: DatetimeObject
value: float | None = None # Nullable to deal with errors
# datetime: DatetimeObject
value: float | None = None # Nullable to deal with errors
parameter: ParameterBase
period: Period | None = None
coordinates: Coordinates | None = None
Expand All @@ -258,7 +263,7 @@ class HourlyData(JsonBase):


class DailyData(JsonBase):
#datetime: DatetimeObject
# datetime: DatetimeObject
value: float
parameter: ParameterBase
period: Period | None = None
Expand All @@ -268,7 +273,7 @@ class DailyData(JsonBase):


class AnnualData(JsonBase):
#datetime: DatetimeObject
# datetime: DatetimeObject
value: float
parameter: ParameterBase
period: Period | None = None
Expand Down Expand Up @@ -301,12 +306,15 @@ class LocationsResponse(OpenAQResult):
class MeasurementsResponse(OpenAQResult):
results: list[Measurement]


class HourlyDataResponse(OpenAQResult):
results: list[HourlyData]


class DailyDataResponse(OpenAQResult):
results: list[DailyData]


class AnnualDataResponse(OpenAQResult):
results: list[AnnualData]

Expand Down Expand Up @@ -341,3 +349,7 @@ class ManufacturersResponse(OpenAQResult):

class OwnersResponse(OpenAQResult):
results: list[Owner]


class LatestResponse(OpenAQResult):
results: list[Latest]
175 changes: 175 additions & 0 deletions openaq_api/openaq_api/v3/routers/latest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
from datetime import date, datetime
import logging
from typing import Annotated

from fastapi import APIRouter, Depends, Path, Query

from openaq_api.db import DB
from openaq_api.v3.models.queries import QueryBaseModel, QueryBuilder, Paging
from openaq_api.v3.models.responses import LatestResponse

logger = logging.getLogger("latest")

router = APIRouter(
prefix="/v3",
tags=["v3"],
include_in_schema=True,
)


class DatetimeMinQuery(QueryBaseModel):
"""Pydantic query model for the `datetime_min` query parameter
Inherits from QueryBaseModel
Attributes:
datetime_min: date or datetime in ISO-8601 format to filter results to a mininum data
"""

datetime_min: datetime | date | None = Query(
None,
description="Minimum datetime",
examples=["2022-10-01T11:19:38-06:00", "2022-10-01"],
)

def where(self) -> str:
"""Generates SQL condition for filtering to datetime.
Overrides the base QueryBaseModel `where` method
If `datetime_min` is a `date` or `datetime` without a timezone a timezone
is added as local timezone.
Returns:
string of WHERE clause if `datetime_min` is set
"""
tz = self.map("timezone", "tzid")
dt = self.map("datetime", "datetime_last")

if self.datetime_min is None:
return None
elif isinstance(self.datetime_min, datetime):
if self.datetime_min.tzinfo is None:
return f"{dt} > (:datetime_min::timestamp AT TIME ZONE {tz})"
else:
return f"{dt} > :datetime_min"
elif isinstance(self.datetime_min, date):
return f"{dt} > (:datetime_min::timestamp AT TIME ZONE {tz})"


class ParameterLatestPathQuery(QueryBaseModel):
"""Path query to filter results by parameters ID
Inherits from QueryBaseModel
Attributes:
parameters_id: countries ID value
"""

parameters_id: int = Path(
..., description="Limit the results to a specific parameters id", ge=1
)

def where(self) -> str:
"""Generates SQL condition for filtering to a single parameters_id
Overrides the base QueryBaseModel `where` method
Returns:
string of WHERE clause
"""
return "m.measurands_id = :parameters_id"


class ParametersLatestQueries(ParameterLatestPathQuery, DatetimeMinQuery, Paging): ...


@router.get(
"/parameters/{parameters_id}/latest",
response_model=LatestResponse,
summary="Get a owner by ID",
description="Provides a owner by owner ID",
)
async def parameters_latest_get(
parameters_latest: Annotated[
ParametersLatestQueries, Depends(ParametersLatestQueries.depends())
],
db: DB = Depends(),
):
response = await fetch_latest(parameters_latest, db)
return response


class LocationLatestPathQuery(QueryBaseModel):
"""Path query to filter results by locations ID.
Inherits from QueryBaseModel.
Attributes:
locations_id: locations ID value.
"""

locations_id: int = Path(
description="Limit the results to a specific location by id", ge=1
)

def where(self) -> str:
"""Generates SQL condition for filtering to a single locations_id
Overrides the base QueryBaseModel `where` method
Returns:
string of WHERE clause
"""
return "n.sensor_nodes_id = :locations_id"


class LocationsLatestQueries(LocationLatestPathQuery, DatetimeMinQuery, Paging): ...


@router.get(
"/locations/{locations_id}/latest",
response_model=LatestResponse,
summary="Get a owner by ID",
description="Provides a owner by owner ID",
)
async def owner_get(
locations_latest: Annotated[
LocationsLatestQueries, Depends(LocationsLatestQueries.depends())
],
db: DB = Depends(),
):
response = await fetch_latest(locations_latest, db)
return response


async def fetch_latest(query, db):
query_builder = QueryBuilder(query)
sql = f"""
SELECT
n.sensor_nodes_id AS locations_id
,s.sensors_id AS sensors_id
,get_datetime_object(r.datetime_last, t.tzid) as datetime
,r.value_latest AS value
,json_build_object(
'latitude', st_y(COALESCE(r.geom_latest, n.geom))
,'longitude', st_x(COALESCE(r.geom_latest, n.geom))
) AS coordinates
{query_builder.total()}
FROM
sensors s
JOIN
sensor_systems sy ON (s.sensor_systems_id = sy.sensor_systems_id)
JOIN
sensor_nodes n ON (sy.sensor_nodes_id = n.sensor_nodes_id)
JOIN
timezones t ON (n.timezones_id = t.timezones_id)
JOIN
measurands m ON (s.measurands_id = m.measurands_id)
LEFT JOIN
sensors_rollup r ON (s.sensors_id = r.sensors_id)
{query_builder.where()}
{query_builder.pagination()}
"""
response = await db.fetchPage(sql, query_builder.params())
return response
4 changes: 2 additions & 2 deletions openaq_api/openaq_api/v3/routers/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
)


class SensorQuery(QueryBaseModel):
class SensorPathQuery(QueryBaseModel):
sensors_id: int = Path(
..., description="Limit the results to a specific sensors id", ge=1
)
Expand Down Expand Up @@ -62,7 +62,7 @@ async def sensors_get(
description="Provides a sensor by sensor ID",
)
async def sensor_get(
sensors: Annotated[SensorQuery, Depends(SensorQuery.depends())],
sensors: Annotated[SensorPathQuery, Depends(SensorPathQuery.depends())],
db: DB = Depends(),
):
return await fetch_sensors(sensors, db)
Expand Down
Loading

0 comments on commit 8347e35

Please sign in to comment.