Refactor health targets and server settings modules

Improved health targets CRUD logic, updated models to use SQLAlchemy 2.0 style, and split Pydantic schemas for read/update operations with validation. Enhanced error handling in health targets and server settings CRUD functions. Updated FastAPI routers to use new schemas and response models, and made login photo deletion asynchronous with proper status codes.
This commit is contained in:
João Vitória Silva
2026-01-07 22:53:49 +00:00
parent d3b9cf7ed3
commit 1043d08c3b
8 changed files with 270 additions and 124 deletions

View File

@@ -0,0 +1,38 @@
"""
Health targets module for managing user health goals.
This module provides CRUD operations and data models for user
health targets including weight, steps, and sleep goals.
Exports:
- CRUD: get_health_targets_by_user_id, create_health_targets,
edit_health_target
- Schemas: HealthTargetsBase, HealthTargetsUpdate,
HealthTargetsRead
- Models: HealthTargets (ORM model)
"""
from .crud import (
get_health_targets_by_user_id,
create_health_targets,
edit_health_target,
)
from .models import HealthTargets as HealthTargetsModel
from .schema import (
HealthTargetsBase,
HealthTargetsRead,
HealthTargetsUpdate,
)
__all__ = [
# CRUD operations
"get_health_targets_by_user_id",
"create_health_targets",
"edit_health_target",
# Database model
"HealthTargetsModel",
# Pydantic schemas
"HealthTargetsBase",
"HealthTargetsRead",
"HealthTargetsUpdate",
]

View File

@@ -1,6 +1,7 @@
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
import health.health_targets.models as health_targets_models
import health.health_targets.schema as health_targets_schema
@@ -11,28 +12,56 @@ import core.logger as core_logger
def get_health_targets_by_user_id(
user_id: int, db: Session
) -> health_targets_models.HealthTargets | None:
"""
Retrieve health targets for a specific user.
Args:
user_id: The ID of the user to fetch targets for.
db: SQLAlchemy database session.
Returns:
The HealthTargets model if found, None otherwise.
Raises:
HTTPException: 500 error if database query fails.
"""
try:
# Get the health_targets from the database
return (
db.query(health_targets_models.HealthTargets)
.filter(health_targets_models.HealthTargets.user_id == user_id)
.first()
stmt = select(health_targets_models.HealthTargets).where(
health_targets_models.HealthTargets.user_id == user_id
)
except Exception as err:
return db.execute(stmt).scalar_one_or_none()
except SQLAlchemyError as db_err:
# Log the exception
core_logger.print_to_log(
f"Error in get_health_targets_by_user_id: {err}", "error", exc=err
f"Database error in get_health_targets_by_user_id: " f"{db_err}",
"error",
exc=db_err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
# Raise an HTTPException with a 500 status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
detail="Database error occurred",
) from db_err
def create_health_targets(
user_id: int, db: Session
) -> health_targets_schema.HealthTargets:
) -> health_targets_schema.HealthTargetsRead:
"""
Create new health targets for a user.
Args:
user_id: The ID of the user to create targets for.
db: SQLAlchemy database session.
Returns:
The created HealthTargetsRead schema.
Raises:
HTTPException: 409 error if targets already exist.
HTTPException: 500 error if database operation fails.
"""
try:
# Create a new health_target
db_health_targets = health_targets_models.HealthTargets(
@@ -45,7 +74,7 @@ def create_health_targets(
db.commit()
db.refresh(db_health_targets)
health_targets = health_targets_schema.HealthTargets(
health_targets = health_targets_schema.HealthTargetsRead(
id=db_health_targets.id,
user_id=user_id,
)
@@ -56,40 +85,52 @@ def create_health_targets(
# Rollback the transaction
db.rollback()
# Raise an HTTPException with a 409 Internal Server Error status code
# Raise an HTTPException with a 409 Conflict status code
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Duplicate entry error. Check if there is already an entry created for the user",
detail="Duplicate entry error. Check if there is "
"already an entry created for the user",
) from integrity_error
except Exception as err:
except SQLAlchemyError as db_err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in create_health_targets: {err}", "error", exc=err
f"Database error in create_health_targets: {db_err}",
"error",
exc=db_err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
# Raise an HTTPException with a 500 status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
detail="Database error occurred",
) from db_err
def edit_health_target(
health_target: health_targets_schema.HealthTargets,
health_target: health_targets_schema.HealthTargetsUpdate,
user_id: int,
db: Session,
) -> health_targets_models.HealthTargets:
"""
Update health targets for a specific user.
Args:
health_target: Schema with fields to update.
user_id: The ID of the user to update targets for.
db: SQLAlchemy database session.
Returns:
The updated HealthTargets model.
Raises:
HTTPException: 404 error if targets not found.
HTTPException: 500 error if database operation fails.
"""
try:
# Get the user health target from the database
db_health_target = (
db.query(health_targets_models.HealthTargets)
.filter(
health_targets_models.HealthTargets.user_id == user_id,
)
.first()
)
db_health_target = get_health_targets_by_user_id(user_id, db)
if db_health_target is None:
raise HTTPException(
@@ -100,27 +141,30 @@ def edit_health_target(
# Dictionary of the fields to update if they are not None
health_target_data = health_target.model_dump(exclude_unset=True)
# Iterate over the fields and update the db_user dynamically
# Iterate over the fields and update dynamically
for key, value in health_target_data.items():
setattr(db_health_target, key, value)
# Commit the transaction
db.commit()
db.refresh(db_health_target)
return db_health_target
except HTTPException as http_err:
raise http_err
except Exception as err:
except SQLAlchemyError as db_err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in edit_health_target: {err}", "error", exc=err
f"Database error in edit_health_target: {db_err}",
"error",
exc=db_err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
# Raise an HTTPException with a 500 status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error: {err}",
) from err
detail="Database error occurred",
) from db_err

View File

@@ -1,64 +1,48 @@
from sqlalchemy import (
Column,
Integer,
ForeignKey,
DECIMAL,
)
from sqlalchemy.orm import relationship
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class HealthTargets(Base):
"""
SQLAlchemy model representing health targets for users.
This table stores health-related goals and targets for individual users,
including weight, daily steps, and sleep duration objectives.
User health targets configuration.
Attributes:
id (int): Primary key, auto-incremented unique identifier for the health target.
user_id (int): Foreign key referencing users.id. Each user can have only one
set of health targets (unique constraint). Cascades on delete.
weight (Decimal): Target weight in kilograms with precision of 10 digits
and 2 decimal places. Optional field.
steps (int): Target number of daily steps. Optional field.
sleep (int): Target sleep duration in seconds. Optional field.
user (relationship): SQLAlchemy relationship to the User model, establishing
a bidirectional link via the 'health_targets' back_populates attribute.
Relationships:
- One-to-one relationship with User model (enforced by unique constraint on user_id)
Indexes:
- user_id: Indexed for efficient lookups
id: Primary key.
user_id: Foreign key to users table (unique).
weight: Target weight in kg.
steps: Target daily steps count.
sleep: Target sleep duration in seconds.
user: Relationship to User model.
"""
__tablename__ = "health_targets"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(
Integer,
id: Mapped[int] = mapped_column(
primary_key=True,
autoincrement=True,
)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
comment="User ID that the health_target belongs",
)
weight = Column(
DECIMAL(precision=10, scale=2),
weight: Mapped[Decimal | None] = mapped_column(
Numeric(precision=10, scale=2),
nullable=True,
comment="Weight in kg",
)
steps = Column(
Integer,
steps: Mapped[int | None] = mapped_column(
nullable=True,
comment="Number of steps taken",
)
sleep = Column(
Integer,
sleep: Mapped[int | None] = mapped_column(
nullable=True,
comment="Number of hours slept in seconds",
)
# Define a relationship to the User model
# TODO: Change to Mapped["User"] when all modules use mapped
user = relationship("User", back_populates="health_targets")

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Callable
from fastapi import APIRouter, Depends, Security
from fastapi import APIRouter, Depends, Security, status
from sqlalchemy.orm import Session
import health.health_targets.schema as health_targets_schema
@@ -16,7 +16,7 @@ router = APIRouter()
@router.get(
"/",
response_model=health_targets_schema.HealthTargets | None,
response_model=health_targets_schema.HealthTargetsRead | None,
)
async def read_health_targets_all(
_check_scopes: Annotated[
@@ -30,7 +30,7 @@ async def read_health_targets_all(
Session,
Depends(core_database.get_db),
],
) -> health_targets_schema.HealthTargets | None:
) -> health_targets_schema.HealthTargetsRead | None:
"""
Retrieve all health targets for the authenticated user.
@@ -46,8 +46,9 @@ async def read_health_targets_all(
db (Session): SQLAlchemy database session dependency for executing database queries.
Returns:
health_targets_schema.HealthTargets | None: The health targets object containing
all targets for the user, or None if no targets are found.
health_targets_schema.HealthTargetsRead | None: The
health targets object containing all targets for
the user, or None if no targets are found.
Raises:
HTTPException: May raise 401 Unauthorized if the token is invalid or expired.
@@ -59,10 +60,11 @@ async def read_health_targets_all(
@router.put(
"/",
response_model=health_targets_schema.HealthTargets,
response_model=health_targets_schema.HealthTargetsRead,
status_code=status.HTTP_200_OK,
)
async def update_health_targets(
health_targets: health_targets_schema.HealthTargets,
health_targets: health_targets_schema.HealthTargetsUpdate,
_check_scopes: Annotated[
Callable, Security(auth_security.check_scopes, scopes=["health:write"])
],
@@ -74,7 +76,7 @@ async def update_health_targets(
Session,
Depends(core_database.get_db),
],
) -> health_targets_schema.HealthTargets:
) -> health_targets_schema.HealthTargetsRead:
"""
Update health targets for the authenticated user.
@@ -82,16 +84,22 @@ async def update_health_targets(
weight goals, activity targets, or other health metrics.
Args:
health_targets (health_targets_schema.HealthTargets): The health targets data
to be updated, containing the new values for various health metrics.
_check_scopes (Callable): Security dependency that verifies the user has
'health:write' scope permission.
token_user_id (int): The authenticated user's ID extracted from the access token.
db (Session): Database session dependency for database operations.
health_targets (
health_targets_schema.HealthTargetsUpdate): The
health targets data to be updated, containing the
new values for various health metrics.
_check_scopes (Callable): Security dependency that
verifies the user has 'health:write' scope
permission.
token_user_id (int): The authenticated user's ID
extracted from the access token.
db (Session): Database session dependency for database
operations.
Returns:
health_targets_schema.HealthTargets: The updated health targets object
reflecting the changes made in the database.
health_targets_schema.HealthTargetsRead: The updated
health targets object reflecting the changes made
in the database.
Raises:
HTTPException: May raise authentication or authorization errors if the user

View File

@@ -1,32 +1,102 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
class HealthTargets(BaseModel):
class HealthTargetsBase(BaseModel):
"""
Pydantic model representing health targets for a user.
This model defines the structure for storing and validating health-related goals
such as weight, daily steps, and sleep duration.
Base schema for health targets with shared fields.
Attributes:
id (int | None): Unique identifier for the health target record. Defaults to None.
user_id (int | None): Foreign key reference to the user. Defaults to None.
weight (float | None): Target weight in kilograms or pounds. Defaults to None.
steps (int | None): Target number of daily steps. Defaults to None.
sleep (int | None): Target sleep duration in minutes. Defaults to None.
Configuration:
from_attributes: Allows model creation from ORM objects.
extra: Forbids extra fields not defined in the model.
validate_assignment: Validates values when attributes are assigned after creation.
weight: Target weight in kg.
steps: Target daily steps count.
sleep: Target sleep duration in seconds.
"""
id: int | None = None
user_id: int | None = None
weight: float | None = None
steps: int | None = None
sleep: int | None = None
@field_validator("weight")
@classmethod
def validate_weight(cls, v: float | None) -> float | None:
"""
Validate weight is positive and reasonable.
Args:
v: Weight value to validate.
Returns:
Validated weight value.
Raises:
ValueError: If weight is not between 0 and 500 kg.
"""
if v is not None and (v <= 0 or v > 500):
raise ValueError("Weight must be between 0 and 500 kg")
return v
@field_validator("steps")
@classmethod
def validate_steps(cls, v: int | None) -> int | None:
"""
Validate steps is non-negative.
Args:
v: Steps value to validate.
Returns:
Validated steps value.
Raises:
ValueError: If steps is negative.
"""
if v is not None and v < 0:
raise ValueError("Steps cannot be negative")
return v
@field_validator("sleep")
@classmethod
def validate_sleep(cls, v: int | None) -> int | None:
"""
Validate sleep duration is within reasonable bounds.
Args:
v: Sleep duration in seconds to validate.
Returns:
Validated sleep duration.
Raises:
ValueError: If sleep is not between 0 and 86400
seconds (24 hours).
"""
if v is not None and (v < 0 or v > 86400):
raise ValueError("Sleep must be between 0 and 86400 seconds (24 hours)")
return v
class HealthTargetsUpdate(HealthTargetsBase):
"""
Schema for updating health targets.
Inherits all validation from HealthTargetsBase.
All fields are optional for partial updates.
"""
class HealthTargetsRead(HealthTargetsBase):
"""
Schema for reading health targets.
Attributes:
id: Unique identifier for the health target record.
user_id: Foreign key reference to the user.
"""
id: int
user_id: int
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
from_attributes=True,
extra="forbid",
validate_assignment=True,
)

View File

@@ -1,6 +1,7 @@
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
import server_settings.schema as server_settings_schema
import server_settings.models as server_settings_models
@@ -27,16 +28,18 @@ def get_server_settings(db: Session) -> server_settings_models.ServerSettings |
server_settings_models.ServerSettings.id == 1
)
return db.execute(stmt).scalar_one_or_none()
except Exception as err:
except SQLAlchemyError as db_err:
# Log the exception
core_logger.print_to_log(
f"Error in get_server_settings: {err}", "error", exc=err
f"Database error in get_server_settings: {db_err}",
"error",
exc=db_err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
# Raise an HTTPException with a 500 status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal Server Error",
) from err
detail="Database error occurred",
) from db_err
def edit_server_settings(
@@ -79,17 +82,19 @@ def edit_server_settings(
return db_server_settings
except HTTPException as http_err:
raise http_err
except Exception as err:
except SQLAlchemyError as db_err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in edit_server_settings: {err}", "error", exc=err
f"Database error in edit_server_settings: {db_err}",
"error",
exc=db_err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
# Raise an HTTPException with a 500 status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal Server Error",
) from err
detail="Database error occurred",
) from db_err

View File

@@ -22,7 +22,7 @@ async def read_public_server_settings(
Session,
Depends(core_database.get_db),
],
):
) -> server_settings_schema.ServerSettingsReadPublic:
"""
Get public server settings (unauthenticated).
@@ -49,7 +49,7 @@ async def read_public_server_settings(
async def list_tile_maps_templates(
request: Request,
response: Response,
):
) -> list[server_settings_schema.TileMapsTemplate]:
"""
Retrieve available tile map templates for server settings (unauthenticated).

View File

@@ -143,7 +143,7 @@ async def upload_login_photo(
await file_validator.validate_image_file(file)
# Ensure the 'server_images' directory exists
upload_dir = core_config.SERVER_IMAGES_DIR
os.makedirs(upload_dir, exist_ok=True)
await aiofiles.os.makedirs(upload_dir, exist_ok=True)
# Build the full path with the name "login.png"
file_path = os.path.join(upload_dir, "login.png")
@@ -172,15 +172,15 @@ async def upload_login_photo(
@router.delete(
"/upload/login",
status_code=status.HTTP_200_OK,
response_model=dict,
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
async def delete_login_photo(
_check_scopes: Annotated[
Callable,
Security(auth_security.check_scopes, scopes=["server_settings:write"]),
],
) -> dict:
) -> None:
"""
Delete custom login page photo.
@@ -200,11 +200,8 @@ async def delete_login_photo(
)
# Check if the file exists and delete it asynchronously
if os.path.exists(file_path):
if await aiofiles.os.path.exists(file_path):
await aiofiles.os.remove(file_path)
return {"detail": "Login photo deleted successfully"}
except Exception as err:
# Log the exception
core_logger.print_to_log(