14 Commits
Author SHA1 Message Date
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
28 changed files with 3132 additions and 85 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/b
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",
+22
View File
@@ -12,6 +12,8 @@ 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.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 +49,8 @@ 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(health_router) app.include_router(health_router)
@@ -75,6 +79,24 @@ 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("/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})>"
+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})>"
+11
View File
@@ -0,0 +1,11 @@
"""Feeding type enumeration."""
from enum import StrEnum
class FeedingType(StrEnum):
"""Enumeration of feeding types."""
LEFT_BREAST = "left_breast"
RIGHT_BREAST = "right_breast"
BOTTLE = "bottle"
+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
@@ -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)
@@ -30,17 +30,64 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base: "type[DeclarativeBase]" = 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:
"""Initialize the database by creating all tables.""" """Initialize the database by creating all tables."""
# 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.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 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,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,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."""
@@ -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
] ]
+25 -58
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"])
@@ -55,22 +54,15 @@ def verify_admin(
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], user_repo: Annotated[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(status_code=403, detail="Admin access required")
return user_id
# If not in database but has valid token with user_id=1, if not user.get("is_admin", False):
# it's the environment-based admin (only assigned during env admin login) raise HTTPException(status_code=403, detail="Admin access required")
if user_id == 1:
return user_id
# User not found and not environment admin return user_id
raise HTTPException(status_code=401, detail="User not found")
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
@@ -78,9 +70,6 @@ def login(
credentials: LoginRequest, credentials: LoginRequest,
token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)], token_repo: Annotated[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.
@@ -88,38 +77,25 @@ 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 # Generate a secure random token
if creds_repo.verify_admin_credentials(credentials.username, credentials.password): access_token = secrets.token_urlsafe(32)
# Generate a secure random token token_repo.store(access_token, user_id=user["id"], ttl=3600)
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( return LoginResponse(
message="Login successful", message="Login successful",
username=credentials.username, username=credentials.username,
access_token=access_token, access_token=access_token,
is_admin=True, is_admin=user.get("is_admin", False),
) )
raise HTTPException(status_code=401, detail="Invalid username or password")
@router.post("/logout") @router.post("/logout")
@@ -177,11 +153,11 @@ def register(
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
@@ -203,21 +179,12 @@ def register(
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[UserRepositoryInterface, Depends(get_user_repository)], user_repo: Annotated[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"],
+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)
+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")
+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>
File diff suppressed because it is too large Load Diff
+390 -8
View File
@@ -117,6 +117,92 @@
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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -129,6 +215,8 @@
<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" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div> <div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div> </div>
@@ -138,6 +226,40 @@
</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>
<script> <script>
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username"); const username = localStorage.getItem("username");
@@ -160,6 +282,14 @@
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("menuLogout").addEventListener("click", () => { document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu(); closeMenu();
logout(); logout();
@@ -187,20 +317,272 @@
} }
}) })
.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;
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];
}
}
} catch (error) {
console.error("Error loading feeding status:", error);
}
renderContent();
startUpdateTimer();
}
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 updateFeedingTimeDisplay() {
const timeElement = document.getElementById("feedingTimeInfo");
if (timeElement && activeFeeding) {
timeElement.textContent = formatTime(activeFeeding.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>
`;
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
${feedingButtonHtml}
<p>Your session is active.</p>
`;
}
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.");
}
}
async function logout() { async function logout() {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
+391
View File
@@ -0,0 +1,391 @@
<!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>
+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"