From 166de33fae529b718cb978a16625d659017c4880 Mon Sep 17 00:00:00 2001 From: BuriedInCode <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 4 Jun 2024 16:27:26 +1200 Subject: [PATCH] Swap to using SQLModel instead of pony.orm --- freyr.sqlite | Bin 0 -> 16384 bytes freyr/__init__.py | 2 +- freyr/__main__.py | 3 + freyr/database.py | 18 +++ freyr/database/__init__.py | 26 ----- freyr/database/tables.py | 68 ------------ freyr/models.py | 86 +++++++++++++++ freyr/models/__init__.py | 31 ------ freyr/models/device.py | 56 ---------- freyr/models/reading.py | 38 ------- freyr/routers/api.py | 201 ++++++++++++++++++++++++++++++++++ freyr/routers/api/__init__.py | 14 --- freyr/routers/api/device.py | 73 ------------ freyr/routers/api/reading.py | 144 ------------------------ freyr/routers/html.py | 89 +++++++-------- freyr/settings.py | 6 + freyr/utils.py | 167 ++++++++++++++++------------ pyproject.toml | 13 +-- requirements-dev.lock | 13 ++- requirements.lock | 13 ++- static/js/dashboard.js | 72 ++++++------ 21 files changed, 511 insertions(+), 622 deletions(-) create mode 100644 freyr.sqlite create mode 100644 freyr/database.py delete mode 100644 freyr/database/__init__.py delete mode 100644 freyr/database/tables.py create mode 100644 freyr/models.py delete mode 100644 freyr/models/__init__.py delete mode 100644 freyr/models/device.py delete mode 100644 freyr/models/reading.py create mode 100644 freyr/routers/api.py delete mode 100644 freyr/routers/api/__init__.py delete mode 100644 freyr/routers/api/device.py delete mode 100644 freyr/routers/api/reading.py diff --git a/freyr.sqlite b/freyr.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..9e89310dc4d7214907bf9cf33f9d7bad260cb14f GIT binary patch literal 16384 zcmeI(&2G~`5C`zxxN4M0U~W+rRhnZgNacLBQn?qollN>e zctCntM#nM^l0__aZ76)VpO)V&A{DCS>}pMgf17W{%=~J8(g^_p2tWV=5P$##AOHafKmY;| zfWSWys2S^y{%d`yX*b(-d%JG8sj=N{+TCVr%U(vW@i~nzc0qR*^UhAaWm99P+iG^( Uook)F!G|<{9 literal 0 HcmV?d00001 diff --git a/freyr/__init__.py b/freyr/__init__.py index 55c63de..495d595 100644 --- a/freyr/__init__.py +++ b/freyr/__init__.py @@ -20,7 +20,7 @@ from freyr.console import CONSOLE -__version__ = "0.5.3" +__version__ = "0.6.0" def get_cache() -> Path: diff --git a/freyr/__main__.py b/freyr/__main__.py index bd9329c..8889d63 100644 --- a/freyr/__main__.py +++ b/freyr/__main__.py @@ -9,6 +9,7 @@ from freyr import __version__, elapsed_timer, get_project, setup_logging from freyr.constants import constants +from freyr.database import create_db_and_tables from freyr.routers.api import router as api_router from freyr.routers.html import router as html_router @@ -30,6 +31,8 @@ def create_app() -> FastAPI: async def startup_event() -> None: setup_logging() + create_db_and_tables() + LOGGER.info( "Listening on %s:%s", constants.settings.website.host, constants.settings.website.port ) diff --git a/freyr/database.py b/freyr/database.py new file mode 100644 index 0000000..d84c240 --- /dev/null +++ b/freyr/database.py @@ -0,0 +1,18 @@ +from sqlmodel import Session, SQLModel, create_engine + +from freyr.constants import constants + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +connect_args = {"check_same_thread": False} +engine = create_engine(constants.settings.database.db_url, echo=False, connect_args=connect_args) + + +def create_db_and_tables() -> None: + SQLModel.metadata.create_all(engine) + + +def get_session() -> Session: + with Session(engine) as session: + yield session diff --git a/freyr/database/__init__.py b/freyr/database/__init__.py deleted file mode 100644 index b7866f2..0000000 --- a/freyr/database/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -__all__ = [] - - -from freyr import get_data -from freyr.constants import constants -from freyr.database.tables import db -from freyr.settings import Source - -if constants.settings.database.source == Source.POSTGRES: - db.bind( - provider="postgres", - user=constants.settings.database.user, - password=constants.settings.database.password, - host=constants.settings.database.host, - database=constants.settings.database.name, - ) -else: - filepath = get_data() / constants.settings.database.name - db.bind(provider="sqlite", filename=str(filepath), create_db=True) -db.generate_mapping(create_tables=True) - - -@db.on_connect(provider="sqlite") -def sqlite_case_sensitivity(database, connection) -> None: # noqa: ANN001, ARG001 - cursor = connection.cursor() - cursor.execute("PRAGMA case_sensitive_like = OFF") diff --git a/freyr/database/tables.py b/freyr/database/tables.py deleted file mode 100644 index 63fdf33..0000000 --- a/freyr/database/tables.py +++ /dev/null @@ -1,68 +0,0 @@ -__all__ = ["db", "Device", "Reading"] - -from datetime import datetime -from decimal import Decimal -from typing import Self - -from pony.orm import Database, Optional, PrimaryKey, Required, Set, composite_key - -from freyr.models.device import Device as DeviceModel, DeviceEntry -from freyr.models.reading import Reading as ReadingModel, ReadingEntry - -db = Database() - - -class Device(db.Entity): - _table_ = "devices" - - id: int = PrimaryKey(int, auto=True) - name: str = Required(str, unique=True) - readings: list["Reading"] = Set("Reading") - - def to_entry_model(self: Self) -> DeviceEntry: - return DeviceEntry(id=self.id, name=self.name) - - def to_model(self: Self) -> DeviceModel: - return DeviceModel( - id=self.id, - name=self.name, - readings=list( - { - DeviceModel.Reading( - id=x.id, - timestamp=x.timestamp, - temperature=x.temperature, - humidity=x.humidity, - ) - for x in self.readings - } - ), - ) - - -class Reading(db.Entity): - _table_ = "readings" - - id: int = PrimaryKey(int, auto=True) - device: Device = Required(Device) - timestamp: datetime = Required(datetime) - temperature: Decimal | None = Optional(Decimal, nullable=True) - humidity: Decimal | None = Optional(Decimal, nullable=True) - - composite_key(device, timestamp) - - def to_entry_model(self: Self) -> ReadingEntry: - return ReadingEntry( - id=self.id, - timestamp=self.timestamp, - temperature=self.temperature, - humidity=self.humidity, - ) - - def to_model(self: Self) -> ReadingModel: - return ReadingModel( - id=self.id, - timestamp=self.timestamp, - temperature=self.temperature, - humidity=self.humidity, - ) diff --git a/freyr/models.py b/freyr/models.py new file mode 100644 index 0000000..dce885a --- /dev/null +++ b/freyr/models.py @@ -0,0 +1,86 @@ +from datetime import datetime +from decimal import Decimal +from typing import Annotated, Self + +from sqlmodel import Field, Relationship, SQLModel + + +class DeviceBase(SQLModel): + name: str = Field(index=True) + + +class Device(DeviceBase, table=True): + __tablename__ = "devices" + + id: int | None = Field(default=None, primary_key=True) + readings: list["Reading"] = Relationship(back_populates="device") + + +class DeviceCreate(DeviceBase): + pass + + +class DevicePublic(DeviceBase): + id: int + readings: list["ReadingPublic"] = Field(default_factory=list) + + +class ReadingBase(SQLModel): + temperature: Decimal | None = None + humidity: Decimal | None = None + + +class Reading(ReadingBase, table=True): + __tablename__ = "readings" + + id: int | None = Field(default=None, primary_key=True) + timestamp: datetime + device_id: int = Field(foreign_key="devices.id") + device: Device = Relationship(back_populates="readings") + + def __lt__(self: Self, other) -> int: # noqa: ANN001 + if not isinstance(other, Reading): + raise NotImplementedError + return self.timestamp < other.timestamp + + def __eq__(self: Self, other) -> bool: # noqa: ANN001 + if not isinstance(other, Reading): + raise NotImplementedError + return self.timestamp == other.timestamp + + def __hash__(self: Self) -> int: + return hash((type(self), self.timestamp)) + + +class ReadingCreate(ReadingBase): + device_id: int | None = None + timestamp: datetime | None = None + + +class ReadingPublic(ReadingBase): + id: int + timestamp: datetime + + +class Summary(SQLModel): + class Reading(SQLModel): + timestamp: datetime + temperature: Annotated[Decimal, Field(decimal_places=2)] | None = None + humidity: Annotated[Decimal, Field(decimal_places=2)] | None = None + + def __lt__(self: Self, other) -> int: # noqa: ANN001 + if not isinstance(other, Summary.Reading): + raise NotImplementedError + return self.timestamp < other.timestamp + + def __eq__(self: Self, other) -> bool: # noqa: ANN001 + if not isinstance(other, Summary.Reading): + raise NotImplementedError + return self.timestamp == other.timestamp + + def __hash__(self: Self) -> int: + return hash((type(self), self.timestamp)) + + highs: list[Reading] = Field(default_factory=list) + averages: list[Reading] = Field(default_factory=list) + lows: list[Reading] = Field(default_factory=list) diff --git a/freyr/models/__init__.py b/freyr/models/__init__.py deleted file mode 100644 index 3670725..0000000 --- a/freyr/models/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -__all__ = ["Summary"] - -from datetime import datetime -from decimal import Decimal -from typing import Self - -from pydantic import BaseModel, Field - - -class Summary(BaseModel): - class Reading(BaseModel): - timestamp: datetime - temperature: Decimal | None - humidity: Decimal | None - - def __lt__(self: Self, other) -> int: # noqa: ANN001 - if not isinstance(other, Summary.Reading): - raise NotImplementedError - return self.timestamp < other.timestamp - - def __eq__(self: Self, other) -> bool: # noqa: ANN001 - if not isinstance(other, Summary.Reading): - raise NotImplementedError - return self.timestamp == other.timestamp - - def __hash__(self: Self) -> int: - return hash((type(self), self.timestamp)) - - highs: list[Reading] = Field(default_factory=list) - averages: list[Reading] = Field(default_factory=list) - lows: list[Reading] = Field(default_factory=list) diff --git a/freyr/models/device.py b/freyr/models/device.py deleted file mode 100644 index 7f8b0f5..0000000 --- a/freyr/models/device.py +++ /dev/null @@ -1,56 +0,0 @@ -__all__ = ["Device", "DeviceEntry", "DeviceInput"] - -from datetime import datetime -from decimal import Decimal -from typing import Self - -from pydantic import BaseModel, Field - - -class BaseDevice(BaseModel): - name: str - - def __lt__(self: Self, other) -> int: # noqa: ANN001 - if not isinstance(other, BaseDevice): - raise NotImplementedError - return self.name.casefold() < other.name.casefold() - - def __eq__(self: Self, other) -> bool: # noqa: ANN001 - if not isinstance(other, BaseDevice): - raise NotImplementedError - return self.name.casefold() == other.name.casefold() - - def __hash__(self: Self) -> int: - return hash((type(self), self.name.casefold())) - - -class Device(BaseDevice): - class Reading(BaseModel): - id: int - timestamp: datetime - temperature: Decimal - humidity: Decimal - - def __lt__(self: Self, other) -> int: # noqa: ANN001 - if not isinstance(other, Device.Reading): - raise NotImplementedError - return self.timestamp < other.timestamp - - def __eq__(self: Self, other) -> bool: # noqa: ANN001 - if not isinstance(other, Device.Reading): - raise NotImplementedError - return self.timestamp == other.timestamp - - def __hash__(self: Self) -> int: - return hash((type(self), self.timestamp)) - - id: int - readings: list[Reading] = Field(default_factory=list) - - -class DeviceEntry(BaseDevice): - id: int - - -class DeviceInput(BaseDevice): - pass diff --git a/freyr/models/reading.py b/freyr/models/reading.py deleted file mode 100644 index c253d36..0000000 --- a/freyr/models/reading.py +++ /dev/null @@ -1,38 +0,0 @@ -__all__ = ["Reading", "ReadingEntry", "ReadingInput"] - -from datetime import datetime -from decimal import Decimal -from typing import Self - -from pydantic import BaseModel - - -class BaseReading(BaseModel): - timestamp: datetime | None = None - temperature: Decimal | None = None - humidity: Decimal | None = None - - def __lt__(self: Self, other) -> int: # noqa: ANN001 - if not isinstance(other, BaseReading): - raise NotImplementedError - return self.timestamp < other.timestamp - - def __eq__(self: Self, other) -> bool: # noqa: ANN001 - if not isinstance(other, BaseReading): - raise NotImplementedError - return self.timestamp == other.timestamp - - def __hash__(self: Self) -> int: - return hash((type(self), self.timestamp)) - - -class Reading(BaseReading): - id: int - - -class ReadingEntry(BaseReading): - id: int - - -class ReadingInput(BaseReading): - pass diff --git a/freyr/routers/api.py b/freyr/routers/api.py new file mode 100644 index 0000000..d557ffc --- /dev/null +++ b/freyr/routers/api.py @@ -0,0 +1,201 @@ +__all__ = ["router"] + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, desc, select + +from freyr.database import get_session +from freyr.models import ( + Device, + DeviceCreate, + DevicePublic, + Reading, + ReadingCreate, + ReadingPublic, + Summary, +) +from freyr.responses import ErrorResponse +from freyr.utils import ( + get_daily_average_readings, + get_daily_high_readings, + get_daily_low_readings, + get_hourly_average_readings, + get_hourly_high_readings, + get_hourly_low_readings, + get_monthly_average_readings, + get_monthly_high_readings, + get_monthly_low_readings, + get_yearly_average_readings, + get_yearly_high_readings, + get_yearly_low_readings, +) + +LOGGER = logging.getLogger(__name__) +router = APIRouter( + prefix="/api", responses={422: {"description": "Validation error", "model": ErrorResponse}} +) + + +@router.get(path="/devices", response_model=list[DevicePublic]) +def list_devices( + *, + session: Session = Depends(get_session), + offset: int = 0, + limit: int = Query(default=100, le=100), +): + return session.exec(select(Device).offset(offset).limit(limit)).all() + + +@router.post(path="/devices", status_code=201, response_model=DevicePublic) +def create_device(*, session: Session = Depends(get_session), device: DeviceCreate): + db_device = Device.model_validate(device) + session.add(db_device) + session.commit() + session.refresh(db_device) + return db_device + + +@router.get(path="/devices/{device_id}", response_model=DevicePublic) +def get_device(*, session: Session = Depends(get_session), device_id: int): + device = session.get(Device, device_id) + if not device: + raise HTTPException(status_code=404, detail="Device not found.") + return device + + +@router.get(path="/devices/{device_id}/readings", response_model=list[ReadingPublic]) +def list_device_readings( + *, + session: Session = Depends(get_session), + device_id: int, + offset: int = 0, + limit: int = Query(default=100, le=100), +): + return list_readings(session=session, device_id=device_id, offset=offset, limit=limit) + + +@router.post(path="/devices/{device_id}/readings", status_code=201, response_model=ReadingPublic) +def create_device_reading( + *, session: Session = Depends(get_session), device_id: int, reading: ReadingCreate +): + if reading.device_id is None: + reading.device_id = device_id + elif reading.device_id != device_id: + raise HTTPException(status_code=400, detail="Body device_id doesn't match Path device_id") + return create_reading(session=session, reading=reading) + + +@router.get(path="/devices/{device_id}/readings/yearly") +def yearly_readings( + *, session: Session = Depends(get_session), device_id: int, limit: int = 100, offset: int = 0 +) -> Summary: + readings = session.exec(select(Reading).where(Reading.device_id == device_id)).all() + return Summary( + highs=get_yearly_high_readings(readings=readings)[offset : offset + limit], + averages=get_yearly_average_readings(readings=readings)[offset : offset + limit], + lows=get_yearly_low_readings(readings=readings)[offset : offset + limit], + ) + + +@router.get(path="/monthly") +def monthly_readings( + *, + session: Session = Depends(get_session), + device_id: int, + year: int | None = None, + limit: int = 100, + offset: int = 0, +) -> Summary: + readings = session.exec(select(Reading).where(Reading.device_id == device_id)).all() + return Summary( + highs=get_monthly_high_readings(readings=readings, year=year)[offset : offset + limit], + averages=get_monthly_average_readings(readings=readings, year=year)[ + offset : offset + limit + ], + lows=get_monthly_low_readings(readings=readings, year=year)[offset : offset + limit], + ) + + +@router.get(path="/daily") +def daily_readings( + *, + session: Session = Depends(get_session), + device_id: int, + year: int | None = None, + month: int | None = None, + limit: int = 100, + offset: int = 0, +) -> Summary: + readings = session.exec(select(Reading).where(Reading.device_id == device_id)).all() + return Summary( + highs=get_daily_high_readings(readings=readings, year=year, month=month)[ + offset : offset + limit + ], + averages=get_daily_average_readings(readings=readings, year=year, month=month)[ + offset : offset + limit + ], + lows=get_daily_low_readings(readings=readings, year=year, month=month)[ + offset : offset + limit + ], + ) + + +@router.get(path="/hourly") +def hourly_readings( + *, + session: Session = Depends(get_session), + device_id: int, + year: int | None = None, + month: int | None = None, + day: int | None = None, + limit: int = 100, + offset: int = 0, +) -> Summary: + readings = session.exec(select(Reading).where(Reading.device_id == device_id)).all() + return Summary( + highs=get_hourly_high_readings(readings=readings, year=year, month=month, day=day)[ + offset : offset + limit + ], + averages=get_hourly_average_readings(readings=readings, year=year, month=month, day=day)[ + offset : offset + limit + ], + lows=get_hourly_low_readings(readings=readings, year=year, month=month, day=day)[ + offset : offset + limit + ], + ) + + +@router.get(path="/readings", response_model=list[ReadingPublic]) +def list_readings( + *, + session: Session = Depends(get_session), + device_id: int | None = None, + offset: int = 0, + limit: int = Query(default=100, le=100), +): + if device_id: + readings = session.exec( + select(Reading) + .where(Reading.device_id == device_id) + .order_by(desc(Reading.timestamp)) + .offset(offset) + .limit(limit) + ).all() + else: + readings = session.exec( + select(Reading).order_by(desc(Reading.timestamp)).offset(offset).limit(limit) + ).all() + return readings + + +@router.post(path="/readings", status_code=201, response_model=ReadingPublic) +def create_reading(*, session: Session = Depends(get_session), reading: ReadingCreate): + if reading.timestamp is None: + reading.timestamp = datetime.fromisoformat(datetime.now().isoformat(timespec="seconds")) + db_reading = Reading.model_validate(reading) + session.add(db_reading) + session.commit() + session.refresh(db_reading) + return db_reading diff --git a/freyr/routers/api/__init__.py b/freyr/routers/api/__init__.py deleted file mode 100644 index 1e1c115..0000000 --- a/freyr/routers/api/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -__all__ = ["router"] - -import logging - -from fastapi import APIRouter - -from freyr.responses import ErrorResponse -from freyr.routers.api.device import router as device_router - -LOGGER = logging.getLogger(__name__) -router = APIRouter( - prefix="/api", responses={422: {"description": "Validation error", "model": ErrorResponse}} -) -router.include_router(device_router) diff --git a/freyr/routers/api/device.py b/freyr/routers/api/device.py deleted file mode 100644 index 6e13a9a..0000000 --- a/freyr/routers/api/device.py +++ /dev/null @@ -1,73 +0,0 @@ -__all__ = ["router"] - -import logging - -from fastapi import APIRouter, HTTPException -from pony.orm import db_session, flush - -from freyr.database.tables import Device -from freyr.models.device import Device as DeviceModel, DeviceEntry, DeviceInput -from freyr.responses import ErrorResponse -from freyr.routers.api.reading import router as readings_router - -LOGGER = logging.getLogger(__name__) -router = APIRouter( - prefix="/devices", responses={422: {"description": "Validation error", "model": ErrorResponse}} -) - - -@router.get(path="") -def list_endpoint(*, name: str | None = None) -> list[DeviceEntry]: - with db_session: - resources = Device.select() - if name: - resources = [x for x in resources if x.name in name or name in x.name] - - return sorted(x.to_entry_model() for x in resources) - - -@router.post(path="", status_code=201) -def create_endpoint(*, body: DeviceInput) -> DeviceModel: - with db_session: - if Device.get(name=body.name): - raise HTTPException(status_code=409, detail="Device already exists.") - resource = Device(name=body.name) - flush() - - return resource.to_model() - - -def get_resource(device_id: int) -> Device: - if resource := Device.get(id=device_id): - return resource - raise HTTPException(status_code=404, detail="Device not found.") - - -@router.get(path="/{device_id}") -def get_endpoint(*, device_id: int) -> DeviceModel: - with db_session: - return get_resource(device_id=device_id).to_model() - - -@router.put(path="/{device_id}") -def update_endpoint(*, device_id: int, body: DeviceInput) -> DeviceModel: - with db_session: - resource = get_resource(device_id=device_id) - exists = Device.get(name=body.name) - if exists and exists != resource: - raise HTTPException(status_code=409, detail="Device already exists.") - - resource.name = body.name - flush() - - return resource.to_model() - - -@router.delete(path="/{device_id}", status_code=204) -def delete_endpoint(*, device_id: int) -> None: - with db_session: - resource = get_resource(device_id=device_id) - resource.delete() - - -router.include_router(readings_router) diff --git a/freyr/routers/api/reading.py b/freyr/routers/api/reading.py deleted file mode 100644 index cf0124a..0000000 --- a/freyr/routers/api/reading.py +++ /dev/null @@ -1,144 +0,0 @@ -__all__ = ["router"] - -import logging -from datetime import datetime - -from fastapi import APIRouter, HTTPException -from pony.orm import db_session, flush - -from freyr.database.tables import Device, Reading -from freyr.models import Summary -from freyr.models.reading import Reading as ReadingModel, ReadingEntry, ReadingInput -from freyr.responses import ErrorResponse -from freyr.utils import ( - get_daily_avg_readings, - get_daily_high_readings, - get_daily_low_readings, - get_hourly_avg_readings, - get_hourly_high_readings, - get_hourly_low_readings, - get_monthly_avg_readings, - get_monthly_high_readings, - get_monthly_low_readings, - get_yearly_avg_readings, - get_yearly_high_readings, - get_yearly_low_readings, -) - -LOGGER = logging.getLogger(__name__) -router = APIRouter( - prefix="/{device_id}/readings", - responses={422: {"description": "Validation error", "model": ErrorResponse}}, -) - - -def get_device(device_id: int) -> Device: - if resource := Device.get(id=device_id): - return resource - raise HTTPException(status_code=404, detail="Device not found.") - - -@router.get(path="") -def list_endpoint(*, device_id: int, limit: int = 100, offset: int = 0) -> list[ReadingEntry]: - with db_session: - device = get_device(device_id=device_id) - resources = device.readings - - return sorted((x.to_entry_model() for x in resources), reverse=True)[ - offset : offset + limit - ] - - -@router.post(path="", status_code=201) -def create_endpoint(*, device_id: int, body: ReadingInput) -> ReadingModel: - with db_session: - device = get_device(device_id=device_id) - resource = Reading( - device=device, - timestamp=body.timestamp - or datetime.fromisoformat(datetime.now().isoformat(timespec="seconds")), - temperature=body.temperature, - humidity=body.humidity, - ) - flush() - - return resource.to_model() - - -@router.get(path="/yearly") -def yearly_readings(*, device_id: int, limit: int = 100, offset: int = 0) -> Summary: - with db_session: - device = get_device(device_id=device_id) - return Summary( - highs=get_yearly_high_readings(entries=device.readings)[offset : offset + limit], - averages=get_yearly_avg_readings(entries=device.readings)[offset : offset + limit], - lows=get_yearly_low_readings(entries=device.readings)[offset : offset + limit], - ) - - -@router.get(path="/monthly") -def monthly_readings( - *, device_id: int, year: int | None = None, limit: int = 100, offset: int = 0 -) -> Summary: - with db_session: - device = get_device(device_id=device_id) - return Summary( - highs=get_monthly_high_readings(entries=device.readings, year=year)[ - offset : offset + limit - ], - averages=get_monthly_avg_readings(entries=device.readings, year=year)[ - offset : offset + limit - ], - lows=get_monthly_low_readings(entries=device.readings, year=year)[ - offset : offset + limit - ], - ) - - -@router.get(path="/daily") -def daily_readings( - *, - device_id: int, - year: int | None = None, - month: int | None = None, - limit: int = 100, - offset: int = 0, -) -> Summary: - with db_session: - device = get_device(device_id=device_id) - return Summary( - highs=get_daily_high_readings(entries=device.readings, year=year, month=month)[ - offset : offset + limit - ], - averages=get_daily_avg_readings(entries=device.readings, year=year, month=month)[ - offset : offset + limit - ], - lows=get_daily_low_readings(entries=device.readings, year=year, month=month)[ - offset : offset + limit - ], - ) - - -@router.get(path="/hourly") -def hourly_readings( - *, - device_id: int, - year: int | None = None, - month: int | None = None, - day: int | None = None, - limit: int = 100, - offset: int = 0, -) -> Summary: - with db_session: - device = get_device(device_id=device_id) - return Summary( - highs=get_hourly_high_readings( - entries=device.readings, year=year, month=month, day=day - )[offset : offset + limit], - averages=get_hourly_avg_readings( - entries=device.readings, year=year, month=month, day=day - )[offset : offset + limit], - lows=get_hourly_low_readings(entries=device.readings, year=year, month=month, day=day)[ - offset : offset + limit - ], - ) diff --git a/freyr/routers/html.py b/freyr/routers/html.py index 329c0a0..5f7895a 100644 --- a/freyr/routers/html.py +++ b/freyr/routers/html.py @@ -1,70 +1,63 @@ __all__ = ["router"] -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates -from pony.orm import db_session +from sqlmodel import Session, select from freyr import get_project -from freyr.database.tables import Device +from freyr.database import get_session +from freyr.models import Device router = APIRouter(tags=["WebInterface"], include_in_schema=False) templates = Jinja2Templates(directory=get_project() / "templates") @router.get("/", response_class=HTMLResponse) -def dashboard(*, request: Request) -> Response: - with db_session: - return templates.TemplateResponse( - name="dashboard.html.jinja", - context={"request": request, "devices": sorted(x for x in Device.select())}, - ) +def dashboard(*, request: Request, session: Session = Depends(get_session)) -> Response: + devices = session.exec(select(Device)).all() + return templates.TemplateResponse( + name="dashboard.html.jinja", context={"request": request, "devices": sorted(devices)} + ) @router.get(path="/{device_id}", response_class=HTMLResponse) -def device( +def get_device( *, - device_id: int, request: Request, + session: Session = Depends(get_session), + device_id: int, year: int | None = None, month: int | None = None, day: int | None = None, ) -> Response: - with db_session: - resource = Device.get(id=device_id) - if not resource: - raise HTTPException(status_code=404, detail="Device not found.") - return templates.TemplateResponse( - name="device.html.jinja", - context={ - "request": request, - "devices": sorted(x for x in Device.select()), - "resource": resource, - "options": { - "years": sorted({x.timestamp.year for x in resource.readings}), - "months": ( - sorted( - { - x.timestamp.month - for x in resource.readings - if x.timestamp.year == year - } - ) - if year - else [] - ), - "days": ( - sorted( - { - x.timestamp.day - for x in resource.readings - if x.timestamp.year == year and x.timestamp.month == month - } - ) - if year and month - else [] - ), - }, - "selected": {"year": year, "month": month, "day": day}, + devices = session.exec(select(Device)).all() + device = session.get(Device, device_id) + if not device: + raise HTTPException(status_code=404, detail="Device not found.") + return templates.TemplateResponse( + name="device.html.jinja", + context={ + "request": request, + "devices": sorted(devices), + "resource": device, + "options": { + "years": sorted({x.timestamp.year for x in device.readings}), + "months": sorted( + {x.timestamp.month for x in device.readings if x.timestamp.year == year} + ) + if year + else [], + "days": sorted( + { + x.timestamp.day + for x in device.readings + if x.timestamp.year == year and x.timestamp.month == month + } + ) + if year and month + else [], }, - ) + "selected": {"year": year, "month": month, "day": day}, + }, + ) diff --git a/freyr/settings.py b/freyr/settings.py index ef63b84..7bb9dff 100644 --- a/freyr/settings.py +++ b/freyr/settings.py @@ -34,6 +34,12 @@ class DatabaseSettings(SettingsModel): source: Source = Source.SQLITE user: str = "" + @property + def db_url(self: Self) -> str: + if self.source == Source.POSTGRES: + return f"postgres+psycopg://{self.user}:{self.password}@{self.host}/{self.name}" + return f"sqlite:///{self.name}" + class WebsiteSettings(SettingsModel): host: str = "127.0.0.1" diff --git a/freyr/utils.py b/freyr/utils.py index 7f2ffaf..0ca7fe4 100644 --- a/freyr/utils.py +++ b/freyr/utils.py @@ -1,51 +1,48 @@ __all__ = [ "get_hourly_low_readings", - "get_hourly_avg_readings", + "get_hourly_average_readings", "get_hourly_high_readings", "get_daily_low_readings", - "get_daily_avg_readings", + "get_daily_average_readings", "get_daily_high_readings", "get_monthly_low_readings", - "get_monthly_avg_readings", + "get_monthly_average_readings", "get_monthly_high_readings", "get_yearly_low_readings", - "get_yearly_avg_readings", + "get_yearly_average_readings", "get_yearly_high_readings", ] from collections.abc import Callable from datetime import datetime -from freyr.database.tables import Reading -from freyr.models import Summary +from freyr.models import Reading, Summary -def filter_entries( - entries: list[Summary.Reading], +def filter_readings( + readings: list[Summary.Reading], year: int | None = None, month: int | None = None, day: int | None = None, ) -> list[Summary.Reading]: if year: - entries = [x for x in entries if x.timestamp.year == year] + readings = [x for x in readings if x.timestamp.year == year] if month: - entries = [x for x in entries if x.timestamp.month == month] + readings = [x for x in readings if x.timestamp.month == month] if day: - entries = [x for x in entries if x.timestamp.day == day] - return sorted(entries) + readings = [x for x in readings if x.timestamp.day == day] + return sorted(readings) -def aggregate_entries( - entries: list[Reading], +def aggregate_readings( + readings: list[Reading], grouping: Callable[[datetime], datetime], aggregation: Callable[[datetime, list[Reading]], Summary.Reading], ) -> list[Summary.Reading]: grouped = {} - for entry in entries: + for entry in readings: key = grouping(entry.timestamp) - if key not in grouped: - grouped[key] = [] - grouped[key].append(entry) + grouped.setdefault(key, []).append(entry) return [aggregation(key, values) for key, values in grouped.items()] @@ -66,122 +63,152 @@ def year_grouping(value: datetime) -> datetime: def high_aggregation(key: datetime, values: list[Reading]) -> Summary.Reading: - temperature = max((x.temperature for x in values if x.temperature), default=None) - humidity = max((x.humidity for x in values if x.humidity), default=None) + temperature = max((x.temperature for x in values if x.temperature is not None), default=None) + humidity = max((x.humidity for x in values if x.humidity is not None), default=None) return Summary.Reading(timestamp=key, temperature=temperature, humidity=humidity) def average_aggregation(key: datetime, values: list[Reading]) -> Summary.Reading: - temperatures = [x.temperature for x in values if x.temperature] + temperatures = [x.temperature for x in values if x.temperature is not None] temperature = round(sum(temperatures) / len(temperatures), 2) if temperatures else None - humidities = [x.humidity for x in values if x.humidity] + humidities = [x.humidity for x in values if x.humidity is not None] humidity = round(sum(humidities) / len(humidities), 2) if humidities else None return Summary.Reading(timestamp=key, temperature=temperature, humidity=humidity) def low_aggregation(key: datetime, values: list[Reading]) -> Summary.Reading: - temperature = min((x.temperature for x in values if x.temperature), default=None) - humidity = min((x.humidity for x in values if x.humidity), default=None) + temperature = min((x.temperature for x in values if x.temperature is not None), default=None) + humidity = min((x.humidity for x in values if x.humidity is not None), default=None) return Summary.Reading(timestamp=key, temperature=temperature, humidity=humidity) +def get_readings( + readings: list[Reading], + grouping: Callable[[datetime], datetime], + aggregation: Callable[[datetime, list[Reading]], Summary.Reading], + year: int | None = None, + month: int | None = None, + day: int | None = None, +) -> list[Summary.Reading]: + aggregated = aggregate_readings(readings=readings, grouping=grouping, aggregation=aggregation) + return filter_readings(readings=aggregated, year=year, month=month, day=day) + + def get_hourly_high_readings( - entries: list[Reading], + readings: list[Reading], year: int | None = None, month: int | None = None, day: int | None = None, ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=hour_grouping, aggregation=high_aggregation + return get_readings( + readings=readings, + grouping=hour_grouping, + aggregation=high_aggregation, + year=year, + month=month, + day=day, ) - return filter_entries(entries=entries, year=year, month=month, day=day) -def get_hourly_avg_readings( - entries: list[Reading], +def get_hourly_average_readings( + readings: list[Reading], year: int | None = None, month: int | None = None, day: int | None = None, ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=hour_grouping, aggregation=average_aggregation + return get_readings( + readings=readings, + grouping=hour_grouping, + aggregation=average_aggregation, + year=year, + month=month, + day=day, ) - return filter_entries(entries=entries, year=year, month=month, day=day) def get_hourly_low_readings( - entries: list[Reading], + readings: list[Reading], year: int | None = None, month: int | None = None, day: int | None = None, ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=hour_grouping, aggregation=low_aggregation + return get_readings( + readings=readings, + grouping=hour_grouping, + aggregation=low_aggregation, + year=year, + month=month, + day=day, ) - return filter_entries(entries=entries, year=year, month=month, day=day) def get_daily_high_readings( - entries: list[Reading], year: int | None = None, month: int | None = None + readings: list[Reading], year: int | None = None, month: int | None = None ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=day_grouping, aggregation=high_aggregation + return get_readings( + readings=readings, + grouping=day_grouping, + aggregation=high_aggregation, + year=year, + month=month, ) - return filter_entries(entries=entries, year=year, month=month) -def get_daily_avg_readings( - entries: list[Reading], year: int | None = None, month: int | None = None +def get_daily_average_readings( + readings: list[Reading], year: int | None = None, month: int | None = None ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=day_grouping, aggregation=average_aggregation + return get_readings( + readings=readings, + grouping=day_grouping, + aggregation=average_aggregation, + year=year, + month=month, ) - return filter_entries(entries=entries, year=year, month=month) def get_daily_low_readings( - entries: list[Reading], year: int | None = None, month: int | None = None + readings: list[Reading], year: int | None = None, month: int | None = None ) -> list[Summary.Reading]: - entries = aggregate_entries(entries=entries, grouping=day_grouping, aggregation=low_aggregation) - return filter_entries(entries=entries, year=year, month=month) + return get_readings( + readings=readings, + grouping=day_grouping, + aggregation=low_aggregation, + year=year, + month=month, + ) def get_monthly_high_readings( - entries: list[Reading], year: int | None = None + readings: list[Reading], year: int | None = None ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=month_grouping, aggregation=high_aggregation + return get_readings( + readings=readings, grouping=month_grouping, aggregation=high_aggregation, year=year ) - return filter_entries(entries=entries, year=year) -def get_monthly_avg_readings( - entries: list[Reading], year: int | None = None +def get_monthly_average_readings( + readings: list[Reading], year: int | None = None ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=month_grouping, aggregation=average_aggregation + return get_readings( + readings=readings, grouping=month_grouping, aggregation=average_aggregation, year=year ) - return filter_entries(entries=entries, year=year) def get_monthly_low_readings( - entries: list[Reading], year: int | None = None + readings: list[Reading], year: int | None = None ) -> list[Summary.Reading]: - entries = aggregate_entries( - entries=entries, grouping=month_grouping, aggregation=low_aggregation + return get_readings( + readings=readings, grouping=month_grouping, aggregation=low_aggregation, year=year ) - return filter_entries(entries=entries, year=year) -def get_yearly_high_readings(entries: list[Reading]) -> list[Summary.Reading]: - return aggregate_entries(entries=entries, grouping=year_grouping, aggregation=high_aggregation) +def get_yearly_high_readings(readings: list[Reading]) -> list[Summary.Reading]: + return get_readings(readings=readings, grouping=year_grouping, aggregation=high_aggregation) -def get_yearly_avg_readings(entries: list[Reading]) -> list[Summary.Reading]: - return aggregate_entries( - entries=entries, grouping=year_grouping, aggregation=average_aggregation - ) +def get_yearly_average_readings(readings: list[Reading]) -> list[Summary.Reading]: + return get_readings(readings=readings, grouping=year_grouping, aggregation=average_aggregation) -def get_yearly_low_readings(entries: list[Reading]) -> list[Summary.Reading]: - return aggregate_entries(entries=entries, grouping=year_grouping, aggregation=low_aggregation) +def get_yearly_low_readings(readings: list[Reading]) -> list[Summary.Reading]: + return get_readings(readings=readings, grouping=year_grouping, aggregation=low_aggregation) diff --git a/pyproject.toml b/pyproject.toml index 1fee0a1..db2ebb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,20 +22,15 @@ classifiers = [ dependencies = [ "fastapi-slim >= 0.110.0", "jinja2 >= 3.1.4", - "pony >= 0.7.17", "pydantic >= 2.7.2", "rich >= 13.7.1", + "sqlmodel>=0.0.18", "tomli-w >= 1.0.0", "uvicorn >= 0.30.0" ] description = "Tracks temperature and humidity readings and graphs the results in a web dashboard." dynamic = ["version"] -keywords = [ - "dht22", - "fastapi", - "micropython", - "pi-pico-w" -] +keywords = ["fastapi"] license = {text = "MIT"} name = "freyr" readme = "README.md" @@ -43,7 +38,7 @@ requires-python = ">= 3.11" [project.optional-dependencies] postgres = [ - "psycopg2-binary >= 2.9.9" + "psycopg >= 3.1.19" ] [project.scripts] @@ -70,6 +65,7 @@ skip-magic-trailing-comma = true [tool.ruff.lint] ignore = [ + "B008", "COM812", "D", "DTZ", @@ -97,6 +93,7 @@ split-on-trailing-comma = false classmethod-decorators = ["classmethod", "pydantic.field_validator"] [tool.ruff.lint.per-file-ignores] +"freyr/routers/api.py" = ["ANN202"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/requirements-dev.lock b/requirements-dev.lock index 3d9e44d..2f401b2 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -23,6 +23,8 @@ fastapi-slim==0.111.0 # via freyr filelock==3.14.0 # via virtualenv +greenlet==3.0.3 + # via sqlalchemy h11==0.14.0 # via uvicorn identify==2.5.36 @@ -41,14 +43,13 @@ nodeenv==1.9.0 # via pre-commit platformdirs==4.2.2 # via virtualenv -pony==0.7.17 - # via freyr pre-commit==3.7.1 -psycopg2-binary==2.9.9 +psycopg==3.1.19 # via freyr pydantic==2.7.3 # via fastapi-slim # via freyr + # via sqlmodel pydantic-core==2.18.4 # via pydantic pygments==2.18.0 @@ -59,14 +60,20 @@ rich==13.7.1 # via freyr sniffio==1.3.1 # via anyio +sqlalchemy==2.0.30 + # via sqlmodel +sqlmodel==0.0.19 + # via freyr starlette==0.37.2 # via fastapi-slim tomli-w==1.0.0 # via freyr typing-extensions==4.12.1 # via fastapi-slim + # via psycopg # via pydantic # via pydantic-core + # via sqlalchemy uvicorn==0.30.1 # via freyr virtualenv==20.26.2 diff --git a/requirements.lock b/requirements.lock index 37b2a93..b925317 100644 --- a/requirements.lock +++ b/requirements.lock @@ -17,6 +17,8 @@ click==8.1.7 # via uvicorn fastapi-slim==0.111.0 # via freyr +greenlet==3.0.3 + # via sqlalchemy h11==0.14.0 # via uvicorn idna==3.7 @@ -29,13 +31,12 @@ markupsafe==2.1.5 # via jinja2 mdurl==0.1.2 # via markdown-it-py -pony==0.7.17 - # via freyr -psycopg2-binary==2.9.9 +psycopg==3.1.19 # via freyr pydantic==2.7.3 # via fastapi-slim # via freyr + # via sqlmodel pydantic-core==2.18.4 # via pydantic pygments==2.18.0 @@ -44,13 +45,19 @@ rich==13.7.1 # via freyr sniffio==1.3.1 # via anyio +sqlalchemy==2.0.30 + # via sqlmodel +sqlmodel==0.0.19 + # via freyr starlette==0.37.2 # via fastapi-slim tomli-w==1.0.0 # via freyr typing-extensions==4.12.1 # via fastapi-slim + # via psycopg # via pydantic # via pydantic-core + # via sqlalchemy uvicorn==0.30.1 # via freyr diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 8a1ed02..062976d 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -1,15 +1,15 @@ -function appendToContent(content) { +async function appendToContent(content) { document.getElementById("content").insertAdjacentHTML("beforeend", content); } function createNoContent() { - const noContent = ` + appendToContent(`

No Devices

-
`; - appendToContent(noContent); + + `); } function statEntry(id, value) { @@ -17,72 +17,66 @@ function statEntry(id, value) { } function createColumn(name) { - const timeEntry = `

{time}

`; - const tempEntry = statEntry(`${name}-temp`, "{temp}"); - const humidEntry = statEntry(`${name}-humid`, "{humid}"); - const feelsEntry = statEntry(`${name}-feels`, "{feels}"); - - const newColumn = ` + appendToContent(`

${name}

-
${timeEntry}
-
${tempEntry}
-
${humidEntry}
-
${feelsEntry}
+
+

{time}

+
+
${statEntry(`${name}-temperature`, "{temperature}")}
+
${statEntry(`${name}-humidity`, "{humidity}")}
+
${statEntry(`${name}-feels`, "{feels}")}
-
`; - appendToContent(newColumn); + + `); } function calculateFeelsLike(temperature, humidity) { - temperature = parseFloat(temperature); - humidity = parseFloat(humidity) / 100; - // Calculate the water vapor pressure - const pressure = humidity * 6.105 * Math.exp((17.27 * temperature) / (237.7 + temperature)); - // Calculate the feels like temperature - const feels = temperature + 0.33 * pressure - 4.00; - return feels.toFixed(2); + const temp = parseFloat(temperature); + const hum = parseFloat(humidity) / 100; + const pressure = hum * 6.105 * Math.exp((17.27 * temp) / (237.7 + temp)); + return (temp + 0.33 * pressure - 4.00).toFixed(2); } function updateColumn(name, reading) { const timeLabel = document.getElementById(`${name}-time`); - timeLabel.textContent = moment(reading.timestamp, "YYYY-MM-DD[T]hh:mm:ss").fromNow(); + const temperatureLabel = document.getElementById(`${name}-temperature`); + const humidityLabel = document.getElementById(`${name}-humidity`); + const feelsLabel = document.getElementById(`${name}-feels`); - const tempLabel = document.getElementById(`${name}-temp`); - tempLabel.textContent = `${reading.temperature}°C`; - const humidLabel = document.getElementById(`${name}-humid`); - humidLabel.textContent = `${reading.humidity}%`; + const temperature = reading.temperature !== null ? parseFloat(reading.temperature).toFixed(2) : "null"; + const humidity = reading.humidity !== null ? parseFloat(reading.humidity).toFixed(2) : "null"; + const feelsLike = (reading.temperature !== null && reading.humidity !== null) ? calculateFeelsLike(reading.temperature, reading.humidity) : "null"; - const feelsLabel = document.getElementById(`${name}-feels`); - if (reading.temperature === null || reading.humidity === null) - feelsLabel.textContent = `${null}°C`; - else - feelsLabel.textContent = `${calculateFeelsLike(reading.temperature, reading.humidity)}°C`; + timeLabel.textContent = moment(reading.timestamp, "YYYY-MM-DD[T]hh:mm:ss").fromNow(); + temperatureLabel.textContent = `${temperature}°C`; + humidityLabel.textContent = `${humidity}%`; + feelsLabel.textContent = `${feelsLike}°C`; } async function getCurrentReadings() { - let response = await submitRequest("/api/devices", "GET"); + const response = await submitRequest("/api/devices", "GET"); if (!response || response.length === 0) { createNoContent(); return; } const noContent = document.getElementById("no-content"); - if (noContent != null) + if (noContent) noContent.remove(); for (const device of response) { - let reading = await submitRequest(`/api/devices/${device.id}/readings?limit=1`, "GET"); - reading = reading ? reading[0] || null : null; + const readings = await submitRequest(`/api/devices/${device.id}/readings?limit=1`, "GET"); + const reading = readings ? readings[0] || null : null; - if (reading !== null) { - if (document.getElementById(device.name) == null) + if (reading) { + if (!document.getElementById(device.name)) createColumn(device.name); updateColumn(device.name, reading); } else