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

This commit is contained in:
Brian Bjarke Jensen
2025-11-09 11:54:28 +01:00
parent 1950b600cf
commit 16e7153f5f
10 changed files with 656 additions and 2 deletions
+8
View File
@@ -12,6 +12,7 @@ from fastapi.staticfiles import StaticFiles
from baby_monitor.routers.auth import router as auth_router
from baby_monitor.routers.auth import verify_token
from baby_monitor.routers.admin import router as admin_router
from baby_monitor.routers.child import router as child_router
from baby_monitor.routers.health import router as health_router
from baby_monitor.repositories.dependencies.get_database import init_db
@@ -47,6 +48,7 @@ app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
# Include routers
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(child_router)
app.include_router(health_router)
@@ -75,6 +77,12 @@ def serve_admin() -> FileResponse:
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("/api/")
def read_root(token: Annotated[str, Depends(verify_token)]) -> dict:
"""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,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)
@@ -77,6 +77,7 @@ def init_db() -> None:
# Import models to register them with Base.metadata
# This must be done before create_all() is called
from baby_monitor.models.db.user import User # noqa: F401
from baby_monitor.models.db.child import Child # noqa: F401
from baby_monitor.models.invitation import Invitation # noqa: F401
# Ensure data directory exists
@@ -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."""
+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)
+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>
+22 -2
View File
@@ -129,6 +129,7 @@
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
@@ -160,6 +161,10 @@
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
@@ -187,14 +192,29 @@
}
})
.then((user) => {
// Show authenticated content
document.getElementById("content").innerHTML = `
// Check if user has any children
return fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
})
.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;
}
// Show authenticated content
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
<p>Your session is active.</p>
`;
});
})
.catch((error) => {
console.error("Error:", error);