Skip to content

Commit

Permalink
Merge pull request #23 from luftdaten-at/add-endoint-city-current
Browse files Browse the repository at this point in the history
Add endoint city current
  • Loading branch information
silvioheinze authored Oct 5, 2024
2 parents a139d37 + b9967c3 commit a5ef23c
Show file tree
Hide file tree
Showing 14 changed files with 371 additions and 26 deletions.
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ FROM python:3.12-alpine
ENV TZ=Europe/Vienna

# Install required packages
RUN apk update && apk add netcat-openbsd
RUN apk update && apk add --no-cache \
build-base \
cmake \
netcat-openbsd \
ninja \
libffi-dev \
openssl-dev

# set env variables
ENV PYTHONDONTWRITEBYTECODE=1
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Development version:
docker compose up -d


### Database migration
#### Database migration
Setup alembic folder and config files:

docker compose exec app alembic init alembic
Expand All @@ -25,18 +25,37 @@ Rollback migrations:

docker compose exec app alembic downgrade

#### Database reset

### Production
docker compose down
docker volume ls
docker volume rm luftdaten-api_postgres_data
docker compose up -d
docker compose exec app alembic upgrade head


#### Deployment

Build and push to Dockerhub.

docker build -f Dockerfile.prod -t luftdaten/api:tagname --platform linux/amd64 .
docker push luftdaten/api:tagname

Currently automaticly done by Github Workflow.
Tags:
- **staging**: latest version for testing
- **x.x.x**: released versions for production

### Production

Create docker-compose.prod.yml from example-docker-compose.prod.yml by setting the secret key. Then run:

docker compose -f docker-compose.prod.yml up -d

Create database structure:

docker compose exec app alembic upgrade head

## API Documentation

Open API Standard 3.1
Expand Down
30 changes: 30 additions & 0 deletions code/alembic/versions/2dcba7bf5523_add_tz_to_city.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add tz to City
Revision ID: 2dcba7bf5523
Revises: a44ff1a4c716
Create Date: 2024-10-05 17:47:44.650879
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '2dcba7bf5523'
down_revision: Union[str, None] = 'a44ff1a4c716'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('cities', sa.Column('tz', sa.String(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('cities', 'tz')
# ### end Alembic commands ###
36 changes: 36 additions & 0 deletions code/alembic/versions/a44ff1a4c716_add_slug_to_city_and_country.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""add slug to City and Country
Revision ID: a44ff1a4c716
Revises: dab145493248
Create Date: 2024-10-05 15:55:11.117312
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'a44ff1a4c716'
down_revision: Union[str, None] = 'dab145493248'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('cities', sa.Column('slug', sa.String(), nullable=True))
op.create_index(op.f('ix_cities_slug'), 'cities', ['slug'], unique=True)
op.add_column('countries', sa.Column('slug', sa.String(), nullable=True))
op.create_index(op.f('ix_countries_slug'), 'countries', ['slug'], unique=True)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_countries_slug'), table_name='countries')
op.drop_column('countries', 'slug')
op.drop_index(op.f('ix_cities_slug'), table_name='cities')
op.drop_column('cities', 'slug')
# ### end Alembic commands ###
67 changes: 64 additions & 3 deletions code/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,68 @@ class Dimension():
NO2 = 16
SGP40_RAW_GAS = 17
SGP40_ADJUSTED_GAS = 18


# Dictionary für die Einheiten der Dimensionen
_units = {
PM0_1: "µg/m³",
PM1_0: "µg/m³",
PM2_5: "µg/m³",
PM4_0: "µg/m³",
PM10_0: "µg/m³",
HUMIDITY: "%",
TEMPERATURE: "°C",
VOC_INDEX: "Index",
NOX_INDEX: "Index",
PRESSURE: "hPa",
CO2: "ppm",
O3: "ppb",
AQI: "Index",
GAS_RESISTANCE: "Ω",
TVOC: "ppb",
NO2: "ppb",
SGP40_RAW_GAS: "Ω",
SGP40_ADJUSTED_GAS: "Ω",
}

_names = {
PM0_1: "PM0.1",
PM1_0: "PM1.0",
PM2_5: "PM2.5",
PM4_0: "PM4.0",
PM10_0: "PM10.0",
HUMIDITY: "Humidity",
TEMPERATURE: "Temperature",
VOC_INDEX: "VOC Index",
NOX_INDEX: "NOx Index",
PRESSURE: "Pressure",
CO2: "CO2",
O3: "Ozone (O3)",
AQI: "Air Quality Index (AQI)",
GAS_RESISTANCE: "Gas Resistance",
TVOC: "Total VOC",
NO2: "Nitrogen Dioxide (NO2)",
SGP40_RAW_GAS: "SGP40 Raw Gas",
SGP40_ADJUSTED_GAS: "SGP40 Adjusted Gas",
}

@classmethod
def get_unit(cls, dimension_id: int) -> str:
"""
Gibt die Einheit der angegebenen Dimension zurück.
:param dimension_id: Die ID der Dimension
:return: Die zugehörige Einheit oder 'Unknown', wenn keine Einheit vorhanden ist
"""
return cls._units.get(dimension_id, "Unknown")

@classmethod
def get_name(cls, dimension_id: int) -> str:
"""
Gibt den Namen der angegebenen Dimension zurück.
:param dimension_id: Die ID der Dimension
:return: Der zugehörige Name oder 'Unknown', wenn kein Name vorhanden ist
"""
return cls._names.get(dimension_id, "Unknown")

class SensorModel():
SEN5X = 1
BMP280 = 2
Expand All @@ -31,7 +92,7 @@ class SensorModel():
SHT4X = 10
SGP40 = 11

_model_names = {
_names = {
SEN5X: "SEN5X",
BMP280: "BMP280",
BME280: "BME280",
Expand All @@ -47,7 +108,7 @@ class SensorModel():

@classmethod
def get_sensor_name(cls, sensor_model):
return cls._model_names.get(sensor_model, "Unknown Sensor")
return cls._names.get(sensor_model, "Unknown Sensor")

class LdProduct():
AIR_AROUND = 1
Expand Down
19 changes: 18 additions & 1 deletion code/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,43 @@
from sqlalchemy.orm import relationship
from database import Base

from slugify import slugify


class Country(Base):
__tablename__ = "countries"

id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
code = Column(String, unique=True, index=True) # Optional: Ländercode (z.B. 'AT' für Österreich)
slug = Column(String, unique=True, index=True)
code = Column(String, unique=True, index=True)
# Relationships:
cities = relationship("City", back_populates="country")

def __init__(self, name, code):
self.name = name
self.slug = slugify(name)
self.code = code


class City(Base):
__tablename__ = "cities"

id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
slug = Column(String, unique=True, index=True)
tz = Column(String, nullable=True)
# Relationships:
country_id = Column(Integer, ForeignKey('countries.id'))
country = relationship("Country", back_populates="cities")
locations = relationship("Location", back_populates="city")

def __init__(self, name, country_id, tz):
self.name = name
self.slug = slugify(name)
self.country_id = country_id
self.tz = tz


class Location(Base):
__tablename__ = "locations"
Expand Down
103 changes: 90 additions & 13 deletions code/routers/city.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,96 @@
from fastapi import APIRouter, Depends, Response, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from database import get_db
from models import Station, Measurement, Values
from schemas import StationDataCreate, SensorsCreate
from utils import get_or_create_location, download_csv
import json
from sqlalchemy import func, desc
from datetime import datetime

from models import City, Country, Station, Measurement, Values
from enums import Dimension


router = APIRouter()


# @router.get("/v1/city/current/", response_class=Response)
# async def get_current_station_data(
# city: str = None
# ):
# data = ""
# return Response(content=data, media_type="application/json")
@router.get("/all")
async def get_all_cities(db: Session = Depends(get_db)):
# Abfrage aller Städte in der Datenbank
cities = db.query(City, Country).join(Country, City.country_id == Country.id).all()

if not cities:
raise HTTPException(status_code=404, detail="No cities found")

response = {
"cities": [
{
"id": city.id,
"name": city.name,
"slug": city.slug,
"country": {
"name": country.name,
"slug": country.slug
}
}
for city, country in cities
]
}
return response


@router.get("/current")
async def get_average_measurements_by_city(
city_slug: str = Query(..., description="The name of the city to get the average measurements for."),
db: Session = Depends(get_db)
):
# Suche die Stadt in der Datenbank
city = db.query(City).filter(City.slug == city_slug).first()

if not city:
raise HTTPException(status_code=404, detail="City not found")

# Finde alle Stationen, die mit dieser Stadt verknüpft sind
stations = db.query(Station).filter(Station.location.has(city_id=city.id)).all()

if not stations:
raise HTTPException(status_code=404, detail="No stations found in this city")

# Erstelle eine Liste, um die letzten Messwerte jeder Station zu speichern
last_measurements = []

for station in stations:
# Finde die letzte Messung für die Station (nach time_measured)
last_measurement = db.query(Measurement).filter(
Measurement.station_id == station.id
).order_by(desc(Measurement.time_measured)).first()

if last_measurement:
# Füge alle Werte (dimension, value) der letzten Messung hinzu
values = db.query(Values).filter(Values.measurement_id == last_measurement.id).all()
last_measurements.extend(values)

if not last_measurements:
raise HTTPException(status_code=404, detail="No measurements found for stations in this city")

# Berechne den Durchschnitt der letzten Messwerte pro Dimension
avg_measurements = db.query(
Values.dimension,
func.avg(Values.value).label("avg_value")
).filter(Values.id.in_([value.id for value in last_measurements]))\
.group_by(Values.dimension)\
.all()

# Bereite die Antwort im GeoJSON-Format vor
response = {
"city": city.name,
"time": datetime.now(city.tz).isoformat(),
"values": [
{
"dimension": dimension,
"name": Dimension.get_name(dimension),
"average": avg_value,
"unit": Dimension.get_unit(dimension)
}
for dimension, avg_value in avg_measurements
]
}

return response
Loading

0 comments on commit a5ef23c

Please sign in to comment.