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:
João Vitória Silva
2025-11-25 16:36:31 +00:00
parent e3f021094d
commit 3a9a77007d
11 changed files with 8982 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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

View 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 ###

View File

@@ -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}",

View File

View 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

View 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")

View 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

View File

@@ -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",

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
{
"dashboardSection": "Dashboard",
"weightSection": "Weight",
"stepsSection": "Steps"
"stepsSection": "Steps",
"sleepSection": "Sleep"
}