Migrate poo recorder and align Alembic naming
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_homeassistant_client, get_poo_db
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.schemas.poo import PooRecordRequest
|
||||
from app.services.poo import publish_latest_poo_status, record_poo
|
||||
|
||||
router = APIRouter(tags=["poo"])
|
||||
logger = logging.getLogger(__name__)
|
||||
BAD_REQUEST_MESSAGE = "bad request"
|
||||
INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
||||
|
||||
|
||||
@router.post("/poo/record")
|
||||
async def create_poo_record(
|
||||
request: Request,
|
||||
db: Session = Depends(get_poo_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
) -> Response:
|
||||
try:
|
||||
raw_payload = await request.body()
|
||||
data = json.loads(raw_payload)
|
||||
payload = PooRecordRequest.model_validate(data)
|
||||
record_poo(
|
||||
db,
|
||||
payload,
|
||||
settings=settings,
|
||||
homeassistant_client=homeassistant_client,
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Rejected poo record request due to invalid JSON: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except ValidationError as exc:
|
||||
logger.warning("Rejected poo record request due to validation failure: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except ValueError as exc:
|
||||
logger.warning("Rejected poo record request due to invalid numeric input: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to store poo record: %s", exc)
|
||||
return PlainTextResponse(
|
||||
INTERNAL_SERVER_ERROR_MESSAGE,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@router.get("/poo/latest")
|
||||
def notify_latest_poo(
|
||||
db: Session = Depends(get_poo_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
) -> Response:
|
||||
try:
|
||||
publish_latest_poo_status(
|
||||
session=db,
|
||||
settings=settings,
|
||||
homeassistant_client=homeassistant_client,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to publish latest poo status: %s", exc)
|
||||
return PlainTextResponse(
|
||||
INTERNAL_SERVER_ERROR_MESSAGE,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
|
||||
home_assistant_auth_token: str = ""
|
||||
home_assistant_timeout_seconds: float = 1.0
|
||||
home_assistant_action_task_project_id: str = ""
|
||||
poo_webhook_id: str = ""
|
||||
poo_sensor_entity_name: str = "sensor.test_poo_status"
|
||||
poo_sensor_friendly_name: str = "Poo Status"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -4,6 +4,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
|
||||
|
||||
def get_app_settings() -> Settings:
|
||||
@@ -13,3 +15,10 @@ def get_app_settings() -> Settings:
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
|
||||
def get_poo_db() -> Generator[Session, None, None]:
|
||||
yield from get_poo_db_session()
|
||||
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
return HomeAssistantClient(get_settings())
|
||||
|
||||
+15
@@ -8,8 +8,10 @@ from app import models # noqa: F401
|
||||
from app.api.routes import pages, status
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.config import get_settings
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||
|
||||
|
||||
def ensure_location_db_ready() -> None:
|
||||
@@ -23,6 +25,17 @@ def ensure_location_db_ready() -> None:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def ensure_poo_db_ready() -> None:
|
||||
settings = get_settings()
|
||||
if settings.poo_sqlite_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
validate_poo_runtime_db(settings.poo_database_url)
|
||||
except PooDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
settings = get_settings()
|
||||
for path in (settings.location_sqlite_path, settings.poo_sqlite_path):
|
||||
@@ -34,6 +47,7 @@ def ensure_runtime_dirs() -> None:
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_runtime_dirs()
|
||||
ensure_location_db_ready()
|
||||
ensure_poo_db_ready()
|
||||
yield
|
||||
|
||||
|
||||
@@ -57,6 +71,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(pages.router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.poo_db import PooBase
|
||||
|
||||
|
||||
class PooRecord(PooBase):
|
||||
__tablename__ = "poo_records"
|
||||
|
||||
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||
latitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
longitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
@@ -0,0 +1,28 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class PooBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
connect_args: dict[str, object] = {}
|
||||
if settings.poo_database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
poo_engine = create_engine(settings.poo_database_url, connect_args=connect_args)
|
||||
PooSessionLocal = sessionmaker(bind=poo_engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_poo_db_session() -> Generator[Session, None, None]:
|
||||
session = PooSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PooRecordRequest(BaseModel):
|
||||
status: str
|
||||
latitude: str
|
||||
longitude: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from sqlalchemy import desc, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.integrations.homeassistant import (
|
||||
HomeAssistantClient,
|
||||
HomeAssistantConfigError,
|
||||
HomeAssistantRequestError,
|
||||
)
|
||||
from app.models.poo import PooRecord
|
||||
from app.schemas.poo import PooRecordRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LatestPooRecord:
|
||||
timestamp: str
|
||||
status: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
def _parse_required_float(value: str, field_name: str) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid numeric value for {field_name}") from exc
|
||||
|
||||
|
||||
def _utc_now_minute_precision() -> str:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
return now.strftime("%Y-%m-%dT%H:%MZ")
|
||||
|
||||
|
||||
def record_poo(
|
||||
session: Session,
|
||||
payload: PooRecordRequest,
|
||||
*,
|
||||
settings: Settings,
|
||||
homeassistant_client: HomeAssistantClient,
|
||||
) -> None:
|
||||
stmt = insert(PooRecord).prefix_with("OR IGNORE").values(
|
||||
timestamp=_utc_now_minute_precision(),
|
||||
status=payload.status,
|
||||
latitude=_parse_required_float(payload.latitude, "latitude"),
|
||||
longitude=_parse_required_float(payload.longitude, "longitude"),
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
try:
|
||||
publish_latest_poo_status(
|
||||
session=session,
|
||||
settings=settings,
|
||||
homeassistant_client=homeassistant_client,
|
||||
)
|
||||
except (HomeAssistantConfigError, HomeAssistantRequestError) as exc:
|
||||
logger.warning("Failed to publish latest poo status to Home Assistant: %s", exc)
|
||||
|
||||
if settings.poo_webhook_id:
|
||||
try:
|
||||
homeassistant_client.trigger_webhook(
|
||||
webhook_id=settings.poo_webhook_id,
|
||||
body={"status": payload.status},
|
||||
)
|
||||
except (HomeAssistantConfigError, HomeAssistantRequestError) as exc:
|
||||
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
|
||||
|
||||
|
||||
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
|
||||
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
||||
record = session.execute(stmt).scalar_one_or_none()
|
||||
if record is None:
|
||||
logger.info("No poo record is available yet")
|
||||
return None
|
||||
return LatestPooRecord(
|
||||
timestamp=record.timestamp,
|
||||
status=record.status,
|
||||
latitude=record.latitude,
|
||||
longitude=record.longitude,
|
||||
)
|
||||
|
||||
|
||||
def publish_latest_poo_status(
|
||||
*,
|
||||
session: Session,
|
||||
settings: Settings,
|
||||
homeassistant_client: HomeAssistantClient,
|
||||
) -> LatestPooRecord | None:
|
||||
latest = get_latest_poo_record(session)
|
||||
if latest is None:
|
||||
logger.info("Skipping Home Assistant poo sensor publish because no poo record exists yet")
|
||||
return None
|
||||
|
||||
record_time = datetime.fromisoformat(latest.timestamp.replace("Z", "+00:00")).astimezone()
|
||||
|
||||
homeassistant_client.publish_sensor(
|
||||
entity_id=settings.poo_sensor_entity_name,
|
||||
state=latest.status,
|
||||
attributes={
|
||||
"last_poo": record_time.strftime("%a | %Y-%m-%d | %H:%M"),
|
||||
"friendly_name": settings.poo_sensor_friendly_name,
|
||||
},
|
||||
)
|
||||
return latest
|
||||
Reference in New Issue
Block a user