Source code for app.domain.exercises.schemas

from datetime import datetime
from enum import StrEnum
from re import compile as re_compile
from typing import (
    Annotated,
    Self,
)
from uuid import UUID

from pydantic import (
    ConfigDict,
    Field,
    computed_field,
    model_validator,
)
from pydantic.json_schema import SkipJsonSchema

from app.config.base import get_settings
from app.domain.catalogs.schemas import (
    EquipmentRead,
    ExerciseTagRead,
    MuscleGroupRead,
)
from app.lib.schema import (
    CamelizedBaseSchema,
    CamelizedBaseStruct,
)

__all__ = (
    "CategoryType",
    "DifficultyLevelType",
    "ExerciseCreate",
    "ExerciseCreateSystem",
    "ExerciseRead",
    "ExerciseScope",
    "ExerciseUpdate",
    "ExerciseUpdateSystem",
    "ForceType",
    "MechanicType",
)

settings = get_settings()

FULL_CDN_PREFIX = settings.app.cdn_exercises_url_prefix

SLUG_PATTERN = re_compile(r"[^a-z0-9]+")
PATH_PATTERN = r"^/[a-z0-9\-_]+(?:/[a-z0-9\-_]+)*\.(?:jpg|jpeg|png|webp)$"

StartPath = Annotated[
    str,
    Field(
        pattern=PATH_PATTERN,
        description=(
            "Path relative to the exercise folder. Must start with a slash. Used for 'start' position images. "
            "Example format: '/{exercise name}/{filename}.jpg'"
        ),
        examples=["/ab-roller/0.jpg", "/bench-press/images/start.webp"],
    ),
]
EndPath = Annotated[
    str,
    Field(
        pattern=PATH_PATTERN,
        description=(
            "Path relative to the exercise folder. Must start with a slash. Used for 'end' position images. "
            "Example format: '/{exercise name}/{filename}.jpg'"
        ),
        examples=["/ab-roller/0.jpg", "/bench-press/images/start.webp"],
    ),
]


def slugify(name: str) -> str:
    return SLUG_PATTERN.sub("-", name.lower()).strip("-")


[docs] class ExerciseScope(StrEnum): """Defines the visibility and ownership scope of an exercise. Used for filtering system-provided vs user-created content. """ SYSTEM = "system" USER = "user" ALL = "all"
[docs] class DifficultyLevelType(StrEnum): """The perceived difficulty level of an exercise.""" BEGINNER = "beginner" INTERMEDIATE = "intermediate" EXPERT = "expert"
[docs] class ForceType(StrEnum): """The type of force required to perform the exercise.""" PULL = "pull" PUSH = "push" STATIC = "static"
[docs] class MechanicType(StrEnum): """The mechanical action of the exercise.""" COMPOUND = "compound" ISOLATION = "isolation"
[docs] class CategoryType(StrEnum): """The primary fitness category or goal of the exercise.""" STRENGTH = "strength" STRETCHING = "stretching" PLYOMETRICS = "plyometrics" STRONGMAN = "strongman" POWERLIFTING = "powerlifting" CARDIO = "cardio" OLYMPIC_WEIGHTLIFTING = "olympic weightlifting"
[docs] class ExerciseBase(CamelizedBaseSchema): """Base exercise attributes used as a blueprint for other schemas.""" name: Annotated[ str, Field( default=None, min_length=3, max_length=100, description="Unique exercise name (3-100 characters).", ), ] primary_muscles: Annotated[ list[int] | None, Field( min_length=1, description="IDs of the **main muscle groups** targeted. Must include at least one ID.", ), ] = None secondary_muscles: Annotated[ list[int] | None, Field( min_length=1, description="IDs of the **assisting muscle groups** involved. Must include at least one ID.", ), ] = None force: ForceType | None = None difficulty_level: DifficultyLevelType | None = None mechanic: MechanicType | None = None equipment: Annotated[ list[int] | None, Field( min_length=1, description="IDs of the **equipment** required for the exercise. Must include at least one ID.", ), ] = None category: CategoryType | None = None instructions: str | None = None
[docs] class ExerciseCreate(ExerciseBase): """Schema for user-level exercise creation. Requires core fields.""" name: Annotated[ str, Field( min_length=3, max_length=100, description="Unique exercise name (3-100 characters).", ), ] primary_muscles: Annotated[ list[int], Field( min_length=1, description="IDs of the **main muscle groups** targeted. Must include at least one ID.", ), ] difficulty_level: DifficultyLevelType category: CategoryType
[docs] class ExerciseCreateSystem(ExerciseCreate): """Admin-level schema for system exercises with specific fields.""" image_path_start: StartPath | None = None image_path_end: EndPath | None = None instructions: str tags: Annotated[ list[int], Field( min_length=1, description="System-defined tag IDs (e.g., equipment types, goals). Must include at least one ID.", ), ] @computed_field # type: ignore[prop-decorator] @property def slug(self) -> str: """URL-friendly identifier generated from the name.""" return slugify(self.name)
[docs] class ExerciseUpdate(ExerciseBase): """Schema for partial updates of user exercises.""" primary_muscles: Annotated[ list[int], Field( default=None, min_length=1, description="IDs of the **main muscle groups** targeted. Must include at least one ID.", ), ]
[docs] class ExerciseUpdateSystem(ExerciseUpdate): """Schema for administrative updates of system exercises.""" model_config = ConfigDict(frozen=False) image_path_start: StartPath | None = None image_path_end: EndPath | None = None tags: Annotated[ list[int], Field( default=None, min_length=1, description="Updated list of system tag IDs. Must include at least one ID.", ), ] slug: Annotated[str | None, SkipJsonSchema()] = None @model_validator(mode="after") def generate_slug(self) -> Self: """Generate a new slug only if the name is being updated.""" if self.name: self.slug = slugify(self.name) self.__pydantic_fields_set__.add("slug") return self
[docs] class ExerciseRead(CamelizedBaseStruct): """Complete exercise data representation for read operations.""" id: UUID name: str primary_muscles: list[MuscleGroupRead] secondary_muscles: list[MuscleGroupRead] force: str | None difficulty_level: str mechanic: str | None equipment: list[EquipmentRead] category: str instructions: str | None image_path_start: str | None image_path_end: str | None tags: list[ExerciseTagRead] slug: str | None created_by: UUID | None created_at: datetime updated_at: datetime def __post_init__(self) -> None: if self.image_path_start: self.image_path_start = f"{FULL_CDN_PREFIX}{self.image_path_start}" if self.image_path_end: self.image_path_end = f"{FULL_CDN_PREFIX}{self.image_path_end}"