diff --git a/src/api/app.py b/src/api/app.py index 6e9904e..542a328 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -17,6 +17,7 @@ from src.api.roles.shared.chat import router as shared_chat_router from src.api.roles.shared.client_coach_relationship import router as shared_client_coach_relationship_router from src.api.roles.client.fitness import router as client_fitness_router +from src.api.roles.client.telemetry import router as client_telemetry_router from src.api.roles.coach.fitness import router as coach_fitness_router from src.api.roles.admin.admin import router as admin_router @@ -38,6 +39,7 @@ app.include_router(shared_chat_router) app.include_router(shared_client_coach_relationship_router) app.include_router(client_fitness_router) +app.include_router(client_telemetry_router) app.include_router(coach_fitness_router) app.include_router(admin_router) diff --git a/src/api/roles/client/client.py b/src/api/roles/client/client.py index a159c28..0c9513b 100644 --- a/src/api/roles/client/client.py +++ b/src/api/roles/client/client.py @@ -37,6 +37,7 @@ from src.database.reports.models import CoachReport, CoachReviews from src.database.payment.models import PaymentInformation + router = APIRouter(prefix="/roles/client", tags=["client"]) @router.post("/initial_survey", response_model=CreateClientResponse) diff --git a/src/api/roles/client/domain.py b/src/api/roles/client/domain.py index 6afc47a..dc6a974 100644 --- a/src/api/roles/client/domain.py +++ b/src/api/roles/client/domain.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel, Field, model_validator +from datetime import datetime +from pydantic import BaseModel, Field, field_validator, model_validator from typing import List, Optional from fastapi import HTTPException #Client @@ -23,6 +24,31 @@ class HirableCoachItem(BaseModel): experiences: Optional[List[Experience]] = None certifications: Optional[List[Certifications]] = None +class StepCountUpdateInput(BaseModel): + step_count: int + + @field_validator("step_count") + @classmethod + def step_count_must_be_non_negative(cls, v): + if 0 > v or v > 100000: + raise ValueError("Step count must be a non-negative integer") + return v + +class StepCountUpdateOutput(BaseModel): + step_count: int + +class DunderInput(BaseModel): + pass + +class WeightUpdateInput(BaseModel): + @field_validator("weight") + @classmethod + def weight_must_be_valid(cls, v: int) -> int: + if v <= 0: + raise ValueError("Weight must be greater than 0") + return v + weight: int + class InitialSurveyInput(BaseModel): #creates a client fitness_goals: FitnessGoals payment_information: PaymentInformation diff --git a/src/api/roles/client/fitness.py b/src/api/roles/client/fitness.py index c5106fe..ea4aba1 100644 --- a/src/api/roles/client/fitness.py +++ b/src/api/roles/client/fitness.py @@ -1,14 +1,275 @@ -from typing import Optional -from fastapi import APIRouter, Depends -from sqlmodel import Session, select +from datetime import datetime +from typing import Optional, Tuple +from fastapi import APIRouter, Depends, HTTPException +from pydantic import field_validator, model_validator +from sqlmodel import Session, select, SQLModel +from sqlalchemy.exc import IntegrityError from src.database.session import get_session +from src.database.workouts_and_activities.models import WorkoutPlanActivity from src.database.account.models import Account from src.api.dependencies import get_client_account, PaginationParams -from src.database.client.models import ClientWorkoutPlan +from src.database.client.models import ClientWorkoutPlan +from src.database.meal.models import ClientPrescribedMeal +from src.database.telemetry.models import ( + ClientTelemetry, + DailyMoodSurvey, + DailyWorkoutSurvey, + DailyBodyMetricsSurvey, + DailyStepsSurvey, + DailyMealSurvey, + CompletedSurvey, + CompletedWorkout, + CompletedWorkoutActivity, + HealthMetrics, + StepCount, + CompletedMealActivity, +) router = APIRouter(prefix="/roles/client/fitness", tags=["client", "fitness"]) +class DailySurveySubmitPayload(SQLModel): + happiness_meter: int + alertness: int + healthiness: int + todays_goals: str + todays_appreciation: str + + @field_validator("happiness_meter", "alertness", "healthiness") + @classmethod + def validate_meter(cls, v): + if not (1 <= v <= 10): + raise ValueError("Value must be between 1 and 10") + return v + +class WorkoutSurveySubmitPayload(SQLModel): + workout_plan_activity_id: Optional[int] = None + workout_activity_id: Optional[int] = None + completed_reps: Optional[int] = None + completed_sets: Optional[int] = None + completed_duration: Optional[int] = None + estimated_calories: Optional[int] = None + + @field_validator("completed_reps", "completed_sets", "completed_duration", "estimated_calories") + @classmethod + def validate_non_negative_metrics(cls, v): + if v is not None and v < 0: + raise ValueError("Workout values cannot be negative") + return v + + @model_validator(mode="after") + def validate_workout_submission(self): + if self.workout_plan_activity_id is None and self.workout_activity_id is None: + raise ValueError("Either workout_plan_activity_id or workout_activity_id is required") + + has_progress_data = any( + value is not None + for value in [ + self.completed_reps, + self.completed_sets, + self.completed_duration, + self.estimated_calories, + ] + ) + + if not has_progress_data: + raise ValueError("At least one of completed_reps, completed_sets, completed_duration, or estimated_calories is required") + + return self + +class BodyMetricsSurveySubmitPayload(SQLModel): + weight: int + progress_pic_url: Optional[str] = None + + @field_validator("weight") + @classmethod + def validate_weight(cls, v: int) -> int: + if v <= 0: + raise ValueError("Weight must be greater than 0") + return v + +class StepsSurveySubmitPayload(SQLModel): + step_count: int + + @field_validator("step_count") + @classmethod + def validate_step_count(cls, v: int) -> int: + if v < 0 or v > 100000: + raise ValueError("Step count must be between 0 and 100000") + return v + +class MealSurveySubmitPayload(SQLModel): + client_prescribed_meal_id: Optional[int] = None + on_demand_meal_id: Optional[int] = None + + @model_validator(mode="after") + def validate_meal_choice(self): + if self.client_prescribed_meal_id is None and self.on_demand_meal_id is None: + raise ValueError("Either client_prescribed_meal_id or on_demand_meal_id is required") + return self + +class DailySurveyResponse(SQLModel): + survey_id: int + telemetry_id: int + is_seen: bool + is_started: bool + is_finished: bool + completed_survey_id: Optional[int] = None + +class DailyWorkoutSurveyResponse(SQLModel): + survey_id: int + telemetry_id: int + is_seen: bool + is_started: bool + is_finished: bool + completed_workout_id: Optional[int] = None + +class DailyBodyMetricsSurveyResponse(SQLModel): + survey_id: int + telemetry_id: int + is_seen: bool + is_started: bool + is_finished: bool + completed_health_metrics_id: Optional[int] = None + +class DailyStepsSurveyResponse(SQLModel): + survey_id: int + telemetry_id: int + is_seen: bool + is_started: bool + is_finished: bool + step_count_id: Optional[int] = None + +class DailyMealSurveyResponse(SQLModel): + survey_id: int + telemetry_id: int + is_seen: bool + is_started: bool + is_finished: bool + completed_meal_activity_id: Optional[int] = None + +def _validate_workout_plan_activity_belongs_to_client(db: Session, client_id: int, workout_plan_activity_id: int): + workout_plan = db.exec(select(ClientWorkoutPlan).where(ClientWorkoutPlan.client_id == client_id)).all() + + allowed_plan_ids = [plan.id for plan in workout_plan] + + if not allowed_plan_ids: + raise HTTPException(status_code=403, detail="No workout plans found for this client") + + workout_plan_activity = db.exec( + select(WorkoutPlanActivity).where( + WorkoutPlanActivity.id == workout_plan_activity_id, + WorkoutPlanActivity.workout_plan_id.in_(allowed_plan_ids) + ) + ).first() + + if workout_plan_activity is None: + raise HTTPException( + status_code=403, + detail="Workout plan activity does not belong to this client" + ) + + return workout_plan_activity + +def _validate_client_prescribed_meal_belongs_to_client(db: Session, client_id: int, client_prescribed_meal_id: int): + prescribed_meal = db.exec( + select(ClientPrescribedMeal).where( + ClientPrescribedMeal.id == client_prescribed_meal_id, + ClientPrescribedMeal.client_id == client_id + ) + ).first() + + if prescribed_meal is None: + raise HTTPException(status_code=403,detail="Prescribed meal does not belong to this client") + + return prescribed_meal + +def _get_or_create_telemetry(db: Session, client_id: int) -> ClientTelemetry: + today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + telemetry = db.exec( + select(ClientTelemetry).where( + ClientTelemetry.client_id == client_id, + ClientTelemetry.date == today + ) + ).first() + + if telemetry is not None: + return telemetry + + telemetry = ClientTelemetry(client_id=client_id, date=today) + db.add(telemetry) + + try: + db.commit() + db.refresh(telemetry) + return telemetry + except IntegrityError: + db.rollback() + telemetry = db.exec( + select(ClientTelemetry).where( + ClientTelemetry.client_id == client_id, + ClientTelemetry.date == today + ) + ).first() + + if telemetry is None: + raise + + return telemetry + + +def _get_or_create_daily_survey(db: Session, client_id: int, survey_model): + telemetry = _get_or_create_telemetry(db, client_id) + survey = db.exec( + select(survey_model).where( + survey_model.client_telemetry_id == telemetry.id + ) + ).first() + + if survey is not None: + return telemetry, survey + + survey = survey_model( + is_seen=True, + is_started=False, + is_finished=False, + client_telemetry_id=telemetry.id + ) + db.add(survey) + + try: + db.commit() + db.refresh(survey) + return telemetry, survey + except IntegrityError: + db.rollback() + survey = db.exec( + select(survey_model).where( + survey_model.client_telemetry_id == telemetry.id + ) + ).first() + + if survey is None: + raise + + return telemetry, survey + + +def _create_survey_response(survey, telemetry, completed_key: str, response_model): + response_data = { + "survey_id": survey.id, + "telemetry_id": telemetry.id, + "is_seen": survey.is_seen, + "is_started": survey.is_started, + "is_finished": survey.is_finished, + completed_key: getattr(survey, completed_key) + } + return response_model(**response_data) + + +def get_or_create_daily_survey(db: Session, client_id: int) -> Tuple[ClientTelemetry, DailyMoodSurvey]: + return _get_or_create_daily_survey(db, client_id, DailyMoodSurvey) + @router.get("/query/plans") def query_client_workout_plans( pagination: PaginationParams = Depends(PaginationParams), @@ -18,3 +279,390 @@ def query_client_workout_plans( query = select(ClientWorkoutPlan).where(ClientWorkoutPlan.client_id == acc.client_id) plans = db.exec(query.offset(pagination.skip).limit(pagination.limit)).all() return plans + +@router.get("/daily-survey/today", response_model=DailySurveyResponse) +def get_today_daily_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = get_or_create_daily_survey(db, acc.client_id) + return DailySurveyResponse( + survey_id=survey.id, + telemetry_id=telemetry.id, + is_seen=survey.is_seen, + is_started=survey.is_started, + is_finished=survey.is_finished, + completed_survey_id=survey.completed_survey_id + ) + +@router.post("/daily-survey/start", response_model=DailySurveyResponse) +def start_daily_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = get_or_create_daily_survey(db, acc.client_id) + + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + if not survey.is_started: + survey.is_started = True + survey.is_seen = True + db.add(survey) + db.commit() + db.refresh(survey) + + return DailySurveyResponse( + survey_id=survey.id, + telemetry_id=telemetry.id, + is_seen=survey.is_seen, + is_started=survey.is_started, + is_finished=survey.is_finished, + completed_survey_id=survey.completed_survey_id + ) + +@router.post("/daily-survey/submit", response_model=DailySurveyResponse) +def submit_daily_survey( + payload: DailySurveySubmitPayload, + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = get_or_create_daily_survey(db, acc.client_id) + + if not survey.is_started: + raise HTTPException(status_code=400, detail="Survey has not been started yet") + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + completed_survey = CompletedSurvey( + happiness_meter=payload.happiness_meter, + alertness=payload.alertness, + healthiness=payload.healthiness, + todays_goals=payload.todays_goals, + todays_appreciation=payload.todays_appreciation + ) + db.add(completed_survey) + db.flush() + + survey.is_finished = True + survey.completed_survey_id = completed_survey.id + db.add(survey) + + db.commit() + db.refresh(completed_survey) + db.refresh(survey) + + return DailySurveyResponse( + survey_id=survey.id, + telemetry_id=telemetry.id, + is_seen=survey.is_seen, + is_started=survey.is_started, + is_finished=survey.is_finished, + completed_survey_id=survey.completed_survey_id + ) + +@router.get("/daily-survey/workout/today", response_model=DailyWorkoutSurveyResponse) +def get_today_workout_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyWorkoutSurvey) + return _create_survey_response(survey, telemetry, "completed_workout_id", DailyWorkoutSurveyResponse) + + +@router.post("/daily-survey/workout/start", response_model=DailyWorkoutSurveyResponse) +def start_daily_workout_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyWorkoutSurvey) + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + if not survey.is_started: + survey.is_started = True + survey.is_seen = True + db.add(survey) + db.commit() + db.refresh(survey) + + return _create_survey_response( + survey, + telemetry, + "completed_workout_id", + DailyWorkoutSurveyResponse + ) + + +@router.post("/daily-survey/workout/submit", response_model=DailyWorkoutSurveyResponse) +def submit_daily_workout_survey( + payload: WorkoutSurveySubmitPayload, + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + if payload.workout_plan_activity_id is not None: + _validate_workout_plan_activity_belongs_to_client(db, acc.client_id, payload.workout_plan_activity_id) + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyWorkoutSurvey) + if not survey.is_started: + raise HTTPException(status_code=400, detail="Survey has not been started yet") + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + completed_workout_details = CompletedWorkoutActivity( + completed_reps=payload.completed_reps, + completed_sets=payload.completed_sets, + completed_duration=payload.completed_duration, + estimated_calories=payload.estimated_calories, + ) + db.add(completed_workout_details) + db.flush() + + completed_workout = CompletedWorkout( + workout_plan_activity_id=payload.workout_plan_activity_id, + workout_activity_id=payload.workout_activity_id, + completed_workout_details_id=completed_workout_details.id, + client_telemetry_id=telemetry.id, + ) + db.add(completed_workout) + db.flush() + + survey.is_finished = True + survey.completed_workout_id = completed_workout.id + db.add(survey) + + db.commit() + db.refresh(completed_workout_details) + db.refresh(completed_workout) + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "completed_workout_id", DailyWorkoutSurveyResponse) + + +@router.get("/daily-survey/body-metrics/today", response_model=DailyBodyMetricsSurveyResponse) +def get_today_body_metrics_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyBodyMetricsSurvey) + return _create_survey_response(survey, telemetry, "completed_health_metrics_id", DailyBodyMetricsSurveyResponse) + + +@router.post("/daily-survey/body-metrics/start", response_model=DailyBodyMetricsSurveyResponse) +def start_daily_body_metrics_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyBodyMetricsSurvey) + + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + if not survey.is_started: + survey.is_started = True + survey.is_seen = True + db.add(survey) + db.commit() + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "completed_health_metrics_id", DailyBodyMetricsSurveyResponse) + +@router.post("/daily-survey/body-metrics/submit", response_model=DailyBodyMetricsSurveyResponse) +def submit_daily_body_metrics_survey( + payload: BodyMetricsSurveySubmitPayload, + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyBodyMetricsSurvey) + if not survey.is_started: + raise HTTPException(status_code=400, detail="Survey has not been started yet") + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + health_metrics = HealthMetrics( + weight=payload.weight, + progress_pic_url=payload.progress_pic_url, + client_telemetry_id=telemetry.id, + ) + db.add(health_metrics) + db.flush() + + survey.is_finished = True + survey.completed_health_metrics_id = health_metrics.id + db.add(survey) + db.commit() + db.refresh(health_metrics) + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "completed_health_metrics_id", DailyBodyMetricsSurveyResponse) + + +@router.get("/daily-survey/steps/today", response_model=DailyStepsSurveyResponse) +def get_today_steps_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyStepsSurvey) + return _create_survey_response(survey, telemetry, "step_count_id", DailyStepsSurveyResponse) + + +@router.post("/daily-survey/steps/start", response_model=DailyStepsSurveyResponse) +def start_daily_steps_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyStepsSurvey) + + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + if not survey.is_started: + survey.is_started = True + survey.is_seen = True + db.add(survey) + db.commit() + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "step_count_id", DailyStepsSurveyResponse) + + +@router.post("/daily-survey/steps/submit", response_model=DailyStepsSurveyResponse) +def submit_daily_steps_survey( + payload: StepsSurveySubmitPayload, + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyStepsSurvey) + if not survey.is_started: + raise HTTPException(status_code=400, detail="Survey has not been started yet") + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + step_count = StepCount( + step_count=payload.step_count, + client_telemetry_id=telemetry.id, + ) + db.add(step_count) + db.flush() + + survey.is_finished = True + survey.step_count_id = step_count.id + db.add(survey) + db.commit() + db.refresh(step_count) + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "step_count_id", DailyStepsSurveyResponse) + + +@router.get("/daily-survey/meal/today", response_model=DailyMealSurveyResponse) +def get_today_meal_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyMealSurvey) + return _create_survey_response(survey, telemetry, "completed_meal_activity_id", DailyMealSurveyResponse) + + +@router.post("/daily-survey/meal/start", response_model=DailyMealSurveyResponse) +def start_daily_meal_survey( + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyMealSurvey) + + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + if not survey.is_started: + survey.is_started = True + survey.is_seen = True + db.add(survey) + db.commit() + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "completed_meal_activity_id", DailyMealSurveyResponse) + + +@router.post("/daily-survey/meal/submit", response_model=DailyMealSurveyResponse) +def submit_daily_meal_survey( + payload: MealSurveySubmitPayload, + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + if payload.client_prescribed_meal_id is not None: + _validate_client_prescribed_meal_belongs_to_client( + db, + acc.client_id, + payload.client_prescribed_meal_id + ) + + telemetry, survey = _get_or_create_daily_survey(db, acc.client_id, DailyMealSurvey) + if not survey.is_started: + raise HTTPException(status_code=400, detail="Survey has not been started yet") + if survey.is_finished: + raise HTTPException(status_code=400, detail="Survey has already been submitted") + + completed_meal = CompletedMealActivity( + client_prescribed_meal_id=payload.client_prescribed_meal_id, + on_demand_meal_id=payload.on_demand_meal_id, + client_telemetry_id=telemetry.id, + ) + db.add(completed_meal) + db.flush() + + survey.is_finished = True + survey.completed_meal_activity_id = completed_meal.id + db.add(survey) + db.commit() + db.refresh(completed_meal) + db.refresh(survey) + + return _create_survey_response(survey, telemetry, "completed_meal_activity_id", DailyMealSurveyResponse) diff --git a/src/api/roles/client/telemetry.py b/src/api/roles/client/telemetry.py new file mode 100644 index 0000000..7a544fc --- /dev/null +++ b/src/api/roles/client/telemetry.py @@ -0,0 +1,150 @@ +from src.api.roles.client.fitness import APIRouter, PaginationParams, Session, _get_or_create_telemetry, datetime, select +from src.database.telemetry.models import ClientTelemetry, CompletedMealActivity, HealthMetrics, StepCount, CompletedSurvey, DailyMoodSurvey, CompletedWorkout +from src.api.roles.client.domain import StepCountUpdateInput, StepCountUpdateOutput, WeightUpdateInput +from src.database.session import get_session +from src.database.account.models import Account +from fastapi import Depends, HTTPException +from src.api.dependencies import get_client_account + +today = datetime.utcnow().date() + +router = APIRouter(prefix="/roles/client/telemetry", tags=["client", "telemetry"]) + +@router.put("/update_steps") +def update_steps(step_count: StepCountUpdateInput, db = Depends(get_session), acc: Account = Depends(get_client_account)): + + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + telemetry = _get_or_create_telemetry(db, acc.client_id) + + step_count_entry = db.exec(select(StepCount).where(StepCount.client_telemetry_id == telemetry.id)).first() + if not step_count_entry: + step_count_entry = StepCount(step_count=step_count.step_count, client_telemetry_id=telemetry.id) + db.add(step_count_entry) + else: + step_count_entry.step_count = step_count.step_count + + db.commit() + db.refresh(step_count_entry) + + return StepCountUpdateOutput(step_count=step_count_entry.step_count) + + +@router.get("/query/steps", response_model=list[StepCount]) +def query_step_counts( + pagination: PaginationParams = Depends(PaginationParams), + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + query = select(StepCount).join(ClientTelemetry, StepCount.client_telemetry_id == ClientTelemetry.id).where(ClientTelemetry.client_id == acc.client_id).order_by(StepCount.id.desc()) + steps = db.exec(query.offset(pagination.skip).limit(pagination.limit)).all() + return steps + +@router.get("/query/weights", response_model=list[HealthMetrics]) +def query_weights( + pagination: PaginationParams = Depends(PaginationParams), + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + query = select(HealthMetrics).join(ClientTelemetry, HealthMetrics.client_telemetry_id == ClientTelemetry.id).where(ClientTelemetry.client_id == acc.client_id).order_by(HealthMetrics.id.desc()) + + weights = db.exec(query.offset(pagination.skip).limit(pagination.limit)).all() + + return weights + +@router.put("/update_weight/{health_metrics_id}", response_model=HealthMetrics) +def update_weight(health_metrics_id: int, payload: WeightUpdateInput, db: Session = Depends(get_session), acc: Account = Depends(get_client_account)): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + health_metrics = db.get(HealthMetrics, health_metrics_id) + if health_metrics is None: + raise HTTPException(status_code=404, detail="Weight entry not found") + + telemetry = db.get(ClientTelemetry, health_metrics.client_telemetry_id) + if telemetry is None or telemetry.client_id != acc.client_id: + raise HTTPException(status_code=403, detail="Not authorized to update this weight entry") + + health_metrics.weight = payload.weight + + db.add(health_metrics) + db.commit() + db.refresh(health_metrics) + + return health_metrics + +@router.delete("/delete_weight/{health_metrics_id}") +def delete_weight( + health_metrics_id: int, + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + health_metrics = db.get(HealthMetrics, health_metrics_id) + if health_metrics is None: + raise HTTPException(status_code=404, detail="Weight entry not found") + + telemetry = db.get(ClientTelemetry, health_metrics.client_telemetry_id) + if telemetry is None or telemetry.client_id != acc.client_id: + raise HTTPException(status_code=403, detail="Not authorized to delete this weight entry") + + db.delete(health_metrics) + db.commit() + + return {"message": "Weight entry deleted successfully"} + + +@router.get("/query/moods", response_model=list[CompletedSurvey]) +def query_moods( + pagination: PaginationParams = Depends(PaginationParams), + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + query = select(CompletedSurvey).join(DailyMoodSurvey, DailyMoodSurvey.completed_survey_id == CompletedSurvey.id).join(ClientTelemetry, DailyMoodSurvey.client_telemetry_id == ClientTelemetry.id).where(ClientTelemetry.client_id == acc.client_id).order_by(CompletedSurvey.id.desc()) + + moods = db.exec(query.offset(pagination.skip).limit(pagination.limit)).all() + + return moods + + +@router.get("/query/workouts", response_model=list[CompletedWorkout]) +def query_workouts( + pagination: PaginationParams = Depends(PaginationParams), + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + query = select(CompletedWorkout).join(ClientTelemetry, CompletedWorkout.client_telemetry_id == ClientTelemetry.id).where(ClientTelemetry.client_id == acc.client_id).order_by(CompletedWorkout.id.desc()) + + workouts = db.exec(query.offset(pagination.skip).limit(pagination.limit)).all() + + return workouts + +@router.get("/query/meals", response_model=list[CompletedMealActivity]) +def query_meals( + pagination: PaginationParams = Depends(PaginationParams), + db: Session = Depends(get_session), + acc: Account = Depends(get_client_account) +): + if acc.client_id is None: + raise HTTPException(status_code=404, detail="Client profile not found") + + query = select(CompletedMealActivity).join(ClientTelemetry, CompletedMealActivity.client_telemetry_id == ClientTelemetry.id).where(ClientTelemetry.client_id == acc.client_id).order_by(CompletedMealActivity.id.desc()) + + meals = db.exec(query.offset(pagination.skip).limit(pagination.limit)).all() + + return meals \ No newline at end of file diff --git a/src/database/telemetry/models.py b/src/database/telemetry/models.py index 954c9e3..6f429a4 100644 --- a/src/database/telemetry/models.py +++ b/src/database/telemetry/models.py @@ -1,6 +1,7 @@ -from datetime import date +from datetime import datetime from typing import Optional +from pydantic import field_validator from sqlmodel import Field from src.database.base import SQLModelLU @@ -11,7 +12,7 @@ class ClientTelemetry(SQLModelLU, table=True): id: Optional[int] = Field(default=None, primary_key=True) client_id: int = Field(foreign_key="client.id", ondelete="CASCADE") - date: date + date: datetime class StepCount(SQLModelLU, table=True): @@ -47,9 +48,9 @@ class DailyMoodSurvey(SQLModelLU, table=True): __tablename__ = "daily_mood_survey" # type: ignore id: Optional[int] = Field(default=None, primary_key=True) - is_seen: bool - is_started: bool - is_finished: bool + is_seen: bool = False + is_started: bool = False + is_finished: bool = False completed_survey_id: Optional[int] = Field(default=None, foreign_key="completed_survey.id") client_telemetry_id: int = Field(foreign_key="client_telemetry.id", ondelete="CASCADE") @@ -59,6 +60,56 @@ class HealthMetrics(SQLModelLU, table=True): id: Optional[int] = Field(default=None, primary_key=True) weight: int + progress_pic_url: Optional[str] = None + client_telemetry_id: int = Field(foreign_key="client_telemetry.id", ondelete="CASCADE") + + @field_validator("weight") + def weight_must_be_positive(cls, v): + if v <= 0: + raise ValueError("Weight must be a positive integer") + return v + +class DailyWorkoutSurvey(SQLModelLU, table=True): + __tablename__ = "daily_workout_survey" # type: ignore + + id: Optional[int] = Field(default=None, primary_key=True) + is_seen: bool = False + is_started: bool = False + is_finished: bool = False + completed_workout_id: Optional[int] = Field(default=None, foreign_key="completed_workout.id") + client_telemetry_id: int = Field(foreign_key="client_telemetry.id", ondelete="CASCADE") + + +class DailyBodyMetricsSurvey(SQLModelLU, table=True): + __tablename__ = "daily_body_metrics_survey" # type: ignore + + id: Optional[int] = Field(default=None, primary_key=True) + is_seen: bool = False + is_started: bool = False + is_finished: bool = False + completed_health_metrics_id: Optional[int] = Field(default=None, foreign_key="health_metrics.id") + client_telemetry_id: int = Field(foreign_key="client_telemetry.id", ondelete="CASCADE") + + +class DailyStepsSurvey(SQLModelLU, table=True): + __tablename__ = "daily_steps_survey" # type: ignore + + id: Optional[int] = Field(default=None, primary_key=True) + is_seen: bool = False + is_started: bool = False + is_finished: bool = False + step_count_id: Optional[int] = Field(default=None, foreign_key="step_count.id") + client_telemetry_id: int = Field(foreign_key="client_telemetry.id", ondelete="CASCADE") + + +class DailyMealSurvey(SQLModelLU, table=True): + __tablename__ = "daily_meal_survey" # type: ignore + + id: Optional[int] = Field(default=None, primary_key=True) + is_seen: bool = False + is_started: bool = False + is_finished: bool = False + completed_meal_activity_id: Optional[int] = Field(default=None, foreign_key="completed_meal_activity.id") client_telemetry_id: int = Field(foreign_key="client_telemetry.id", ondelete="CASCADE") diff --git a/tests/test_client.py b/tests/test_client.py index 06d0db2..a3b1870 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ from datetime import date -from sqlmodel import Session +from sqlmodel import Session, select from tests.payload_tools.client import build_client_init_payload +from src.database.telemetry.models import HealthMetrics, StepCount def test_client_survey_flow(test_client, auth_header): """ @@ -19,6 +20,130 @@ def test_client_survey_flow(test_client, auth_header): assert "client_id" in client_survey_data assert isinstance(client_survey_data["client_id"], int) + +def test_client_update_steps_creates_and_updates_step_count(test_client, client_auth_header, db_session): + """ + Test that a client can create and later update step count telemetry. + """ + create_payload = {"step_count": 12345} + + create_response = test_client.put( + "/roles/client/telemetry/update_steps", + json=create_payload, + headers=client_auth_header, + ) + + assert create_response.status_code == 200 + assert create_response.json() == {"step_count": 12345} + + created_step_count = db_session.exec(select(StepCount)).first() + assert created_step_count is not None + assert created_step_count.step_count == 12345 + + update_payload = {"step_count": 54321} + + update_response = test_client.put( + "/roles/client/telemetry/update_steps", + json=update_payload, + headers=client_auth_header, + ) + + assert update_response.status_code == 200 + assert update_response.json() == {"step_count": 54321} + + db_session.refresh(created_step_count) + assert created_step_count.step_count == 54321 + + +def test_client_query_step_counts_returns_latest_steps(test_client, client_auth_header): + create_payload = {"step_count": 1234} + + create_response = test_client.put( + "/roles/client/telemetry/update_steps", + json=create_payload, + headers=client_auth_header, + ) + assert create_response.status_code == 200 + + query_response = test_client.get( + "/roles/client/telemetry/query/steps", + headers=client_auth_header, + ) + assert query_response.status_code == 200 + query_data = query_response.json() + assert isinstance(query_data, list) + assert len(query_data) == 1 + assert query_data[0]["step_count"] == 1234 + + +def test_client_weight_crud_and_query(test_client, client_auth_header): + weights_response = test_client.get( + "/roles/client/telemetry/query/weights", + headers=client_auth_header, + ) + assert weights_response.status_code == 200 + weights_data = weights_response.json() + assert isinstance(weights_data, list) + assert len(weights_data) >= 1 + + initial_weight = weights_data[0] + health_metrics_id = initial_weight["id"] + assert initial_weight["weight"] == 170 + + update_payload = {"weight": 175} + update_response = test_client.put( + f"/roles/client/telemetry/update_weight/{health_metrics_id}", + json=update_payload, + headers=client_auth_header, + ) + assert update_response.status_code == 200 + assert update_response.json()["weight"] == 175 + + weights_after_update = test_client.get( + "/roles/client/telemetry/query/weights", + headers=client_auth_header, + ) + assert weights_after_update.status_code == 200 + assert weights_after_update.json()[0]["weight"] == 175 + + delete_response = test_client.delete( + f"/roles/client/telemetry/delete_weight/{health_metrics_id}", + headers=client_auth_header, + ) + assert delete_response.status_code == 200 + assert delete_response.json()["message"] == "Weight entry deleted successfully" + + weights_after_delete = test_client.get( + "/roles/client/telemetry/query/weights", + headers=client_auth_header, + ) + assert weights_after_delete.status_code == 200 + assert weights_after_delete.json() == [] + + +def test_client_query_moods_workouts_and_meals_return_empty_lists(test_client, client_auth_header): + moods_response = test_client.get( + "/roles/client/telemetry/query/moods", + headers=client_auth_header, + ) + assert moods_response.status_code == 200 + assert moods_response.json() == [] + + workouts_response = test_client.get( + "/roles/client/telemetry/query/workouts", + headers=client_auth_header, + ) + assert workouts_response.status_code == 200 + assert workouts_response.json() == [] + + meals_response = test_client.get( + "/roles/client/telemetry/query/meals", + headers=client_auth_header, + ) + assert meals_response.status_code == 200 + assert meals_response.json() == [] + + def test_client_me_endpoint(test_client, client_auth_header): """ Test that a registered client can fetch their client profile details via the /me endpoint.