From 55de11dcddcb54f70577bd1756efbc468701602d Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sun, 9 Nov 2025 17:22:37 +0100 Subject: [PATCH] added feeding data objects and workflow --- src/baby_monitor/main.py | 8 + src/baby_monitor/models/db/feeding.py | 28 ++ src/baby_monitor/models/enums.py | 11 + src/baby_monitor/models/feeding.py | 35 ++ .../repositories/dependencies/get_database.py | 1 + .../dependencies/get_feeding_repository.py | 17 + .../repositories/feeding/sqlite_feeding.py | 109 +++++ .../feeding_repository_interface.py | 44 +++ src/baby_monitor/routers/feeding.py | 158 ++++++++ src/baby_monitor/static/index.html | 373 +++++++++++++++++- src/baby_monitor/static/log-feeding.html | 328 +++++++++++++++ 11 files changed, 1104 insertions(+), 8 deletions(-) create mode 100644 src/baby_monitor/models/db/feeding.py create mode 100644 src/baby_monitor/models/enums.py create mode 100644 src/baby_monitor/models/feeding.py create mode 100644 src/baby_monitor/repositories/dependencies/get_feeding_repository.py create mode 100644 src/baby_monitor/repositories/feeding/sqlite_feeding.py create mode 100644 src/baby_monitor/repositories/interfaces/feeding_repository_interface.py create mode 100644 src/baby_monitor/routers/feeding.py create mode 100644 src/baby_monitor/static/log-feeding.html diff --git a/src/baby_monitor/main.py b/src/baby_monitor/main.py index 1d6a848..aeeef30 100644 --- a/src/baby_monitor/main.py +++ b/src/baby_monitor/main.py @@ -13,6 +13,7 @@ from baby_monitor.routers.auth import router as auth_router from baby_monitor.routers.auth import verify_token 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.health import router as health_router from baby_monitor.repositories.dependencies.get_database import init_db @@ -49,6 +50,7 @@ app.mount("/static", StaticFiles(directory=str(static_path)), name="static") app.include_router(auth_router) app.include_router(admin_router) app.include_router(child_router) +app.include_router(feeding_router) app.include_router(health_router) @@ -83,6 +85,12 @@ def serve_add_child() -> FileResponse: return FileResponse(static_path / "add-child.html") +@app.get("/log-feeding.html", include_in_schema=False) +def serve_log_feeding() -> FileResponse: + """Serve the log feeding page.""" + return FileResponse(static_path / "log-feeding.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/feeding.py b/src/baby_monitor/models/db/feeding.py new file mode 100644 index 0000000..60d8c7c --- /dev/null +++ b/src/baby_monitor/models/db/feeding.py @@ -0,0 +1,28 @@ +"""Feeding database model.""" + +from typing import TYPE_CHECKING +from sqlalchemy import Column, Integer, DateTime, ForeignKey, String +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 Feeding(Base): + """Feeding log database model.""" + + __tablename__ = "feedings" + + 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) + feeding_type = Column(String, nullable=False) # stores FeedingType enum + created_at = Column(DateTime, default=datetime.now(UTC), nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/src/baby_monitor/models/enums.py b/src/baby_monitor/models/enums.py new file mode 100644 index 0000000..5427f87 --- /dev/null +++ b/src/baby_monitor/models/enums.py @@ -0,0 +1,11 @@ +"""Feeding type enumeration.""" + +from enum import StrEnum + + +class FeedingType(StrEnum): + """Enumeration of feeding types.""" + + LEFT_BREAST = "left_breast" + RIGHT_BREAST = "right_breast" + BOTTLE = "bottle" diff --git a/src/baby_monitor/models/feeding.py b/src/baby_monitor/models/feeding.py new file mode 100644 index 0000000..a574f46 --- /dev/null +++ b/src/baby_monitor/models/feeding.py @@ -0,0 +1,35 @@ +"""Feeding-related request and response models.""" + +from datetime import datetime +from pydantic import BaseModel, Field + +from baby_monitor.models.enums import FeedingType + + +class CreateFeedingRequest(BaseModel): + """Request model for creating a feeding log.""" + + child_id: int = Field(..., gt=0) + start_time: datetime + end_time: datetime | None = None + feeding_type: FeedingType + + +class UpdateFeedingRequest(BaseModel): + """Request model for updating a feeding log.""" + + start_time: datetime | None = None + end_time: datetime | None = None + feeding_type: FeedingType | None = None + + +class FeedingResponse(BaseModel): + """Response model for feeding log data.""" + + id: int + child_id: int + start_time: datetime + end_time: datetime | None + feeding_type: str + 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 ec35bcf..3d1fa83 100644 --- a/src/baby_monitor/repositories/dependencies/get_database.py +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -78,6 +78,7 @@ def init_db() -> None: # This must be done before create_all() is called from baby_monitor.models.db.user import User # noqa: F401 from baby_monitor.models.db.child import Child # noqa: F401 + from baby_monitor.models.db.feeding import Feeding # noqa: F401 from baby_monitor.models.invitation import Invitation # noqa: F401 # Ensure data directory exists diff --git a/src/baby_monitor/repositories/dependencies/get_feeding_repository.py b/src/baby_monitor/repositories/dependencies/get_feeding_repository.py new file mode 100644 index 0000000..6f5cdb4 --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_feeding_repository.py @@ -0,0 +1,17 @@ +"""Dependency for getting feeding 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.feeding.sqlite_feeding import ( + SQLiteFeedingRepository, +) + + +def get_feeding_repository( + db: Annotated[Session, Depends(get_database)], +) -> SQLiteFeedingRepository: + """Get feeding repository instance.""" + return SQLiteFeedingRepository(db) diff --git a/src/baby_monitor/repositories/feeding/sqlite_feeding.py b/src/baby_monitor/repositories/feeding/sqlite_feeding.py new file mode 100644 index 0000000..75367ad --- /dev/null +++ b/src/baby_monitor/repositories/feeding/sqlite_feeding.py @@ -0,0 +1,109 @@ +"""SQLite implementation of feeding repository.""" + +from datetime import datetime +from sqlalchemy.orm import Session + +from baby_monitor.repositories.interfaces.feeding_repository_interface import ( + FeedingRepositoryInterface, +) +from baby_monitor.models.db.feeding import Feeding +from baby_monitor.models.db.child import Child + + +class SQLiteFeedingRepository(FeedingRepositoryInterface): + """SQLite implementation for feeding log data access.""" + + def __init__(self, db: Session): + self.db = db + + def create( + self, + child_id: int, + start_time: datetime, + feeding_type: str, + end_time: datetime | None = None, + ) -> dict: + """Create a new feeding log entry.""" + feeding = Feeding( + child_id=child_id, + start_time=start_time, + end_time=end_time, + feeding_type=feeding_type, + ) + self.db.add(feeding) + self.db.commit() + self.db.refresh(feeding) + + return self._to_dict(feeding) + + def get_by_id(self, feeding_id: int) -> dict | None: + """Get a feeding log by ID.""" + feeding = self.db.query(Feeding).filter(Feeding.id == feeding_id).first() + if not feeding: + return None + return self._to_dict(feeding) + + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all feeding logs for a specific child.""" + feedings = ( + self.db.query(Feeding) + .filter(Feeding.child_id == child_id) + .order_by(Feeding.start_time.desc()) + .all() + ) + return [self._to_dict(feeding) for feeding in feedings] + + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all feeding logs for a specific user's children.""" + feedings = ( + self.db.query(Feeding) + .join(Child) + .filter(Child.user_id == user_id) + .order_by(Feeding.start_time.desc()) + .all() + ) + return [self._to_dict(feeding) for feeding in feedings] + + def update( + self, + feeding_id: int, + start_time: datetime | None = None, + end_time: datetime | None = None, + feeding_type: str | None = None, + ) -> dict | None: + """Update a feeding log entry.""" + feeding = self.db.query(Feeding).filter(Feeding.id == feeding_id).first() + if not feeding: + return None + + if start_time is not None: + feeding.start_time = start_time # type: ignore[assignment] + if end_time is not None: + feeding.end_time = end_time # type: ignore[assignment] + if feeding_type is not None: + feeding.feeding_type = feeding_type # type: ignore[assignment] + + self.db.commit() + self.db.refresh(feeding) + return self._to_dict(feeding) + + def delete(self, feeding_id: int) -> bool: + """Delete a feeding log entry.""" + feeding = self.db.query(Feeding).filter(Feeding.id == feeding_id).first() + if not feeding: + return False + + self.db.delete(feeding) + self.db.commit() + return True + + def _to_dict(self, feeding: Feeding) -> dict: + """Convert Feeding model to dictionary.""" + return { + "id": feeding.id, + "child_id": feeding.child_id, + "start_time": feeding.start_time, + "end_time": feeding.end_time, + "feeding_type": feeding.feeding_type, + "created_at": feeding.created_at, + } diff --git a/src/baby_monitor/repositories/interfaces/feeding_repository_interface.py b/src/baby_monitor/repositories/interfaces/feeding_repository_interface.py new file mode 100644 index 0000000..0e49f8a --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/feeding_repository_interface.py @@ -0,0 +1,44 @@ +"""Interface for feeding repository operations.""" + +from abc import ABC, abstractmethod +from datetime import datetime + + +class FeedingRepositoryInterface(ABC): + """Interface for feeding log data access operations.""" + + @abstractmethod + def create( + self, + child_id: int, + start_time: datetime, + feeding_type: str, + end_time: datetime | None = None, + ) -> dict: + """Create a new feeding log entry.""" + + @abstractmethod + def get_by_id(self, feeding_id: int) -> dict | None: + """Get a feeding log by ID.""" + + @abstractmethod + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all feeding logs for a specific child.""" + + @abstractmethod + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all feeding logs for a specific user's children.""" + + @abstractmethod + def update( + self, + feeding_id: int, + start_time: datetime | None = None, + end_time: datetime | None = None, + feeding_type: str | None = None, + ) -> dict | None: + """Update a feeding log entry.""" + + @abstractmethod + def delete(self, feeding_id: int) -> bool: + """Delete a feeding log entry.""" diff --git a/src/baby_monitor/routers/feeding.py b/src/baby_monitor/routers/feeding.py new file mode 100644 index 0000000..ff15153 --- /dev/null +++ b/src/baby_monitor/routers/feeding.py @@ -0,0 +1,158 @@ +"""Feeding log management router.""" + +from typing import Annotated +from fastapi import APIRouter, HTTPException, Depends + +from baby_monitor.models.feeding import ( + CreateFeedingRequest, + UpdateFeedingRequest, + FeedingResponse, +) +from baby_monitor.routers.auth import verify_token +from baby_monitor.repositories.feeding.sqlite_feeding import ( + SQLiteFeedingRepository, +) +from baby_monitor.repositories.dependencies.get_feeding_repository import ( + get_feeding_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/feedings", tags=["feedings"]) + + +@router.post("", response_model=FeedingResponse, status_code=201) +def create_feeding( + request: CreateFeedingRequest, + user_id: Annotated[int, Depends(verify_token)], + feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> FeedingResponse: + """Create a new feeding 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") + + feeding = feeding_repo.create( + child_id=request.child_id, + start_time=request.start_time, + end_time=request.end_time, + feeding_type=request.feeding_type.value, + ) + + return FeedingResponse(**feeding) + + +@router.get("/active", response_model=FeedingResponse | None) +def get_active_feeding( + user_id: Annotated[int, Depends(verify_token)], + feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> FeedingResponse | None: + """Get the current active feeding (where end_time is null) for the user.""" + feedings = feeding_repo.get_by_user_id(user_id) + + # Find the first feeding with no end_time + for feeding in feedings: + if feeding["end_time"] is None: + # Get child info to include in response + child = child_repo.get_by_id(feeding["child_id"]) + response_data = feeding.copy() + if child: + response_data["child_name"] = child["name"] + return FeedingResponse(**response_data) + + return None + + +@router.get("", response_model=list[FeedingResponse]) +def get_user_feedings( + user_id: Annotated[int, Depends(verify_token)], + feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)], +) -> list[FeedingResponse]: + """Get all feeding logs for the authenticated user's children.""" + feedings = feeding_repo.get_by_user_id(user_id) + return [FeedingResponse(**feeding) for feeding in feedings] + + +@router.get("/{feeding_id}", response_model=FeedingResponse) +def get_feeding( + feeding_id: int, + user_id: Annotated[int, Depends(verify_token)], + feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> FeedingResponse: + """Get a specific feeding log by ID.""" + feeding = feeding_repo.get_by_id(feeding_id) + + if not feeding: + raise HTTPException(status_code=404, detail="Feeding log not found") + + # Verify the feeding belongs to user's child + child = child_repo.get_by_id(feeding["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + return FeedingResponse(**feeding) + + +@router.put("/{feeding_id}", response_model=FeedingResponse) +def update_feeding( + feeding_id: int, + request: UpdateFeedingRequest, + user_id: Annotated[int, Depends(verify_token)], + feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> FeedingResponse: + """Update a feeding log entry.""" + feeding = feeding_repo.get_by_id(feeding_id) + + if not feeding: + raise HTTPException(status_code=404, detail="Feeding log not found") + + # Verify ownership + child = child_repo.get_by_id(feeding["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + # Update the feeding + updated_feeding = feeding_repo.update( + feeding_id=feeding_id, + start_time=request.start_time, + end_time=request.end_time, + feeding_type=request.feeding_type.value if request.feeding_type else None, + ) + + if not updated_feeding: + raise HTTPException(status_code=500, detail="Update failed") + + return FeedingResponse(**updated_feeding) + + +@router.delete("/{feeding_id}", status_code=204) +def delete_feeding( + feeding_id: int, + user_id: Annotated[int, Depends(verify_token)], + feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> None: + """Delete a feeding log entry.""" + feeding = feeding_repo.get_by_id(feeding_id) + + if not feeding: + raise HTTPException(status_code=404, detail="Feeding log not found") + + # Verify ownership + child = child_repo.get_by_id(feeding["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + success = feeding_repo.delete(feeding_id) + if not success: + raise HTTPException(status_code=500, detail="Delete failed") diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 0bbdb36..28f6ddf 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -117,6 +117,92 @@ text-align: center; padding: 2rem; } + .feeding-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; + } + .feeding-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + } + .feeding-button.active { + background: linear-gradient(135deg, #f44336 0%, #e91e63 100%); + } + .feeding-button.active:hover { + background: linear-gradient(135deg, #e91e63 0%, #f44336 100%); + } + .feeding-info { + font-size: 0.9rem; + margin-top: 0.5rem; + opacity: 0.9; + } + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + justify-content: center; + align-items: center; + } + .modal.show { + display: flex; + } + .modal-content { + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 400px; + width: 90%; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + } + .modal h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: #333; + } + .form-group { + margin-bottom: 1rem; + } + .form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-weight: 600; + } + .form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + } + .modal-buttons { + display: flex; + gap: 0.5rem; + margin-top: 1.5rem; + } + .modal-buttons button { + flex: 1; + margin: 0; + } + .cancel-button { + background-color: #6c757d; + } + .cancel-button:hover { + background-color: #5a6268; + } @@ -139,6 +225,40 @@ + + + + +