"""Exercise Management Endpoints.
Provides functionality for CRUD operations on exercises.
Distinguishes between user-defined custom exercises and system-wide defaults.
Creating, updating, and deleting system-default exercises requires superuser privileges.
"""
from typing import Annotated
from uuid import UUID
from advanced_alchemy.exceptions import DuplicateKeyError
from fastapi import (
APIRouter,
Depends,
Query,
Response,
status,
)
from app.config.constants import FITNESS_TRAINER_ROLE_SLUG
from app.domain.exercises import urls
from app.domain.exercises.deps import ExerciseServiceDep
from app.domain.exercises.filters import ExerciseFilters
from app.domain.exercises.schemas import (
ExerciseCreate,
ExerciseCreateSystem,
ExerciseRead,
ExerciseUpdate,
ExerciseUpdateSystem,
)
from app.domain.users.auth import Authenticate
from app.domain.users.schemas import UserAuth
from app.lib.exceptions import (
ConflictException,
NotFoundException,
PermissionDeniedException,
)
from app.lib.json_response import MsgSpecJSONResponse
exercise_router = APIRouter(
tags=["Exercises"],
)
[docs]
@exercise_router.post(
path=urls.USER_EXERCISE_CREATE,
operation_id="CreateUserExercise",
name="exercises:create",
summary="Create a new user-defined exercise.",
)
async def create_exercise(
user_auth: Annotated[UserAuth, Depends(Authenticate.get_current_active_user())],
exercise_service: ExerciseServiceDep,
exercise_create: ExerciseCreate,
) -> MsgSpecJSONResponse:
"""Create a new exercise for the current user.
Returns:
ExerciseRead: The created exercise data.
Raises:
ConflictException: If an exercise with the same name already exists for the user.
"""
try:
db_obj = await exercise_service.create(
data=exercise_create.model_dump(exclude_unset=True, exclude_none=True) | {"created_by": user_auth.id},
)
exercise = exercise_service.to_schema(db_obj, schema_type=ExerciseRead)
return MsgSpecJSONResponse(content=exercise, status_code=status.HTTP_201_CREATED)
except DuplicateKeyError as exc:
msg = (
f"An exercise with the name '{exercise_create.name}'"
" already exists in your account. Please choose a different name"
)
raise ConflictException(message=msg) from exc
[docs]
@exercise_router.post(
path=urls.SYSTEM_EXERCISE_CREATE,
operation_id="CreateSystemExercise",
name="exercises:create-system",
summary="Create a new system-wide exercise.",
)
async def create_system_exercise(
_: Annotated[UserAuth, Depends(Authenticate.superuser_required())],
exercise_service: ExerciseServiceDep,
exercise_create: ExerciseCreateSystem,
) -> MsgSpecJSONResponse:
"""Create a new system exercise.
Requires superuser privileges.
Returns:
ExerciseRead: The created system exercise data.
Raises:
ConflictException: If a similar exercise already exists in the system.
"""
try:
db_obj = await exercise_service.create(
data=exercise_create.model_dump(exclude_unset=True, exclude_none=True) | {"is_system_default": True},
)
exercise = exercise_service.to_schema(db_obj, schema_type=ExerciseRead)
return MsgSpecJSONResponse(content=exercise, status_code=status.HTTP_201_CREATED)
except DuplicateKeyError as exc:
msg = (
f"Exercise '{exercise_create.name}' cannot be created: a similar exercise "
"(with matching normalized naming) already exists in the system"
)
raise ConflictException(message=msg) from exc
[docs]
@exercise_router.get(
path=urls.EXERCISE_DETAIL,
operation_id="GetExercise",
name="exercises:get",
summary="Get details for a specific exercise.",
)
async def get_exercise(
user_auth: Annotated[UserAuth, Depends(Authenticate.get_current_active_user())],
exercise_service: ExerciseServiceDep,
exercise_id: UUID,
) -> MsgSpecJSONResponse:
"""Get details for a specific exercise by its ID.
Users can access their own exercises, system-default exercises,
or exercises shared with them if they have the 'trainer' role.
Returns:
ExerciseRead: Detailed exercise data.
Raises:
NotFoundException: If the exercise is not found.
PermissionDeniedException: If the user lacks access to the exercise.
"""
db_obj = await exercise_service.get_one_or_none(id=exercise_id)
if db_obj is None:
msg = "Exercise not found"
raise NotFoundException(message=msg)
if not db_obj.is_system_default and (
db_obj.created_by != user_auth.id and user_auth.role_slug != FITNESS_TRAINER_ROLE_SLUG
):
msg = "You do not have permission to access this exercise"
raise PermissionDeniedException(message=msg)
exercise = exercise_service.to_schema(db_obj, schema_type=ExerciseRead)
return MsgSpecJSONResponse(content=exercise)
[docs]
@exercise_router.get(
path=urls.EXERCISE_FIND,
operation_id="FindExercise",
name="exercises:find",
summary="Find an exercise by name or slug.",
)
async def find_exercise(
user_auth: Annotated[UserAuth, Depends(Authenticate.get_current_active_user())],
exercise_service: ExerciseServiceDep,
name: Annotated[str | None, Query(description="Search by exact name for user custom exercise.")] = None,
slug: Annotated[str | None, Query(description="Search by unique slug for system exercise.")] = None,
) -> MsgSpecJSONResponse:
"""Find a user-defined or system exercise by name or slug.
Returns:
ExerciseRead: The found exercise data.
Raises:
NotFoundException: If no exercise matches the criteria.
"""
exercise = await exercise_service.get_exercise_by_filter(
user_id=user_auth.id,
name=name,
slug=slug,
)
return MsgSpecJSONResponse(content=exercise)
[docs]
@exercise_router.get(
path=urls.EXERCISE_LIST,
operation_id="ListExercises",
name="exercises:list",
summary="List of exercises.",
)
async def get_list_exercises(
user_auth: Annotated[UserAuth, Depends(Authenticate.get_current_active_user())],
exercise_service: ExerciseServiceDep,
params: Annotated[ExerciseFilters, Query()],
) -> MsgSpecJSONResponse:
"""Retrieve a paginated list of exercises based on filters.
Returns:
OffsetPagination[ExerciseRead]: A paginated list of exercises.
"""
exercises = await exercise_service.get_exercises_paginated_dto(params=params, user_id=user_auth.id)
return MsgSpecJSONResponse(content=exercises)
[docs]
@exercise_router.patch(
path=urls.USER_EXERCISE_UPDATE,
operation_id="UpdateUserExercise",
name="exercises:update",
summary="Update a user-defined exercise.",
)
async def update_user_exercise(
user_auth: Annotated[UserAuth, Depends(Authenticate.get_current_active_user())],
exercise_service: ExerciseServiceDep,
exercise_update: ExerciseUpdate,
exercise_id: UUID,
) -> MsgSpecJSONResponse:
"""Update details for a specific user-defined exercise.
Returns:
ExerciseRead: The updated exercise data.
Raises:
NotFoundException: If the exercise is not found.
ConflictException: If the new name conflicts with an existing exercise.
"""
try:
db_obj = await exercise_service.update_exercise(
exercise_id=exercise_id,
data=exercise_update.model_dump(exclude_unset=True),
extra_filters={"created_by": user_auth.id},
)
exercise = exercise_service.to_schema(db_obj, schema_type=ExerciseRead)
return MsgSpecJSONResponse(content=exercise)
except DuplicateKeyError as exc:
msg = (
f"An exercise with the name '{exercise_update.name}'"
" already exists in your account. Please choose a different name"
)
raise ConflictException(message=msg) from exc
[docs]
@exercise_router.patch(
path=urls.SYSTEM_EXERCISE_UPDATE,
operation_id="UpdateSystemExercise",
name="exercises:update-system",
summary="Update a system-wide exercise.",
)
async def update_system_exercise(
_: Annotated[UserAuth, Depends(Authenticate.superuser_required())],
exercise_service: ExerciseServiceDep,
exercise_update: ExerciseUpdateSystem,
exercise_id: UUID,
) -> MsgSpecJSONResponse:
"""Update details for a specific system exercise.
Requires superuser privileges.
Returns:
ExerciseRead: The updated system exercise data.
Raises:
NotFoundException: If the system exercise is not found.
ConflictException: If the new name conflicts with an existing system exercise.
"""
try:
db_obj = await exercise_service.update_exercise(
exercise_id=exercise_id,
data=exercise_update.model_dump(exclude_unset=True),
extra_filters={"is_system_default": True},
)
exercise = exercise_service.to_schema(db_obj, schema_type=ExerciseRead)
return MsgSpecJSONResponse(content=exercise)
except DuplicateKeyError as exc:
msg = (
f"Exercise '{exercise_update.name}' cannot be created: a similar exercise "
"(with matching normalized naming) already exists in the system"
)
raise ConflictException(message=msg) from exc
[docs]
@exercise_router.delete(
path=urls.EXERCISE_DELETE,
operation_id="DeleteExercise",
name="exercises:delete",
summary="Delete a user-defined or system exercise.",
)
async def delete_exercise(
user_auth: Annotated[UserAuth, Depends(Authenticate.get_current_active_user())],
exercise_service: ExerciseServiceDep,
exercise_id: UUID,
) -> Response:
"""Delete a specific exercise by its ID.
Users can delete their own exercises.
Requires superuser privileges to delete system exercises.
Returns:
Response: 204 No Content on successful deletion.
Raises:
NotFoundException: If the exercise is not found.
PermissionDeniedException: If the user lacks permission to delete the exercise.
"""
await exercise_service.delete_exercise(exercise_id=exercise_id, user_auth=user_auth)
return Response(status_code=status.HTTP_204_NO_CONTENT)