From cd3db4fb8616f3c9d54665ad9ff8845706343a77 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Mon, 10 Nov 2025 21:00:03 +0100 Subject: [PATCH] added sleep logging workflow and overview page --- src/baby_monitor/main.py | 8 + src/baby_monitor/models/db/sleep.py | 27 + src/baby_monitor/models/sleep.py | 28 + .../repositories/dependencies/get_database.py | 1 + .../dependencies/get_sleep_repository.py | 15 + .../interfaces/sleep_repository_interface.py | 40 + .../repositories/sleep/sqlite_sleep.py | 99 ++ src/baby_monitor/routers/sleep.py | 155 +++ src/baby_monitor/static/diapers.html | 5 + src/baby_monitor/static/feedings.html | 5 + src/baby_monitor/static/index.html | 248 +++++ src/baby_monitor/static/sleep.html | 923 ++++++++++++++++++ 12 files changed, 1554 insertions(+) create mode 100644 src/baby_monitor/models/db/sleep.py create mode 100644 src/baby_monitor/models/sleep.py create mode 100644 src/baby_monitor/repositories/dependencies/get_sleep_repository.py create mode 100644 src/baby_monitor/repositories/interfaces/sleep_repository_interface.py create mode 100644 src/baby_monitor/repositories/sleep/sqlite_sleep.py create mode 100644 src/baby_monitor/routers/sleep.py create mode 100644 src/baby_monitor/static/sleep.html diff --git a/src/baby_monitor/main.py b/src/baby_monitor/main.py index 80540d7..fe17988 100644 --- a/src/baby_monitor/main.py +++ b/src/baby_monitor/main.py @@ -15,6 +15,7 @@ from baby_monitor.routers.admin import router as admin_router from baby_monitor.routers.child import router as child_router from baby_monitor.routers.feeding import router as feeding_router from baby_monitor.routers.diaper_change import router as diaper_change_router +from baby_monitor.routers.sleep import router as sleep_router from baby_monitor.routers.health import router as health_router from baby_monitor.repositories.dependencies.get_database import init_db @@ -53,6 +54,7 @@ app.include_router(admin_router) app.include_router(child_router) app.include_router(feeding_router) app.include_router(diaper_change_router) +app.include_router(sleep_router) app.include_router(health_router) @@ -111,6 +113,12 @@ def serve_log_diaper() -> FileResponse: return FileResponse(static_path / "log-diaper.html") +@app.get("/sleep.html", include_in_schema=False) +def serve_sleep() -> FileResponse: + """Serve the sleep overview page.""" + return FileResponse(static_path / "sleep.html") + + @app.get("/api/") def read_root(token: Annotated[str, Depends(verify_token)]) -> dict: """API root endpoint (requires authentication).""" diff --git a/src/baby_monitor/models/db/sleep.py b/src/baby_monitor/models/db/sleep.py new file mode 100644 index 0000000..0b99521 --- /dev/null +++ b/src/baby_monitor/models/db/sleep.py @@ -0,0 +1,27 @@ +"""Sleep database model.""" + +from typing import TYPE_CHECKING +from sqlalchemy import Column, Integer, DateTime, ForeignKey +from datetime import datetime, UTC + +if TYPE_CHECKING: + from sqlalchemy.orm import DeclarativeBase + + Base = DeclarativeBase +else: + from baby_monitor.repositories.dependencies.get_database import Base + + +class Sleep(Base): + """Sleep log database model.""" + + __tablename__ = "sleep_logs" + + id = Column(Integer, primary_key=True, index=True) + child_id = Column(Integer, ForeignKey("children.id"), nullable=False) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.now(UTC), nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/src/baby_monitor/models/sleep.py b/src/baby_monitor/models/sleep.py new file mode 100644 index 0000000..87bb685 --- /dev/null +++ b/src/baby_monitor/models/sleep.py @@ -0,0 +1,28 @@ +"""Sleep-related request and response models.""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class CreateSleepRequest(BaseModel): + """Request model for creating a sleep log.""" + + child_id: int = Field(..., gt=0) + start_time: datetime + + +class UpdateSleepRequest(BaseModel): + """Request model for updating a sleep log.""" + + end_time: datetime | None = None + + +class SleepResponse(BaseModel): + """Response model for sleep log data.""" + + id: int + child_id: int + start_time: datetime + end_time: datetime | None + created_at: datetime + child_name: str | None = None # Optional, populated when needed diff --git a/src/baby_monitor/repositories/dependencies/get_database.py b/src/baby_monitor/repositories/dependencies/get_database.py index 8b3832b..c724566 100644 --- a/src/baby_monitor/repositories/dependencies/get_database.py +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -80,6 +80,7 @@ def init_db() -> None: from baby_monitor.models.db.child import Child # noqa: F401 from baby_monitor.models.db.feeding import Feeding # noqa: F401 from baby_monitor.models.db.diaper_change import DiaperChange # noqa: F401 + from baby_monitor.models.db.sleep import Sleep # noqa: F401 from baby_monitor.models.invitation import Invitation # noqa: F401 # Ensure data directory exists diff --git a/src/baby_monitor/repositories/dependencies/get_sleep_repository.py b/src/baby_monitor/repositories/dependencies/get_sleep_repository.py new file mode 100644 index 0000000..b3f5bea --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_sleep_repository.py @@ -0,0 +1,15 @@ +"""Dependency for getting sleep repository instance.""" + +from typing import Annotated +from fastapi import Depends +from sqlalchemy.orm import Session + +from baby_monitor.repositories.dependencies.get_database import get_database +from baby_monitor.repositories.sleep.sqlite_sleep import SQLiteSleepRepository + + +def get_sleep_repository( + db: Annotated[Session, Depends(get_database)], +) -> SQLiteSleepRepository: + """Get sleep repository instance.""" + return SQLiteSleepRepository(db) diff --git a/src/baby_monitor/repositories/interfaces/sleep_repository_interface.py b/src/baby_monitor/repositories/interfaces/sleep_repository_interface.py new file mode 100644 index 0000000..371ca3e --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/sleep_repository_interface.py @@ -0,0 +1,40 @@ +"""Interface for sleep repository operations.""" + +from abc import ABC, abstractmethod +from datetime import datetime + + +class SleepRepositoryInterface(ABC): + """Interface for sleep log data access operations.""" + + @abstractmethod + def create( + self, + child_id: int, + start_time: datetime, + ) -> dict: + """Create a new sleep log entry.""" + + @abstractmethod + def get_by_id(self, sleep_id: int) -> dict | None: + """Get a sleep log by ID.""" + + @abstractmethod + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all sleep logs for a specific child.""" + + @abstractmethod + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all sleep logs for a specific user's children.""" + + @abstractmethod + def update( + self, + sleep_id: int, + end_time: datetime | None = None, + ) -> dict | None: + """Update a sleep log entry.""" + + @abstractmethod + def delete(self, sleep_id: int) -> bool: + """Delete a sleep log entry.""" diff --git a/src/baby_monitor/repositories/sleep/sqlite_sleep.py b/src/baby_monitor/repositories/sleep/sqlite_sleep.py new file mode 100644 index 0000000..cf3cc7e --- /dev/null +++ b/src/baby_monitor/repositories/sleep/sqlite_sleep.py @@ -0,0 +1,99 @@ +"""SQLite implementation of sleep repository.""" + +from datetime import datetime +from sqlalchemy.orm import Session + +from baby_monitor.repositories.interfaces.sleep_repository_interface import ( + SleepRepositoryInterface, +) +from baby_monitor.models.db.sleep import Sleep +from baby_monitor.models.db.child import Child + + +class SQLiteSleepRepository(SleepRepositoryInterface): + """SQLite implementation for sleep log data access.""" + + def __init__(self, db: Session): + self.db = db + + def create( + self, + child_id: int, + start_time: datetime, + ) -> dict: + """Create a new sleep log entry.""" + sleep = Sleep( + child_id=child_id, + start_time=start_time, + end_time=None, + ) + self.db.add(sleep) + self.db.commit() + self.db.refresh(sleep) + + return self._to_dict(sleep) + + def get_by_id(self, sleep_id: int) -> dict | None: + """Get a sleep log by ID.""" + sleep = self.db.query(Sleep).filter(Sleep.id == sleep_id).first() + if not sleep: + return None + return self._to_dict(sleep) + + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all sleep logs for a specific child.""" + sleeps = ( + self.db.query(Sleep) + .filter(Sleep.child_id == child_id) + .order_by(Sleep.start_time.desc()) + .all() + ) + return [self._to_dict(sleep) for sleep in sleeps] + + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all sleep logs for a specific user's children.""" + sleeps = ( + self.db.query(Sleep) + .join(Child) + .filter(Child.user_id == user_id) + .order_by(Sleep.start_time.desc()) + .all() + ) + return [self._to_dict(sleep) for sleep in sleeps] + + def update( + self, + sleep_id: int, + end_time: datetime | None = None, + ) -> dict | None: + """Update a sleep log entry.""" + sleep = self.db.query(Sleep).filter(Sleep.id == sleep_id).first() + if not sleep: + return None + + if end_time is not None: + sleep.end_time = end_time # type: ignore[assignment] + + self.db.commit() + self.db.refresh(sleep) + return self._to_dict(sleep) + + def delete(self, sleep_id: int) -> bool: + """Delete a sleep log entry.""" + sleep = self.db.query(Sleep).filter(Sleep.id == sleep_id).first() + if not sleep: + return False + + self.db.delete(sleep) + self.db.commit() + return True + + def _to_dict(self, sleep: Sleep) -> dict: + """Convert Sleep model to dictionary.""" + return { + "id": sleep.id, + "child_id": sleep.child_id, + "start_time": sleep.start_time, + "end_time": sleep.end_time, + "created_at": sleep.created_at, + } diff --git a/src/baby_monitor/routers/sleep.py b/src/baby_monitor/routers/sleep.py new file mode 100644 index 0000000..9e95e47 --- /dev/null +++ b/src/baby_monitor/routers/sleep.py @@ -0,0 +1,155 @@ +"""Sleep log management router.""" + +from typing import Annotated +from fastapi import APIRouter, HTTPException, Depends + +from baby_monitor.models.sleep import ( + CreateSleepRequest, + UpdateSleepRequest, + SleepResponse, +) +from baby_monitor.routers.auth import verify_token +from baby_monitor.repositories.sleep.sqlite_sleep import SQLiteSleepRepository +from baby_monitor.repositories.dependencies.get_sleep_repository import ( + get_sleep_repository, +) +from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository +from baby_monitor.repositories.dependencies.get_child_repository import ( + get_child_repository, +) + +router = APIRouter(prefix="/api/sleep", tags=["sleep"]) + + +@router.post("", response_model=SleepResponse, status_code=201) +def create_sleep( + request: CreateSleepRequest, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse: + """Create a new sleep log entry.""" + # Verify the child belongs to the authenticated user + child = child_repo.get_by_id(request.child_id) + if not child: + raise HTTPException(status_code=404, detail="Child not found") + + if child["user_id"] != user_id: + raise HTTPException( + status_code=403, detail="Access denied to this child" + ) + + sleep = sleep_repo.create( + child_id=request.child_id, + start_time=request.start_time, + ) + + return SleepResponse(**sleep) + + +@router.get("/active", response_model=SleepResponse | None) +def get_active_sleep( + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse | None: + """Get the current active sleep (where end_time is null) for the user.""" + sleeps = sleep_repo.get_by_user_id(user_id) + + # Find the first sleep with no end_time + for sleep in sleeps: + if sleep["end_time"] is None: + # Get child info to include in response + child = child_repo.get_by_id(sleep["child_id"]) + response_data = sleep.copy() + if child: + response_data["child_name"] = child["name"] + return SleepResponse(**response_data) + + return None + + +@router.get("", response_model=list[SleepResponse]) +def get_user_sleeps( + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], +) -> list[SleepResponse]: + """Get all sleep logs for the authenticated user's children.""" + sleeps = sleep_repo.get_by_user_id(user_id) + return [SleepResponse(**sleep) for sleep in sleeps] + + +@router.get("/{sleep_id}", response_model=SleepResponse) +def get_sleep( + sleep_id: int, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse: + """Get a specific sleep log by ID.""" + sleep = sleep_repo.get_by_id(sleep_id) + + if not sleep: + raise HTTPException(status_code=404, detail="Sleep log not found") + + # Verify the sleep belongs to user's child + child = child_repo.get_by_id(sleep["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + return SleepResponse(**sleep) + + +@router.put("/{sleep_id}", response_model=SleepResponse) +def update_sleep( + sleep_id: int, + request: UpdateSleepRequest, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse: + """Update an existing sleep log.""" + sleep = sleep_repo.get_by_id(sleep_id) + + if not sleep: + raise HTTPException(status_code=404, detail="Sleep log not found") + + # Verify the sleep belongs to user's child + child = child_repo.get_by_id(sleep["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = sleep_repo.update( + sleep_id=sleep_id, + end_time=request.end_time, + ) + + if not updated: + raise HTTPException( + status_code=500, detail="Failed to update sleep log" + ) + + return SleepResponse(**updated) + + +@router.delete("/{sleep_id}", status_code=204) +def delete_sleep( + sleep_id: int, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> None: + """Delete a sleep log.""" + sleep = sleep_repo.get_by_id(sleep_id) + + if not sleep: + raise HTTPException(status_code=404, detail="Sleep log not found") + + # Verify the sleep belongs to user's child + child = child_repo.get_by_id(sleep["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + success = sleep_repo.delete(sleep_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete sleep log") diff --git a/src/baby_monitor/static/diapers.html b/src/baby_monitor/static/diapers.html index 1723dd9..4150eb7 100644 --- a/src/baby_monitor/static/diapers.html +++ b/src/baby_monitor/static/diapers.html @@ -323,6 +323,7 @@ + @@ -378,6 +379,10 @@ window.location.href = "/feedings.html"; }); + document.getElementById("menuSleep").addEventListener("click", () => { + window.location.href = "/sleep.html"; + }); + document.getElementById("menuLogout").addEventListener("click", () => { closeMenu(); logout(); diff --git a/src/baby_monitor/static/feedings.html b/src/baby_monitor/static/feedings.html index 7c9825d..38375a3 100644 --- a/src/baby_monitor/static/feedings.html +++ b/src/baby_monitor/static/feedings.html @@ -360,6 +360,7 @@ + @@ -415,6 +416,10 @@ window.location.href = "/diapers.html"; }); + document.getElementById("menuSleep").addEventListener("click", () => { + window.location.href = "/sleep.html"; + }); + document.getElementById("menuLogout").addEventListener("click", () => { closeMenu(); logout(); diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 65932fd..6c8639b 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -203,6 +203,33 @@ .cancel-button:hover { background-color: #5a6268; } + .sleep-button { + width: 100%; + padding: 2rem; + font-size: 1.5rem; + margin-bottom: 1.5rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 8px; + transition: + transform 0.2s, + box-shadow 0.2s; + } + .sleep-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + } + .sleep-button.active { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + } + .sleep-button.active:hover { + background: linear-gradient(135deg, #00f2fe 0%, #4facfe 100%); + } + .sleep-info { + font-size: 0.9rem; + margin-top: 0.5rem; + opacity: 0.9; + } @@ -218,6 +245,7 @@ + @@ -261,6 +289,31 @@ + + + + + +