25 Commits
Author SHA1 Message Date
brian df28af880a Merge pull request 'added sleep logging workflow and overview page' (#17) from support-logging-sleep into main
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Build and Push Docker Image / build-and-push (push) Successful in 26s
Reviewed-on: #17
2025-11-10 21:20:55 +01:00
Brian Bjarke Jensen 6c3d301027 formatting fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 18s
2025-11-10 21:19:13 +01:00
Brian Bjarke Jensen cd3db4fb86 added sleep logging workflow and overview page
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Failing after 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-10 21:00:03 +01:00
brian edeb01c889 Merge pull request 'added diaper overview page' (#16) from add-diaper-change-overview into main
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 17s
Build and Push Docker Image / build-and-push (push) Successful in 25s
Reviewed-on: #16
2025-11-10 16:26:43 +01:00
Brian Bjarke Jensen cf3029b545 added diaper overview page
Build and Push Docker Image / build-and-push (pull_request) Successful in 57s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-10 16:24:07 +01:00
brian aaa84b8b1a Merge pull request 'support-add-diaper-entry' (#13) from support-add-diaper-entry into main
Python Test / python-test (push) Successful in 17s
Build and Push Docker Image / build-and-push (push) Successful in 30s
Python Code Quality / python-code-quality (push) Successful in 9s
Reviewed-on: #13
2025-11-10 15:59:45 +01:00
Brian Bjarke Jensen e9e5936267 removed unused text
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m6s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-10 15:56:57 +01:00
Brian Bjarke Jensen 788b473c23 added functionality to log diaper changes 2025-11-10 15:56:08 +01:00
Brian Bjarke Jensen 0853ff0ff4 corrected postgres uri 2025-11-10 15:55:26 +01:00
brian 8fe3694e7a Merge pull request 'add-feeding-overview' (#12) from add-feeding-overview into main
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 17s
Build and Push Docker Image / build-and-push (push) Successful in 25s
Reviewed-on: #12
2025-11-10 00:23:09 +01:00
Brian Bjarke Jensen 3582ba13e8 made example deployments for different environments
Build and Push Docker Image / build-and-push (pull_request) Successful in 25s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-10 00:21:19 +01:00
Brian Bjarke Jensen 44c6f0168f added feedings page with graphs and list of entries with option to edit and manually add new
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-09 21:23:09 +01:00
Brian Bjarke Jensen 8d5e2a99ef removed deprecated yaml field 2025-11-09 19:12:49 +01:00
brian af2c3ed264 Merge pull request 'added feeding data objects and workflow' (#10) from support-add-feeding-entry into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 17s
Reviewed-on: #10
2025-11-09 19:10:45 +01:00
Brian Bjarke Jensen 55de11dcdd added feeding data objects and workflow
Build and Push Docker Image / build-and-push (pull_request) Successful in 57s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-09 17:22:37 +01:00
brian 1f48083db8 Merge pull request 'make-page-to-add-child' (#9) from make-page-to-add-child into main
Build and Push Docker Image / build-and-push (push) Successful in 26s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 17s
Reviewed-on: #9
2025-11-09 12:47:06 +01:00
Brian Bjarke Jensen 16e7153f5f added child endpoint, ability to add and edit child as well as auto redirect to add-child page when user without child accesses home page
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m5s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-09 11:54:28 +01:00
Brian Bjarke Jensen 1950b600cf added clarifying comment 2025-11-09 11:19:03 +01:00
brian b883ef455b Merge pull request 'added password hashing and ensured admin user exists' (#8) from add-password-hashing into main
Build and Push Docker Image / build-and-push (push) Successful in 28s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 17s
Reviewed-on: #8
2025-11-08 00:04:43 +01:00
Brian Bjarke Jensen 19a211121c formatting fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-07 23:56:38 +01:00
Brian Bjarke Jensen ef1c7f83b6 added admin credential env vars for testing 2025-11-07 23:56:15 +01:00
Brian Bjarke Jensen 7b4e597e6f added password hashing and ensured admin user exists
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Failing after 10s
Python Test / python-test (pull_request) Failing after 24s
2025-11-07 23:48:14 +01:00
brian 8b187f134d Merge pull request 'support-adding-users' (#4) from support-adding-users into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 15s
Reviewed-on: #4
2025-11-07 23:14:38 +01:00
Brian Bjarke Jensen 2b956213aa ruff and prettier fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m6s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 15s
2025-11-07 23:10:59 +01:00
Brian Bjarke Jensen 469fd722de mypy fixes 2025-11-07 23:10:25 +01:00
50 changed files with 7392 additions and 738 deletions
-2
View File
@@ -37,8 +37,6 @@ open http://localhost:8000
### Using Docker Compose ### Using Docker Compose
```yaml ```yaml
version: "3.8"
services: services:
baby-monitor: baby-monitor:
image: gitea.gt-proj.com/brian/baby-monitor:latest image: gitea.gt-proj.com/brian/baby-monitor:latest
+60
View File
@@ -0,0 +1,60 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
# Persist database to local directory
- ./data:/data
environment:
# Set environment to production
- ENVIRONMENT=production
# Set admin credentials
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=password
# Use Redis for token storage
- REDIS_URI=redis://redis:6379/0
# Use PostgreSQL for database
- DATABASE_URL=postgresql://baby_monitor:securepassword@postgres:5432/baby_monitor_db
command: ["--host", "0.0.0.0", "--port", "8000"]
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 1s
postgres:
image: postgres:14-alpine
environment:
- POSTGRES_USER=baby_monitor
- POSTGRES_PASSWORD=securepassword
- POSTGRES_DB=baby_monitor_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U baby_monitor -d baby_monitor_db"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
redis-data:
pgdata:
+3 -16
View File
@@ -11,23 +11,10 @@ services:
# Persist database to local directory # Persist database to local directory
- ./data:/data - ./data:/data
environment: environment:
# Set environment to development for hot-reloading and debug features
- ENVIRONMENT=development - ENVIRONMENT=development
# Optional: Override admin credentials for development # Optional: Override admin credentials for development
# - ADMIN_USERNAME=admin - ADMIN_USERNAME=admin
# - ADMIN_PASSWORD=devpassword - ADMIN_PASSWORD=password
# Optional: Use Redis for token storage (uncomment redis service below)
# - REDIS_URI=redis://redis:6379/0
command: ["--host", "0.0.0.0", "--port", "8000", "--reload"] command: ["--host", "0.0.0.0", "--port", "8000", "--reload"]
restart: unless-stopped restart: unless-stopped
# Uncomment to enable Redis token storage
# redis:
# image: redis:7-alpine
# ports:
# - "6379:6379"
# volumes:
# - redis-data:/data
# restart: unless-stopped
# Uncomment if using Redis
# volumes:
# redis-data:
+1
View File
@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"bcrypt>=4.0.0",
"fastapi>=0.121.0", "fastapi>=0.121.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"uvicorn>=0.38.0", "uvicorn>=0.38.0",
+44
View File
@@ -12,6 +12,10 @@ from fastapi.staticfiles import StaticFiles
from baby_monitor.routers.auth import router as auth_router from baby_monitor.routers.auth import router as auth_router
from baby_monitor.routers.auth import verify_token from baby_monitor.routers.auth import verify_token
from baby_monitor.routers.admin import router as admin_router 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.routers.health import router as health_router
from baby_monitor.repositories.dependencies.get_database import init_db from baby_monitor.repositories.dependencies.get_database import init_db
@@ -47,6 +51,10 @@ app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
# Include routers # Include routers
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_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(sleep_router)
app.include_router(health_router) app.include_router(health_router)
@@ -75,6 +83,42 @@ def serve_admin() -> FileResponse:
return FileResponse(static_path / "admin.html") return FileResponse(static_path / "admin.html")
@app.get("/add-child.html", include_in_schema=False)
def serve_add_child() -> FileResponse:
"""Serve the add child page."""
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("/feedings.html", include_in_schema=False)
def serve_feedings() -> FileResponse:
"""Serve the feedings overview page."""
return FileResponse(static_path / "feedings.html")
@app.get("/diapers.html", include_in_schema=False)
def serve_diapers() -> FileResponse:
"""Serve the diaper changes overview page."""
return FileResponse(static_path / "diapers.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("/sleep.html", include_in_schema=False)
def serve_sleep() -> FileResponse:
"""Serve the sleep overview page."""
return FileResponse(static_path / "sleep.html")
@app.get("/api/") @app.get("/api/")
def read_root(token: Annotated[str, Depends(verify_token)]) -> dict: def read_root(token: Annotated[str, Depends(verify_token)]) -> dict:
"""API root endpoint (requires authentication).""" """API root endpoint (requires authentication)."""
+23
View File
@@ -0,0 +1,23 @@
"""Child-related request and response models."""
from datetime import datetime
from pydantic import BaseModel, Field
class CreateChildRequest(BaseModel):
"""Request model for creating a child."""
name: str = Field(..., min_length=1, max_length=100)
birth_time: datetime
birth_weight: float = Field(..., gt=0, description="Birth weight in grams")
class ChildResponse(BaseModel):
"""Response model for child data."""
id: int
name: str
birth_time: datetime
birth_weight: float
user_id: int
created_at: datetime
+28
View File
@@ -0,0 +1,28 @@
"""Child database model."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey
from datetime import datetime
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class Child(Base):
"""Child database model."""
__tablename__ = "children"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
birth_time = Column(DateTime, nullable=False)
birth_weight = Column(Float, nullable=False) # in grams
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
return f"<Child(id={self.id}, name='{self.name}', user_id={self.user_id})>"
@@ -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"<DiaperChange(id={self.id}, child_id={self.child_id})>"
+28
View File
@@ -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"<Feeding(id={self.id}, child_id={self.child_id})>"
+27
View File
@@ -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"<Sleep(id={self.id}, child_id={self.child_id})>"
+7 -1
View File
@@ -1,9 +1,15 @@
"""Database models.""" """Database models."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, String, DateTime, Boolean from sqlalchemy import Column, Integer, String, DateTime, Boolean
from datetime import datetime from datetime import datetime
from baby_monitor.repositories.dependencies.get_database import Base if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class User(Base): class User(Base):
+46
View File
@@ -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
+44
View File
@@ -0,0 +1,44 @@
"""Feeding type enumeration."""
from enum import StrEnum
class FeedingType(StrEnum):
"""Enumeration of feeding types."""
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"
+35
View File
@@ -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
+9 -7
View File
@@ -5,10 +5,16 @@ SQLite will store as naive UTC, which is handled in the repository layer.
""" """
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from baby_monitor.repositories.dependencies.get_database import Base if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class Invitation(Base): class Invitation(Base):
@@ -16,9 +22,7 @@ class Invitation(Base):
__tablename__ = "invitations" __tablename__ = "invitations"
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
Integer, primary_key=True, autoincrement=True
)
token: Mapped[str] = mapped_column(String, unique=True, nullable=False) token: Mapped[str] = mapped_column(String, unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False DateTime(timezone=True), nullable=False
@@ -27,9 +31,7 @@ class Invitation(Base):
DateTime(timezone=True), nullable=False DateTime(timezone=True), nullable=False
) )
created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False) created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
is_consumed: Mapped[bool] = mapped_column( is_consumed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
Boolean, default=False, nullable=False
)
consumed_at: Mapped[datetime | None] = mapped_column( consumed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
+28
View File
@@ -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
@@ -0,0 +1,111 @@
"""SQLite implementation of child repository."""
from datetime import datetime
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.child_repository_interface import (
ChildRepositoryInterface,
)
from baby_monitor.models.db.child import Child
class SQLiteChildRepository(ChildRepositoryInterface):
"""SQLite implementation for child data access."""
def __init__(self, db: Session):
self.db = db
def create(
self, name: str, birth_time: datetime, birth_weight: float, user_id: int
) -> dict:
"""Create a new child record."""
child = Child(
name=name,
birth_time=birth_time,
birth_weight=birth_weight,
user_id=user_id,
)
self.db.add(child)
self.db.commit()
self.db.refresh(child)
return {
"id": child.id,
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def get_by_id(self, child_id: int) -> dict | None:
"""Get a child by ID."""
child = self.db.query(Child).filter(Child.id == child_id).first()
if not child:
return None
return {
"id": child.id,
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def get_by_user_id(self, user_id: int) -> list[dict]:
"""Get all children for a specific user."""
children = self.db.query(Child).filter(Child.user_id == user_id).all()
return [
{
"id": child.id,
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
for child in children
]
def update(
self,
child_id: int,
name: str | None = None,
birth_time: datetime | None = None,
birth_weight: float | None = None,
) -> dict | None:
"""Update a child record."""
child = self.db.query(Child).filter(Child.id == child_id).first()
if not child:
return None
if name is not None:
child.name = name # type: ignore[assignment]
if birth_time is not None:
child.birth_time = birth_time # type: ignore[assignment]
if birth_weight is not None:
child.birth_weight = birth_weight # type: ignore[assignment]
self.db.commit()
self.db.refresh(child)
return {
"id": child.id,
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def delete(self, child_id: int) -> bool:
"""Delete a child record."""
child = self.db.query(Child).filter(Child.id == child_id).first()
if not child:
return False
self.db.delete(child)
self.db.commit()
return True
@@ -0,0 +1,15 @@
"""Dependency for getting child 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.child.sqlite_child import SQLiteChildRepository
def get_child_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteChildRepository:
"""Get child repository instance."""
return SQLiteChildRepository(db)
@@ -3,9 +3,13 @@
import os import os
from pathlib import Path from pathlib import Path
from collections.abc import Generator from collections.abc import Generator
from typing import TYPE_CHECKING
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.orm import declarative_base, sessionmaker, Session
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
# Database directory # Database directory
DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
@@ -23,7 +27,49 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for declarative models # Base class for declarative models
Base = declarative_base() Base: "type[DeclarativeBase]" = declarative_base()
def _ensure_admin_user() -> None:
"""Create or update admin user from environment variables."""
from baby_monitor.models.db.user import User
from baby_monitor.utils.password import hash_password
admin_username = os.getenv("ADMIN_USERNAME")
admin_password = os.getenv("ADMIN_PASSWORD")
if not admin_username:
raise RuntimeError(
"ADMIN_USERNAME environment variable is required but not set"
)
if not admin_password:
raise RuntimeError(
"ADMIN_PASSWORD environment variable is required but not set"
)
db = SessionLocal()
try:
# Check if admin user exists
admin_user = db.query(User).filter(User.username == admin_username).first()
hashed_pw = hash_password(admin_password)
if admin_user:
# Update existing admin user's password and ensure admin flag
admin_user.hashed_password = hashed_pw # type: ignore[assignment]
admin_user.is_admin = True # type: ignore[assignment]
db.commit()
else:
# Create new admin user
new_admin = User(
username=admin_username,
hashed_password=hashed_pw,
is_admin=True,
)
db.add(new_admin)
db.commit()
finally:
db.close()
def init_db() -> None: def init_db() -> None:
@@ -31,12 +77,19 @@ def init_db() -> None:
# Import models to register them with Base.metadata # Import models to register them with Base.metadata
# This must be done before create_all() is called # This must be done before create_all() is called
from baby_monitor.models.db.user import User # noqa: F401 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.db.sleep import Sleep # noqa: F401
from baby_monitor.models.invitation import Invitation # noqa: F401 from baby_monitor.models.invitation import Invitation # noqa: F401
# Ensure data directory exists # Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Create admin user if it doesn't exist
_ensure_admin_user()
def get_database() -> Generator[Session, None, None]: def get_database() -> Generator[Session, None, None]:
""" """
@@ -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)
@@ -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)
@@ -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)
@@ -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,
}
@@ -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,
}
@@ -0,0 +1,36 @@
"""Interface for child repository operations."""
from abc import ABC, abstractmethod
from datetime import datetime
class ChildRepositoryInterface(ABC):
"""Interface for child data access operations."""
@abstractmethod
def create(
self, name: str, birth_time: datetime, birth_weight: float, user_id: int
) -> dict:
"""Create a new child record."""
@abstractmethod
def get_by_id(self, child_id: int) -> dict | None:
"""Get a child by ID."""
@abstractmethod
def get_by_user_id(self, user_id: int) -> list[dict]:
"""Get all children for a specific user."""
@abstractmethod
def update(
self,
child_id: int,
name: str | None = None,
birth_time: datetime | None = None,
birth_weight: float | None = None,
) -> dict | None:
"""Update a child record."""
@abstractmethod
def delete(self, child_id: int) -> bool:
"""Delete a child record."""
@@ -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."""
@@ -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."""
@@ -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."""
@@ -34,9 +34,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
# Convert timezone-aware datetimes to naive UTC for SQLite # Convert timezone-aware datetimes to naive UTC for SQLite
created_at_utc = datetime.now(UTC).replace(tzinfo=None) created_at_utc = datetime.now(UTC).replace(tzinfo=None)
expires_at_utc = ( expires_at_utc = (
expires_at.replace(tzinfo=None) expires_at.replace(tzinfo=None) if expires_at.tzinfo else expires_at
if expires_at.tzinfo
else expires_at
) )
invitation = Invitation( invitation = Invitation(
@@ -60,9 +58,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
Returns: Returns:
True if valid and not consumed, False otherwise True if valid and not consumed, False otherwise
""" """
invitation = ( invitation = self.db.query(Invitation).filter(Invitation.token == token).first()
self.db.query(Invitation).filter(Invitation.token == token).first()
)
if not invitation: if not invitation:
return False return False
@@ -88,9 +84,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
Returns: Returns:
True if successfully consumed, False if invalid or already used True if successfully consumed, False if invalid or already used
""" """
invitation = ( invitation = self.db.query(Invitation).filter(Invitation.token == token).first()
self.db.query(Invitation).filter(Invitation.token == token).first()
)
if not invitation: if not invitation:
return False return False
@@ -114,7 +108,5 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
def cleanup_expired(self) -> None: def cleanup_expired(self) -> None:
"""Remove expired invitation tokens from storage.""" """Remove expired invitation tokens from storage."""
now_utc = datetime.now(UTC).replace(tzinfo=None) now_utc = datetime.now(UTC).replace(tzinfo=None)
self.db.query(Invitation).filter( self.db.query(Invitation).filter(Invitation.expires_at < now_utc).delete()
Invitation.expires_at < now_utc
).delete()
self.db.commit() self.db.commit()
@@ -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,
}
@@ -42,7 +42,7 @@ class InMemoryTokenRepository(TokenRepositoryInterface):
def cleanup_expired(self) -> None: def cleanup_expired(self) -> None:
"""Remove expired tokens.""" """Remove expired tokens."""
now = datetime.utcnow() now = datetime.now(UTC)
expired_tokens = [ expired_tokens = [
token for token, (_, expiry) in self._tokens.items() if now > expiry token for token, (_, expiry) in self._tokens.items() if now > expiry
] ]
+7 -7
View File
@@ -34,31 +34,31 @@ async def generate_invitation_link(
], ],
) -> InvitationResponse: ) -> InvitationResponse:
"""Generate a new invitation link for user registration. """Generate a new invitation link for user registration.
The invitation token is valid for 24 hours and can be used once. The invitation token is valid for 24 hours and can be used once.
Requires admin role. Requires admin role.
Args: Args:
user_id: Admin user ID (from verify_admin) user_id: Admin user ID (from verify_admin)
invitation_repository: Invitation storage backend invitation_repository: Invitation storage backend
Returns: Returns:
InvitationResponse with token and expiration details InvitationResponse with token and expiration details
""" """
# Generate secure random token # Generate secure random token
invitation_token = secrets.token_urlsafe(32) invitation_token = secrets.token_urlsafe(32)
# Calculate expiration (24 hours from now) # Calculate expiration (24 hours from now)
expires_at = datetime.now(UTC) + timedelta(hours=24) expires_at = datetime.now(UTC) + timedelta(hours=24)
# Store invitation token in database # Store invitation token in database
invitation_repository.create_invitation( invitation_repository.create_invitation(
token=invitation_token, token=invitation_token,
created_by_user_id=user_id, created_by_user_id=user_id,
expires_at=expires_at, expires_at=expires_at,
) )
return InvitationResponse( return InvitationResponse(
token=invitation_token, token=invitation_token,
expires_at=expires_at.isoformat(), expires_at=expires_at.isoformat(),
+42 -93
View File
@@ -12,7 +12,6 @@ from baby_monitor.models.auth import (
) )
from baby_monitor.repositories import ( from baby_monitor.repositories import (
get_token_repository, get_token_repository,
get_credentials_repository,
get_user_repository, get_user_repository,
) )
from baby_monitor.repositories.dependencies.get_invitation_repository import ( from baby_monitor.repositories.dependencies.get_invitation_repository import (
@@ -20,10 +19,10 @@ from baby_monitor.repositories.dependencies.get_invitation_repository import (
) )
from baby_monitor.repositories.interfaces import ( from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface, TokenRepositoryInterface,
CredentialsRepositoryInterface,
UserRepositoryInterface, UserRepositoryInterface,
InvitationRepositoryInterface, InvitationRepositoryInterface,
) )
from baby_monitor.utils.password import hash_password, verify_password
router = APIRouter(prefix="/api", tags=["authentication"]) router = APIRouter(prefix="/api", tags=["authentication"])
@@ -52,43 +51,25 @@ def verify_token(
def verify_admin( def verify_admin(
user_id: Annotated[int, Depends(verify_token)], user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[ user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
UserRepositoryInterface, Depends(get_user_repository)
],
) -> int: ) -> int:
"""Verify the user is an admin and return user_id.""" """Verify the user is an admin and return user_id."""
# First try to get user from database
user = user_repo.get_by_id(user_id) user = user_repo.get_by_id(user_id)
if user: if not user:
# Database user - check is_admin field raise HTTPException(status_code=401, detail="User not found")
if not user.get("is_admin", False):
raise HTTPException( if not user.get("is_admin", False):
status_code=403, detail="Admin access required" raise HTTPException(status_code=403, detail="Admin access required")
)
return user_id return user_id
# If not in database but has valid token with user_id=1,
# it's the environment-based admin (only assigned during env admin login)
if user_id == 1:
return user_id
# User not found and not environment admin
raise HTTPException(status_code=401, detail="User not found")
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login( def login(
credentials: LoginRequest, credentials: LoginRequest,
token_repo: Annotated[ token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)],
TokenRepositoryInterface, Depends(get_token_repository) user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
],
user_repo: Annotated[
UserRepositoryInterface, Depends(get_user_repository)
],
creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository)
],
) -> LoginResponse: ) -> LoginResponse:
""" """
API endpoint for user authentication. API endpoint for user authentication.
@@ -96,41 +77,24 @@ def login(
Returns user info and access token on successful login. Returns user info and access token on successful login.
Raises 401 on invalid credentials. Raises 401 on invalid credentials.
""" """
# First check if it's a database user # Check if user exists in database
user = user_repo.get_by_username(credentials.username) user = user_repo.get_by_username(credentials.username)
if user: if not user:
# TODO: Use proper password hashing (bcrypt/argon2) raise HTTPException(status_code=401, detail="Invalid username or password")
# For now, compare plain text (matches registration)
if user["hashed_password"] == credentials.password:
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600)
return LoginResponse( # Verify password using bcrypt
message="Login successful", if not verify_password(credentials.password, user["hashed_password"]):
username=credentials.username, raise HTTPException(status_code=401, detail="Invalid username or password")
access_token=access_token,
is_admin=user.get("is_admin", False),
)
# Fallback to admin credentials from environment
if creds_repo.verify_admin_credentials(
credentials.username, credentials.password
):
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
# Store token with user_id (hardcoded 1 for admin)
token_repo.store(access_token, user_id=1, ttl=3600)
return LoginResponse( # Generate a secure random token
message="Login successful", access_token = secrets.token_urlsafe(32)
username=credentials.username, token_repo.store(access_token, user_id=user["id"], ttl=3600)
access_token=access_token,
is_admin=True, return LoginResponse(
) message="Login successful",
username=credentials.username,
raise HTTPException( access_token=access_token,
status_code=401, detail="Invalid username or password" is_admin=user.get("is_admin", False),
) )
@@ -162,19 +126,15 @@ def verify_invitation(
@router.post("/register", response_model=RegisterResponse) @router.post("/register", response_model=RegisterResponse)
def register( def register(
request: RegisterRequest, request: RegisterRequest,
user_repo: Annotated[ user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
UserRepositoryInterface, Depends(get_user_repository)
],
invitation_repo: Annotated[ invitation_repo: Annotated[
InvitationRepositoryInterface, Depends(get_invitation_repository) InvitationRepositoryInterface, Depends(get_invitation_repository)
], ],
token_repo: Annotated[ token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)],
TokenRepositoryInterface, Depends(get_token_repository)
],
) -> RegisterResponse: ) -> RegisterResponse:
""" """
Register a new user with an invitation token. Register a new user with an invitation token.
Verifies the invitation, creates the user, consumes the invitation, Verifies the invitation, creates the user, consumes the invitation,
and returns an authentication token. and returns an authentication token.
""" """
@@ -184,7 +144,7 @@ def register(
status_code=400, status_code=400,
detail="Invalid or expired invitation token", detail="Invalid or expired invitation token",
) )
# Check if username already exists # Check if username already exists
existing_user = user_repo.get_by_username(request.username) existing_user = user_repo.get_by_username(request.username)
if existing_user: if existing_user:
@@ -192,21 +152,21 @@ def register(
status_code=400, status_code=400,
detail="Username already exists", detail="Username already exists",
) )
# Create the new user # Create the new user with hashed password
# TODO: Hash password before storing (currently plain text) hashed_pw = hash_password(request.password)
user = user_repo.create( user = user_repo.create(
username=request.username, username=request.username,
hashed_password=request.password, hashed_password=hashed_pw,
) )
# Consume the invitation token # Consume the invitation token
invitation_repo.consume_invitation(request.invitation_token) invitation_repo.consume_invitation(request.invitation_token)
# Generate authentication token # Generate authentication token
access_token = secrets.token_urlsafe(32) access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600) token_repo.store(access_token, user_id=user["id"], ttl=3600)
return RegisterResponse( return RegisterResponse(
message="Registration successful", message="Registration successful",
username=user["username"], username=user["username"],
@@ -218,25 +178,14 @@ def register(
@router.get("/me") @router.get("/me")
def get_current_user( def get_current_user(
user_id: Annotated[int, Depends(verify_token)], user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[ user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
UserRepositoryInterface, Depends(get_user_repository)
],
creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository)
],
) -> dict: ) -> dict:
"""Get current user info including admin status.""" """Get current user info including admin status."""
user = user_repo.get_by_id(user_id) user = user_repo.get_by_id(user_id)
# If user not found in DB, might be env-based admin
if not user: if not user:
# Return admin info from environment raise HTTPException(status_code=404, detail="User not found")
return {
"id": user_id,
"username": creds_repo.get_admin_username(),
"is_admin": True, # Env-based admin is always admin
}
return { return {
"id": user["id"], "id": user["id"],
"username": user["username"], "username": user["username"],
+90
View File
@@ -0,0 +1,90 @@
"""Child management router."""
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from baby_monitor.models.child import CreateChildRequest, ChildResponse
from baby_monitor.routers.auth import verify_token
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/children", tags=["children"])
@router.post("", response_model=ChildResponse, status_code=201)
def create_child(
request: CreateChildRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
) -> ChildResponse:
"""Create a new child for the authenticated user."""
child = child_repo.create(
name=request.name,
birth_time=request.birth_time,
birth_weight=request.birth_weight,
user_id=user_id,
)
return ChildResponse(**child)
@router.get("", response_model=list[ChildResponse])
def get_user_children(
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
) -> list[ChildResponse]:
"""Get all children for the authenticated user."""
children = child_repo.get_by_user_id(user_id)
return [ChildResponse(**child) for child in children]
@router.get("/{child_id}", response_model=ChildResponse)
def get_child(
child_id: int,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
) -> ChildResponse:
"""Get a specific child by ID."""
child = child_repo.get_by_id(child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
# Verify the child belongs to the authenticated user
if child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
return ChildResponse(**child)
@router.put("/{child_id}", response_model=ChildResponse)
def update_child(
child_id: int,
request: CreateChildRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
) -> ChildResponse:
"""Update a child's information."""
# First check if child exists and belongs to user
child = child_repo.get_by_id(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")
# Update the child
updated_child = child_repo.update(
child_id=child_id,
name=request.name,
birth_time=request.birth_time,
birth_weight=request.birth_weight,
)
if not updated_child:
raise HTTPException(status_code=500, detail="Update failed")
return ChildResponse(**updated_child)
+153
View File
@@ -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"
)
+158
View File
@@ -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")
+151
View File
@@ -0,0 +1,151 @@
"""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")
+322
View File
@@ -0,0 +1,322 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Add Child - Baby Monitor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 600;
font-size: 14px;
}
input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
input:focus {
outline: none;
border-color: #667eea;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
width: 100%;
margin-top: 10px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button.secondary {
background: #6c757d;
margin-top: 10px;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.message {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.message.show {
display: block;
}
.message.error {
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
}
.message.success {
background: #e8f5e9;
border-left: 4px solid #4caf50;
color: #2e7d32;
}
.help-text {
font-size: 12px;
color: #666;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>👶 Add Child</h1>
<p class="subtitle">Enter your child's information</p>
<div id="message" class="message"></div>
<form id="addChildForm">
<div class="form-group">
<label for="name">Child's Name *</label>
<input
type="text"
id="name"
name="name"
required
placeholder="Enter child's name"
maxlength="100"
/>
</div>
<div class="form-group">
<label for="birthTime">Date and Time of Birth *</label>
<input
type="datetime-local"
id="birthTime"
name="birthTime"
required
/>
<p class="help-text">
Select the date and time when your child was born
</p>
</div>
<div class="form-group">
<label for="birthWeight">Birth Weight (grams) *</label>
<input
type="number"
id="birthWeight"
name="birthWeight"
required
min="1"
step="1"
placeholder="e.g., 3500"
/>
<p class="help-text">Enter weight in grams (1 kg = 1000 grams)</p>
</div>
<button type="submit" class="button" id="submitBtn">Add Child</button>
<button type="button" class="button secondary" onclick="goBack()">
Cancel
</button>
</form>
</div>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
// Get child ID from URL if editing
const urlParams = new URLSearchParams(window.location.search);
const childId = urlParams.get("id");
const isEditMode = !!childId;
const form = document.getElementById("addChildForm");
const messageDiv = document.getElementById("message");
const submitBtn = document.getElementById("submitBtn");
// Update page title and button text if editing
if (isEditMode) {
document.querySelector("h1").textContent = "✏️ Edit Child";
document.querySelector(".subtitle").textContent =
"Update your child's information";
submitBtn.textContent = "Update Child";
// Load existing child data
loadChildData(childId);
}
async function loadChildData(id) {
try {
const response = await fetch(`/api/children/${id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const child = await response.json();
// Populate form fields
document.getElementById("name").value = child.name;
// Format datetime for datetime-local input
const birthDate = new Date(child.birth_time);
const formattedDate = birthDate.toISOString().slice(0, 16);
document.getElementById("birthTime").value = formattedDate;
document.getElementById("birthWeight").value = child.birth_weight;
} else {
showMessage("Failed to load child data", "error");
}
} catch (error) {
showMessage("Error loading child data", "error");
}
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const name = document.getElementById("name").value;
const birthTime = document.getElementById("birthTime").value;
const birthWeight = parseFloat(
document.getElementById("birthWeight").value,
);
// Disable button during request
submitBtn.disabled = true;
const originalText = submitBtn.textContent;
submitBtn.textContent = isEditMode ? "Updating..." : "Adding...";
messageDiv.classList.remove("show");
try {
const url = isEditMode ? `/api/children/${childId}` : "/api/children";
const method = isEditMode ? "PUT" : "POST";
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: name,
birth_time: birthTime,
birth_weight: birthWeight,
}),
});
const data = await response.json();
if (response.ok) {
showMessage(
isEditMode
? "Child updated successfully!"
: "Child added successfully!",
"success",
);
// Redirect to home page after 1 second
setTimeout(() => {
window.location.href = "/";
}, 1000);
} else {
showMessage(
data.detail ||
`Failed to ${isEditMode ? "update" : "add"} child. Please try again.`,
"error",
);
}
} catch (error) {
showMessage("Network error. Please try again.", "error");
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = `message ${type} show`;
}
function goBack() {
window.location.href = "/";
}
</script>
</body>
</html>
+267 -263
View File
@@ -1,315 +1,319 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Admin</title> <title>Baby Monitor - Admin</title>
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
min-height: 100vh; Cantarell, sans-serif;
display: flex; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
justify-content: center; min-height: 100vh;
align-items: center; display: flex;
padding: 20px; justify-content: center;
} align-items: center;
padding: 20px;
}
.container { .container {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px; padding: 40px;
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
} }
h1 { h1 {
color: #333; color: #333;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 28px; font-size: 28px;
} }
.subtitle { .subtitle {
color: #666; color: #666;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 14px; font-size: 14px;
} }
.section { .section {
margin-bottom: 30px; margin-bottom: 30px;
} }
.section h2 { .section h2 {
color: #444; color: #444;
font-size: 18px; font-size: 18px;
margin-bottom: 15px; margin-bottom: 15px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.section h2::before { .section h2::before {
content: '🔗'; content: "🔗";
margin-right: 8px; margin-right: 8px;
} }
.button { .button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
border: none; border: none;
padding: 12px 24px; padding: 12px 24px;
border-radius: 6px; border-radius: 6px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition:
width: 100%; transform 0.2s,
} box-shadow 0.2s;
width: 100%;
}
.button:hover { .button:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
} }
.button:active { .button:active {
transform: translateY(0); transform: translateY(0);
} }
.button:disabled { .button:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.link-container { .link-container {
display: none; display: none;
margin-top: 20px; margin-top: 20px;
padding: 15px; padding: 15px;
background: #f8f9fa; background: #f8f9fa;
border-radius: 8px; border-radius: 8px;
border: 2px solid #667eea; border: 2px solid #667eea;
} }
.link-container.show { .link-container.show {
display: block; display: block;
} }
.link-label { .link-label {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
} }
.link-display { .link-display {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
.link-text { .link-text {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
background: white; background: white;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-family: 'Courier New', monospace; font-family: "Courier New", monospace;
font-size: 13px; font-size: 13px;
word-break: break-all; word-break: break-all;
color: #333; color: #333;
} }
.copy-button { .copy-button {
padding: 10px 16px; padding: 10px 16px;
background: #28a745; background: #28a745;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.copy-button:hover { .copy-button:hover {
background: #218838; background: #218838;
} }
.copy-button.copied { .copy-button.copied {
background: #155724; background: #155724;
} }
.info-box { .info-box {
background: #e7f3ff; background: #e7f3ff;
border-left: 4px solid #2196F3; border-left: 4px solid #2196f3;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-top: 15px; margin-top: 15px;
font-size: 14px; font-size: 14px;
color: #555; color: #555;
} }
.error { .error {
background: #ffebee; background: #ffebee;
border-left: 4px solid #f44336; border-left: 4px solid #f44336;
color: #c62828; color: #c62828;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-top: 15px; margin-top: 15px;
display: none; display: none;
} }
.error.show { .error.show {
display: block; display: block;
} }
.logout-button { .logout-button {
background: #dc3545; background: #dc3545;
padding: 8px 16px; padding: 8px 16px;
font-size: 14px; font-size: 14px;
margin-top: 20px; margin-top: 20px;
} }
.logout-button:hover { .logout-button:hover {
background: #c82333; background: #c82333;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4); box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>👨‍💼 Admin Dashboard</h1> <h1>👨‍💼 Admin Dashboard</h1>
<p class="subtitle">Manage user invitations and settings</p> <p class="subtitle">Manage user invitations and settings</p>
<div class="section"> <div class="section">
<h2>Generate Invitation Link</h2> <h2>Generate Invitation Link</h2>
<p style="color: #666; margin-bottom: 15px; font-size: 14px;"> <p style="color: #666; margin-bottom: 15px; font-size: 14px">
Create a secure one-time link to invite a new user to register. Create a secure one-time link to invite a new user to register.
</p> </p>
<button class="button" id="generateBtn" onclick="generateLink()"> <button class="button" id="generateBtn" onclick="generateLink()">
Generate New Invitation Link Generate New Invitation Link
</button>
<div id="linkContainer" class="link-container">
<div class="link-label">Invitation Link</div>
<div class="link-display">
<div class="link-text" id="linkText"></div>
<button class="copy-button" id="copyBtn" onclick="copyLink()">
Copy
</button> </button>
</div>
<div id="linkContainer" class="link-container"> <div class="info-box">
<div class="link-label">Invitation Link</div> ️ This link expires in 24 hours and can only be used once. Share it
<div class="link-display"> securely with the intended user.
<div class="link-text" id="linkText"></div> </div>
<button class="copy-button" id="copyBtn" onclick="copyLink()">
Copy
</button>
</div>
<div class="info-box">
️ This link expires in 24 hours and can only be used once.
Share it securely with the intended user.
</div>
</div>
<div id="error" class="error"></div>
</div> </div>
<button class="button logout-button" onclick="logout()"> <div id="error" class="error"></div>
Logout </div>
</button>
<button class="button logout-button" onclick="logout()">Logout</button>
</div> </div>
<script> <script>
// Check if user is authenticated and is admin // Check if user is authenticated and is admin
const token = localStorage.getItem('access_token'); const token = localStorage.getItem("access_token");
if (!token) { if (!token) {
window.location.href = '/login.html'; window.location.href = "/login.html";
} }
// Verify user is admin // Verify user is admin
fetch('/api/me', { fetch("/api/me", {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
} },
})
.then((response) => response.json())
.then((user) => {
if (!user.is_admin) {
alert("Access denied. Admin privileges required.");
window.location.href = "/";
}
}) })
.then(response => response.json()) .catch((error) => {
.then(user => { console.error("Error verifying admin status:", error);
if (!user.is_admin) { window.location.href = "/login.html";
alert('Access denied. Admin privileges required.');
window.location.href = '/';
}
})
.catch(error => {
console.error('Error verifying admin status:', error);
window.location.href = '/login.html';
}); });
async function generateLink() { async function generateLink() {
const btn = document.getElementById('generateBtn'); const btn = document.getElementById("generateBtn");
const linkContainer = document.getElementById('linkContainer'); const linkContainer = document.getElementById("linkContainer");
const linkText = document.getElementById('linkText'); const linkText = document.getElementById("linkText");
const errorDiv = document.getElementById('error'); const errorDiv = document.getElementById("error");
// Disable button and show loading state // Disable button and show loading state
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Generating...'; btn.textContent = "Generating...";
errorDiv.classList.remove('show'); errorDiv.classList.remove("show");
try { try {
const response = await fetch('/api/admin/generate-invitation', { const response = await fetch("/api/admin/generate-invitation", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to generate link: ${response.statusText}`); throw new Error(`Failed to generate link: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
// Build the full URL
const baseUrl = window.location.origin;
const inviteUrl = `${baseUrl}/register.html?token=${data.token}`;
// Display the link
linkText.textContent = inviteUrl;
linkContainer.classList.add('show');
} catch (error) { // Build the full URL
errorDiv.textContent = `Error: ${error.message}`; const baseUrl = window.location.origin;
errorDiv.classList.add('show'); const inviteUrl = `${baseUrl}/register.html?token=${data.token}`;
} finally {
btn.disabled = false; // Display the link
btn.textContent = 'Generate New Invitation Link'; linkText.textContent = inviteUrl;
} linkContainer.classList.add("show");
} catch (error) {
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.classList.add("show");
} finally {
btn.disabled = false;
btn.textContent = "Generate New Invitation Link";
} }
}
function copyLink() { function copyLink() {
const linkText = document.getElementById('linkText').textContent; const linkText = document.getElementById("linkText").textContent;
const copyBtn = document.getElementById('copyBtn'); const copyBtn = document.getElementById("copyBtn");
navigator.clipboard.writeText(linkText).then(() => { navigator.clipboard
// Show success state .writeText(linkText)
copyBtn.textContent = '✓ Copied!'; .then(() => {
copyBtn.classList.add('copied'); // Show success state
copyBtn.textContent = "✓ Copied!";
copyBtn.classList.add("copied");
// Reset after 2 seconds // Reset after 2 seconds
setTimeout(() => { setTimeout(() => {
copyBtn.textContent = 'Copy'; copyBtn.textContent = "Copy";
copyBtn.classList.remove('copied'); copyBtn.classList.remove("copied");
}, 2000); }, 2000);
}).catch(err => { })
alert('Failed to copy link: ' + err); .catch((err) => {
}); alert("Failed to copy link: " + err);
} });
}
function logout() { function logout() {
localStorage.removeItem('access_token'); localStorage.removeItem("access_token");
window.location.href = '/login.html'; window.location.href = "/login.html";
} }
</script> </script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+652 -17
View File
@@ -117,6 +117,119 @@
text-align: center; text-align: center;
padding: 2rem; 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;
}
.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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -125,13 +238,15 @@
<div></div> <div></div>
<div></div> <div></div>
</div> </div>
<div class="menu-backdrop" id="menuBackdrop"></div> <div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay"> <div class="menu-overlay" id="menuOverlay">
<div class="menu-item logout" id="menuLogout"> <div class="menu-item" id="menuAddChild">👶 Add Child</div>
🚪 Logout <div class="menu-item" id="menuFeedings">🍼 Feedings</div>
</div> <div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item" id="menuSleep">😴 Sleep</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div> </div>
<div class="container"> <div class="container">
@@ -140,6 +255,65 @@
</div> </div>
</div> </div>
<!-- Start Feeding Modal -->
<div class="modal" id="startFeedingModal">
<div class="modal-content">
<h2>🍼 Start Feeding</h2>
<form id="startFeedingForm">
<div class="form-group">
<label for="modalChildId">Child</label>
<select id="modalChildId" required>
<option value="">Select a child</option>
</select>
</div>
<div class="form-group">
<label for="modalFeedingType">Feeding Type</label>
<select id="modalFeedingType" required>
<option value="">Select type</option>
<option value="left_breast">Left Breast</option>
<option value="right_breast">Right Breast</option>
<option value="bottle">Bottle</option>
</select>
</div>
<div class="modal-buttons">
<button
type="button"
class="cancel-button"
onclick="closeStartFeedingModal()"
>
Cancel
</button>
<button type="submit">Start</button>
</div>
</form>
</div>
</div>
<!-- Start Sleep Modal -->
<div class="modal" id="startSleepModal">
<div class="modal-content">
<h2>😴 Start Sleep</h2>
<form id="startSleepForm">
<div class="form-group">
<label for="modalSleepChildId">Child</label>
<select id="modalSleepChildId" required>
<option value="">Select a child</option>
</select>
</div>
<div class="modal-buttons">
<button
type="button"
class="cancel-button"
onclick="closeStartSleepModal()"
>
Cancel
</button>
<button type="submit">Start</button>
</div>
</form>
</div>
</div>
<script> <script>
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username"); const username = localStorage.getItem("username");
@@ -148,20 +322,36 @@
const burgerMenu = document.getElementById("burgerMenu"); const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay"); const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop"); const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() { function toggleMenu() {
menuOverlay.classList.toggle("open"); menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open"); menuBackdrop.classList.toggle("open");
} }
function closeMenu() { function closeMenu() {
menuOverlay.classList.remove("open"); menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open"); menuBackdrop.classList.remove("open");
} }
burgerMenu.addEventListener("click", toggleMenu); burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu); menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuSleep").addEventListener("click", () => {
window.location.href = "/sleep.html";
});
document.getElementById("menuLogout").addEventListener("click", () => { document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu(); closeMenu();
logout(); logout();
@@ -189,20 +379,465 @@
} }
}) })
.then((user) => { .then((user) => {
// Show authenticated content // Check if user has any children
document.getElementById("content").innerHTML = ` return fetch("/api/children", {
<h1>Welcome to Baby Monitor!</h1> headers: {
<div class="user-info"> Authorization: `Bearer ${token}`,
<p><strong>Logged in as:</strong> ${username}</p> },
</div> })
<p>Your session is active.</p> .then((response) => response.json())
`; .then((children) => {
// If no children, redirect to add-child page
if (children.length === 0) {
window.location.href = "/add-child.html";
return;
}
// Load feeding status and show content
loadFeedingStatus(children);
});
}) })
.catch((error) => { .catch((error) => {
console.error("Error:", error); console.error("Error:", error);
}); });
} }
let activeFeeding = null;
let userChildren = [];
let lastFeeding = null;
let updateTimerInterval = null;
let activeSleep = null;
let lastSleep = null;
let sleepUpdateTimerInterval = null;
async function loadFeedingStatus(children) {
userChildren = children;
try {
// Fetch active feeding
const activeResponse = await fetch("/api/feedings/active", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (activeResponse.ok) {
activeFeeding = await activeResponse.json();
}
// Fetch all feedings to get the last one
const feedingsResponse = await fetch("/api/feedings", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (feedingsResponse.ok) {
const feedings = await feedingsResponse.json();
if (feedings.length > 0) {
// Feedings are ordered by start_time desc, so first is most recent
lastFeeding = feedings[0];
}
}
// Fetch active sleep
const activeSleepResponse = await fetch("/api/sleep/active", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (activeSleepResponse.ok) {
activeSleep = await activeSleepResponse.json();
}
// Fetch all sleep logs to get the last one
const sleepResponse = await fetch("/api/sleep", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (sleepResponse.ok) {
const sleeps = await sleepResponse.json();
if (sleeps.length > 0) {
lastSleep = sleeps[0];
}
}
} catch (error) {
console.error("Error loading feeding status:", error);
}
renderContent();
startUpdateTimer();
startSleepUpdateTimer();
}
function startUpdateTimer() {
// Clear any existing timer
if (updateTimerInterval) {
clearInterval(updateTimerInterval);
}
// Update every 30 seconds if there's an active feeding
if (activeFeeding) {
updateTimerInterval = setInterval(() => {
if (activeFeeding) {
updateFeedingTimeDisplay();
} else {
clearInterval(updateTimerInterval);
updateTimerInterval = null;
}
}, 30000); // Update every 30 seconds
}
}
function startSleepUpdateTimer() {
// Clear any existing timer
if (sleepUpdateTimerInterval) {
clearInterval(sleepUpdateTimerInterval);
}
// Update every 30 seconds if there's an active sleep
if (activeSleep) {
sleepUpdateTimerInterval = setInterval(() => {
if (activeSleep) {
updateSleepTimeDisplay();
} else {
clearInterval(sleepUpdateTimerInterval);
sleepUpdateTimerInterval = null;
}
}, 30000); // Update every 30 seconds
}
}
function updateFeedingTimeDisplay() {
const timeElement = document.getElementById("feedingTimeInfo");
if (timeElement && activeFeeding) {
timeElement.textContent = formatTime(activeFeeding.start_time);
}
}
function updateSleepTimeDisplay() {
const timeElement = document.getElementById("sleepTimeInfo");
if (timeElement && activeSleep) {
timeElement.textContent = formatTime(activeSleep.start_time);
}
}
function renderContent() {
const feedingButtonHtml = activeFeeding
? `
<button class="feeding-button active" onclick="stopFeeding()">
🛑 Stop Feeding
<div class="feeding-info">
${activeFeeding.child_name || "Child"} - ${formatFeedingType(activeFeeding.feeding_type)}<br>
Started <span id="feedingTimeInfo">${formatTime(activeFeeding.start_time)}</span>
</div>
</button>
`
: `
<button class="feeding-button" onclick="showStartFeedingModal()">
▶️ Start Feeding
</button>
`;
const sleepButtonHtml = activeSleep
? `
<button class="sleep-button active" onclick="stopSleep()">
🛑 Stop Sleep
<div class="sleep-info">
${activeSleep.child_name || "Child"}<br>
Started <span id="sleepTimeInfo">${formatTime(activeSleep.start_time)}</span>
</div>
</button>
`
: `
<button class="sleep-button" onclick="showStartSleepModal()">
😴 Start Sleep
</button>
`;
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
${feedingButtonHtml}
${sleepButtonHtml}
<button class="feeding-button" onclick="window.location.href='/log-diaper.html'" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); margin-top: 20px;">
🧷 Log Diaper Change
</button>
`;
}
function formatFeedingType(type) {
const types = {
left_breast: "Left Breast",
right_breast: "Right Breast",
bottle: "Bottle",
};
return types[type] || type;
}
function formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins} min ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ${diffMins % 60}m ago`;
return date.toLocaleString();
}
function showStartFeedingModal() {
const modal = document.getElementById("startFeedingModal");
const childSelect = document.getElementById("modalChildId");
const typeSelect = document.getElementById("modalFeedingType");
// Clear and populate child select
childSelect.innerHTML = '<option value="">Select a child</option>';
userChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// Set defaults based on last feeding
if (lastFeeding) {
childSelect.value = lastFeeding.child_id;
typeSelect.value = lastFeeding.feeding_type;
}
modal.classList.add("show");
}
function closeStartFeedingModal() {
const modal = document.getElementById("startFeedingModal");
modal.classList.remove("show");
document.getElementById("startFeedingForm").reset();
}
document
.getElementById("startFeedingForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(
document.getElementById("modalChildId").value,
);
const feedingType = document.getElementById("modalFeedingType").value;
try {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 19);
const response = await fetch("/api/feedings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
start_time: localDateTime,
end_time: null,
feeding_type: feedingType,
}),
});
if (response.ok) {
const feeding = await response.json();
activeFeeding = feeding;
lastFeeding = feeding; // Update last feeding
// Add child name to active feeding
const child = userChildren.find((c) => c.id === childId);
if (child) {
activeFeeding.child_name = child.name;
}
closeStartFeedingModal();
renderContent();
startUpdateTimer(); // Start the timer for live updates
} else {
const data = await response.json();
alert(data.detail || "Failed to start feeding");
}
} catch (error) {
console.error("Error starting feeding:", error);
alert("Network error. Please try again.");
}
});
async function stopFeeding() {
if (!activeFeeding) return;
try {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 19);
const response = await fetch(`/api/feedings/${activeFeeding.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
end_time: localDateTime,
}),
});
if (response.ok) {
activeFeeding = null;
if (updateTimerInterval) {
clearInterval(updateTimerInterval);
updateTimerInterval = null;
}
renderContent();
} else {
const data = await response.json();
alert(data.detail || "Failed to stop feeding");
}
} catch (error) {
console.error("Error stopping feeding:", error);
alert("Network error. Please try again.");
}
}
function showStartSleepModal() {
const modal = document.getElementById("startSleepModal");
const childSelect = document.getElementById("modalSleepChildId");
// Clear and populate child select
childSelect.innerHTML = '<option value="">Select a child</option>';
userChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// Set default based on last sleep
if (lastSleep) {
childSelect.value = lastSleep.child_id;
}
modal.classList.add("show");
}
function closeStartSleepModal() {
const modal = document.getElementById("startSleepModal");
modal.classList.remove("show");
document.getElementById("startSleepForm").reset();
}
document
.getElementById("startSleepForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(
document.getElementById("modalSleepChildId").value,
);
try {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 19);
const response = await fetch("/api/sleep", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
start_time: localDateTime,
}),
});
if (response.ok) {
const sleep = await response.json();
activeSleep = sleep;
lastSleep = sleep;
// Add child name to active sleep
const child = userChildren.find((c) => c.id === childId);
if (child) {
activeSleep.child_name = child.name;
}
closeStartSleepModal();
renderContent();
startSleepUpdateTimer();
} else {
const data = await response.json();
alert(data.detail || "Failed to start sleep");
}
} catch (error) {
console.error("Error starting sleep:", error);
alert("Network error. Please try again.");
}
});
async function stopSleep() {
if (!activeSleep) return;
try {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 19);
const response = await fetch(`/api/sleep/${activeSleep.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
end_time: localDateTime,
}),
});
if (response.ok) {
activeSleep = null;
if (sleepUpdateTimerInterval) {
clearInterval(sleepUpdateTimerInterval);
sleepUpdateTimerInterval = null;
}
renderContent();
} else {
const data = await response.json();
alert(data.detail || "Failed to stop sleep");
}
} catch (error) {
console.error("Error stopping sleep:", error);
alert("Network error. Please try again.");
}
}
async function logout() { async function logout() {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
+467
View File
@@ -0,0 +1,467 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Diaper Change - Baby Monitor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 600;
font-size: 14px;
}
input,
select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
width: 100%;
margin-top: 10px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button.secondary {
background: #6c757d;
margin-top: 10px;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.message {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.message.show {
display: block;
}
.message.error {
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
}
.message.success {
background: #e8f5e9;
border-left: 4px solid #4caf50;
color: #2e7d32;
}
.help-text {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.section-title {
color: #666;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 25px 0 15px 0;
padding-top: 15px;
border-top: 1px solid #eee;
}
.section-title:first-of-type {
border-top: none;
margin-top: 0;
}
</style>
</head>
<body>
<div class="container">
<h1 id="pageTitle">🧷 Log Diaper Change</h1>
<p class="subtitle">Record a diaper change</p>
<div id="message" class="message"></div>
<form id="diaperForm">
<div class="form-group">
<label for="childSelect">Child *</label>
<select id="childSelect" required>
<option value="">Select a child</option>
</select>
</div>
<div class="form-group">
<label for="changeTime">Change Time *</label>
<input type="datetime-local" id="changeTime" required />
</div>
<div class="section-title">💩 Poop Information (Optional)</div>
<div class="form-group">
<label for="poopAmount">Poop Amount</label>
<select id="poopAmount">
<option value="">None</option>
<option value="light">Light</option>
<option value="medium">Medium</option>
<option value="heavy">Heavy</option>
</select>
</div>
<div class="form-group">
<label for="poopColor">Poop Color</label>
<select id="poopColor">
<option value="">None</option>
<option value="black">Black</option>
<option value="yellow">Yellow</option>
<option value="green">Green</option>
<option value="brown">Brown</option>
</select>
</div>
<div class="section-title">💧 Pee Information (Optional)</div>
<div class="form-group">
<label for="peeAmount">Pee Amount</label>
<select id="peeAmount">
<option value="">None</option>
<option value="light">Light</option>
<option value="medium">Medium</option>
<option value="heavy">Heavy</option>
</select>
</div>
<div class="form-group">
<label for="peeColor">Pee Color</label>
<select id="peeColor">
<option value="">None</option>
<option value="clear">Clear</option>
<option value="yellow">Yellow</option>
<option value="red">Red</option>
</select>
</div>
<button type="submit" class="button" id="submitBtn">
Log Diaper Change
</button>
<button
type="button"
class="button secondary"
onclick="window.location.href='/'"
>
Cancel
</button>
</form>
</div>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
// Check if we're in edit mode
const urlParams = new URLSearchParams(window.location.search);
const diaperChangeId = urlParams.get("id");
const isEditMode = !!diaperChangeId;
// Update UI for edit mode
if (isEditMode) {
document.getElementById("pageTitle").textContent =
"🧷 Edit Diaper Change";
document.getElementById("submitBtn").textContent =
"Update Diaper Change";
}
let children = [];
// Load children when page loads
async function loadChildren() {
try {
const response = await fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
children = await response.json();
const select = document.getElementById("childSelect");
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
select.appendChild(option);
});
// If editing, load the diaper change data
if (isEditMode) {
await loadDiaperChangeData();
} else {
// Set default time to now for new entries
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById("changeTime").value = now
.toISOString()
.slice(0, 16);
// Load last used child from localStorage
const lastDiaperChange = JSON.parse(
localStorage.getItem("lastDiaperChange") || "{}",
);
if (lastDiaperChange.child_id) {
document.getElementById("childSelect").value =
lastDiaperChange.child_id;
}
}
} else {
showMessage("Failed to load children", "error");
}
} catch (error) {
console.error("Error:", error);
showMessage("Network error. Please try again.", "error");
}
}
// Load diaper change data for editing
async function loadDiaperChangeData() {
try {
const response = await fetch(
`/api/diaper-changes/${diaperChangeId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.ok) {
const diaperChange = await response.json();
// Populate form fields
document.getElementById("childSelect").value =
diaperChange.child_id;
// Convert UTC time to local datetime-local format
const changeTime = new Date(diaperChange.change_time);
changeTime.setMinutes(
changeTime.getMinutes() - changeTime.getTimezoneOffset(),
);
document.getElementById("changeTime").value = changeTime
.toISOString()
.slice(0, 16);
document.getElementById("poopAmount").value =
diaperChange.poop_amount || "";
document.getElementById("poopColor").value =
diaperChange.poop_color || "";
document.getElementById("peeAmount").value =
diaperChange.pee_amount || "";
document.getElementById("peeColor").value =
diaperChange.pee_color || "";
} else {
showMessage("Failed to load diaper change data", "error");
}
} catch (error) {
console.error("Error:", error);
showMessage("Network error. Please try again.", "error");
}
}
// Handle form submission
document
.getElementById("diaperForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(
document.getElementById("childSelect").value,
);
const changeTime = new Date(
document.getElementById("changeTime").value,
).toISOString();
const poopAmount =
document.getElementById("poopAmount").value || null;
const poopColor = document.getElementById("poopColor").value || null;
const peeAmount = document.getElementById("peeAmount").value || null;
const peeColor = document.getElementById("peeColor").value || null;
const submitBtn = document.getElementById("submitBtn");
submitBtn.disabled = true;
submitBtn.textContent = isEditMode ? "Updating..." : "Logging...";
try {
const url = isEditMode
? `/api/diaper-changes/${diaperChangeId}`
: "/api/diaper-changes";
const method = isEditMode ? "PUT" : "POST";
const body = isEditMode
? {
change_time: changeTime,
poop_amount: poopAmount,
poop_color: poopColor,
pee_amount: peeAmount,
pee_color: peeColor,
}
: {
child_id: childId,
change_time: changeTime,
poop_amount: poopAmount,
poop_color: poopColor,
pee_amount: peeAmount,
pee_color: peeColor,
};
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (response.ok) {
// Save last used child for next time (only for new entries, not edits)
if (!isEditMode) {
localStorage.setItem(
"lastDiaperChange",
JSON.stringify({
child_id: childId,
}),
);
}
showMessage(
isEditMode
? "Diaper change updated successfully!"
: "Diaper change logged successfully!",
"success",
);
setTimeout(() => {
window.location.href = "/";
}, 1000);
} else {
const errorData = await response.json();
showMessage(
errorData.detail || "Failed to log diaper change",
"error",
);
submitBtn.disabled = false;
submitBtn.textContent = isEditMode
? "Update Diaper Change"
: "Log Diaper Change";
}
} catch (error) {
console.error("Error:", error);
showMessage("Network error. Please try again.", "error");
submitBtn.disabled = false;
submitBtn.textContent = isEditMode
? "Update Diaper Change"
: "Log Diaper Change";
}
});
function showMessage(text, type) {
const messageDiv = document.getElementById("message");
messageDiv.textContent = text;
messageDiv.className = `message ${type} show`;
}
// Initialize
loadChildren();
</script>
</body>
</html>
+401
View File
@@ -0,0 +1,401 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Feeding - Baby Monitor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 600;
font-size: 14px;
}
input,
select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
width: 100%;
margin-top: 10px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button.secondary {
background: #6c757d;
margin-top: 10px;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.message {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.message.show {
display: block;
}
.message.error {
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
}
.message.success {
background: #e8f5e9;
border-left: 4px solid #4caf50;
color: #2e7d32;
}
.help-text {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>🍼 Log Feeding</h1>
<p class="subtitle">Record a feeding session</p>
<div id="message" class="message"></div>
<div id="loadingChildren" class="loading">Loading children...</div>
<form id="logFeedingForm" style="display: none">
<div class="form-group">
<label for="childId">Child *</label>
<select id="childId" name="childId" required>
<option value="">Select a child</option>
</select>
</div>
<div class="form-group">
<label for="startTime">Start Time *</label>
<input
type="datetime-local"
id="startTime"
name="startTime"
required
/>
<p class="help-text">Defaults to current time</p>
</div>
<div class="form-group">
<label for="endTime">End Time</label>
<input type="datetime-local" id="endTime" name="endTime" />
<p class="help-text">Leave empty if feeding is ongoing</p>
</div>
<div class="form-group">
<label for="feedingType">Feeding Type *</label>
<select id="feedingType" name="feedingType" required>
<option value="">Select type</option>
<option value="left_breast">Left Breast</option>
<option value="right_breast">Right Breast</option>
<option value="bottle">Bottle</option>
</select>
</div>
<button type="submit" class="button" id="submitBtn">Log Feeding</button>
<button type="button" class="button secondary" onclick="goBack()">
Cancel
</button>
</form>
</div>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
const form = document.getElementById("logFeedingForm");
const messageDiv = document.getElementById("message");
const submitBtn = document.getElementById("submitBtn");
const loadingDiv = document.getElementById("loadingChildren");
const childSelect = document.getElementById("childId");
// Check if we're editing an existing feeding
const urlParams = new URLSearchParams(window.location.search);
const feedingId = urlParams.get("id");
const isEditMode = !!feedingId;
// Set default start time to now (only for new feedings)
if (!isEditMode) {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("startTime").value = localDateTime;
}
// Update title and button text if editing
if (isEditMode) {
document.querySelector("h1").textContent = "🍼 Edit Feeding";
document.querySelector(".subtitle").textContent =
"Update feeding session details";
submitBtn.textContent = "Update Feeding";
}
// Load user's children
async function loadChildren() {
try {
const response = await fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const children = await response.json();
if (children.length === 0) {
showMessage(
"No children found. Please add a child first.",
"error",
);
setTimeout(() => {
window.location.href = "/add-child.html";
}, 2000);
return;
}
// Populate select dropdown
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// Show form, hide loading
loadingDiv.style.display = "none";
form.style.display = "block";
// If editing, load the feeding data
if (isEditMode) {
loadFeedingData();
}
} else {
showMessage("Failed to load children", "error");
}
} catch (error) {
showMessage("Network error loading children", "error");
}
}
async function loadFeedingData() {
try {
const response = await fetch(`/api/feedings/${feedingId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const feeding = await response.json();
// Populate form with existing data
document.getElementById("childId").value = feeding.child_id;
// Format datetime for datetime-local input
const startTime = new Date(feeding.start_time);
const startLocalDateTime = new Date(
startTime.getTime() - startTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("startTime").value = startLocalDateTime;
if (feeding.end_time) {
const endTime = new Date(feeding.end_time);
const endLocalDateTime = new Date(
endTime.getTime() - endTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("endTime").value = endLocalDateTime;
}
document.getElementById("feedingType").value = feeding.feeding_type;
} else {
showMessage("Failed to load feeding data", "error");
}
} catch (error) {
showMessage("Network error loading feeding", "error");
}
}
loadChildren();
form.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(document.getElementById("childId").value);
const startTime = document.getElementById("startTime").value;
const endTimeValue = document.getElementById("endTime").value;
const feedingType = document.getElementById("feedingType").value;
// Disable button during request
submitBtn.disabled = true;
submitBtn.textContent = isEditMode ? "Updating..." : "Logging...";
messageDiv.classList.remove("show");
try {
const url = isEditMode
? `/api/feedings/${feedingId}`
: "/api/feedings";
const method = isEditMode ? "PUT" : "POST";
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
start_time: startTime,
end_time: endTimeValue || null,
feeding_type: feedingType,
}),
});
const data = await response.json();
if (response.ok) {
showMessage(
isEditMode
? "Feeding updated successfully!"
: "Feeding logged successfully!",
"success",
);
// Redirect to feedings page after 1 second
setTimeout(() => {
window.location.href = "/feedings.html";
}, 1000);
} else {
showMessage(
data.detail ||
`Failed to ${isEditMode ? "update" : "log"} feeding. Please try again.`,
"error",
);
}
} catch (error) {
showMessage("Network error. Please try again.", "error");
} finally {
submitBtn.disabled = false;
submitBtn.textContent = isEditMode ? "Update Feeding" : "Log Feeding";
}
});
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = `message ${type} show`;
}
function goBack() {
window.location.href = "/";
}
</script>
</body>
</html>
+1 -1
View File
@@ -112,7 +112,7 @@
// Store token in localStorage // Store token in localStorage
localStorage.setItem("access_token", data.access_token); localStorage.setItem("access_token", data.access_token);
localStorage.setItem("username", data.username); localStorage.setItem("username", data.username);
// Redirect based on is_admin from login response // Redirect based on is_admin from login response
setTimeout(() => { setTimeout(() => {
if (data.is_admin === true) { if (data.is_admin === true) {
+331 -316
View File
@@ -1,368 +1,383 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Register</title> <title>Baby Monitor - Register</title>
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
min-height: 100vh; Cantarell, sans-serif;
display: flex; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
justify-content: center; min-height: 100vh;
align-items: center; display: flex;
padding: 20px; justify-content: center;
} align-items: center;
padding: 20px;
}
.container { .container {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px; padding: 40px;
max-width: 450px; max-width: 450px;
width: 100%; width: 100%;
} }
h1 { h1 {
color: #333; color: #333;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 28px; font-size: 28px;
text-align: center; text-align: center;
} }
.subtitle { .subtitle {
color: #666; color: #666;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
} }
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 20px;
} }
label { label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
color: #444; color: #444;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
} }
input { input {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
border-radius: 6px; border-radius: 6px;
font-size: 16px; font-size: 16px;
transition: border-color 0.3s; transition: border-color 0.3s;
} }
input:focus { input:focus {
outline: none; outline: none;
border-color: #667eea; border-color: #667eea;
} }
.button { .button {
width: 100%; width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
border: none; border: none;
padding: 14px; padding: 14px;
border-radius: 6px; border-radius: 6px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition:
margin-top: 10px; transform 0.2s,
} box-shadow 0.2s;
margin-top: 10px;
}
.button:hover { .button:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
} }
.button:active { .button:active {
transform: translateY(0); transform: translateY(0);
} }
.button:disabled { .button:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.error { .error {
background: #ffebee; background: #ffebee;
border-left: 4px solid #f44336; border-left: 4px solid #f44336;
color: #c62828; color: #c62828;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
font-size: 14px; font-size: 14px;
} }
.error.show { .error.show {
display: block; display: block;
} }
.success { .success {
background: #e8f5e9; background: #e8f5e9;
border-left: 4px solid #4caf50; border-left: 4px solid #4caf50;
color: #2e7d32; color: #2e7d32;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
font-size: 14px; font-size: 14px;
} }
.success.show { .success.show {
display: block; display: block;
} }
.loading-container { .loading-container {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
} }
.spinner { .spinner {
border: 3px solid #f3f3f3; border: 3px solid #f3f3f3;
border-top: 3px solid #667eea; border-top: 3px solid #667eea;
border-radius: 50%; border-radius: 50%;
width: 40px; width: 40px;
height: 40px; height: 40px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto 20px; margin: 0 auto 20px;
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
} }
100% {
transform: rotate(360deg);
}
}
.info-box { .info-box {
background: #e7f3ff; background: #e7f3ff;
border-left: 4px solid #2196F3; border-left: 4px solid #2196f3;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 14px; font-size: 14px;
color: #555; color: #555;
} }
.hidden { .hidden {
display: none; display: none;
} }
.password-requirements { .password-requirements {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
margin-top: 5px; margin-top: 5px;
} }
.password-requirements ul { .password-requirements ul {
margin: 5px 0 0 20px; margin: 5px 0 0 20px;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div id="loadingContainer" class="loading-container"> <div id="loadingContainer" class="loading-container">
<div class="spinner"></div> <div class="spinner"></div>
<p style="color: #666;">Validating invitation...</p> <p style="color: #666">Validating invitation...</p>
</div> </div>
<div id="registerContainer" class="hidden"> <div id="registerContainer" class="hidden">
<h1>🎉 Create Your Account</h1> <h1>🎉 Create Your Account</h1>
<p class="subtitle">Complete the registration to get started</p> <p class="subtitle">Complete the registration to get started</p>
<div id="error" class="error"></div> <div id="error" class="error"></div>
<div id="success" class="success"></div> <div id="success" class="success"></div>
<form id="registerForm" onsubmit="handleRegister(event)"> <form id="registerForm" onsubmit="handleRegister(event)">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
required required
minlength="3" minlength="3"
autocomplete="username" autocomplete="username"
placeholder="Choose a username" placeholder="Choose a username"
> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
required required
minlength="8" minlength="8"
autocomplete="new-password" autocomplete="new-password"
placeholder="Choose a strong password" placeholder="Choose a strong password"
> />
<div class="password-requirements"> <div class="password-requirements">
<ul> <ul>
<li>At least 8 characters long</li> <li>At least 8 characters long</li>
<li>Mix of letters, numbers recommended</li> <li>Mix of letters, numbers recommended</li>
</ul> </ul>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
minlength="8"
autocomplete="new-password"
placeholder="Re-enter your password"
>
</div>
<button type="submit" class="button" id="submitBtn">
Create Account
</button>
</form>
</div>
<div id="invalidTokenContainer" class="hidden">
<h1>⚠️ Invalid Invitation</h1>
<p class="subtitle">This invitation link is invalid or has expired</p>
<div class="info-box">
This could mean:
<ul style="margin: 10px 0 0 20px; color: #555;">
<li>The invitation has already been used</li>
<li>The invitation has expired (24 hours)</li>
<li>The invitation link is incorrect</li>
</ul>
</div> </div>
<p style="text-align: center; color: #666; margin-top: 20px;"> </div>
Please contact an administrator for a new invitation link.
</p> <div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
minlength="8"
autocomplete="new-password"
placeholder="Re-enter your password"
/>
</div>
<button type="submit" class="button" id="submitBtn">
Create Account
</button>
</form>
</div>
<div id="invalidTokenContainer" class="hidden">
<h1>⚠️ Invalid Invitation</h1>
<p class="subtitle">This invitation link is invalid or has expired</p>
<div class="info-box">
This could mean:
<ul style="margin: 10px 0 0 20px; color: #555">
<li>The invitation has already been used</li>
<li>The invitation has expired (24 hours)</li>
<li>The invitation link is incorrect</li>
</ul>
</div> </div>
<p style="text-align: center; color: #666; margin-top: 20px">
Please contact an administrator for a new invitation link.
</p>
</div>
</div> </div>
<script> <script>
let invitationToken = null; let invitationToken = null;
// Extract token from URL query parameters // Extract token from URL query parameters
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
invitationToken = urlParams.get('token'); invitationToken = urlParams.get("token");
// Validate token on page load // Validate token on page load
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener("DOMContentLoaded", async () => {
if (!invitationToken) { if (!invitationToken) {
showInvalidToken(); showInvalidToken();
return; return;
}
try {
// Verify the invitation token
const response = await fetch(`/api/verify-invitation?token=${encodeURIComponent(invitationToken)}`);
if (response.ok) {
showRegisterForm();
} else {
showInvalidToken();
}
} catch (error) {
console.error('Error validating invitation:', error);
showInvalidToken();
}
});
function showRegisterForm() {
document.getElementById('loadingContainer').classList.add('hidden');
document.getElementById('registerContainer').classList.remove('hidden');
} }
function showInvalidToken() { try {
document.getElementById('loadingContainer').classList.add('hidden'); // Verify the invitation token
document.getElementById('invalidTokenContainer').classList.remove('hidden'); const response = await fetch(
`/api/verify-invitation?token=${encodeURIComponent(invitationToken)}`,
);
if (response.ok) {
showRegisterForm();
} else {
showInvalidToken();
}
} catch (error) {
console.error("Error validating invitation:", error);
showInvalidToken();
}
});
function showRegisterForm() {
document.getElementById("loadingContainer").classList.add("hidden");
document.getElementById("registerContainer").classList.remove("hidden");
}
function showInvalidToken() {
document.getElementById("loadingContainer").classList.add("hidden");
document
.getElementById("invalidTokenContainer")
.classList.remove("hidden");
}
async function handleRegister(event) {
event.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const confirmPassword =
document.getElementById("confirmPassword").value;
const errorDiv = document.getElementById("error");
const successDiv = document.getElementById("success");
const submitBtn = document.getElementById("submitBtn");
// Clear previous messages
errorDiv.classList.remove("show");
successDiv.classList.remove("show");
// Validate passwords match
if (password !== confirmPassword) {
errorDiv.textContent = "Passwords do not match!";
errorDiv.classList.add("show");
return;
} }
async function handleRegister(event) { // Disable submit button
event.preventDefault(); submitBtn.disabled = true;
submitBtn.textContent = "Creating Account...";
const username = document.getElementById('username').value; try {
const password = document.getElementById('password').value; const response = await fetch("/api/register", {
const confirmPassword = document.getElementById('confirmPassword').value; method: "POST",
const errorDiv = document.getElementById('error'); headers: {
const successDiv = document.getElementById('success'); "Content-Type": "application/json",
const submitBtn = document.getElementById('submitBtn'); },
body: JSON.stringify({
username: username,
password: password,
invitation_token: invitationToken,
}),
});
// Clear previous messages const data = await response.json();
errorDiv.classList.remove('show');
successDiv.classList.remove('show');
// Validate passwords match if (response.ok) {
if (password !== confirmPassword) { // Store the access token and username
errorDiv.textContent = 'Passwords do not match!'; localStorage.setItem("access_token", data.access_token);
errorDiv.classList.add('show'); localStorage.setItem("username", data.username);
return;
}
// Disable submit button // Show success message
submitBtn.disabled = true; successDiv.textContent =
submitBtn.textContent = 'Creating Account...'; "Account created successfully! Redirecting...";
successDiv.classList.add("show");
try { // Redirect to home page after a brief delay
const response = await fetch('/api/register', { setTimeout(() => {
method: 'POST', window.location.href = "/";
headers: { }, 1500);
'Content-Type': 'application/json', } else {
}, errorDiv.textContent =
body: JSON.stringify({ data.detail || "Registration failed. Please try again.";
username: username, errorDiv.classList.add("show");
password: password, submitBtn.disabled = false;
invitation_token: invitationToken, submitBtn.textContent = "Create Account";
}), }
}); } catch (error) {
console.error("Registration error:", error);
const data = await response.json(); errorDiv.textContent = "An error occurred. Please try again.";
errorDiv.classList.add("show");
if (response.ok) { submitBtn.disabled = false;
// Store the access token and username submitBtn.textContent = "Create Account";
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('username', data.username);
// Show success message
successDiv.textContent = 'Account created successfully! Redirecting...';
successDiv.classList.add('show');
// Redirect to home page after a brief delay
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
errorDiv.textContent = data.detail || 'Registration failed. Please try again.';
errorDiv.classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = 'Create Account';
}
} catch (error) {
console.error('Registration error:', error);
errorDiv.textContent = 'An error occurred. Please try again.';
errorDiv.classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = 'Create Account';
}
} }
}
</script> </script>
</body> </body>
</html> </html>
+923
View File
@@ -0,0 +1,923 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sleep - Baby Monitor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
min-height: 100vh;
padding: 20px;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.button {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
text-decoration: none;
display: inline-block;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
}
.button.secondary {
background: #6c757d;
margin-left: 10px;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 20px;
}
.empty-state h2 {
color: #333;
margin-bottom: 10px;
}
.empty-state p {
margin-bottom: 30px;
}
.chart-container {
margin-bottom: 40px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.chart-container h2 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.chart-wrapper {
position: relative;
height: 300px;
}
.charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 40px;
}
@media (max-width: 768px) {
.charts-row {
grid-template-columns: 1fr;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-card h3 {
color: #666;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 10px;
}
.stat-card .value {
color: #4facfe;
font-size: 32px;
font-weight: 700;
}
.list-container {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.list-container h2 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.sleep-list {
max-height: 500px;
overflow-y: auto;
}
.sleep-item {
background: white;
padding: 16px;
border-radius: 6px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.sleep-item:last-child {
margin-bottom: 0;
}
.sleep-info {
flex: 1;
}
.sleep-child {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.sleep-time {
color: #666;
font-size: 14px;
margin-bottom: 4px;
}
.sleep-duration {
color: #4facfe;
font-size: 14px;
font-weight: 600;
}
.sleep-actions {
display: flex;
gap: 8px;
}
.edit-button {
background: #4facfe;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.edit-button:hover {
background: #3a9be8;
}
.filters {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.filter-group label {
color: #666;
font-size: 14px;
font-weight: 600;
}
.filter-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
cursor: pointer;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-right: 8px;
}
.badge.ongoing {
background: #4facfe;
color: white;
}
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuHome">🏠 Home</div>
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item" id="menuSleep">😴 Sleep</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<h1>😴 Sleep Tracking</h1>
<p class="subtitle">Monitor your baby's sleep patterns</p>
<div class="filters">
<div class="filter-group">
<label for="childFilter">Child</label>
<select id="childFilter">
<option value="">All Children</option>
</select>
</div>
<div class="filter-group">
<label for="timeRangeFilter">Time Range</label>
<select id="timeRangeFilter">
<option value="1">Last 24 hours</option>
<option value="3">Last 3 days</option>
<option value="7" selected>Last 7 days</option>
<option value="14">Last 14 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
</div>
<div id="content" class="loading">
<p>Loading sleep data...</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script>
const token = localStorage.getItem("access_token");
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuHome").addEventListener("click", () => {
window.location.href = "/";
});
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuSleep").addEventListener("click", () => {
closeMenu();
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Check if user is logged in
if (!token) {
window.location.href = "/login.html";
}
let allSleeps = [];
let allChildren = [];
let selectedChildId = "";
// Filter change handlers
document
.getElementById("childFilter")
.addEventListener("change", function () {
selectedChildId = this.value;
// Save selection to localStorage
if (selectedChildId) {
localStorage.setItem("lastSleepChildFilter", selectedChildId);
} else {
localStorage.removeItem("lastSleepChildFilter");
}
renderCharts();
});
document
.getElementById("timeRangeFilter")
.addEventListener("change", function () {
loadData();
});
async function loadData() {
try {
// Fetch children
const childrenResponse = await fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!childrenResponse.ok) {
throw new Error("Failed to fetch children");
}
allChildren = await childrenResponse.json();
// Populate child filter
const childFilter = document.getElementById("childFilter");
childFilter.innerHTML = '<option value="">All Children</option>';
allChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childFilter.appendChild(option);
});
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastSleepChildFilter");
if (lastChildId) {
childFilter.value = lastChildId;
selectedChildId = lastChildId;
}
// Fetch sleep logs
const sleepResponse = await fetch("/api/sleep", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!sleepResponse.ok) {
throw new Error("Failed to fetch sleep data");
}
allSleeps = await sleepResponse.json();
renderCharts();
} catch (error) {
console.error("Error loading data:", error);
document.getElementById("content").innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">😴</div>
<h2>Error Loading Data</h2>
<p>${error.message}</p>
<button class="button" onclick="window.location.href='/'">Go Home</button>
</div>
`;
}
}
function renderCharts() {
const timeRange = parseInt(
document.getElementById("timeRangeFilter").value,
);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - timeRange);
// Filter sleeps by time range and child
let filteredSleeps = allSleeps.filter((sleep) => {
const sleepDate = new Date(sleep.start_time);
const matchesTimeRange = sleepDate >= cutoffDate;
const matchesChild =
!selectedChildId || sleep.child_id === parseInt(selectedChildId);
return matchesTimeRange && matchesChild;
});
if (filteredSleeps.length === 0) {
document.getElementById("content").innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">😴</div>
<h2>No Sleep Data</h2>
<p>Start tracking sleep sessions from the home page.</p>
<button class="button" onclick="window.location.href='/'">Go Home</button>
</div>
`;
return;
}
// Calculate stats
const completedSleeps = filteredSleeps.filter(
(sleep) => sleep.end_time,
);
const totalSessions = filteredSleeps.length;
const ongoingSessions = filteredSleeps.filter(
(sleep) => !sleep.end_time,
).length;
let totalMinutes = 0;
let longestMinutes = 0;
let shortestMinutes = Infinity;
completedSleeps.forEach((sleep) => {
const duration = calculateDuration(sleep.start_time, sleep.end_time);
totalMinutes += duration;
if (duration > longestMinutes) longestMinutes = duration;
if (duration < shortestMinutes) shortestMinutes = duration;
});
const avgMinutes =
completedSleeps.length > 0
? Math.round(totalMinutes / completedSleeps.length)
: 0;
if (completedSleeps.length === 0) {
shortestMinutes = 0;
}
// Prepare data for charts
const barChartData = prepareBarChartData(filteredSleeps, timeRange);
const durationDistData =
prepareDurationDistributionData(completedSleeps);
let html = `
<div class="stats-grid">
<div class="stat-card">
<h3>Total Sessions</h3>
<div class="value">${totalSessions}</div>
</div>
<div class="stat-card">
<h3>Ongoing</h3>
<div class="value">${ongoingSessions}</div>
</div>
<div class="stat-card">
<h3>Avg Duration</h3>
<div class="value">${formatDuration(avgMinutes)}</div>
</div>
<div class="stat-card">
<h3>Longest Sleep</h3>
<div class="value">${formatDuration(longestMinutes)}</div>
</div>
</div>
<div class="chart-container">
<h2>Sleep Sessions per Day</h2>
<div class="chart-wrapper">
<canvas id="barChart"></canvas>
</div>
</div>
<div class="charts-row">
<div class="chart-container">
<h2>Duration Distribution</h2>
<div class="chart-wrapper">
<canvas id="durationChart"></canvas>
</div>
</div>
<div class="chart-container">
<h2>Average Duration by Day</h2>
<div class="chart-wrapper">
<canvas id="avgDurationChart"></canvas>
</div>
</div>
</div>
<div class="list-container">
<h2>Recent Sleep Sessions</h2>
<div class="sleep-list">
${filteredSleeps
.slice(0, 50)
.map((sleep) => renderSleepItem(sleep))
.join("")}
</div>
</div>
`;
document.getElementById("content").innerHTML = html;
// Render charts
renderBarChart(barChartData);
renderDurationChart(durationDistData);
renderAvgDurationChart(filteredSleeps, timeRange);
}
function calculateDuration(startTime, endTime) {
if (!endTime) return 0;
const start = new Date(startTime);
const end = new Date(endTime);
return Math.round((end - start) / 60000); // minutes
}
function formatDuration(minutes) {
if (minutes === 0) return "0m";
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins}m`;
if (mins === 0) return `${hours}h`;
return `${hours}h ${mins}m`;
}
function prepareBarChartData(sleeps, timeRange) {
const days = {};
const today = new Date();
today.setHours(0, 0, 0, 0);
// Initialize all days in range
for (let i = timeRange - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0];
days[dateStr] = 0;
}
// Count sleeps per day
sleeps.forEach((sleep) => {
const date = new Date(sleep.start_time);
date.setHours(0, 0, 0, 0);
const dateStr = date.toISOString().split("T")[0];
if (days.hasOwnProperty(dateStr)) {
days[dateStr]++;
}
});
const labels = Object.keys(days).map((date) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
});
return {
labels,
data: Object.values(days),
};
}
function prepareDurationDistributionData(sleeps) {
const ranges = {
"0-30m": 0,
"30m-1h": 0,
"1-2h": 0,
"2-3h": 0,
"3-4h": 0,
"4h+": 0,
};
sleeps.forEach((sleep) => {
const duration = calculateDuration(sleep.start_time, sleep.end_time);
if (duration < 30) ranges["0-30m"]++;
else if (duration < 60) ranges["30m-1h"]++;
else if (duration < 120) ranges["1-2h"]++;
else if (duration < 180) ranges["2-3h"]++;
else if (duration < 240) ranges["3-4h"]++;
else ranges["4h+"]++;
});
return {
labels: Object.keys(ranges),
data: Object.values(ranges),
};
}
function renderBarChart(chartData) {
const ctx = document.getElementById("barChart");
new Chart(ctx, {
type: "bar",
data: {
labels: chartData.labels,
datasets: [
{
label: "Sleep Sessions",
data: chartData.data,
backgroundColor: "rgba(79, 172, 254, 0.6)",
borderColor: "rgba(79, 172, 254, 1)",
borderWidth: 1,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
},
},
},
plugins: {
legend: {
display: false,
},
},
},
});
}
function renderDurationChart(chartData) {
const ctx = document.getElementById("durationChart");
new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
datasets: [
{
data: chartData.data,
backgroundColor: [
"rgba(79, 172, 254, 0.8)",
"rgba(0, 242, 254, 0.8)",
"rgba(58, 155, 232, 0.8)",
"rgba(32, 137, 220, 0.8)",
"rgba(21, 119, 208, 0.8)",
"rgba(11, 101, 196, 0.8)",
],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom",
},
},
},
});
}
function renderAvgDurationChart(sleeps, timeRange) {
const days = {};
const today = new Date();
today.setHours(0, 0, 0, 0);
// Initialize all days
for (let i = timeRange - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0];
days[dateStr] = { total: 0, count: 0 };
}
// Calculate totals
sleeps
.filter((sleep) => sleep.end_time)
.forEach((sleep) => {
const date = new Date(sleep.start_time);
date.setHours(0, 0, 0, 0);
const dateStr = date.toISOString().split("T")[0];
if (days.hasOwnProperty(dateStr)) {
const duration = calculateDuration(
sleep.start_time,
sleep.end_time,
);
days[dateStr].total += duration;
days[dateStr].count++;
}
});
const labels = Object.keys(days).map((date) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
});
const data = Object.values(days).map((day) =>
day.count > 0 ? Math.round(day.total / day.count) : 0,
);
const ctx = document.getElementById("avgDurationChart");
new Chart(ctx, {
type: "line",
data: {
labels,
datasets: [
{
label: "Avg Duration (minutes)",
data,
borderColor: "rgba(79, 172, 254, 1)",
backgroundColor: "rgba(79, 172, 254, 0.1)",
fill: true,
tension: 0.4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function (value) {
return value + "m";
},
},
},
},
plugins: {
legend: {
display: false,
},
},
},
});
}
function renderSleepItem(sleep) {
const child = allChildren.find((c) => c.id === sleep.child_id);
const childName = child ? child.name : "Unknown";
const startDate = new Date(sleep.start_time);
const isOngoing = !sleep.end_time;
let durationText = "";
if (isOngoing) {
durationText = '<span class="badge ongoing">Ongoing</span>';
} else {
const duration = calculateDuration(sleep.start_time, sleep.end_time);
durationText = `Duration: ${formatDuration(duration)}`;
}
return `
<div class="sleep-item">
<div class="sleep-info">
<div class="sleep-child">${childName}</div>
<div class="sleep-time">
${startDate.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
${isOngoing ? "" : ` - ${new Date(sleep.end_time).toLocaleString("en-US", { hour: "numeric", minute: "2-digit" })}`}
</div>
<div class="sleep-duration">${durationText}</div>
</div>
</div>
`;
}
async function logout() {
try {
await fetch("/api/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
} catch (error) {
console.error("Logout error:", error);
} finally {
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/login.html";
}
}
// Load data on page load
loadData();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
"""Password hashing utilities using bcrypt."""
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password to hash
Returns:
Hashed password as a string
"""
salt = bcrypt.gensalt()
hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(password: str, hashed_password: str) -> bool:
"""Verify a password against a hashed password.
Args:
password: Plain text password to verify
hashed_password: Previously hashed password
Returns:
True if password matches, False otherwise
"""
result: bool = bcrypt.checkpw(
password.encode("utf-8"), hashed_password.encode("utf-8")
)
return result
+6
View File
@@ -12,3 +12,9 @@ def pytest_configure(config: pytest.Config) -> None:
tmpdir = tempfile.mkdtemp(prefix="baby_monitor_test_") tmpdir = tempfile.mkdtemp(prefix="baby_monitor_test_")
os.environ["DATA_DIR"] = tmpdir os.environ["DATA_DIR"] = tmpdir
os.environ["ENVIRONMENT"] = "development" os.environ["ENVIRONMENT"] = "development"
# Set admin credentials for tests
if "ADMIN_USERNAME" not in os.environ:
os.environ["ADMIN_USERNAME"] = "admin"
if "ADMIN_PASSWORD" not in os.environ:
os.environ["ADMIN_PASSWORD"] = "password"
Generated
+68
View File
@@ -39,6 +39,7 @@ name = "baby-monitor"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn" }, { name = "uvicorn" },
@@ -67,6 +68,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "fastapi", specifier = ">=0.121.0" }, { name = "fastapi", specifier = ">=0.121.0" },
{ name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.0" }, { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.0" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" },
@@ -88,6 +90,72 @@ dev = [
{ name = "types-psycopg2", specifier = ">=2.9.21.20251012" }, { name = "types-psycopg2", specifier = ">=2.9.21.20251012" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "http://10.0.0.2:5001/index/" }
sdist = { url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" }
wheels = [
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" version = "2025.10.5"