Unify health data models and migrations for steps and weight

Consolidates Alembic migrations for health_steps and health_weight into a single migration, removing redundant migration files. Refactors models and schemas to use a unified 'source' field (replacing garminconnect_body_composition_id) for both health_steps and health_weight, and updates related backend and frontend code to use this new field. Adds 'steps' to health_targets and updates enum usage for source fields. Frontend components now check 'source' for Garmin integration display.
This commit is contained in:
João Vitória Silva
2025-11-25 11:45:37 +00:00
parent 8d804477e1
commit 2cc06345e6
13 changed files with 281 additions and 260 deletions

View File

@@ -300,14 +300,237 @@ def upgrade() -> None:
comment="Auto-redirect to SSO if only one IdP (true - yes, false - no)",
existing_type=sa.Boolean(),
)
# Create table health_steps
op.create_table(
"health_steps",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
comment="User ID that the health_steps belongs",
),
sa.Column(
"date", sa.Date(), nullable=False, comment="Health steps date (date)"
),
sa.Column(
"steps", sa.Integer(), nullable=False, comment="Number of steps taken"
),
sa.Column(
"source",
sa.String(length=250),
nullable=True,
comment="Source of the health steps data",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_health_steps_user_id"), "health_steps", ["user_id"], unique=False
)
# Migrate data from health_data to health_weight
op.create_table(
"health_weight",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
comment="User ID that the health_weight belongs",
),
sa.Column(
"date",
sa.Date(),
nullable=False,
comment="Health weight date (date)",
),
sa.Column(
"weight",
sa.DECIMAL(precision=10, scale=2),
nullable=False,
comment="Weight in kg",
),
sa.Column(
"bmi",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Body mass index (BMI)",
),
sa.Column(
"body_fat",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Body fat percentage",
),
sa.Column(
"body_water",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Body hydration percentage",
),
sa.Column(
"bone_mass",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Bone mass percentage",
),
sa.Column(
"muscle_mass",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Muscle mass percentage",
),
sa.Column(
"physique_rating", sa.Integer(), nullable=True, comment="Physique rating"
),
sa.Column(
"visceral_fat",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Visceral fat rating",
),
sa.Column(
"metabolic_age", sa.Integer(), nullable=True, comment="Metabolic age"
),
sa.Column(
"source",
sa.String(length=250),
nullable=True,
comment="Source of the health weight data",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_health_weight_user_id"), "health_weight", ["user_id"], unique=False
)
# Copy data from health_data to health_weight
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO health_weight (
user_id, date, weight, bmi, source,
body_fat, body_water, bone_mass, muscle_mass,
physique_rating, visceral_fat, metabolic_age
)
SELECT
user_id, date, weight, bmi, garminconnect_body_composition_id,
NULL as body_fat, NULL as body_water, NULL as bone_mass, NULL as muscle_mass,
NULL as physique_rating, NULL as visceral_fat, NULL as metabolic_age
FROM health_data
"""
)
)
op.drop_index(op.f("ix_health_data_user_id"), table_name="health_data")
op.drop_table("health_data")
# Add steps column to health_targets
op.add_column(
"health_targets",
sa.Column(
"steps", sa.Integer(), nullable=True, comment="Number of steps taken"
),
)
# Update health_weight source column to "garmin" where not null
connection.execute(
sa.text(
"""
UPDATE health_weight
SET source = 'garmin'
WHERE source IS NOT NULL
"""
)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Remove steps column from health_targets
op.drop_column("health_targets", "steps")
# Revert and migrate data from health_weight to health_data
op.create_table(
"health_data",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"user_id",
sa.INTEGER(),
autoincrement=False,
nullable=False,
comment="User ID that the health_data belongs",
),
sa.Column(
"weight",
sa.NUMERIC(precision=10, scale=2),
autoincrement=False,
nullable=True,
comment="Weight in kg",
),
sa.Column(
"date",
sa.DATE(),
autoincrement=False,
nullable=False,
comment="Health data creation date (date)",
),
sa.Column(
"bmi",
sa.NUMERIC(precision=10, scale=2),
autoincrement=False,
nullable=True,
comment="Body mass index (BMI)",
),
sa.Column(
"garminconnect_body_composition_id",
sa.VARCHAR(length=45),
autoincrement=False,
nullable=True,
comment="Garmin Connect body composition ID",
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("health_data_user_id_fkey"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("health_data_pkey")),
)
op.create_index(
op.f("ix_health_data_user_id"), "health_data", ["user_id"], unique=False
)
# Copy data from health_weight back to health_data
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO health_data (
user_id, date, weight, bmi, garminconnect_body_composition_id
)
SELECT
user_id, date, weight, bmi, source
FROM health_weight
"""
)
)
op.drop_index(op.f("ix_health_weight_user_id"), table_name="health_weight")
op.drop_table("health_weight")
# Drop health_steps table
op.drop_index(op.f("ix_health_steps_user_id"), table_name="health_steps")
op.drop_table("health_steps")
# Remove added columns from server_settings
op.drop_column("server_settings", "sso_auto_redirect")
op.drop_column("server_settings", "local_login_enabled")
op.drop_column("server_settings", "sso_enabled")
# Drop users_identity_providers table
op.drop_index(
op.f("ix_users_identity_providers_user_id"),
table_name="users_identity_providers",
@@ -321,12 +544,14 @@ def downgrade() -> None:
table_name="users_identity_providers",
)
op.drop_table("users_identity_providers")
# Drop identity_providers table
op.drop_index(op.f("ix_identity_providers_slug"), table_name="identity_providers")
op.drop_index(op.f("ix_identity_providers_id"), table_name="identity_providers")
op.drop_index(
op.f("ix_identity_providers_enabled"), table_name="identity_providers"
)
op.drop_table("identity_providers")
# Revert the refresh_token column alteration
op.alter_column(
"users_sessions",
"refresh_token",

View File

@@ -1,193 +0,0 @@
"""v0.16.0 migration health data to weight
Revision ID: 9ca353e5d874
Revises: 2af2c0629b37
Create Date: 2025-11-24 14:47:35.920652
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "9ca353e5d874"
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_weight",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
comment="User ID that the health_weight belongs",
),
sa.Column(
"date",
sa.Date(),
nullable=False,
comment="Health weight date (date)",
),
sa.Column(
"weight",
sa.DECIMAL(precision=10, scale=2),
nullable=False,
comment="Weight in kg",
),
sa.Column(
"bmi",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Body mass index (BMI)",
),
sa.Column(
"body_fat",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Body fat percentage",
),
sa.Column(
"body_water",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Body hydration percentage",
),
sa.Column(
"bone_mass",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Bone mass percentage",
),
sa.Column(
"muscle_mass",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Muscle mass percentage",
),
sa.Column(
"physique_rating", sa.Integer(), nullable=True, comment="Physique rating"
),
sa.Column(
"visceral_fat",
sa.DECIMAL(precision=10, scale=2),
nullable=True,
comment="Visceral fat rating",
),
sa.Column(
"metabolic_age", sa.Integer(), nullable=True, comment="Metabolic age"
),
sa.Column(
"garminconnect_body_composition_id",
sa.String(length=45),
nullable=True,
comment="Garmin Connect body composition ID",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_health_weight_user_id"), "health_weight", ["user_id"], unique=False
)
# Copy data from health_data to health_weight
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO health_weight (
user_id, date, weight, bmi, garminconnect_body_composition_id,
body_fat, body_water, bone_mass, muscle_mass,
physique_rating, visceral_fat, metabolic_age
)
SELECT
user_id, date, weight, bmi, garminconnect_body_composition_id,
NULL as body_fat, NULL as body_water, NULL as bone_mass, NULL as muscle_mass,
NULL as physique_rating, NULL as visceral_fat, NULL as metabolic_age
FROM health_data
"""
)
)
op.drop_index(op.f("ix_health_data_user_id"), table_name="health_data")
op.drop_table("health_data")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"health_data",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"user_id",
sa.INTEGER(),
autoincrement=False,
nullable=False,
comment="User ID that the health_data belongs",
),
sa.Column(
"weight",
sa.NUMERIC(precision=10, scale=2),
autoincrement=False,
nullable=True,
comment="Weight in kg",
),
sa.Column(
"date",
sa.DATE(),
autoincrement=False,
nullable=False,
comment="Health data creation date (date)",
),
sa.Column(
"bmi",
sa.NUMERIC(precision=10, scale=2),
autoincrement=False,
nullable=True,
comment="Body mass index (BMI)",
),
sa.Column(
"garminconnect_body_composition_id",
sa.VARCHAR(length=45),
autoincrement=False,
nullable=True,
comment="Garmin Connect body composition ID",
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("health_data_user_id_fkey"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("health_data_pkey")),
)
op.create_index(
op.f("ix_health_data_user_id"), "health_data", ["user_id"], unique=False
)
# Copy data from health_weight back to health_data
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO health_data (
user_id, date, weight, bmi, garminconnect_body_composition_id
)
SELECT
user_id, date, weight, bmi, garminconnect_body_composition_id
FROM health_weight
"""
)
)
op.drop_index(op.f("ix_health_weight_user_id"), table_name="health_weight")
op.drop_table("health_weight")
# ### end Alembic commands ###

View File

@@ -1,52 +0,0 @@
"""v0.16.0 migration health steps
Revision ID: 9ce568f3bce0
Revises: 9ca353e5d874
Create Date: 2025-11-24 16:14:09.794492
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "9ce568f3bce0"
down_revision: Union[str, None] = "9ca353e5d874"
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_steps",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
comment="User ID that the health_steps belongs",
),
sa.Column(
"date", sa.Date(), nullable=False, comment="Health steps date (date)"
),
sa.Column(
"steps", sa.Integer(), nullable=False, comment="Number of steps taken"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_health_steps_user_id"), "health_steps", ["user_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_health_steps_user_id"), table_name="health_steps")
op.drop_table("health_steps")
# ### end Alembic commands ###

View File

@@ -72,7 +72,7 @@ def fetch_and_process_bc_by_dates(
physique_rating=bc["physiqueRating"],
visceral_fat=bc["visceralFat"],
metabolic_age=bc["metabolicAge"],
garminconnect_body_composition_id=str(bc["samplePk"]),
source=health_weight_schema.Source.GARMIN,
)
health_weight_db = health_weight_crud.get_health_weight_by_date(
@@ -138,6 +138,7 @@ def fetch_and_process_ds_by_dates(
user_id=user_id,
date=ds["calendarDate"],
steps=ds["totalSteps"],
source=health_steps_schema.Source.GARMIN,
)
health_steps_db = health_steps_crud.get_health_steps_by_date(

View File

@@ -31,6 +31,11 @@ class HealthSteps(Base):
nullable=False,
comment="Number of steps taken",
)
source = Column(
String(250),
nullable=True,
comment="Source of the health steps data",
)
# Define a relationship to the User model
user = relationship("User", back_populates="health_steps")

View File

@@ -1,13 +1,29 @@
from enum import Enum
from pydantic import BaseModel, ConfigDict
from datetime import date as datetime_date
class Source(Enum):
"""
An enumeration representing supported sources for the application.
Members:
GARMIN: Garmin health data source
"""
GARMIN = "garmin"
class HealthSteps(BaseModel):
id: int | None = None
user_id: int | None = None
date: datetime_date | None = None
steps: int | None = None
source: Source | None = None
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
from_attributes=True,
extra="forbid",
validate_assignment=True,
use_enum_values=True,
)

View File

@@ -25,6 +25,11 @@ class HealthTargets(Base):
nullable=True,
comment="Weight in kg",
)
steps = Column(
Integer,
nullable=True,
comment="Number of steps taken",
)
# Define a relationship to the User model
user = relationship("User", back_populates="health_targets")

View File

@@ -1,11 +1,12 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class HealthTargets(BaseModel):
id: int | None = None
user_id: int | None = None
weight: float | None = None
steps: int | None = None
model_config = {
"from_attributes": True
}
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
)

View File

@@ -71,8 +71,8 @@ class HealthWeight(Base):
nullable=True,
comment="Metabolic age",
)
garminconnect_body_composition_id = Column(
String(length=45), nullable=True, comment="Garmin Connect body composition ID"
source = Column(
String(length=250), nullable=True, comment="Source of the health weight data"
)
# Define a relationship to the User model

View File

@@ -1,7 +1,19 @@
from enum import Enum
from pydantic import BaseModel, ConfigDict
from datetime import date as datetime_date
class Source(Enum):
"""
An enumeration representing supported sources for the application.
Members:
GARMIN: Garmin health data source
"""
GARMIN = "garmin"
class HealthWeight(BaseModel):
id: int | None = None
user_id: int | None = None
@@ -15,8 +27,11 @@ class HealthWeight(BaseModel):
physique_rating: int | None = None
visceral_fat: float | None = None
metabolic_age: int | None = None
garminconnect_body_composition_id: str | None = None
source: Source | None = None
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
from_attributes=True,
extra="forbid",
validate_assignment=True,
use_enum_values=True,
)

View File

@@ -42,7 +42,7 @@ def calculate_bmi_all_user_entries(user_id: int, db: Session):
date=health_weight.date,
weight=health_weight.weight,
bmi=health_weight.bmi,
garminconnect_body_composition_id=health_weight.garminconnect_body_composition_id,
source=health_weight.source,
)
aux_health_weight = calculate_bmi(aux_health_weight, user_id, db)
health_weight_crud.edit_health_weight(user_id, aux_health_weight, db)

View File

@@ -12,10 +12,9 @@
</div>
</div>
<div>
<!--<span class="badge bg-primary-subtle border border-primary-subtle text-primary-emphasis align-middle ms-2" v-if="data.garminconnect_body_composition_id">{{ $t("healthWeightListComponent.labelGarminConnect") }}</span>-->
<span
class="align-middle me-3 d-none d-sm-inline"
v-if="data.garminconnect_body_composition_id"
v-if="data.source === 'garmin'"
>
<img
:src="INTEGRATION_LOGOS.garminConnectApp"

View File

@@ -15,10 +15,9 @@
</div>
</div>
<div>
<!--<span class="badge bg-primary-subtle border border-primary-subtle text-primary-emphasis align-middle ms-2" v-if="data.garminconnect_body_composition_id">{{ $t("healthWeightListComponent.labelGarminConnect") }}</span>-->
<span
class="align-middle me-3 d-none d-sm-inline"
v-if="data.garminconnect_body_composition_id"
v-if="data.source === 'garmin'"
>
<img
:src="INTEGRATION_LOGOS.garminConnectApp"