added settings page with ability to update password, delete user and CRUD and share abilities for associated children
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 18s

This commit is contained in:
Brian Bjarke Jensen
2025-11-11 00:58:55 +01:00
parent 0d27c47f39
commit d066c6d399
19 changed files with 1361 additions and 25 deletions
+111 -3
View File
@@ -3,6 +3,7 @@
import secrets
from fastapi import APIRouter, HTTPException, Depends, Header
from typing import Annotated
from pydantic import BaseModel
from baby_monitor.models.auth import (
LoginRequest,
@@ -11,22 +12,35 @@ from baby_monitor.models.auth import (
RegisterResponse,
)
from baby_monitor.repositories import (
get_child_repository,
get_diaper_change_repository,
get_feeding_repository,
get_invitation_repository,
get_sleep_repository,
get_token_repository,
get_user_repository,
)
from baby_monitor.repositories.dependencies.get_invitation_repository import (
get_invitation_repository,
)
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
UserRepositoryInterface,
InvitationRepositoryInterface,
ChildRepositoryInterface,
FeedingRepositoryInterface,
DiaperChangeRepositoryInterface,
SleepRepositoryInterface,
)
from baby_monitor.utils.password import hash_password, verify_password
router = APIRouter(prefix="/api", tags=["authentication"])
class ChangePasswordRequest(BaseModel):
"""Request model for password change."""
current_password: str
new_password: str
def verify_token(
authorization: Annotated[str | None, Header()] = None,
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
@@ -191,3 +205,97 @@ def get_current_user(
"username": user["username"],
"is_admin": user.get("is_admin", False),
}
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], # noqa: E501
) -> dict:
"""Change password for the current user."""
# Get user with credentials
user = user_repo.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verify current password
if not verify_password(request.current_password, user["hashed_password"]):
raise HTTPException(status_code=401, detail="Current password is incorrect")
# Hash new password
new_password_hash = hash_password(request.new_password)
# Update password
success = user_repo.update_password(user_id, new_password_hash)
if not success:
raise HTTPException(status_code=500, detail="Failed to update password")
return {"message": "Password changed successfully"}
@router.delete("/account")
def delete_account(
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], # noqa: E501
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)], # noqa: E501
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
], # noqa: E501
diaper_repo: Annotated[
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
], # noqa: E501
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)], # noqa: E501
) -> dict:
"""Delete current user's account and cascade delete orphaned children."""
# Get user's children
children = child_repo.get_by_user_id(user_id)
deleted_children = []
deleted_feedings = 0
deleted_diapers = 0
deleted_sleeps = 0
# Process each child
for child in children:
child_id = child["id"]
# Remove the parent-child relationship
child_repo.remove_parent(child_id, user_id)
# Check if child has any remaining parents
remaining_parents = child_repo.get_parent_ids(child_id)
if len(remaining_parents) == 0:
# Child is orphaned, delete all logs first
feedings = feeding_repo.get_by_child_id(child_id)
for feeding in feedings:
feeding_repo.delete(feeding["id"])
deleted_feedings += 1
diapers = diaper_repo.get_by_child_id(child_id)
for diaper in diapers:
diaper_repo.delete(diaper["id"])
deleted_diapers += 1
sleeps = sleep_repo.get_by_child_id(child_id)
for sleep in sleeps:
sleep_repo.delete(sleep["id"])
deleted_sleeps += 1
# Now delete the child
child_repo.delete(child_id)
deleted_children.append(child["name"])
# Finally, delete the user
user_repo.delete(user_id)
return {
"message": "Account deleted successfully",
"deleted_children": deleted_children,
"deleted_logs": {
"feedings": deleted_feedings,
"diaper_changes": deleted_diapers,
"sleep_sessions": deleted_sleeps,
},
}
+116
View File
@@ -1,7 +1,10 @@
"""Child management router."""
import secrets
from datetime import datetime, timedelta, UTC
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from baby_monitor.models.child import CreateChildRequest, ChildResponse
from baby_monitor.routers.auth import verify_token
@@ -9,10 +12,35 @@ from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.repositories.dependencies.get_child_invitation_repository import ( # noqa: E501
get_child_invitation_repository,
)
from baby_monitor.repositories.interfaces import ChildInvitationRepositoryInterface # noqa: E501
router = APIRouter(prefix="/api/children", tags=["children"])
class CreateChildInvitationRequest(BaseModel):
"""Request model for creating a child invitation."""
child_id: int
class ChildInvitationResponse(BaseModel):
"""Response model for child invitation."""
code: str
child_id: int
expires_at: str
message: str
class RedeemChildInvitationRequest(BaseModel):
"""Request model for redeeming a child invitation."""
code: str
@router.post("", response_model=ChildResponse, status_code=201)
def create_child(
request: CreateChildRequest,
@@ -90,3 +118,91 @@ def update_child(
raise HTTPException(status_code=500, detail="Update failed")
return ChildResponse(**updated_child)
@router.post("/invitations", response_model=ChildInvitationResponse)
def create_child_invitation(
request: CreateChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], # noqa: E501
invitation_repo: Annotated[
ChildInvitationRepositoryInterface, Depends(get_child_invitation_repository)
], # noqa: E501
) -> ChildInvitationResponse:
"""Create an invitation code to share child access with another user."""
# Verify the child exists and user has access
child = child_repo.get_by_id(request.child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
parent_ids = child_repo.get_parent_ids(request.child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
# Generate a short invitation code (8 characters)
code = secrets.token_urlsafe(6)[:8].upper()
# Calculate expiration (7 days from now)
expires_at = datetime.now(UTC) + timedelta(days=7)
# Create the invitation
invitation_repo.create_invitation(
code=code,
child_id=request.child_id,
created_by_user_id=user_id,
expires_at=expires_at,
)
return ChildInvitationResponse(
code=code,
child_id=request.child_id,
expires_at=expires_at.isoformat(),
message="Invitation code created. Valid for 7 days.",
)
@router.post("/invitations/redeem")
def redeem_child_invitation(
request: RedeemChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], # noqa: E501
invitation_repo: Annotated[
ChildInvitationRepositoryInterface, Depends(get_child_invitation_repository)
], # noqa: E501
) -> dict:
"""Redeem an invitation code to gain access to a child."""
# Verify the invitation code
if not invitation_repo.verify_invitation(request.code):
raise HTTPException(
status_code=400, detail="Invalid or expired invitation code"
)
# Get the invitation details
invitation = invitation_repo.get_by_code(request.code)
if not invitation:
raise HTTPException(status_code=400, detail="Invalid invitation code")
child_id = invitation["child_id"]
# Check if user already has access to this child
parent_ids = child_repo.get_parent_ids(child_id)
if user_id in parent_ids:
raise HTTPException(
status_code=400, detail="You already have access to this child"
)
# Add user as a parent
child_repo.add_parent(child_id, user_id)
# Consume the invitation
invitation_repo.consume_invitation(request.code, user_id, datetime.now(UTC))
# Get child details
child = child_repo.get_by_id(child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
return {
"message": f"Successfully added access to {child['name']}",
"child": ChildResponse(**child),
}