diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 22536ac..5b074b2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,7 +17,7 @@ services: # Use Redis for token storage - REDIS_URI=redis://redis:6379/0 # Use PostgreSQL for database - - DATABASE_URL=postgresql://baby_monitor:securepassword@postgres:5432/b + - DATABASE_URL=postgresql://baby_monitor:securepassword@postgres:5432/baby_monitor_db command: ["--host", "0.0.0.0", "--port", "8000"] restart: unless-stopped depends_on: diff --git a/src/baby_monitor/main.py b/src/baby_monitor/main.py index 0d45a8b..33f73ee 100644 --- a/src/baby_monitor/main.py +++ b/src/baby_monitor/main.py @@ -14,6 +14,7 @@ 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.diaper_change import router as diaper_change_router from baby_monitor.routers.health import router as health_router from baby_monitor.repositories.dependencies.get_database import init_db @@ -51,6 +52,7 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(child_router) app.include_router(feeding_router) +app.include_router(diaper_change_router) app.include_router(health_router) @@ -97,6 +99,12 @@ def serve_feedings() -> FileResponse: return FileResponse(static_path / "feedings.html") +@app.get("/log-diaper.html", include_in_schema=False) +def serve_log_diaper() -> FileResponse: + """Serve the log diaper change page.""" + return FileResponse(static_path / "log-diaper.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/diaper_change.py b/src/baby_monitor/models/db/diaper_change.py new file mode 100644 index 0000000..66daa56 --- /dev/null +++ b/src/baby_monitor/models/db/diaper_change.py @@ -0,0 +1,30 @@ +"""Diaper change 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 DiaperChange(Base): + """Diaper change log database model.""" + + __tablename__ = "diaper_changes" + + id = Column(Integer, primary_key=True, index=True) + child_id = Column(Integer, ForeignKey("children.id"), nullable=False) + change_time = Column(DateTime, nullable=False) + poop_amount = Column(String, nullable=True) # stores PoopAmount enum + poop_color = Column(String, nullable=True) # stores PoopColor enum + pee_amount = Column(String, nullable=True) # stores PeeAmount enum + pee_color = Column(String, nullable=True) # stores PeeColor enum + created_at = Column(DateTime, default=datetime.now(UTC), nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/src/baby_monitor/models/diaper_change.py b/src/baby_monitor/models/diaper_change.py new file mode 100644 index 0000000..ce60860 --- /dev/null +++ b/src/baby_monitor/models/diaper_change.py @@ -0,0 +1,46 @@ +"""Diaper change-related request and response models.""" + +from datetime import datetime +from pydantic import BaseModel, Field + +from baby_monitor.models.enums import ( + PeeAmount, + PeeColor, + PoopAmount, + PoopColor, +) + + +class CreateDiaperChangeRequest(BaseModel): + """Request model for creating a diaper change log.""" + + child_id: int = Field(..., gt=0) + change_time: datetime + poop_amount: PoopAmount | None = None + poop_color: PoopColor | None = None + pee_amount: PeeAmount | None = None + pee_color: PeeColor | None = None + + +class UpdateDiaperChangeRequest(BaseModel): + """Request model for updating a diaper change log.""" + + change_time: datetime | None = None + poop_amount: PoopAmount | None = None + poop_color: PoopColor | None = None + pee_amount: PeeAmount | None = None + pee_color: PeeColor | None = None + + +class DiaperChangeResponse(BaseModel): + """Response model for diaper change log data.""" + + id: int + child_id: int + change_time: datetime + poop_amount: str | None + poop_color: str | None + pee_amount: str | None + pee_color: str | None + created_at: datetime + child_name: str | None = None # Optional, populated when needed diff --git a/src/baby_monitor/models/enums.py b/src/baby_monitor/models/enums.py index 5427f87..593819e 100644 --- a/src/baby_monitor/models/enums.py +++ b/src/baby_monitor/models/enums.py @@ -9,3 +9,36 @@ class FeedingType(StrEnum): LEFT_BREAST = "left_breast" RIGHT_BREAST = "right_breast" BOTTLE = "bottle" + + +class PoopAmount(StrEnum): + """Enumeration of poop amounts.""" + + LIGHT = "light" + MEDIUM = "medium" + HEAVY = "heavy" + + +class PoopColor(StrEnum): + """Enumeration of poop colors.""" + + BLACK = "black" + YELLOW = "yellow" + GREEN = "green" + BROWN = "brown" + + +class PeeAmount(StrEnum): + """Enumeration of pee amounts.""" + + LIGHT = "light" + MEDIUM = "medium" + HEAVY = "heavy" + + +class PeeColor(StrEnum): + """Enumeration of pee colors.""" + + CLEAR = "clear" + YELLOW = "yellow" + RED = "red" diff --git a/src/baby_monitor/repositories/dependencies/get_database.py b/src/baby_monitor/repositories/dependencies/get_database.py index 3d1fa83..8b3832b 100644 --- a/src/baby_monitor/repositories/dependencies/get_database.py +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -79,6 +79,7 @@ def init_db() -> None: 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.db.diaper_change import DiaperChange # noqa: F401 from baby_monitor.models.invitation import Invitation # noqa: F401 # Ensure data directory exists diff --git a/src/baby_monitor/repositories/dependencies/get_diaper_change_repository.py b/src/baby_monitor/repositories/dependencies/get_diaper_change_repository.py new file mode 100644 index 0000000..5616377 --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_diaper_change_repository.py @@ -0,0 +1,17 @@ +"""Dependency for getting diaper change 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.diaper_change.sqlite_diaper_change import ( + SQLiteDiaperChangeRepository, +) + + +def get_diaper_change_repository( + db: Annotated[Session, Depends(get_database)], +) -> SQLiteDiaperChangeRepository: + """Get diaper change repository instance.""" + return SQLiteDiaperChangeRepository(db) diff --git a/src/baby_monitor/repositories/diaper_change/sqlite_diaper_change.py b/src/baby_monitor/repositories/diaper_change/sqlite_diaper_change.py new file mode 100644 index 0000000..2bf0f8f --- /dev/null +++ b/src/baby_monitor/repositories/diaper_change/sqlite_diaper_change.py @@ -0,0 +1,133 @@ +"""SQLite implementation of diaper change repository.""" + +from datetime import datetime +from sqlalchemy.orm import Session + +from baby_monitor.repositories.interfaces.diaper_change_repository_interface import ( # noqa: E501 + DiaperChangeRepositoryInterface, +) +from baby_monitor.models.db.diaper_change import DiaperChange +from baby_monitor.models.db.child import Child + + +class SQLiteDiaperChangeRepository(DiaperChangeRepositoryInterface): + """SQLite implementation for diaper change log data access.""" + + def __init__(self, db: Session): + self.db = db + + def create( + self, + child_id: int, + change_time: datetime, + poop_amount: str | None = None, + poop_color: str | None = None, + pee_amount: str | None = None, + pee_color: str | None = None, + ) -> dict: + """Create a new diaper change log entry.""" + diaper_change = DiaperChange( + child_id=child_id, + change_time=change_time, + poop_amount=poop_amount, + poop_color=poop_color, + pee_amount=pee_amount, + pee_color=pee_color, + ) + self.db.add(diaper_change) + self.db.commit() + self.db.refresh(diaper_change) + + return self._to_dict(diaper_change) + + def get_by_id(self, diaper_change_id: int) -> dict | None: + """Get a diaper change log by ID.""" + diaper_change = ( + self.db.query(DiaperChange) + .filter(DiaperChange.id == diaper_change_id) + .first() + ) + if not diaper_change: + return None + return self._to_dict(diaper_change) + + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all diaper change logs for a specific child.""" + diaper_changes = ( + self.db.query(DiaperChange) + .filter(DiaperChange.child_id == child_id) + .order_by(DiaperChange.change_time.desc()) + .all() + ) + return [self._to_dict(dc) for dc in diaper_changes] + + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all diaper change logs for a specific user's children.""" + diaper_changes = ( + self.db.query(DiaperChange) + .join(Child) + .filter(Child.user_id == user_id) + .order_by(DiaperChange.change_time.desc()) + .all() + ) + return [self._to_dict(dc) for dc in diaper_changes] + + def update( + self, + diaper_change_id: int, + change_time: datetime | None = None, + poop_amount: str | None = None, + poop_color: str | None = None, + pee_amount: str | None = None, + pee_color: str | None = None, + ) -> dict | None: + """Update a diaper change log entry.""" + diaper_change = ( + self.db.query(DiaperChange) + .filter(DiaperChange.id == diaper_change_id) + .first() + ) + if not diaper_change: + return None + + if change_time is not None: + diaper_change.change_time = change_time # type: ignore[assignment] + if poop_amount is not None: + diaper_change.poop_amount = poop_amount # type: ignore[assignment] + if poop_color is not None: + diaper_change.poop_color = poop_color # type: ignore[assignment] + if pee_amount is not None: + diaper_change.pee_amount = pee_amount # type: ignore[assignment] + if pee_color is not None: + diaper_change.pee_color = pee_color # type: ignore[assignment] + + self.db.commit() + self.db.refresh(diaper_change) + return self._to_dict(diaper_change) + + def delete(self, diaper_change_id: int) -> bool: + """Delete a diaper change log entry.""" + diaper_change = ( + self.db.query(DiaperChange) + .filter(DiaperChange.id == diaper_change_id) + .first() + ) + if not diaper_change: + return False + + self.db.delete(diaper_change) + self.db.commit() + return True + + def _to_dict(self, diaper_change: DiaperChange) -> dict: + """Convert DiaperChange model to dictionary.""" + return { + "id": diaper_change.id, + "child_id": diaper_change.child_id, + "change_time": diaper_change.change_time, + "poop_amount": diaper_change.poop_amount, + "poop_color": diaper_change.poop_color, + "pee_amount": diaper_change.pee_amount, + "pee_color": diaper_change.pee_color, + "created_at": diaper_change.created_at, + } diff --git a/src/baby_monitor/repositories/interfaces/diaper_change_repository_interface.py b/src/baby_monitor/repositories/interfaces/diaper_change_repository_interface.py new file mode 100644 index 0000000..d65b660 --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/diaper_change_repository_interface.py @@ -0,0 +1,48 @@ +"""Interface for diaper change repository operations.""" + +from abc import ABC, abstractmethod +from datetime import datetime + + +class DiaperChangeRepositoryInterface(ABC): + """Interface for diaper change log data access operations.""" + + @abstractmethod + def create( + self, + child_id: int, + change_time: datetime, + poop_amount: str | None = None, + poop_color: str | None = None, + pee_amount: str | None = None, + pee_color: str | None = None, + ) -> dict: + """Create a new diaper change log entry.""" + + @abstractmethod + def get_by_id(self, diaper_change_id: int) -> dict | None: + """Get a diaper change log by ID.""" + + @abstractmethod + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all diaper change logs for a specific child.""" + + @abstractmethod + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all diaper change logs for a specific user's children.""" + + @abstractmethod + def update( + self, + diaper_change_id: int, + change_time: datetime | None = None, + poop_amount: str | None = None, + poop_color: str | None = None, + pee_amount: str | None = None, + pee_color: str | None = None, + ) -> dict | None: + """Update a diaper change log entry.""" + + @abstractmethod + def delete(self, diaper_change_id: int) -> bool: + """Delete a diaper change log entry.""" diff --git a/src/baby_monitor/routers/diaper_change.py b/src/baby_monitor/routers/diaper_change.py new file mode 100644 index 0000000..19154b4 --- /dev/null +++ b/src/baby_monitor/routers/diaper_change.py @@ -0,0 +1,153 @@ +"""Diaper change log management router.""" + +from typing import Annotated +from fastapi import APIRouter, HTTPException, Depends + +from baby_monitor.models.diaper_change import ( + CreateDiaperChangeRequest, + UpdateDiaperChangeRequest, + DiaperChangeResponse, +) +from baby_monitor.routers.auth import verify_token +from baby_monitor.repositories.diaper_change.sqlite_diaper_change import ( + SQLiteDiaperChangeRepository, +) +from baby_monitor.repositories.dependencies.get_diaper_change_repository import ( # noqa: E501 + get_diaper_change_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/diaper-changes", tags=["diaper-changes"]) + + +@router.post("", response_model=DiaperChangeResponse, status_code=201) +def create_diaper_change( + request: CreateDiaperChangeRequest, + user_id: Annotated[int, Depends(verify_token)], + diaper_repo: Annotated[ + SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository) + ], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> DiaperChangeResponse: + """Create a new diaper change 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") + + diaper_change = diaper_repo.create( + child_id=request.child_id, + change_time=request.change_time, + poop_amount=request.poop_amount.value if request.poop_amount else None, + poop_color=request.poop_color.value if request.poop_color else None, + pee_amount=request.pee_amount.value if request.pee_amount else None, + pee_color=request.pee_color.value if request.pee_color else None, + ) + + return DiaperChangeResponse(**diaper_change) + + +@router.get("", response_model=list[DiaperChangeResponse]) +def get_user_diaper_changes( + user_id: Annotated[int, Depends(verify_token)], + diaper_repo: Annotated[ + SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository) + ], +) -> list[DiaperChangeResponse]: + """Get all diaper change logs for the authenticated user's children.""" + diaper_changes = diaper_repo.get_by_user_id(user_id) + return [DiaperChangeResponse(**dc) for dc in diaper_changes] + + +@router.get("/{diaper_change_id}", response_model=DiaperChangeResponse) +def get_diaper_change( + diaper_change_id: int, + user_id: Annotated[int, Depends(verify_token)], + diaper_repo: Annotated[ + SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository) + ], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> DiaperChangeResponse: + """Get a specific diaper change log by ID.""" + diaper_change = diaper_repo.get_by_id(diaper_change_id) + + if not diaper_change: + raise HTTPException(status_code=404, detail="Diaper change log not found") + + # Verify the diaper change belongs to user's child + child = child_repo.get_by_id(diaper_change["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + return DiaperChangeResponse(**diaper_change) + + +@router.put("/{diaper_change_id}", response_model=DiaperChangeResponse) +def update_diaper_change( + diaper_change_id: int, + request: UpdateDiaperChangeRequest, + user_id: Annotated[int, Depends(verify_token)], + diaper_repo: Annotated[ + SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository) + ], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> DiaperChangeResponse: + """Update an existing diaper change log.""" + diaper_change = diaper_repo.get_by_id(diaper_change_id) + + if not diaper_change: + raise HTTPException(status_code=404, detail="Diaper change log not found") + + # Verify the diaper change belongs to user's child + child = child_repo.get_by_id(diaper_change["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = diaper_repo.update( + diaper_change_id=diaper_change_id, + change_time=request.change_time, + poop_amount=request.poop_amount.value if request.poop_amount else None, + poop_color=request.poop_color.value if request.poop_color else None, + pee_amount=request.pee_amount.value if request.pee_amount else None, + pee_color=request.pee_color.value if request.pee_color else None, + ) + + if not updated: + raise HTTPException( + status_code=500, detail="Failed to update diaper change log" + ) + + return DiaperChangeResponse(**updated) + + +@router.delete("/{diaper_change_id}", status_code=204) +def delete_diaper_change( + diaper_change_id: int, + user_id: Annotated[int, Depends(verify_token)], + diaper_repo: Annotated[ + SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository) + ], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> None: + """Delete a diaper change log.""" + diaper_change = diaper_repo.get_by_id(diaper_change_id) + + if not diaper_change: + raise HTTPException(status_code=404, detail="Diaper change log not found") + + # Verify the diaper change belongs to user's child + child = child_repo.get_by_id(diaper_change["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + success = diaper_repo.delete(diaper_change_id) + if not success: + raise HTTPException( + status_code=500, detail="Failed to delete diaper change log" + ) diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 00d7fca..29a4433 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -431,7 +431,9 @@

Logged in as: ${username}

${feedingButtonHtml} -

Your session is active.

+ `; } diff --git a/src/baby_monitor/static/log-diaper.html b/src/baby_monitor/static/log-diaper.html new file mode 100644 index 0000000..94f2978 --- /dev/null +++ b/src/baby_monitor/static/log-diaper.html @@ -0,0 +1,454 @@ + + + + + + Log Diaper Change - Baby Monitor + + + +
+

🧷 Log Diaper Change

+

Record a diaper change

+ +
+ +
+
+ + +
+ +
+ + +
+ +
💩 Poop Information (Optional)
+ +
+ + +
+ +
+ + +
+ +
💧 Pee Information (Optional)
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + + +