mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-09 15:57:59 -05:00
Add health sleep tracking support and models
Introduces backend support for health sleep data, including new models, CRUD operations, and schema in the health_sleep module. Adds Alembic migration for sleep data, updates Garmin health utilities, and integrates sleep data retrieval in aux_gc_getbcstats.py. Frontend sidebar and i18n files updated to reflect new health sleep features.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import activities.activity_workout_steps.models
|
||||
import followers.models
|
||||
import gears.gear.models
|
||||
import gears.gear_components.models
|
||||
import health_sleep.models
|
||||
import health_steps.models
|
||||
import health_targets.models
|
||||
import health_weight.models
|
||||
|
||||
230
backend/app/alembic/versions/v0_16_0_migration_health_sleep.py
Normal file
230
backend/app/alembic/versions/v0_16_0_migration_health_sleep.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""v0.16.0 migration health_sleep
|
||||
|
||||
Revision ID: 4c6af41fffa0
|
||||
Revises: 2af2c0629b37
|
||||
Create Date: 2025-11-25 16:25:56.221138
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4c6af41fffa0"
|
||||
down_revision: Union[str, None] = "2af2c0629b37"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"health_sleep",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="User ID that the health_sleep belongs",
|
||||
),
|
||||
sa.Column(
|
||||
"date",
|
||||
sa.Date(),
|
||||
nullable=False,
|
||||
comment="Calendar date of the sleep session",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_start_time_gmt",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="Start time of sleep in GMT",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_end_time_gmt",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="End time of sleep in GMT",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_start_time_local",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="Start time of sleep in local time",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_end_time_local",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="End time of sleep in local time",
|
||||
),
|
||||
sa.Column(
|
||||
"total_sleep_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Total duration of sleep in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"nap_time_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Duration of naps in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"unmeasurable_sleep_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Unmeasurable sleep duration in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"deep_sleep_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Duration of deep sleep in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"light_sleep_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Duration of light sleep in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"rem_sleep_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Duration of REM sleep in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"awake_sleep_seconds",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Duration of awake time in seconds",
|
||||
),
|
||||
sa.Column(
|
||||
"avg_heart_rate",
|
||||
sa.DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average heart rate during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"min_heart_rate",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Minimum heart rate during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"max_heart_rate",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Maximum heart rate during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"avg_spo2",
|
||||
sa.DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average SpO2 oxygen saturation percentage",
|
||||
),
|
||||
sa.Column(
|
||||
"lowest_spo2",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Lowest SpO2 reading during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"highest_spo2",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Highest SpO2 reading during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"avg_respiration",
|
||||
sa.DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average respiration rate",
|
||||
),
|
||||
sa.Column(
|
||||
"lowest_respiration",
|
||||
sa.DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Lowest respiration rate",
|
||||
),
|
||||
sa.Column(
|
||||
"highest_respiration",
|
||||
sa.DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Highest respiration rate",
|
||||
),
|
||||
sa.Column(
|
||||
"avg_stress_level",
|
||||
sa.DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average stress level during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"awake_count",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Number of times awakened during sleep",
|
||||
),
|
||||
sa.Column(
|
||||
"restless_moments_count",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Count of restless moments",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_score_overall",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Overall sleep score (0-100)",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_score_duration",
|
||||
sa.String(length=50),
|
||||
nullable=True,
|
||||
comment="Sleep duration score (e.g., GOOD, EXCELLENT, POOR)",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_score_quality",
|
||||
sa.String(length=50),
|
||||
nullable=True,
|
||||
comment="Sleep quality score",
|
||||
),
|
||||
sa.Column(
|
||||
"garminconnect_sleep_id",
|
||||
sa.String(length=250),
|
||||
nullable=True,
|
||||
comment="External Garmin Connect sleep ID",
|
||||
),
|
||||
sa.Column(
|
||||
"sleep_stages",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="List of sleep stage intervals as JSON",
|
||||
),
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.String(length=250),
|
||||
nullable=True,
|
||||
comment="Source of the health sleep data",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_health_sleep_date"), "health_sleep", ["date"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_health_sleep_user_id"), "health_sleep", ["user_id"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_health_sleep_user_id"), table_name="health_sleep")
|
||||
op.drop_index(op.f("ix_health_sleep_date"), table_name="health_sleep")
|
||||
op.drop_table("health_sleep")
|
||||
# ### end Alembic commands ###
|
||||
@@ -15,6 +15,9 @@ import health_weight.schema as health_weight_schema
|
||||
import health_steps.crud as health_steps_crud
|
||||
import health_steps.schema as health_steps_schema
|
||||
|
||||
import health_sleep.crud as health_sleep_crud
|
||||
import health_sleep.schema as health_sleep_schema
|
||||
|
||||
import users.user.crud as users_crud
|
||||
|
||||
from core.database import SessionLocal
|
||||
@@ -162,6 +165,200 @@ def fetch_and_process_ds_by_dates(
|
||||
return count_processed
|
||||
|
||||
|
||||
def fetch_and_process_sleep_by_dates(
|
||||
garminconnect_client: garminconnect.Garmin,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
user_id: int,
|
||||
db: Session,
|
||||
) -> int:
|
||||
"""
|
||||
Fetch and process sleep data from Garmin Connect.
|
||||
|
||||
Args:
|
||||
garminconnect_client: Authenticated Garmin Connect client.
|
||||
start_date: Start date for sleep data retrieval.
|
||||
end_date: End date for sleep data retrieval.
|
||||
user_id: ID of the user to process sleep data for.
|
||||
db: Database session.
|
||||
|
||||
Returns:
|
||||
Number of sleep records processed.
|
||||
"""
|
||||
count_processed = 0
|
||||
current_date = start_date
|
||||
|
||||
# Iterate through each date since get_sleep_data only supports
|
||||
# single date
|
||||
while current_date <= end_date:
|
||||
date_string = current_date.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
garmin_sleep = garminconnect_client.get_sleep_data(date_string)
|
||||
except Exception as err:
|
||||
core_logger.print_to_log(
|
||||
f"Error fetching sleep data for user "
|
||||
f"{user_id} on {date_string}: {err}",
|
||||
"error",
|
||||
exc=err,
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
if (
|
||||
garmin_sleep is None
|
||||
or "dailySleepDTO" not in garmin_sleep
|
||||
or not garmin_sleep["dailySleepDTO"]
|
||||
):
|
||||
core_logger.print_to_log(
|
||||
f"User {user_id}: No Garmin Connect sleep data "
|
||||
f"found for {date_string}"
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
sleep_dto = garmin_sleep["dailySleepDTO"]
|
||||
|
||||
# Convert timestamps from milliseconds to datetime
|
||||
sleep_start_gmt = (
|
||||
datetime.fromtimestamp(
|
||||
sleep_dto["sleepStartTimestampGMT"] / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
if sleep_dto.get("sleepStartTimestampGMT")
|
||||
else None
|
||||
)
|
||||
sleep_end_gmt = (
|
||||
datetime.fromtimestamp(
|
||||
sleep_dto["sleepEndTimestampGMT"] / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
if sleep_dto.get("sleepEndTimestampGMT")
|
||||
else None
|
||||
)
|
||||
sleep_start_local = (
|
||||
datetime.fromtimestamp(
|
||||
sleep_dto["sleepStartTimestampLocal"] / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
if sleep_dto.get("sleepStartTimestampLocal")
|
||||
else None
|
||||
)
|
||||
sleep_end_local = (
|
||||
datetime.fromtimestamp(
|
||||
sleep_dto["sleepEndTimestampLocal"] / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
if sleep_dto.get("sleepEndTimestampLocal")
|
||||
else None
|
||||
)
|
||||
|
||||
# Process sleep stages from sleepLevels array
|
||||
sleep_stages = []
|
||||
if "sleepLevels" in garmin_sleep:
|
||||
for level in garmin_sleep["sleepLevels"]:
|
||||
activity_level = level.get("activityLevel")
|
||||
if activity_level is not None:
|
||||
# Map Garmin activity levels to sleep stage types
|
||||
# 0=deep, 1=light, 2=REM, 3=awake
|
||||
stage_type = health_sleep_schema.SleepStageType(activity_level)
|
||||
|
||||
start_gmt_str = level.get("startGMT")
|
||||
end_gmt_str = level.get("endGMT")
|
||||
|
||||
start_gmt = (
|
||||
datetime.strptime(
|
||||
start_gmt_str,
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
).replace(tzinfo=timezone.utc)
|
||||
if start_gmt_str
|
||||
else None
|
||||
)
|
||||
end_gmt = (
|
||||
datetime.strptime(
|
||||
end_gmt_str,
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
).replace(tzinfo=timezone.utc)
|
||||
if end_gmt_str
|
||||
else None
|
||||
)
|
||||
|
||||
duration_seconds = None
|
||||
if start_gmt and end_gmt:
|
||||
duration_seconds = int((end_gmt - start_gmt).total_seconds())
|
||||
|
||||
sleep_stage = health_sleep_schema.HealthSleepStage(
|
||||
stage_type=stage_type,
|
||||
start_time_gmt=start_gmt,
|
||||
end_time_gmt=end_gmt,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
sleep_stages.append(sleep_stage)
|
||||
|
||||
# Extract sleep scores
|
||||
sleep_scores = sleep_dto.get("sleepScores", {})
|
||||
overall_score = sleep_scores.get("overall", {})
|
||||
total_duration_score = sleep_scores.get(
|
||||
"totalDuration",
|
||||
{},
|
||||
)
|
||||
|
||||
health_sleep = health_sleep_schema.HealthSleep(
|
||||
user_id=user_id,
|
||||
date=sleep_dto["calendarDate"],
|
||||
sleep_start_time_gmt=sleep_start_gmt,
|
||||
sleep_end_time_gmt=sleep_end_gmt,
|
||||
sleep_start_time_local=sleep_start_local,
|
||||
sleep_end_time_local=sleep_end_local,
|
||||
total_sleep_seconds=sleep_dto.get("sleepTimeSeconds"),
|
||||
nap_time_seconds=sleep_dto.get("napTimeSeconds"),
|
||||
unmeasurable_sleep_seconds=sleep_dto.get("unmeasurableSleepSeconds"),
|
||||
deep_sleep_seconds=sleep_dto.get("deepSleepSeconds"),
|
||||
light_sleep_seconds=sleep_dto.get("lightSleepSeconds"),
|
||||
rem_sleep_seconds=sleep_dto.get("remSleepSeconds"),
|
||||
awake_sleep_seconds=sleep_dto.get("awakeSleepSeconds"),
|
||||
avg_heart_rate=sleep_dto.get("avgHeartRate"),
|
||||
min_heart_rate=None,
|
||||
max_heart_rate=None,
|
||||
avg_spo2=sleep_dto.get("averageSpO2Value"),
|
||||
lowest_spo2=sleep_dto.get("lowestSpO2Value"),
|
||||
highest_spo2=sleep_dto.get("highestSpO2Value"),
|
||||
avg_respiration=sleep_dto.get("averageRespirationValue"),
|
||||
lowest_respiration=sleep_dto.get("lowestRespirationValue"),
|
||||
highest_respiration=sleep_dto.get("highestRespirationValue"),
|
||||
avg_stress_level=sleep_dto.get("avgSleepStress"),
|
||||
awake_count=sleep_dto.get("awakeCount"),
|
||||
restless_moments_count=None,
|
||||
sleep_score_overall=overall_score.get("value"),
|
||||
sleep_score_duration=total_duration_score.get("qualifierKey"),
|
||||
sleep_score_quality=overall_score.get("qualifierKey"),
|
||||
garminconnect_sleep_id=str(sleep_dto.get("id")),
|
||||
sleep_stages=sleep_stages if sleep_stages else None,
|
||||
source=health_sleep_schema.Source.GARMIN,
|
||||
)
|
||||
|
||||
health_sleep_db = health_sleep_crud.get_health_sleep_by_date(
|
||||
user_id, health_sleep.date, db
|
||||
)
|
||||
|
||||
if health_sleep_db:
|
||||
health_sleep.id = health_sleep_db.id
|
||||
health_sleep_crud.edit_health_sleep(user_id, health_sleep, db)
|
||||
core_logger.print_to_log(
|
||||
f"User {user_id}: Sleep data edited for date " f"{health_sleep.date}"
|
||||
)
|
||||
else:
|
||||
health_sleep_crud.create_health_sleep(user_id, health_sleep, db)
|
||||
core_logger.print_to_log(
|
||||
f"User {user_id}: Sleep data created for date " f"{health_sleep.date}"
|
||||
)
|
||||
|
||||
count_processed += 1
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return count_processed
|
||||
|
||||
|
||||
def retrieve_garminconnect_users_health_for_days(days: int):
|
||||
# Create a new database session using context manager
|
||||
with SessionLocal() as db:
|
||||
@@ -231,12 +428,19 @@ def get_user_garminconnect_health_by_dates(
|
||||
garminconnect_client, start_date, end_date, user_id, db
|
||||
)
|
||||
|
||||
num_garminconnect_sleep_processed = fetch_and_process_sleep_by_dates(
|
||||
garminconnect_client, start_date, end_date, user_id, db
|
||||
)
|
||||
|
||||
core_logger.print_to_log(
|
||||
f"User {user_id}: {num_garminconnect_bc_processed} Garmin Connect body composition processed"
|
||||
)
|
||||
core_logger.print_to_log(
|
||||
f"User {user_id}: {num_garminconnect_ds_processed} Garmin Connect daily steps processed"
|
||||
)
|
||||
core_logger.print_to_log(
|
||||
f"User {user_id}: {num_garminconnect_sleep_processed} Garmin Connect sleep data processed"
|
||||
)
|
||||
except Exception as err:
|
||||
core_logger.print_to_log(
|
||||
f"Error in get_user_garminconnect_health_by_dates: {err}",
|
||||
|
||||
0
backend/app/health_sleep/__init__.py
Normal file
0
backend/app/health_sleep/__init__.py
Normal file
137
backend/app/health_sleep/crud.py
Normal file
137
backend/app/health_sleep/crud.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, desc
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
import health_sleep.schema as health_sleep_schema
|
||||
import health_sleep.models as health_sleep_models
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
|
||||
def get_health_sleep_by_date(user_id: int, date: str, db: Session):
|
||||
try:
|
||||
# Get the health_sleep from the database
|
||||
health_sleep = (
|
||||
db.query(health_sleep_models.HealthSleep)
|
||||
.filter(
|
||||
health_sleep_models.HealthSleep.date == date,
|
||||
health_sleep_models.HealthSleep.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Check if there are health_sleep if not return None
|
||||
if not health_sleep:
|
||||
return None
|
||||
|
||||
# Return the health_sleep
|
||||
return health_sleep
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in get_health_sleep_by_date: {err}", "error", exc=err
|
||||
)
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def create_health_sleep(
|
||||
user_id: int, health_sleep: health_sleep_schema.HealthSleep, db: Session
|
||||
):
|
||||
try:
|
||||
# Check if date is None
|
||||
if health_sleep.date is None:
|
||||
# Set the date to the current date
|
||||
health_sleep.date = func.now()
|
||||
|
||||
# Create a new health_sleep
|
||||
db_health_sleep = health_sleep_models.HealthSleep(
|
||||
**health_sleep.model_dump(
|
||||
exclude={"id", "user_id"}, exclude_none=False, mode="json"
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Add the health_sleep to the database
|
||||
db.add(db_health_sleep)
|
||||
db.commit()
|
||||
db.refresh(db_health_sleep)
|
||||
|
||||
# Set the id of the health_sleep
|
||||
health_sleep.id = db_health_sleep.id
|
||||
|
||||
# Return the health_sleep
|
||||
return health_sleep
|
||||
except IntegrityError as integrity_error:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Raise an HTTPException with a 409 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Duplicate entry error. Check if there is already a entry created for {health_sleep.date}",
|
||||
) from integrity_error
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in create_health_sleep: {err}", "error", exc=err
|
||||
)
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def edit_health_sleep(
|
||||
user_id, health_sleep: health_sleep_schema.HealthSleep, db: Session
|
||||
):
|
||||
try:
|
||||
# Get the health_sleep from the database
|
||||
db_health_sleep = (
|
||||
db.query(health_sleep_models.HealthSleep)
|
||||
.filter(
|
||||
health_sleep_models.HealthSleep.id == health_sleep.id,
|
||||
health_sleep_models.HealthSleep.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_health_sleep is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Health sleep not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Dictionary of the fields to update if they are not None
|
||||
health_sleep_data = health_sleep.model_dump(exclude_unset=True, mode="json")
|
||||
# Iterate over the fields and update the db_health_sleep dynamically
|
||||
for key, value in health_sleep_data.items():
|
||||
setattr(db_health_sleep, key, value)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return health_sleep
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(f"Error in edit_health_sleep: {err}", "error", exc=err)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
179
backend/app/health_sleep/models.py
Normal file
179
backend/app/health_sleep/models.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
DECIMAL,
|
||||
JSON,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class HealthSleep(Base):
|
||||
__tablename__ = "health_sleep"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="User ID that the health_sleep belongs",
|
||||
)
|
||||
date = Column(
|
||||
Date,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Calendar date of the sleep session",
|
||||
)
|
||||
sleep_start_time_gmt = Column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="Start time of sleep in GMT",
|
||||
)
|
||||
sleep_end_time_gmt = Column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="End time of sleep in GMT",
|
||||
)
|
||||
sleep_start_time_local = Column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="Start time of sleep in local time",
|
||||
)
|
||||
sleep_end_time_local = Column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="End time of sleep in local time",
|
||||
)
|
||||
total_sleep_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Total duration of sleep in seconds",
|
||||
)
|
||||
nap_time_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Duration of naps in seconds",
|
||||
)
|
||||
unmeasurable_sleep_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Unmeasurable sleep duration in seconds",
|
||||
)
|
||||
deep_sleep_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Duration of deep sleep in seconds",
|
||||
)
|
||||
light_sleep_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Duration of light sleep in seconds",
|
||||
)
|
||||
rem_sleep_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Duration of REM sleep in seconds",
|
||||
)
|
||||
awake_sleep_seconds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Duration of awake time in seconds",
|
||||
)
|
||||
avg_heart_rate = Column(
|
||||
DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average heart rate during sleep",
|
||||
)
|
||||
min_heart_rate = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Minimum heart rate during sleep",
|
||||
)
|
||||
max_heart_rate = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Maximum heart rate during sleep",
|
||||
)
|
||||
avg_spo2 = Column(
|
||||
DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average SpO2 oxygen saturation percentage",
|
||||
)
|
||||
lowest_spo2 = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Lowest SpO2 reading during sleep",
|
||||
)
|
||||
highest_spo2 = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Highest SpO2 reading during sleep",
|
||||
)
|
||||
avg_respiration = Column(
|
||||
DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average respiration rate",
|
||||
)
|
||||
lowest_respiration = Column(
|
||||
DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Lowest respiration rate",
|
||||
)
|
||||
highest_respiration = Column(
|
||||
DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Highest respiration rate",
|
||||
)
|
||||
avg_stress_level = Column(
|
||||
DECIMAL(precision=10, scale=2),
|
||||
nullable=True,
|
||||
comment="Average stress level during sleep",
|
||||
)
|
||||
awake_count = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Number of times awakened during sleep",
|
||||
)
|
||||
restless_moments_count = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Count of restless moments",
|
||||
)
|
||||
sleep_score_overall = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Overall sleep score (0-100)",
|
||||
)
|
||||
sleep_score_duration = Column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="Sleep duration score (e.g., GOOD, EXCELLENT, POOR)",
|
||||
)
|
||||
sleep_score_quality = Column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="Sleep quality score",
|
||||
)
|
||||
garminconnect_sleep_id = Column(
|
||||
String(250),
|
||||
nullable=True,
|
||||
comment="External Garmin Connect sleep ID",
|
||||
)
|
||||
sleep_stages = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="List of sleep stage intervals as JSON",
|
||||
)
|
||||
source = Column(
|
||||
String(250),
|
||||
nullable=True,
|
||||
comment="Source of the health sleep data",
|
||||
)
|
||||
|
||||
# Define a relationship to the User model
|
||||
user = relationship("User", back_populates="health_sleep")
|
||||
181
backend/app/health_sleep/schema.py
Normal file
181
backend/app/health_sleep/schema.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from datetime import datetime, date as datetime_date
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class Source(Enum):
|
||||
"""
|
||||
An enumeration representing supported sources.
|
||||
|
||||
Members:
|
||||
GARMIN: Garmin health data source
|
||||
"""
|
||||
|
||||
GARMIN = "garmin"
|
||||
|
||||
|
||||
class SleepStageType(Enum):
|
||||
"""
|
||||
An enumeration representing sleep stage types.
|
||||
|
||||
Members:
|
||||
DEEP: Deep sleep stage
|
||||
LIGHT: Light sleep stage
|
||||
REM: REM (Rapid Eye Movement) sleep stage
|
||||
AWAKE: Awake periods during sleep
|
||||
"""
|
||||
|
||||
DEEP = 0
|
||||
LIGHT = 1
|
||||
REM = 2
|
||||
AWAKE = 3
|
||||
|
||||
|
||||
class HealthSleepStage(BaseModel):
|
||||
"""
|
||||
Represents individual sleep stage interval.
|
||||
|
||||
Attributes:
|
||||
stage_type: Type of sleep stage.
|
||||
start_time_gmt: Start time of the stage in GMT.
|
||||
end_time_gmt: End time of the stage in GMT.
|
||||
duration_seconds: Duration of the stage in seconds.
|
||||
"""
|
||||
|
||||
stage_type: SleepStageType | None = None
|
||||
start_time_gmt: datetime | None = None
|
||||
end_time_gmt: datetime | None = None
|
||||
duration_seconds: int | None = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="forbid",
|
||||
validate_assignment=True,
|
||||
use_enum_values=True,
|
||||
)
|
||||
|
||||
|
||||
class HealthSleep(BaseModel):
|
||||
"""
|
||||
Represents a sleep session with detailed metrics.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the sleep session.
|
||||
user_id: Foreign key reference to the user.
|
||||
date: Calendar date of the sleep session.
|
||||
sleep_start_time_gmt: Start time of sleep in GMT.
|
||||
sleep_end_time_gmt: End time of sleep in GMT.
|
||||
sleep_start_time_local: Start time of sleep in local time.
|
||||
sleep_end_time_local: End time of sleep in local time.
|
||||
total_sleep_seconds: Total duration of sleep in seconds.
|
||||
nap_time_seconds: Duration of naps in seconds.
|
||||
unmeasurable_sleep_seconds: Unmeasurable sleep duration.
|
||||
deep_sleep_seconds: Duration of deep sleep in seconds.
|
||||
light_sleep_seconds: Duration of light sleep in seconds.
|
||||
rem_sleep_seconds: Duration of REM sleep in seconds.
|
||||
awake_sleep_seconds: Duration of awake time in seconds.
|
||||
avg_heart_rate: Average heart rate during sleep.
|
||||
min_heart_rate: Minimum heart rate during sleep.
|
||||
max_heart_rate: Maximum heart rate during sleep.
|
||||
avg_spo2: Average SpO2 oxygen saturation percentage.
|
||||
lowest_spo2: Lowest SpO2 reading during sleep.
|
||||
highest_spo2: Highest SpO2 reading during sleep.
|
||||
avg_respiration: Average respiration rate.
|
||||
lowest_respiration: Lowest respiration rate.
|
||||
highest_respiration: Highest respiration rate.
|
||||
avg_stress_level: Average stress level during sleep.
|
||||
awake_count: Number of times awakened during sleep.
|
||||
restless_moments_count: Count of restless moments.
|
||||
sleep_score_overall: Overall sleep score.
|
||||
sleep_score_duration: Sleep duration score.
|
||||
sleep_score_quality: Sleep quality score.
|
||||
garminconnect_sleep_id: External Garmin Connect sleep ID.
|
||||
sleep_stages: List of sleep stage intervals as JSON.
|
||||
source: Data source of the sleep session.
|
||||
created_at: Timestamp when record was created.
|
||||
updated_at: Timestamp when record was last updated.
|
||||
"""
|
||||
|
||||
id: int | None = None
|
||||
user_id: int | None = None
|
||||
date: datetime_date | None = None
|
||||
sleep_start_time_gmt: datetime | None = None
|
||||
sleep_end_time_gmt: datetime | None = None
|
||||
sleep_start_time_local: datetime | None = None
|
||||
sleep_end_time_local: datetime | None = None
|
||||
total_sleep_seconds: int | None = None
|
||||
nap_time_seconds: int | None = None
|
||||
unmeasurable_sleep_seconds: int | None = None
|
||||
deep_sleep_seconds: int | None = None
|
||||
light_sleep_seconds: int | None = None
|
||||
rem_sleep_seconds: int | None = None
|
||||
awake_sleep_seconds: int | None = None
|
||||
avg_heart_rate: Decimal | None = None
|
||||
min_heart_rate: int | None = None
|
||||
max_heart_rate: int | None = None
|
||||
avg_spo2: Decimal | None = None
|
||||
lowest_spo2: int | None = None
|
||||
highest_spo2: int | None = None
|
||||
avg_respiration: Decimal | None = None
|
||||
lowest_respiration: Decimal | None = None
|
||||
highest_respiration: Decimal | None = None
|
||||
avg_stress_level: Decimal | None = None
|
||||
awake_count: int | None = None
|
||||
restless_moments_count: int | None = None
|
||||
sleep_score_overall: int | None = None
|
||||
sleep_score_duration: str | None = None
|
||||
sleep_score_quality: str | None = None
|
||||
garminconnect_sleep_id: str | None = None
|
||||
sleep_stages: list[HealthSleepStage] | None = None
|
||||
source: Source | None = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="forbid",
|
||||
validate_assignment=True,
|
||||
use_enum_values=True,
|
||||
)
|
||||
|
||||
@field_validator("avg_heart_rate", "min_heart_rate", "max_heart_rate")
|
||||
@classmethod
|
||||
def validate_heart_rate(cls, v: int | Decimal | None) -> int | Decimal | None:
|
||||
"""Validate heart rate is within reasonable range (20-220 bpm)."""
|
||||
if v is not None:
|
||||
value = float(v) if isinstance(v, Decimal) else v
|
||||
if value < 20 or value > 220:
|
||||
raise ValueError("Heart rate must be between 20 and 220 bpm")
|
||||
return v
|
||||
|
||||
@field_validator("avg_spo2", "lowest_spo2", "highest_spo2")
|
||||
@classmethod
|
||||
def validate_spo2(cls, v: int | Decimal | None) -> int | Decimal | None:
|
||||
"""Validate SpO2 is within reasonable range (70-100%)."""
|
||||
if v is not None:
|
||||
value = float(v) if isinstance(v, Decimal) else v
|
||||
if value < 70 or value > 100:
|
||||
raise ValueError("SpO2 must be between 70 and 100%")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_sleep_times(self) -> "HealthSleep":
|
||||
"""Validate sleep start < end."""
|
||||
# Validate sleep start < sleep end (GMT)
|
||||
if (
|
||||
self.sleep_start_time_gmt is not None
|
||||
and self.sleep_end_time_gmt is not None
|
||||
):
|
||||
if self.sleep_start_time_gmt >= self.sleep_end_time_gmt:
|
||||
raise ValueError("Sleep start time must be before sleep end time")
|
||||
|
||||
# Validate sleep start < sleep end (Local)
|
||||
if (
|
||||
self.sleep_start_time_local is not None
|
||||
and self.sleep_end_time_local is not None
|
||||
):
|
||||
if self.sleep_start_time_local >= self.sleep_end_time_local:
|
||||
raise ValueError(
|
||||
"Sleep start time (local) must be before sleep end time (local)"
|
||||
)
|
||||
|
||||
return self
|
||||
@@ -44,6 +44,7 @@ class User(Base):
|
||||
activities: List of activities performed by the user.
|
||||
followers: List of Follower objects representing users who follow this user.
|
||||
following: List of Follower objects representing users this user is following.
|
||||
health_sleep: List of health sleep records for the user.
|
||||
health_weight: List of health weight records for the user.
|
||||
health_steps: List of health steps records for the user.
|
||||
health_targets: List of health targets for the user.
|
||||
@@ -214,6 +215,13 @@ class User(Base):
|
||||
foreign_keys=[followers_models.Follower.follower_id],
|
||||
)
|
||||
|
||||
# Establish a one-to-many relationship with 'health_sleep'
|
||||
health_sleep = relationship(
|
||||
"HealthSleep",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Establish a one-to-many relationship with 'health_weight'
|
||||
health_weight = relationship(
|
||||
"HealthWeight",
|
||||
|
||||
@@ -38,6 +38,17 @@
|
||||
<span class="ms-1">{{ $t('healthSideBarComponent.stepsSection') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
class="nav-link link-body-emphasis"
|
||||
:class="{ active: activeSection === 'sleep' }"
|
||||
@click.prevent="changeActive('sleep')"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'bed']" />
|
||||
<span class="ms-1">{{ $t('healthSideBarComponent.sleepSection') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dashboardSection": "Dashboard",
|
||||
"weightSection": "Weight",
|
||||
"stepsSection": "Steps"
|
||||
"stepsSection": "Steps",
|
||||
"sleepSection": "Sleep"
|
||||
}
|
||||
Reference in New Issue
Block a user