From 55e37fbca811886fba9cd04d0c448c6c8957df6a Mon Sep 17 00:00:00 2001 From: sstent Date: Fri, 9 Jan 2026 09:59:36 -0800 Subject: [PATCH] added activity view --- FitnessSync/backend/.coverage | Bin 0 -> 53248 bytes FitnessSync/backend/README.md | 64 +- .../backend/__pycache__/main.cpython-311.pyc | Bin 5074 -> 5310 bytes .../backend/__pycache__/main.cpython-313.pyc | Bin 4541 -> 4743 bytes .../1e157f880117_create_jobs_table.py | 46 + ...73e349ef1d88_add_bike_setup_to_activity.py | 32 + .../versions/85c60ed462bf_add_state_tables.py | 53 + .../95af0e911216_add_bike_setups_table.py | 41 + ...7f880117_create_jobs_table.cpython-311.pyc | Bin 0 -> 3243 bytes ...7f880117_create_jobs_table.cpython-313.pyc | Bin 0 -> 3038 bytes ...add_bike_setup_to_activity.cpython-311.pyc | Bin 0 -> 1678 bytes ...add_bike_setup_to_activity.cpython-313.pyc | Bin 0 -> 1537 bytes ...60ed462bf_add_state_tables.cpython-311.pyc | Bin 0 -> 3906 bytes ...60ed462bf_add_state_tables.cpython-313.pyc | Bin 0 -> 3698 bytes ...1216_add_bike_setups_table.cpython-311.pyc | Bin 0 -> 2643 bytes ...1216_add_bike_setups_table.cpython-313.pyc | Bin 0 -> 2468 bytes ...a5_add_fitbit_redirect_uri.cpython-313.pyc | Bin 0 -> 1322 bytes ...nd_activity_schema_metrics.cpython-311.pyc | Bin 0 -> 5461 bytes ...nd_activity_schema_metrics.cpython-313.pyc | Bin 0 -> 5293 bytes ...a0528865_expand_activity_schema_metrics.py | 64 + .../docs/references}/garth_reference.md | 0 FitnessSync/backend/main.py | 39 +- FitnessSync/backend/requirements.txt | 2 +- .../__pycache__/activities.cpython-311.pyc | Bin 13207 -> 40067 bytes .../__pycache__/activities.cpython-313.pyc | Bin 11921 -> 36843 bytes .../src/api/__pycache__/auth.cpython-311.pyc | Bin 0 -> 20365 bytes .../src/api/__pycache__/auth.cpython-313.pyc | Bin 0 -> 17866 bytes .../__pycache__/bike_setups.cpython-311.pyc | Bin 0 -> 7406 bytes .../__pycache__/bike_setups.cpython-313.pyc | Bin 0 -> 6548 bytes .../__pycache__/config_routes.cpython-311.pyc | Bin 0 -> 7328 bytes .../__pycache__/config_routes.cpython-313.pyc | Bin 0 -> 6436 bytes .../api/__pycache__/metrics.cpython-311.pyc | Bin 13509 -> 19381 bytes .../api/__pycache__/metrics.cpython-313.pyc | Bin 11868 -> 17190 bytes .../__pycache__/scheduling.cpython-311.pyc | Bin 0 -> 7711 bytes .../__pycache__/scheduling.cpython-313.pyc | Bin 0 -> 6967 bytes .../api/__pycache__/status.cpython-311.pyc | Bin 5733 -> 10935 bytes .../api/__pycache__/status.cpython-313.pyc | Bin 5017 -> 9714 bytes .../src/api/__pycache__/sync.cpython-311.pyc | Bin 21149 -> 20785 bytes .../src/api/__pycache__/sync.cpython-313.pyc | Bin 18169 -> 17955 bytes FitnessSync/backend/src/api/activities.py | 598 ++++++- .../backend/src/api/{setup.py => auth.py} | 258 +-- FitnessSync/backend/src/api/bike_setups.py | 110 ++ FitnessSync/backend/src/api/config_routes.py | 121 ++ FitnessSync/backend/src/api/metrics.py | 126 +- FitnessSync/backend/src/api/scheduling.py | 131 ++ FitnessSync/backend/src/api/status.py | 84 +- FitnessSync/backend/src/api/sync.py | 160 +- FitnessSync/backend/src/models/__init__.py | 7 +- .../__pycache__/__init__.cpython-311.pyc | Bin 618 -> 933 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 529 -> 769 bytes .../__pycache__/activity.cpython-311.pyc | Bin 1709 -> 3073 bytes .../__pycache__/activity.cpython-313.pyc | Bin 1388 -> 2314 bytes .../activity_state.cpython-311.pyc | Bin 0 -> 1243 bytes .../activity_state.cpython-313.pyc | Bin 0 -> 1082 bytes .../__pycache__/bike_setup.cpython-311.pyc | Bin 0 -> 1292 bytes .../__pycache__/bike_setup.cpython-313.pyc | Bin 0 -> 1083 bytes .../__pycache__/health_state.cpython-311.pyc | Bin 0 -> 1382 bytes .../__pycache__/health_state.cpython-313.pyc | Bin 0 -> 1199 bytes .../models/__pycache__/job.cpython-311.pyc | Bin 0 -> 1713 bytes .../models/__pycache__/job.cpython-313.pyc | Bin 0 -> 1358 bytes .../__pycache__/scheduled_job.cpython-311.pyc | Bin 0 -> 1630 bytes .../__pycache__/scheduled_job.cpython-313.pyc | Bin 0 -> 1309 bytes .../__pycache__/weight_record.cpython-311.pyc | Bin 1540 -> 1591 bytes .../__pycache__/weight_record.cpython-313.pyc | Bin 1265 -> 1296 bytes FitnessSync/backend/src/models/activity.py | 30 +- .../backend/src/models/activity_state.py | 12 + FitnessSync/backend/src/models/bike_setup.py | 15 + .../backend/src/models/health_state.py | 16 + FitnessSync/backend/src/models/job.py | 19 + .../backend/src/models/scheduled_job.py | 20 + .../backend/src/models/weight_record.py | 1 + FitnessSync/backend/src/routers/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 145 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 172 bytes .../routers/__pycache__/web.cpython-311.pyc | Bin 0 -> 2850 bytes .../routers/__pycache__/web.cpython-313.pyc | Bin 0 -> 2173 bytes FitnessSync/backend/src/routers/web.py | 34 + .../__pycache__/bike_matching.cpython-311.pyc | Bin 0 -> 5356 bytes .../__pycache__/bike_matching.cpython-313.pyc | Bin 0 -> 4559 bytes .../__pycache__/fitbit_client.cpython-311.pyc | Bin 6719 -> 6789 bytes .../__pycache__/fitbit_client.cpython-313.pyc | Bin 6269 -> 6339 bytes .../__pycache__/garth_helper.cpython-311.pyc | Bin 0 -> 2582 bytes .../__pycache__/garth_helper.cpython-313.pyc | Bin 0 -> 2408 bytes .../__pycache__/job_manager.cpython-311.pyc | Bin 3928 -> 16621 bytes .../__pycache__/job_manager.cpython-313.pyc | Bin 3764 -> 14294 bytes .../postgresql_manager.cpython-311.pyc | Bin 2807 -> 2996 bytes .../postgresql_manager.cpython-313.pyc | Bin 2524 -> 2680 bytes .../__pycache__/scheduler.cpython-311.pyc | Bin 0 -> 8353 bytes .../__pycache__/scheduler.cpython-313.pyc | Bin 0 -> 7535 bytes .../__pycache__/sync_app.cpython-311.pyc | Bin 25497 -> 7002 bytes .../__pycache__/sync_app.cpython-313.pyc | Bin 23313 -> 5832 bytes .../backend/src/services/bike_matching.py | 129 ++ .../backend/src/services/fitbit_client.py | 14 +- .../garmin/__pycache__/auth.cpython-311.pyc | Bin 12940 -> 13783 bytes .../garmin/__pycache__/auth.cpython-313.pyc | Bin 11891 -> 12744 bytes .../garmin/__pycache__/client.cpython-311.pyc | Bin 2285 -> 5703 bytes .../garmin/__pycache__/client.cpython-313.pyc | Bin 2226 -> 5420 bytes .../garmin/__pycache__/data.cpython-311.pyc | Bin 12239 -> 19210 bytes .../garmin/__pycache__/data.cpython-313.pyc | Bin 10266 -> 16945 bytes .../backend/src/services/garmin/auth.py | 18 +- .../backend/src/services/garmin/client.py | 75 +- .../backend/src/services/garmin/data.py | 453 +++-- .../backend/src/services/garth_helper.py | 35 + .../backend/src/services/job_manager.py | 221 ++- .../src/services/postgresql_manager.py | 5 +- FitnessSync/backend/src/services/scheduler.py | 161 ++ .../backend/src/services/sync/__init__.py | 0 .../sync/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 151 bytes .../sync/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 178 bytes .../sync/__pycache__/activity.cpython-311.pyc | Bin 0 -> 16970 bytes .../sync/__pycache__/activity.cpython-313.pyc | Bin 0 -> 14244 bytes .../sync/__pycache__/health.cpython-311.pyc | Bin 0 -> 21667 bytes .../sync/__pycache__/health.cpython-313.pyc | Bin 0 -> 19505 bytes .../sync/__pycache__/utils.cpython-311.pyc | Bin 0 -> 2471 bytes .../sync/__pycache__/utils.cpython-313.pyc | Bin 0 -> 2257 bytes .../sync/__pycache__/weight.cpython-311.pyc | Bin 0 -> 13178 bytes .../sync/__pycache__/weight.cpython-313.pyc | Bin 0 -> 12350 bytes .../backend/src/services/sync/activity.py | 305 ++++ .../backend/src/services/sync/health.py | 392 +++++ .../backend/src/services/sync/utils.py | 56 + .../backend/src/services/sync/weight.py | 274 +++ FitnessSync/backend/src/services/sync_app.py | 484 +----- .../__pycache__/definitions.cpython-311.pyc | Bin 0 -> 11658 bytes FitnessSync/backend/src/tasks/definitions.py | 173 ++ FitnessSync/backend/startup_dummy.db | Bin 0 -> 77824 bytes FitnessSync/backend/templates/activities.html | 944 +++++++---- .../backend/templates/activity_view.html | 496 ++++++ FitnessSync/backend/templates/base.html | 221 +++ .../backend/templates/bike_setups.html | 202 +++ .../backend/templates/fitbit_health.html | 329 ++++ .../backend/templates/garmin_health.html | 305 ++++ FitnessSync/backend/templates/index.html | 1300 +++++++++++---- FitnessSync/backend/templates/setup.html | 1469 +++++++++++------ FitnessSync/backend/test.db | Bin 0 -> 122880 bytes FitnessSync/backend/test_bike_setups.db | Bin 0 -> 131072 bytes FitnessSync/backend/test_runtime.db | Bin 0 -> 77824 bytes .../backend/tests/functional/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 177 bytes ...t_bike_setups.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 10441 bytes ...est_setup_api.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6290 bytes .../tests/functional/test_bike_setups.py | 88 + .../tests/functional/test_setup_api.py | 61 + ...bike_matching.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 5725 bytes ...t_garmin_auth.cpython-313-pytest-9.0.2.pyc | Bin 16292 -> 16249 bytes ...ect_migration.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 13340 bytes ...test_sync_app.cpython-313-pytest-9.0.2.pyc | Bin 29711 -> 16737 bytes .../backend/tests/unit/test_bike_matching.py | 66 + .../backend/tests/unit/test_garmin_data.py | 115 -- .../backend/tests/unit/test_sync_app.py | 261 +-- FitnessSync/requirements.txt | 3 +- FitnessSync/response.json | 1 - FitnessSync/scratch/check_config_db.py | 19 + .../{ => scratch}/check_garth_mfa_arg.py | 0 FitnessSync/scratch/check_tokens.py | 17 + .../{ => scratch}/debug_garth_connection.py | 0 FitnessSync/{ => scratch}/inspect_activity.py | 0 FitnessSync/scratch/inspect_activity_keys.py | 45 + .../{ => scratch}/inspect_db_tokens.py | 0 .../inspect_db_tokens_standalone.py | 0 FitnessSync/scratch/inspect_fitbit_api_bmi.py | 129 ++ FitnessSync/scratch/inspect_fitbit_bmi.py | 39 + FitnessSync/scratch/inspect_garmin_lib.py | 21 + .../{ => scratch}/inspect_garth_client.py | 0 FitnessSync/scratch/inspect_weight_count.py | 49 + .../scratch/recreate_scheduler_table.py | 19 + FitnessSync/scratch/test_api.py | 30 + FitnessSync/scratch/verify_controls.py | 89 + .../specs/002-fitbit-garmin-sync/plan.md | 29 +- 168 files changed, 8799 insertions(+), 2426 deletions(-) create mode 100644 FitnessSync/backend/.coverage create mode 100644 FitnessSync/backend/alembic/versions/1e157f880117_create_jobs_table.py create mode 100644 FitnessSync/backend/alembic/versions/73e349ef1d88_add_bike_setup_to_activity.py create mode 100644 FitnessSync/backend/alembic/versions/85c60ed462bf_add_state_tables.py create mode 100644 FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/73e349ef1d88_add_bike_setup_to_activity.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/73e349ef1d88_add_bike_setup_to_activity.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/b5a6d7ef97a5_add_fitbit_redirect_uri.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py rename FitnessSync/{ => backend/docs/references}/garth_reference.md (100%) create mode 100644 FitnessSync/backend/src/api/__pycache__/auth.cpython-311.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/auth.cpython-313.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-311.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-313.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/config_routes.cpython-311.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/config_routes.cpython-313.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc rename FitnessSync/backend/src/api/{setup.py => auth.py} (51%) create mode 100644 FitnessSync/backend/src/api/bike_setups.py create mode 100644 FitnessSync/backend/src/api/config_routes.py create mode 100644 FitnessSync/backend/src/api/scheduling.py create mode 100644 FitnessSync/backend/src/models/__pycache__/activity_state.cpython-311.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/activity_state.cpython-313.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/bike_setup.cpython-311.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/bike_setup.cpython-313.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/health_state.cpython-311.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/health_state.cpython-313.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/job.cpython-311.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/job.cpython-313.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/scheduled_job.cpython-311.pyc create mode 100644 FitnessSync/backend/src/models/__pycache__/scheduled_job.cpython-313.pyc create mode 100644 FitnessSync/backend/src/models/activity_state.py create mode 100644 FitnessSync/backend/src/models/bike_setup.py create mode 100644 FitnessSync/backend/src/models/health_state.py create mode 100644 FitnessSync/backend/src/models/job.py create mode 100644 FitnessSync/backend/src/models/scheduled_job.py create mode 100644 FitnessSync/backend/src/routers/__init__.py create mode 100644 FitnessSync/backend/src/routers/__pycache__/__init__.cpython-311.pyc create mode 100644 FitnessSync/backend/src/routers/__pycache__/__init__.cpython-313.pyc create mode 100644 FitnessSync/backend/src/routers/__pycache__/web.cpython-311.pyc create mode 100644 FitnessSync/backend/src/routers/__pycache__/web.cpython-313.pyc create mode 100644 FitnessSync/backend/src/routers/web.py create mode 100644 FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/garth_helper.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/garth_helper.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/bike_matching.py create mode 100644 FitnessSync/backend/src/services/garth_helper.py create mode 100644 FitnessSync/backend/src/services/scheduler.py create mode 100644 FitnessSync/backend/src/services/sync/__init__.py create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/__init__.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/__init__.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/health.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/health.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/utils.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/utils.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/sync/activity.py create mode 100644 FitnessSync/backend/src/services/sync/health.py create mode 100644 FitnessSync/backend/src/services/sync/utils.py create mode 100644 FitnessSync/backend/src/services/sync/weight.py create mode 100644 FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc create mode 100644 FitnessSync/backend/src/tasks/definitions.py create mode 100644 FitnessSync/backend/startup_dummy.db create mode 100644 FitnessSync/backend/templates/activity_view.html create mode 100644 FitnessSync/backend/templates/base.html create mode 100644 FitnessSync/backend/templates/bike_setups.html create mode 100644 FitnessSync/backend/templates/fitbit_health.html create mode 100644 FitnessSync/backend/templates/garmin_health.html create mode 100644 FitnessSync/backend/test.db create mode 100644 FitnessSync/backend/test_bike_setups.db create mode 100644 FitnessSync/backend/test_runtime.db create mode 100644 FitnessSync/backend/tests/functional/__init__.py create mode 100644 FitnessSync/backend/tests/functional/__pycache__/__init__.cpython-313.pyc create mode 100644 FitnessSync/backend/tests/functional/__pycache__/test_bike_setups.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/functional/__pycache__/test_setup_api.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/functional/test_bike_setups.py create mode 100644 FitnessSync/backend/tests/functional/test_setup_api.py create mode 100644 FitnessSync/backend/tests/unit/__pycache__/test_bike_matching.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/unit/__pycache__/test_garminconnect_migration.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/unit/test_bike_matching.py delete mode 100644 FitnessSync/backend/tests/unit/test_garmin_data.py delete mode 100644 FitnessSync/response.json create mode 100644 FitnessSync/scratch/check_config_db.py rename FitnessSync/{ => scratch}/check_garth_mfa_arg.py (100%) create mode 100644 FitnessSync/scratch/check_tokens.py rename FitnessSync/{ => scratch}/debug_garth_connection.py (100%) rename FitnessSync/{ => scratch}/inspect_activity.py (100%) create mode 100644 FitnessSync/scratch/inspect_activity_keys.py rename FitnessSync/{ => scratch}/inspect_db_tokens.py (100%) rename FitnessSync/{ => scratch}/inspect_db_tokens_standalone.py (100%) create mode 100644 FitnessSync/scratch/inspect_fitbit_api_bmi.py create mode 100644 FitnessSync/scratch/inspect_fitbit_bmi.py create mode 100644 FitnessSync/scratch/inspect_garmin_lib.py rename FitnessSync/{ => scratch}/inspect_garth_client.py (100%) create mode 100644 FitnessSync/scratch/inspect_weight_count.py create mode 100644 FitnessSync/scratch/recreate_scheduler_table.py create mode 100644 FitnessSync/scratch/test_api.py create mode 100644 FitnessSync/scratch/verify_controls.py diff --git a/FitnessSync/backend/.coverage b/FitnessSync/backend/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..68f392df0beb122e400881ce716e7d72b5dec179 GIT binary patch literal 53248 zcmeI)Pi)&%90zba>5@84 zJgX`_^_qL>jpe1)WwE;S;;Ch!_K71ILCeB|SQ4IlLA0eO>UL8K+o{=AE3ln45j12R zy5q|l&snsOW-Si57{=$dvR$KCfn1{`+MeCAye)BFZWY1+(b)pI8K@nUh_u%n4k3<2 z(dUIH>(Y}>Rr)FyN9@{sX8xm#nRNc(LH&vh%Vc>~dQ1$JhIaIeaqt1jhpGf<`V+*UDgMbmb|VmiJZ*sdewrmS`XS=+UM1xAY(!W%3NrFE7I zbXLWT&NZOZkGxCFD0mdPPMLGun6)2~Pfd}-JUg9Mg~-o!T5YZzilI&%xT9i@iW|65 zVeh+ccB#vLd55YqCg&Peu{w^u-jTh^FNl83LRbvPEeI|IwSdDDsr>PSx<;*1Zu;v@ z`sh@)I)NK*m#Om0C3?wTer!CQpPkjOtcMMSdCC<#@Ovx9P^0PX9?^98_~>@Sy=1tN za4%{$g)*vG&8C=QMhs`S{d)A z$I|(!DZQ(D1dmJnJTO!U-qY^_J0^nr&wkqq8shG}^KQ!c+<{d7`KeJE=PZ;<{F=Hv znFzD|Nix)L(V)-I=9PQ1fAM>fS4^@7px3^0ehY7w;8w4a?_I$e$Ax*KJD6QF8;uIO)?l zZC`|WxO-MQ(NDwQ;xyJa=E6M1lM4^?tJLY@{zWUTmgUr<`HH7ul`4(>+&TF?Y7n7_ zVvahcPhE6JnsK7hQ6914^cUM?!l|~bn(FRcDFZ29Xvn*gQ?t>_Rv-hrB?SiwGZQwf z3zjdco}`1(TpDJK#2PzurILA-j>KcAD$V>{@?LF+tGHa^SH4Of=Z|vTSv{RUdQ|Ur zq8_XUr?O9<1X@;H)@U;^)UtLQ>^HL4X_TgqNP~@w!3PX^Q=NfFL{;q-MHh#PJ|Jc0 z`2t@zw>5fTfdB*`009U<00Izz00bZa0SG`~{|O}Zgr4T>|AhIQX8u7hSReoa2tWV= z5P$##AOHafKmY;|cq9d~iR2+O{&n%XXEtWD=s)Tt%GjEu;9?1eC z(-43F1Rwwb2tWV=5P$##AOHaf^aQfWLwfu*Kq8ZzF`{n))ZhPK)68qVpjd$b1Rwwb z2tWV=5P$##AOHafKwwV=R`fBgd$`zeTe9f;fpmi6iszn_)xa;lVh5}AD@*5}<~91k z82`+cQ!Q3#k92B9->VjV>228bn?^;Sj+Cu-n|@@0@Bi!OHyS;#KmY;|fB*y_009U< z00Izz00bcLa0sl7bq^c==k-5+|L@_bSmXi%5P$##AOHafKmY;|fB*y_@PGu8dQ!`& z`~SB!^Y#Ne3zCHZ1Rwwb2tWV=5P$##AOHafKwwV=lF6K*?*BJ5v$3aH!O0;20SG_< z0uX=z1Rwwb2tWV=`$C{Qr5`YiOND>${CeGdT+@DN)u+CF_ld8>r~iEM*Y`gs%%5g| zY~1|p?@Y$X{r=s@?|gpZ`t{>i&$P#TrJtlVeyXLnria_T&G>lu{>z;cH;rh|V2BUH zeYd{8dNHZ)|8HyNb`%b85P$##AOHafKmY;|fB*y_009W>CxNjs!wB#H4}7h#pUQ`_ zKmY;|fB*y_009U<00Izz00bZq3Gn@YT>qmvKmY;|fB*y_009U<00Izz00j280RR6# euK)LU9iwy*fB*y_009U<00Izz00bZaf&Tz|JdO?k literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/README.md b/FitnessSync/backend/README.md index c5c896c..f594bf2 100644 --- a/FitnessSync/backend/README.md +++ b/FitnessSync/backend/README.md @@ -118,29 +118,55 @@ docker-compose up --build backend/ ├── main.py ├── src/ -│ ├── models/ -│ │ ├── __init__.py -│ │ ├── config.py -│ │ ├── weight_record.py -│ │ ├── activity.py -│ │ ├── health_metric.py -│ │ ├── sync_log.py -│ │ └── api_token.py -│ ├── services/ -│ │ ├── __init__.py -│ │ ├── fitbit_client.py -│ │ ├── garmin_client.py -│ │ ├── postgresql_manager.py -│ │ └── sync_app.py │ ├── api/ │ │ ├── __init__.py -│ │ ├── auth.py -│ │ ├── sync.py -│ │ ├── setup.py -│ │ └── metrics.py +│ │ ├── activities.py +│ │ ├── auth.py # Refactored from setup.py +│ │ ├── config_routes.py # Refactored from setup.py +│ │ ├── logs.py +│ │ ├── metrics.py +│ │ ├── scheduling.py +│ │ ├── status.py +│ │ └── sync.py +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── activity.py +│ │ ├── activity_state.py +│ │ ├── api_token.py +│ │ ├── auth_status.py +│ │ ├── base.py +│ │ ├── config.py +│ │ ├── health_metric.py +│ │ ├── health_state.py +│ │ ├── job.py +│ │ ├── scheduled_job.py +│ │ ├── sync_log.py +│ │ └── weight_record.py +│ ├── routers/ +│ │ ├── __init__.py +│ │ └── web.py +│ ├── services/ +│ │ ├── garmin/ +│ │ │ ├── auth.py +│ │ │ ├── client.py +│ │ │ └── data.py +│ │ ├── sync/ +│ │ │ ├── activity.py +│ │ │ ├── health.py +│ │ │ ├── utils.py +│ │ │ └── weight.py +│ │ ├── __init__.py +│ │ ├── fitbit_client.py +│ │ ├── garth_helper.py +│ │ ├── job_manager.py +│ │ ├── postgresql_manager.py +│ │ ├── scheduler.py +│ │ └── sync_app.py │ └── utils/ │ ├── __init__.py -│ └── helpers.py +│ ├── config.py +│ ├── helpers.py +│ └── logging_config.py ├── templates/ │ ├── index.html │ └── setup.html diff --git a/FitnessSync/backend/__pycache__/main.cpython-311.pyc b/FitnessSync/backend/__pycache__/main.cpython-311.pyc index c59b2c8c7b61241843f0c5a4c736cefaa39417fe..e8b58cbab611761b0907f7917080c313aff2385a 100644 GIT binary patch delta 1591 zcmbVM+fQ6Y7@yg5+1WjNVGq#mvMX%a1w3GZrpT^PTO$GGr6$ssqJqor(4M-t&N&N+ zt)z)24T;t|eK7G7qDhTuOlbPn7$2n6#Fw5Wn@CQg;ScD;nm$NOG|p^UC}MnY^3Cu3 z=9}*}GvDq;*Y}$5Yp>T0*x0@|r9X6E_XXkB?XEYVFV{NRnroYEg8-{5QF=^jlmHH1 zI706_21FdHla@MZtCMh@MC#2R~OKeBQ0l4Hfik12qR zOvj|7&`C{6gNS}1oq{g(4b@mLRw!tpvD}6WnAK-U4({iZy!o zhlU{EM^{-u9}ChJKpAjZd{>-@^I$CPM#+(WCFwh1ZUaR zOzcF|8Yq$9blJ``qZ4PwPrpPr{hv+)(Z9Or9&kg?vd3y3Ts_8sNf{+7C}ACt zZ8oYNfq8Qx1IbMnUE!swLzKM#UXX9uS4P1K3R-p9t~wyM_FNUOiY$zAf}Qn;c=w1O ztohv|!@^o9I5H@#4N8pPYw8;n!Mf-k?HAU4!OmFzOQAh8{F@9jP(hc07=K4 z?_77G5@4Z-%Atu~QR1(*a2jTyNu-N?Y*#v!%}(d>f=Qx$jR-c1g*86eu-)RFqf6n%%3;9`{1um3KY%Gd>siMxhPc9NXt1mF7%;<07X#<<3qQREJ zJf6`yZ0Br>L2898x;4!oXMYcIf@YSq1E~2fqox(OiK|RR>qU4C#-7D?_ha;C~98Q(vH;s$uo{ zN@LfmYyq_l5*3iJK!RRZJLp3-0JrF0YMB11M);iC0&UiQ*#yGRK-~c9N5hrIjxy-1 ifKCf^RuO;^3xuDPtzFaOH-^ffwE|kLx{%ZC!><;F$@L3{0kmyMtO9|vN1d_ppy}Y)Ef=Bt8BR zy@%6B^!7*6J07C<>b_%0>tqe$1>qTieJ*aZAEY)cu{+X}SZ1=UVJ{nz4`3h5%X4A> zgU4dT&U<^=cXC+P5uBqk#rFm?eIK$Je;*FAlm2%EO=3Z1uVhLP5}Q>9)NYulAm$im zD@vCArCgr9EBa5(R{C90IX8c1-VW_6ipi3gv}>Ok1EZUvq3LmSb6lAj^xTqSGd-SL zJrc|{OuMiD)bKr4gTSI84iR=f(07zu(N+LWqCGG;{&XsnSOO zpt^o`^G3V)ZbK%wv`EQY1!7tz-3vST5Jq_v!JgW3{k0`{HIOJq5pNm=A*HKr}>?wkDnYD9iYQu-pz{S^95ZTeO1zpWnY-^hIP$_=lbJXBQYO6r^~&TRt=t*MDr+A>xQ%OK|e z9anJY=$+u`ez=y71Js96M_tUPmI$2!!VD+zEI$}bzdbPat#;$Mz5np@MRlR1F4*D% zodn*RoNyEO_<^MVJwfiN{}qGcvMTORgExmj=yM#nZ(1MwG;~RA;<{Va-Rxkvd7amw zBQ56&S*wzCuSwIxP;qpGGoRw{G>3g0k{m_>oPd!{X9_xj)E6v59l1JsmPn-%+!v(W zkFAOW?jLZ^H?f@43mGy=UxY2ptJnO6{T}|Jw;7LK^p+6}JMgCm3C$(kR2KO1F#9@k zdU)DKkMS`hIu#zhF!7~qqeu}YN+@BY1p6%7&;E{v@g3IK6kjjPNRZC8oN0MKdb<5= zds)Ev1$^;%8S$|mxj%s#VaKYYh+9gyh1(s$8)Mw!h-+6vZWp`HDEn&H#fmsm!p}yk VH!juRRhe_kXbD#t_4{sC{{jDchZO(- diff --git a/FitnessSync/backend/__pycache__/main.cpython-313.pyc b/FitnessSync/backend/__pycache__/main.cpython-313.pyc index 9a72b9f370c5362b393ce87eb7ca26c1260fe1de..45ac20375ba3d2c5c783f24cabfeb2d81cc6b6bc 100644 GIT binary patch delta 1562 zcmaJ>O-vg{6rS1LS+D>Y)cJs1%7FI%8uXmFP2%z{xq0J4x|P#3f?E`001TpK-$ z0GQp+9OAPuVk6(9xAmIt(nR9YEPi|*b9A!^)bwilnvyB!6};d`G+bFNrEp2f zL>F3Psa#&k$faT(lMm8IWb%y|f==?2_lgqn$Q@9n84MBSR?e zWo|1q5rjMFqUqDAe14{&+$dq0#S&K3`C@?%h22dRQ6sYlhZt+h7iVX%@@`pCODgWA zQ2&E|V;D9`nn=f0Dywu}Hw$S^NR><1NWs*4UNh{^HCLF`%;{nwo1+rNa!FAagr3xV zj$S8m9xK`04eA-M=H5|eRHam&S82#!SFT3F=50QR>}cO{{f^ z@fagd8kt-jQSr+Z`Woy|3+e|Wot-PP?_msIq!{~$Qa>j_Tc^7nnm^O+0J`k(fNR?3 zsQ1@vkfdx0U9t+2KZ7KBXd5O^ZEovm)!DJ`>{u4pfMXpDksobs#BVplEz)iGkS==* sd;6b(z$W0UEX8s6Ow}1)cZN5>OMgoM`nC>;{!d5ljXVK7%Q6rR~X+q*b+oH(}QxLInaWEB*rB&(!J9B9I!rD{sOEfpdnwd}Pq@*lHn zNP|%Na|j4=2-B)9ryd)rhoW9MfK)YzUMl1&prYjhQZC#Q86hfhU}o*eaV3I~7-_zF z^XA((@0)qEs{`LeJL+z?2uNG_wSh4 zZX+F}q?p$C3@Oq^isqck0eqXi%5V&x;eHKI18|l}cD3uKGmXa;{xB=33=RtY(1E|@ zqtJyXgdtW`A-*c8(1~S7C@`sk5_go#!YoLz{U8g+*(_8f{I=sfe(3lNUw6i!2Y>DS zkWqM?b`9|cPr~Dyu9K2X42g8(5x_gHBt9)3?b_m8zi{H}iAyJH-q9bqu|I$J;Ge`7 zCjT-<2=nqRd=$@lI^KS0St zzD8aOKK!XWmJCueL_p!uK{8mbbgEETEa^)o3e&k0=|-hoGIVr+Ozos)+E71TeXdZ> zEh7EBs&1GD8X~Sk_h82W>=7)%FjHpLusGvFDQ)qFZdNOJ$`f8$=HjVJUV)ZSL3%d7 zWWjj4dF|wT0O+|~A-z6HAPQt~4ZKCHMoY50R%_It#+(HsP-Pmv7_iTtonIF7rHsBb z{Ek^H*y`396kj1E%X>yIRthOoPd3%9Lz`gGM4H-MNKa)JQMqg)QXb%C((4fdzk!Qj zOX_|i4Lp_xZWZpndEZeRJGLnuuW`qp);Yqs916o_DV|E3`Ss~tJHDb3fo`KM@xP?m+b zeT#e5{C6?u_qi@R{J#kiZB|3VEJ^Da>4^Te>?D;KzUlemOgEj|#V%8eJ}*lw<};as z{y_@qcBxu?v0N>gC{7n021*Z;x None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('jobs', + sa.Column('id', sa.String(), nullable=False), + sa.Column('operation', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('start_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('progress', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), nullable=True), + sa.Column('result', sa.JSON(), nullable=True), + sa.Column('cancel_requested', sa.Boolean(), nullable=True), + sa.Column('paused', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_jobs_id'), 'jobs', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_jobs_id'), table_name='jobs') + op.drop_table('jobs') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/73e349ef1d88_add_bike_setup_to_activity.py b/FitnessSync/backend/alembic/versions/73e349ef1d88_add_bike_setup_to_activity.py new file mode 100644 index 0000000..d339d49 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/73e349ef1d88_add_bike_setup_to_activity.py @@ -0,0 +1,32 @@ +"""add bike setup to activity + +Revision ID: 73e349ef1d88 +Revises: 95af0e911216 +Create Date: 2026-01-07 13:47:24.670293 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '73e349ef1d88' +down_revision: Union[str, None] = '95af0e911216' +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.add_column('activities', sa.Column('bike_setup_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'activities', 'bike_setups', ['bike_setup_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'activities', type_='foreignkey') + op.drop_column('activities', 'bike_setup_id') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/85c60ed462bf_add_state_tables.py b/FitnessSync/backend/alembic/versions/85c60ed462bf_add_state_tables.py new file mode 100644 index 0000000..f15f860 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/85c60ed462bf_add_state_tables.py @@ -0,0 +1,53 @@ +"""Add state tables + +Revision ID: 85c60ed462bf +Revises: b5a6d7ef97a5 +Create Date: 2026-01-01 17:01:04.348349 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '85c60ed462bf' +down_revision: Union[str, None] = 'b5a6d7ef97a5' +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('garmin_activity_state', + sa.Column('garmin_activity_id', sa.String(), nullable=False), + sa.Column('activity_name', sa.String(), nullable=True), + sa.Column('activity_type', sa.String(), nullable=True), + sa.Column('start_time', sa.DateTime(), nullable=True), + sa.Column('sync_status', sa.String(), nullable=True), + sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('garmin_activity_id') + ) + op.create_index(op.f('ix_garmin_activity_state_garmin_activity_id'), 'garmin_activity_state', ['garmin_activity_id'], unique=False) + op.create_table('health_sync_state', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('metric_type', sa.String(), nullable=False), + sa.Column('source', sa.String(), nullable=False), + sa.Column('sync_status', sa.String(), nullable=True), + sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('date', 'metric_type', 'source', name='uq_health_state') + ) + op.create_index(op.f('ix_health_sync_state_id'), 'health_sync_state', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_health_sync_state_id'), table_name='health_sync_state') + op.drop_table('health_sync_state') + op.drop_index(op.f('ix_garmin_activity_state_garmin_activity_id'), table_name='garmin_activity_state') + op.drop_table('garmin_activity_state') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py b/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py new file mode 100644 index 0000000..267209d --- /dev/null +++ b/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py @@ -0,0 +1,41 @@ +"""add bike setups table + +Revision ID: 95af0e911216 +Revises: 1e157f880117 +Create Date: 2026-01-07 11:46:19.649500 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '95af0e911216' +down_revision: Union[str, None] = '1e157f880117' +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('bike_setups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('frame', sa.String(), nullable=False), + sa.Column('chainring', sa.Integer(), nullable=False), + sa.Column('rear_cog', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_bike_setups_id'), 'bike_setups', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_bike_setups_id'), table_name='bike_setups') + op.drop_table('bike_setups') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e18edf271c8b8d8d9f78a4b651095d0e5e292088 GIT binary patch literal 3243 zcmb_eO>7fK6yCMRe;eBgX#fSH5CTOc@8VUrip4W36mnxup#Rj^HXA$sFrPPGBnztc%i(ShvR%AnBunZcB73& z)3>9a>sJ!?xcciCLRbOmT1^KI*t?EaV9Z)$oNH907yDfO?~CUDplAUXT4OhGa6PmF z`?R*bS9`Tq+B0HskNel;LIiiMrd_A8SG2GTE%2Ub5f@tUJ<*~rw9tE^HGfdF78lw( zZi-8Zi3a+=VfG?~y{J}MD+j~B8^}l0YxU=u_$ENyjVvlx*E2%(eDq$hLNT^~0 zOESM)JuF0hQ zgKG51V_1y8q~!s(CrbH&uy8HBR3Z!W7z*=W)YVb}jF~iPUdyo{4;xcp0SjE#Op+s% zp(*mfWa!h$YZEMRLDyBHXbhRWv+XmQFDP{O3YopAYlcacyk<6L$!nNAVB*XV!Yh6b zuRj@7ip9ZcC3^=pW>8T{VLG231Z#M7GX{4?8~KemI8dBrfl@I?6-;Oh;Pyi?egxi| z6aGa0N7oj{euK~DdHndo;^l9yEM8gt*g`QI#T*o?O71Kt%4e3&md|eNx6rVSh8;9q zmE2nPltW8R<)&3)p&=U$IcTUVQI{{2uWYng9Vxpb<#eQehN0s&8h6lmRpIt>clp$+ zX&oA}4-GkohF+K!8nw}=gGRR%dYAgjeSm^eHcB}tRh8tITgwNR+RN>$Qx;0tDB++) zRl?U3SPGRxtD_c*+bHg!ctv7GyT0pw-urFe^S&1Vciu+l9dv$skhByohha7EHsTlh z^8T;)?eQDV_zi1%#zHw8!Y;?&%m!MJ2bO4So3#F0x1?6n=>T9Up z_7ZS;x2HZ9i@Utrv+i91=~_c$7h7zz)-H~z`9GSVod8Zb+kh8I6@QU6r9gU6)srMl z^`cz4;j|C5;Gx4iDvGq9kL3o^K`sjM7gF`&CgHyqiZKjT_2OQA+`V{w;Y+*qxYK%k zv35_Z$h{r6U`_25jaKLisP2r$b&s#xZ-;ufC zJdEvxYzv*6-LbjId}(3w>G6e|PV*59@-M#QdJJ*e+1*X A$^ZZW literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..afbe5cfa61ba44cabf41c93c30d929b3ae380d59 GIT binary patch literal 3038 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 c&wEz?#5+5wclS{74@ux|bAM(8t{7J4Zk literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52e954d75409465ae8856329d1897569f2ee88cd GIT binary patch literal 3906 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 W51(wH)C*sjo96zMJlrwoX8r~|H~#Se literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/b5a6d7ef97a5_add_fitbit_redirect_uri.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/b5a6d7ef97a5_add_fitbit_redirect_uri.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e83e7c273e1ce295dafbcc191c06973bd8a9da12 GIT binary patch literal 1322 zcma)6&1)N15P$oj)oQJk8|MpBXoQ@!QH%9iC6O9>sMCV&Mig&yFcxf9yU+5bt6gPw zH?0c=kxPAa4uSS&N}=ih&{JFJRmKF&%dzbtw-C4V+Ii9nJ0*cU^k(PHZ=PoM_Z#+5 z*ENFc!OyqcPbEVB;D_mw6X);|IuFT3Vh|vPV2CTivIyd`1X7-#r4@NO1*x$wuPBf< zQlNG+9nDbQ!J9@JW;#+QW61A|DLi9b9mi$J@_0w=WOy8LDZw)duD=4;#<*_GoFjK+ z$Qink9ecAqxtRM3EXOf7+^FkDCQ!!(YDZ=auBNThdv53ketzkCE8lHcmz^tgqj|+@ zOb{Bj^0j8usW-cojcVP|7J*t3&0j}@NTqgpp;E27@-s9xujJNcv#ev{1Yh{r@}xE~WCr^5$m};|}9rzn=F9s_@3PN`BC0g7>W>i1+@ZCIG#Oc9*WEvOF4!pSU zGcmN7vN-V-BLdg&{X`CVUFMZHgFY>XVMP6?{22syP~foqi5uMn%l^E^J)ef5vE|$4 zE=G{GvgOf!*R{*{DDVjg%f}TlUokPI!4^x$gC0&4g|k@uH165@#R9gIP_4l=3)_z?dt($unZa#4^Me=#|=_<4wq+ zDT))?W*i6x32$(Uqr5DfLBle9q$d5J!}FWC!#OmQ{@eH4qm%Qai>=4<2jfdP!V1tR z2ESc=gQZQ=32f73O0+dV0f2W7{1sR_;WH5~yz5z>y-E99aDwNgd5#oDz-CR;^8Fy1 z%z|kGA6MWX9hl*UvkJVm|97rl>jzHk(GQ`3sDL{R(L9p`L3l1Ig8Xle2=jlDGf&9b zXDT^yeyDEG?~}I<3YDS09q$<5Uf5aNn?1izDhJg@61uco|8C*YsnO|$z1h+}X&jVl nLv8!xo%x+Fcclkk?>eKoclWaI?UUNyiYRo2f3l)*ZOqJn=j%V3 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3af13888e04cf129dcd17bc96ed97b613089a1b6 GIT binary patch literal 5461 zcmc&&OKcNI7+%}IUmHIl4;~?e<|*s`e7eIYcs24?W~KD{UpTQl&~&4?R?h#Enz`aqQsmC<|)i)y(Ia z|Nmyb^~~(fzw>=C$a8pE&*$>LwsG7atkSrkUU>Ek79MgLPUf&~N%zB%;zYF@|&n3p(-|AiO1 zt`{}r`s>`F4K82fhUCC~$cE&Q9K_$S3^QCLys}J|%90|BQclk==JjP!%iSbJNh}gw z&F3_ppC*fWEniZEOBY9lc{w#GCDW;M=SI>y9-@s3=hC^6B$0&L}7paahh$dBO@b zi&;Lkejiq#SkQ7Tl9IbA7NmJn&@5giWunNMSW+yHO7sO)$?c9f7JQgp zn4e8!=^>{(UN~>z9>nC`GZGPNs_sEGg%sZFk zT~*yJQT6Om-5rnr`M-KwqUzhDx?l6^Z;2}364kbrs0Q|^?$;U)wnR1564me?)%|*M z4zxt|;2zceT21XOQSE4nYUdu+{aQ_3|Bb55VSIIH!s6?ZRi0>;PtI#P#wTPJgB+i- zP{1&yKrCNQDwI@4@JNd{#G9(+DN0N1*U#!&nGo6XGt*8nL<(e4(lJ&NZ%BE?YN{7X znr5}JRyj$o1yyaVU8CaGbW+B!NFr)!KA#hHVg)3nsa6v#m+lbMW({|Wyi!t&^|DJx z1@B@hC89s#QQ5-B4rN))l?n?*#d2zr<+-p^c7I$bNxJ2|r0C=ZQGei`v7qPqctR?d z6Z2B;3k=Q@Qh^llW)h1;Wr3!aXb$|ue~(Y&seP`;t!zg45ycNb=D8>6dx+@IjN z|LN+$cg}D9kE0v&m7D9gDz~lO`fenJ`6R>bLhHp4~WKxx7AJ8Gl=kwu${4 zYGrACrLwYhkqT3WFl8dbvFlPvJCL&xj;TnZ& zO?|!t^>OS;d$oJBr`kg&XQ_}egp7#@b0*ADm}~0v9hiL^smkd3SY>SM3>B^#!c`Ly zCQX>6FxlMYz!PWHx5-!eE!3ViglQ8IW=xo&Fw;zoKRH!ByBV!Uw{$AZ8p5oJ2pJPH z6f(_3HkvEzcPn?dWGc)U!i&2 zn20cL!Z?NTX6eYrbmg=4Y$f};;lUBYq@Kl)VABQ)8+{jM5A0)EN zHWO?!-L{z?+f1)*rq4FhZ=1>6X13X825d8fwwWQ@%&=|d0o%-jwwdj=nH?`PYnyQA zgc`!fKt0JoU~qy#FN5O@dKesI(9M9Iv#3WH9AV5JreNcXq)x zSuK_8+o0OVyzH4&BM6qCom1>=Q!%U$VAdw^h+$`&nphoMJ8D3`3H=oM*^|#KT?X`- z&_|)KVd*g-ZbF~h1i=dy#^#rNK!~PIIs3|*no%$5ekt8=ePk06A}~> zZ?w>pg9fBbNKr`DNv|=j*}2Cf21HGWQiwLxUbS=^Fkr#}g@Fd=g$0e6Y6Fhb5y0V{ zh#auIq9~VgqG);a<#Ha!pS=%N_F}ZW^{}HJz-YG%Qh^iCW@+``PnI#p19|GfNgs*8C>tvQ^|Y52Men+U!Ph1+zg+f zToRMPC7%wa>yEQ)L*K+6chaty8IDsfjmhBBPvfb3{DY5HPp>YmxxV^xO{VQpGtf`D b6efd9ZF@Q#Q;u!!6^wT{V$A$ns7=T}%5V|7 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93bf42eb2c7e35c80508ce8bb96b70a21376418f GIT binary patch literal 5293 zcmc&&UrgIZ7-q18@>@`6`0wngxBN47td3)RTu(xV#nzX0gB_V`JwDq(REB@vC?)%;S z&Ud!&JMZ~?EQJsA?|aGT9*X*zIQs`E8+$9*_>#Ir@f4zX8&410hG;|&*^!+izI_-B zIgq1hgJCCf@eagHxu;m(c^TK_UC1+KpK|k{pLSp$movq3fV`x9ik@Pv(2M1!M2-M=-U`!f_K)HV)n)sN#j-aSR?goZ97mY**dLq5} z$3>&@SYN!iKhzf+9O#R&>~Huos32`JJbavmiUdu^q=JQlHj`E}Bc``n%w$lxAEvij z&h*LZ6Gf;5eF-Eg(u|N26ELNjtPC?yku^b8BqFzexUpf!=iVkmX6xa8IWz-Y9x_6seD(q-)`uNqqu|(kD@3 zDNxh+c|{5*a0$*5!(s}iai7Dp5Rr^m3s+~L!mAVIP-fnAWi!(V4+;w6F0|oOdw|ot zMd}ye{QU0IyGz~Q(%-qiaj$f(Ce~(hGxe14#RB(%0Y+<^=-#Ban001@-NqW@2HiF9I-2ME6;$=PuqloP64()5mE~U7yc@N{8>}z=1%1(s0v~S^orM4ZG*iK7q zmnAl1iS4$;W-YNjme^iPY@a2z-x9mQ61&k7yU7x}*%G_ufQ{GI))C~x4;11EoF{OO zz*z!k2%IJmAaII6JAsn~P7oj~0BR-BLZF#I6M;qoehfjnw77X>q-M&i8+w~W$&-yb zF_>=hfuppnbYq{K8fDvGRz{wVEL~kbWq>Q>ZBg>t3~>3tJ7a**fp^*fJx6)x3=lbp z9jf}p?YC6BjJiD+2F7aPoT>7ZcaDJDHyy%UUi#nJ4zV2CR z-D&G7G=z4k*o#nfk$v|5(uJkDW&0POEz3L2T?OxzT`KyAv&A-M`!m#H>nV=RKXd*^ A00000 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py b/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py new file mode 100644 index 0000000..6404a12 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py @@ -0,0 +1,64 @@ +"""expand_activity_schema_metrics + +Revision ID: bd21a0528865 +Revises: 85c60ed462bf +Create Date: 2026-01-01 22:53:14.358635 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bd21a0528865' +down_revision: Union[str, None] = '85c60ed462bf' +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.add_column('activities', sa.Column('distance', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('calories', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_hr', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('max_hr', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('avg_speed', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('max_speed', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('elevation_gain', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('elevation_loss', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_cadence', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('max_cadence', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('steps', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('aerobic_te', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('anaerobic_te', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_power', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('max_power', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('norm_power', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('tss', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('vo2_max', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activities', 'vo2_max') + op.drop_column('activities', 'tss') + op.drop_column('activities', 'norm_power') + op.drop_column('activities', 'max_power') + op.drop_column('activities', 'avg_power') + op.drop_column('activities', 'anaerobic_te') + op.drop_column('activities', 'aerobic_te') + op.drop_column('activities', 'steps') + op.drop_column('activities', 'max_cadence') + op.drop_column('activities', 'avg_cadence') + op.drop_column('activities', 'elevation_loss') + op.drop_column('activities', 'elevation_gain') + op.drop_column('activities', 'max_speed') + op.drop_column('activities', 'avg_speed') + op.drop_column('activities', 'max_hr') + op.drop_column('activities', 'avg_hr') + op.drop_column('activities', 'calories') + op.drop_column('activities', 'distance') + # ### end Alembic commands ### diff --git a/FitnessSync/garth_reference.md b/FitnessSync/backend/docs/references/garth_reference.md similarity index 100% rename from FitnessSync/garth_reference.md rename to FitnessSync/backend/docs/references/garth_reference.md diff --git a/FitnessSync/backend/main.py b/FitnessSync/backend/main.py index 4bbc604..0151e8a 100644 --- a/FitnessSync/backend/main.py +++ b/FitnessSync/backend/main.py @@ -18,7 +18,7 @@ async def lifespan(app: FastAPI): alembic_cfg = Config("alembic.ini") database_url = os.getenv("DATABASE_URL") - if database_url: + if database_url and not os.getenv("TESTING"): alembic_cfg.set_main_option("sqlalchemy.url", database_url) try: command.upgrade(alembic_cfg, "head") @@ -28,9 +28,22 @@ async def lifespan(app: FastAPI): else: logger.warning("DATABASE_URL not set, skipping migrations.") + # Start Scheduler + try: + from src.services.scheduler import scheduler + scheduler.start() + logger.info("Scheduler started.") + except Exception as e: + logger.error(f"Failed to start scheduler: {e}") + yield logger.info("--- Application Shutting Down ---") + try: + from src.services.scheduler import scheduler + scheduler.stop() + except: + pass app = FastAPI(lifespan=lifespan) @@ -50,25 +63,27 @@ async def log_requests(request: Request, call_next): app.mount("/static", StaticFiles(directory="../static"), name="static") templates = Jinja2Templates(directory="templates") -from src.api import status, sync, setup, logs, metrics, activities +from src.api import status, sync, auth, logs, metrics, activities, scheduling, config_routes app.include_router(status.router, prefix="/api") app.include_router(sync.router, prefix="/api") -app.include_router(setup.router, prefix="/api") +app.include_router(auth.router, prefix="/api") +app.include_router(config_routes.router, prefix="/api") app.include_router(logs.router, prefix="/api") app.include_router(metrics.router, prefix="/api") + app.include_router(activities.router, prefix="/api") +app.include_router(activities.router, prefix="/api") +app.include_router(scheduling.router, prefix="/api") + +from src.api import bike_setups +app.include_router(bike_setups.router) -@app.get("/") -async def read_root(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) -@app.get("/activities") -async def activities_page(request: Request): - return templates.TemplateResponse("activities.html", {"request": request}) +from src.routers import web + +app.include_router(web.router) + -@app.get("/setup") -async def setup_page(request: Request): - return templates.TemplateResponse("setup.html", {"request": request}) diff --git a/FitnessSync/backend/requirements.txt b/FitnessSync/backend/requirements.txt index 7392245..d98fd38 100644 --- a/FitnessSync/backend/requirements.txt +++ b/FitnessSync/backend/requirements.txt @@ -14,4 +14,4 @@ httpx==0.25.2 aiofiles==23.2.1 pytest==7.4.3 pytest-asyncio==0.21.1 -alembic==1.13.1 \ No newline at end of file +alembic==1.13.1 diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc index b35daccf4b3328abf6a75e72ceb13c01475e15d7..506252002fd7c36fd4c249f3d4dc089d177c454e 100644 GIT binary patch literal 40067 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 delta 3459 zcma)8U2Gf25#HsINAmbDiW2ohia+`>WtsAyqR5tP*{o%YOzcJ%xpqaDprw<1Hbu&N zC%FoW$aajTMiSJ*KEw%%=A}jJw-joDwl8gpI&cgmjbOA6E>M%A=z|fom20PO0lKp% ziiy&oJ>ceMXJ%(-XTI6JPu{RBpYcBOcqGK0&#s?T-|SfShAqFdEN_c0#gL3-OHRz% ziJkL8&XIKzXI3Io)%6^OLRu4Y79!zh7dcQcYb zd3&F*p^N0hV?z`_8=Uawo%x83iDln_C2wK2emNl5$-x1Ek6>5zOk-odTdThJ3UVml zCO5FRF(2A1%Hd{o-g+KyMis02cQw`gQ>*3RiJA|a+?<#4alUrTCfB;jMK(EDLz1Ig z)<{&>SZb@rwIxa{$MSI=p;eA=nVn>rPUPCN9i(F;`<!3_E7*sjdg$BxR#*;$Au>cH#bmQ;<6Pdpor?G1f=(jtag@_epn>ZW5TWq&$ZI(4?*|sQTiVqRmyxn)0BYSsphm z>J;7Q(yX^os*av@_GL)~oB?nRK`rZsuTUtK%N4z-t87}KK%!iw>4da_Wei9hNCHR_ zNIQ@Y`YUHkw3D%q*vs$-q@_w=b?pkxxUWcmvUZVPE~oW@4Bksm1(Gfw6THCQM?C^v zKA5II5A3Gj?P_(8<7)cGTr}Pm6i^?g?~gRoKja6mS z4AFnodzf{5%{m~+)&XZVxEOl+ExHu%7`07wZN^}&YQ0Dn_XyQd42@gcQ5;Qdt6JJo zaggmooTv)W*mmeak9}fCRWvSAPogmciQ|v#ke#2`R=s=C9*ZmuSQIP^W$}9~^=IWfjAz zYL)2KQp$$&$hRkzIRGpDdXr-b@F>JDU+RAC6?hS5h^-P;)_^1`GCTXV|&=09{7wbuBjfuck=9h#JDLo?1Tj zgLC&nUH3!j)lmAU3->}#+z$<|h6aB*elL`{A3C}kI(jcO`aNk~MA6v$j^+>PrH&T^ z4}(&m?%#sj<9#TgrsU%&eYZtj(o$@pt9YayUppcmaiQB1@V8ykNIK}0nZ@X=b ze>)&B`E!BZTAaOR5g<}S()1$H3sXg1F&r%Yoa5a)35RX<+3dw1SrJOIP108nZli0h zKMoAA+W_t+F)NluhGUU+T|K=OnfMCL_UK)+L0#h#T|HaB5^Q@G`(?+M8)t`e0`|zC zaj6DoS^7fbNW5{n>1wE&Tob)iNW{B#RcFg?*)y@(Cgbj!FV+}dnoBfcA5{|N z`V=-CkP2O$RgRN=Y(Cjf|CZ>-gEW$iFAXt4s)-DP{1A}CKp;1UIIWg+g_vjIGa&Iy zlgexjt#L5rr(y=UuL0pl;TEVL0Y&W`?SSD3kfUgc(PO~Y5bZG+JPy(mK==xPZ8J6= ziDRS2x*_rk>tqxRvOsb`o?=AvFtP#f&x730!NO7A!Y&V0(a1?=2_b1#%LdFFdg*uC zFX2`CxIN8ivO~sSgpW?OFr(<^-37ombG(swesp{Tq&%}Zy4;hl<4OC%nLt;28f_{wySnoC zo=@-!-WL(@9Bms)Mmm`^6*Oydp7Dm$#7p7qBAKLxp@FahQriq`#&d>~1vahD%oBxN zpl=Q}ca%Wst`z6>bGr+A<$_XXaA4sK0|7{+MDis4=TJ|>97uR*u+M5C>8=B<;`2=K z3$!91I4y*{Fb5m%qeU{SmNO-lr6EP%I`j<7aPaUEme13NUmPB0Ry?0ZgQk!LbyCsN zEJ8LTJ;|q#%gn+EY$~>~kLj^cF-7iTg3$;*Q$2XG#L z5Nx@bSsq#!VDPa&d^5Q0S+~J}P4+e3(5}y}+xgIe8lpERm(Q#_`A`DaE?~j+G3J_! a7cd=urO)!|H|ft_$+{l0^}dDq_WuL2B?;~T diff --git a/FitnessSync/backend/src/api/__pycache__/auth.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26a76459bebff8febd417c8e402ae1a3591ced81 GIT binary patch literal 20365 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f91ad21a4ab0a2f83d7003dc6792d8c374bde35 GIT binary patch literal 7406 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 Sm|Fxk9Fgy-bsj->u>J+o1{?YS literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/api/__pycache__/config_routes.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/config_routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..142f05480a7fd7e9dbf2ddb408baedcee0301da2 GIT binary patch literal 7328 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/api/__pycache__/metrics.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/metrics.cpython-311.pyc index f8caab07a13915460121f3ebe20aa57fd4af9095..5cc30c987827b88bbdbb360491af952cb108c6ef 100644 GIT binary patch literal 19381 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 delta 3464 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 X_3TxzuR_bufaRxau7^m)ue$#NRtMKk diff --git a/FitnessSync/backend/src/api/__pycache__/metrics.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/metrics.cpython-313.pyc index 951e789dee4bf491fc0fc50f2cfd600bc651d590..480659558b8d5551dc6c8c0efa58f63d80db0047 100644 GIT binary patch delta 7700 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 delta 3371 zcmai0U2Ggz6~1?8XLe@x-@9JNj(5FYJ5Dxr;y6y4CUxQ@Y8!jEG09F>BzVzwWA8ef z?vH(ETnG{}rKq$Ikc2Bxc!COn=mQeM2vHtT;eobKI0_PBXw)D;B7NE_6%t5DoO5T_ zc8yU+ns3fM=bpd0=bn3i^cwx3u=>dF*9knoTRdNVb%>C^;-K^R+Qg%mU4(p_7{s7i zlA(-dT+Ee`n3QodcM|!otejDpB4{b=$*4@tXiUrKOwV|kSMc3gU&hb;K+A@b4P=5W zm^_Kfo@|JPfT?D~nJ(5PCTrP9Cd#5>vYzeE#8@mz3N%HIwqs&Fg%BU#6?IUdWE|MaKua6l1f^t-9%lZX30NmgfZd`CfzzuKVCIq(&xREW~fkIF2Oxu6w z!$V{4iD)5VbRTyW+z`y}PB2HP5i3NGxQw1Ya?O2>ZbUwaW;h*-tnMnPg?<@K32hey z?dfP29yjVW`i$L1|8Z%XSJ{Xcv_eW)O>9sD8&t9|B;wMI!RWTYhBpFB6$Ty~(x;P^ zjGdg}4^WJuZGQGFdeXxW@eiimwsz*iOs=_Nm6K-h`328UrZkjc0W&Xv-CZo2R>`W)7fY7Ks#n0OSuC<)kUfHz zupWfI4n+B+I;|WOLtfW=`480D)dUP}9n3JRxKOgnwpwm12uj$QO{;7VYK$Q4Mc9WR zB6$L-{RjsT&@U6_h2KLsL_im24B+eJUTD|t$Swa}cl=%;cGG;L&X+W|6Vf3O64#!m zDgHo9%%Zi~w6>#n6=ZDFyYRGA2v~y*A;q89cRPNLf}iiw5$^F`xZ?Qm@91L^bw!$K zq(qvhkQzmpLO6}Eliw34Kp5d~d!KRqo>-H7f0Mcnqi6yk?PAA}ev1Dq_`~4-rL`J}a9mHBe>E zFR){LFg&6-4*B_TlJ@XMxL=+FtT z$%Xl?^xgqE?|Q6>kD|J){;vrYObqVdE6I78|E4z@$V*!SlljF1Q9cmR!rB;V=y@$d zrhiUI!<*On^4@4*uGdKulJw^KFjC2z)7#(v_BYY~CV4hC(BsV|J35=++z@{y7MDDE zFAsym&&T{!P%cLRse5z7Z8Ia-SA**DE&u)eFZl7EJu})ImaTa5dM685Cw*cgC-|lM zFc~CsSli<9kP?)A!-pl}Jh=ntq3oR->u~bEO=}ab4xJ=(AZKxlZPKjbeVFL(B zgh2$TEy9Kno&n(N@lpCL|4%%z`Wy^r)4gm4Wf_Dl0#>rERH`-T#E#Vkb9z^>5p3>_wEHMG)bM zVAOV7wroW-Wy=m+EthK6)uOZKO*_=y{IKnpnhmpT+KR(rp952-pO6YkZhgd&VEdQx z^aKrnCcRe=tQ}2(z{L7hluDw%Ozj$f=a%uh2 zmGvtxt=E>p1g9rs-x}9MWu#-< z3&Ux+=1N_Z2onojL1WmR8LkUkL=aW?8Kf{nXx1+;I!6QceOqSD6)2RQmOk2Uv6oO6 zSKRiBT#I5+91m5LT|-%y)wD`AIOpMSw7a{=%~r26N5l4m>mrPt-p@|)2jd6m6b~Fu zUp|c{JdjMi#I8Fv5e3%Pis;ZzWppi;tzx^#I%=i0!GApj4Nt3?>lfM!-{pDFC**bbX=JfLjXl;81~ss@hm&Do)T4bObNTbaS~@ zs?A?5*RL}lr&E1?u}wX-=Hepg@ad^U8t+GTU~}R@*r_apOy7!Ct(gbsn~h3!5w1`- zrg~QQpzRq1yi7S8S6mmw)j%wj1SvD@c+U33)f78|Vcy#=HMTlgZ!WCV%BR>jK?>W2 znBN0zNtDtL$>d!!d7mUcB%^o9=zX&LzD&mcOalDP({ES8aNoJT{QVd1g!lictM~Q@ zAN$z*6unIEg?n!g-HNv)96WIDM=co#ErkSo@RHQ>h@nb+!ImaSooJy?g!&_ixMpa} sCD<0_y=T)?bCeco`>NHF#OQ%@-D=5V1eQVHBWN`69R1iO2IBhmUj{m{n*aa+ diff --git a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52e01a6198455df83a275c74c1c609da8e193556 GIT binary patch literal 7711 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/api/__pycache__/status.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/status.cpython-311.pyc index 1856b516b93fa2728c2ab80eb8b7a59aa9815176..2f561818ef9ae499e2d022d7912d564212c87c5c 100644 GIT binary patch literal 10935 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 A4gdfE delta 1442 zcmZWoO>7%Q6rNr0uf4YOvv%sZZ5l}2Y@5b~DilGfRDM)J`BN!~uBd|RU8gI}+Rp4+ z$O7RWd??})F^60@6}%~o?l2=U%-6GJ+?@7r(Qd$aG& zyf@#x`^R$cb~c+PFiQRRyx)NRj+4D1KOzsG6$sfP4sirZe8Coc(G~@qi!|j+wiNRe zm3_rleAQNc&DMO~)?>Xy(|*Rz0GAzw8h+N!#$2U2zn8c3@kFB&{-iw_bDd84)AlrQ z;-snRo3-AqGt%)Wdem$H+2hggL_ z(a{nk{leR?UT$?lmqqH!Zrg1-fh9!xi?zUgqvg2Nk|ODKF9;*;t#-)YOkL$)r+!on zSj@7#FWr!5fadu~`hh=Tl=)9`PjMs&VDJrPOR%`EmH`*l^RE=aF;ZS?H5=YKdlDu` zL4s+3oBPj$*`v4e_%I9f>;!<7VGGC?5i0zUHh1<2QWaqWVGaRZ*%Cs$5`(eQ9+*Ia z0dH#CCx{@=4G|JQbg6^vkNu8n^AGg}e$lv;>__3>8}DDUjQ=JBi`YqoWdsEw4tWMy zoF7z=uu}*+!h!s%s6&_s39j+apz-cY&%v7{SLNAUbqfEBjq>n2$Zd;!LbkCL__w*k z_dd;iBx^hBK!Vy!eBRvAFoGqr692`l2u1$4Srw{$xln}~d{8)jOk$_Ofjx!rGy3{)uf@;Bsi7_# z%7_5$A`FSlZXJHuyIJW-F0fs(5o{nUZjNGw%1%w>M^bY79h$6 z8?;92H(bBF(qcYK^N%a@CU$dVbV82?EA^y5v;1zQ(95AA@8dlPR)TJ`jyqv^K1t`j zhIWO;*N5RnNa~LV5v5>z@xBgJ{V(kt{IEma^K1?L@X7|a0Ea0-5FU`q{qdU~Px$TQ zU+_yyt5-4twD04v>5WjkJ2uTq!t+D&IBZ@Wp>}s{GWb7B$5u}YaLfRkXXDQpMkyjb Q^Ruuz6!%_oVwe!Qykc+yYZjr3R?vRIi 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 Ju_mVI{|Psu=+6KE delta 1454 zcmZuxO>7%Q6rR~X|E+fuXXDg%#uVpL8@z$T!3$HnoUJftmscqbLiOXi6XjT(o3U z0mTeK$P7c+j6fvVm#nB6gBa(styroVhj_q4mS(Q$pa(+O>NESHKj0B7VGh6m=fsX$ zhG~Ei@R&6Sg9SQ9X7=NPq^+h23>_h3#Ez$ltreshv3CeZf?b{O_WffwRY)!#m~+LY zeNV*hFQn|mtWXffNMUG(=ZoVMwFe5ZQ-W=z$u;R3J+OpKHI^TIiYj@*kIcV&X|b`| za=;H=aGFlN!hR82>`n1kMdcb0XR7p-JjAKaHl+srlJ&{!89NSYRnBX^Znx{@g+_JJ z@tTdg=R7soefg(Img~RwBY8W|K2?gB$GOzulR%P#ubgkxm)$DlxPXdYh{H{?lg_T| z@G|ExiGWu5IGjNKB0?VFB)c6NnL{%`Gw4GYMF@;0kfCMI5q=L+2VijGne4C7#qlno z@<>kykIQ@>j>FFRko^(;AZ>>r&T|d>n%ye3R=vYik1;Q1t@Yy6{u3v62U7^IASehy zAg?0}LJuQ*6(NG~f1yv~0m3mpJ-oqNWG9*0mQp*J?6#EM>1Xe#W6|HGv%OrV+1Kh+ z6uo07bspT^QWxZYiIDddTbY$|WQyR*780{G!fqy}>1FnF;*(Jc&^Me$ID>#u=7)Uw z`l<7t(V>}d^}UF!D!W9MmEACzr|0Rx2Kym-Va=SB?!7jzo73dC@Vstmi_ z#l)>BMbr%I1#v4!Ip&Z0q2&_)O49`_BJk?*w43cpsorwSfZgFMt#;F`S9{m`{4((# zea*XGE7i&$IVA2%ah6TkN$G*3cJuQG#mxtL^>p9Cx@{bIw7D>)}tqYjfBYDW#9e_#={i zOeVI;#3RDib9!O;35op~NvvmXD9-g@>Btxq<8CCS{*P*8SV_p=r&53?o7$#HZi&;V3y2o zvt)5wAg>j4fh;N8oz2qvK#r8_&ShysAWzD7=d*N1AX*?5x(gx03C2K?RO~KhjLblZ zRO&8eX;Yv~DtDJN8FRquwt}oVP~om%-%58S``X+#_*w#0QnkBUvb*i9Y*wH~a=0C= zY<8els&m&ddQPBTa=M*77loGQ1{$PBcOw(Z3p7d1?q;4-aUy>MEcF_ii#_fZpjqcz zQ+aXE4IcRLDv!HO#U12?{EM7WAnI?Zfd}sfZ!hB&0tehL;8lv%=pK}Y+(Xg<_W^0xJuHp5M|e(bxjssB90ikgV&~2J z8!BiByvhR>Rf+r3?KsN|PI0f$AZmn0v3OP|G+os07>Th>oy~uM&h~9OTXyL@xT&-C zo;tO{9--~#-t_iETd|$(;-OiM(6LMBcuMDko%hsv@4ol4ertrTUHT4#Wm4SkiyHSt zO8>zgvFm?nTePW4=-s9J2Ip@%a*fyt@KB4K*C$+~HMY2S{t@%l&QGCQF zE`v z223@lz>0~+O=k0klG^I~76Ku!a9Z@woeiH5Kf5T(;ScwMTv8kGOaAaNr;h&pV9nr` zO58*Uf(>37^{#IFx0aK)I(n&nb#is`F?d%emBzHP`X17U_S>NVsX=f6B-L`5kUGqE zA!H!z=tDcw5bRWM7*dSTj{5jx^kKc7Rv688-PV}HoYw3IX;B9$Ev5t=SYA&b862NZ znkBz1`-5{H!5bE3(h2NiPCZF)6{!WX(keW1NH@ZcR`ntc0qOElsvGLC=M`O5KieDA zl*2EsDPJ>ZuO3-F@)*3UM-)5s(qo2GncHfk-!N2`>`?7O)d;Qh14EV4VHY!?Bjd;p z12Q>s085M^FjMZ(H;N2|cKVHs7DeAE==(x;2N08n1rnMgq72rcQDaH}w$@=m)_JBD zt&-HtghGLx#yEt62wn8Y##2fShiGf&cXfHlDWJd29L&WDnbggRVUI9FPC;gX9x~n9 z=j4-CoPr*2Q1F}=iGOyxt}&weM=QK(_dE=R?H)rJ?T5hiZ;Su&!F$u6m;jOBseajiE#6o zseD8`Z%U=p4?3O;mr+NxWABI0z&ZU%{z2Amn|CpM);8<)2SlOI_NpqGDTlq`McLyE z31U(wh+$ySM=A%SIJRtlvz6tvko$m;%ot7WGX%V+jnOScj?qU%SQFTJqKq2yr;ugv z3Ia1YxgTk`-H_u5xGG=Ok%uw;83YeNk{6-MNywdmoU8+|v4^l3ki~{?V=(=&1|<+U zijsc|u*$9L)Y?=0ZGFx46LEcgLSG+aKWeid-G6;DZth5!J66YT=TyJkm9TfkbGj2b z-K&S!H2LpptUuCNe_YXVYxa$~#K2_i_=Ab#^Ks3&gyvjKbB?}adx-!48@F1A7rH}c z5PSg3d572(wH+11#P5@BCt;?MS-Mc&8l40F#sHDCSRj39h#zU`V?ySUb{ZjlILJAm z?J~NF8C_DUCJP{fBPG8Du*wy!Yd+275A6LthHk%g{~MJh5xl?o9>4_0!yO8Jg zn`;f$p(?^h)bsjOxm~L-C&hsiL?x&rs+*d0&lpY>?Q-oY;-8y?zF^zuNN6!AD1Cvx zDSL`-&buHd^Pq#doBLF1Byju-)X;VM)yl#sVG|*P;ClS@L4N?bo6FCsQC1$C1?&uE zt4;$#xagEMhm~*E4bu-BOu`_7GeO2${|Ww$ta2ZlI7>;wY>(CS#?5^RbKmON2cN#u z+HQ5d(fj6nV$2iQoJnZT#58Aa(=Xa>yp#Tw{XTw@nrf zN*}7RR-mr->X?sKk2wkKjXMRLv`6f^p_p; z2KwKQBKmLUg8YbzSR$%7T>8`cR&!n?>&tK@yQUTjZWhu4 zXB#imgU&j>h=!a_z~`OT-r`8Mkh2WRL9&F&mqzj+T^7mzGIz~@CDWaLxbl>*6hIBX zc24qE>T0OeS8UWEHb@zjk>c~5Tp`%#&l>ibt5!+`8&b#%LN$H5p`5xJdwDxuYHa3n z=qrtO!0$JXs%r!X&1-7pYpJWrURf6@ffm$93b(ba04UCDde)Q2n)X4tziD!58(5xi zZsZ$jb8`{@89Lfr$2ZZcrgD0@xs3i@b2k0U=2ChWzV9`c=CtlOUMuO(n@bMwxXP~B zB9%~ETf_zyP_I;NxBWai!0d@s2|KPsE7dr;imAJ0FW*idbF@e85j!~%elS%8hR#0U zlFCJ_%P`B*8zgj`!)3?GdXU__Yp=FOtP#6st7HY_Hn~D4=) zS=fZSAcw9wdmok+U6#=QgfNV4$gB; zeoLe#z4bg7F>k0eM@$lQ;YQ7*51aRh!*Utf)VW9Im5zu z0&P4(pU{IdN5_Qs(TDf!E7)<*Le9;8`i(t}o!biz2rlrGHurrP(!q!ca42F1JP@$} z4qwXfE(kkI+zCdo~;}#&GDI81#gf7DVEuueNpbKKg!}qujUc9O3W~#RmEyexOiFrcskFL+ZktTM)<1 z<5|a^lsvD&<&huc1rDm*9M{XL>joSmO0pc?+H{o|96HI_xo~bu8OPPYeP)Hn6Cm|m zH{u0at&8wnZlo^o7q~~%)7%B#sXw-CA7j2XoZMhY8xd#W0&!Lp zZ0G%6o6j2v;Auz=dn=-Fv0d;3)8h>@dsx^Ni|$1NCbL7RuFKHPMT*)NDBKUxW{^>1rq%n5_P_i1eaOn zTqU(|_JxR3lbQvB-xnqURKUDfEvrpd4$#$A-k)D(<>D( zRot?~t6YgH*QL>fsbURiEibj)s*G0;C8~#zR=IZC`|fG+N2kTuEQz0%6Q|`^<;t~3 z5|y2|=KnhQ&ET64#k)=|VdS@T{v* z#ofEEyK*t~{@MY@u!eg}V;t^Mzh$uwx2fN1(?S}&m(B4@nusXCRPlv9i^NZ!fMUxv z^vvFBdU|gYZ+rdX-XlDpMdR&x8ug6ir@w6PtyP_yOBxn~I3l6|_xR_b5P25iivUT@ zVh~3CIojXRkPpk=06`WHn>jk=21P&HQNz#C=Q>@Z+WEEiy%W5GOLY7E2rZZ;! zfv`wCGfPSBte-&4q8#NSw4}3*g&xQXo$4&9Vb1$SWU;xZ4u&p}=P>I+IDlZGf6_V3 zchh)hp`DZhC7E~V)Tyacq0mupaA_j~BPX@;LI9dxOLMx)qF+KWtsD@=1@aiC4bTGc zjwO+lVOH&(k+*tHUIZGshVT`ID+mM~W_De3)+*OEHrGwQP;k~i=MjTp=qg$q){k;& z_67V%#dSe%bp7HXv~1G2G2PF514%uMfd>-?45fIWMM(oTiKTP`T)QE%gRMZb`{ghM zU6RJ2c)+Z*AUk?wo@X@@M5f%5g$~fXu zzinz6=~KVmr-k(QnhxZhF5te;^-T1szTcz5bYJFFllBMNq0*^3?GJ4SxXCKbJ2oCN z?^LNK9r}0d`ym~x)PZcwp~7^X7D~jL#_ECeZh>(+NBwT)P~Egi{ZkzenV*`pYWUEp zA-fTMU*?-%d3|N*8NNp_T;w9UksVQ0Ap;y^IJ>u^Ld_m%CA0v;92QNZihm$*4N+#fDn;UV~gjuy?X&+2|g^;FSGC58x%$?(ge z*$|Pu;a#__NFzzC?bWZUNVXGq0uMcKKA=+DMV`CrlB zj@gbTb#jP=MS;aV$xW1b7y(_>PrtOXzz_>pfJ>{~T%DQ3Lu(G&LGPi1_GnI8P!{5T z;bd+)32mTMgEm$IOXw%z6?fxN4(dX*oR^56jh#x_Y-I9V5Z;IOy9@yLTpg#)d}8A9 ziCFePoPOuvH(N^Tzup=zhTv_%?UKq3OZJRIB5pgJ0BfImAXYLRFPTo1Ovf~Zboo$u zG*;0XFWr+U-LqC$zpmnP9#ws-{Z=&IelXE~aGk@vf2nLjIOfK8jIt} zJL9x>(Rc_m^u_Vg3Y^Kyd94uflUpAJ^!SC9RyiD6U?D^L&iKnd(AmVp~qNQoV&Je?ag+w819^SQyL6Gi5Rq_oD<&M1>knX3#yyy^iAh+kb?a|vur&mhHXlq z6k|?ao4g?U{Ih9v;M7a1fwFyjNLn_;;Jm{? zEKxyE1MtddGB(pP3lm1`)!DewkuW+|hu1PK;96-%`P-JVud8oO#w;Uo%Sgg95;Ked zZ}l)+zRu%G{j3*sg7>VZjT5=u1igKT)-MU(VA$^?7@kP#;0^;PtMZ2vjC&AV7Q}>r zJ_z_5p^3thY)B?^WRaZr`$V~gd9dDv1(HL*GEvUw(r-^xMR5yDTIA0LyaC@?QCez& z9g5^5(X<%$2jmvTSs(>S%HPxl_V7sFpcg|*g~-yQkB+CkW?l{jlUf$cC(MV*Vd)l( zVS5xen-oD2f-Yh-S-7b)-3P&F4Ov29{!+dKp>AkS+6q@3A@c@nqowGiB{gDjQ6g4M z>L5G^Zd@`0hU^HtNGN~!f#Kyyv=Xry0dMEbSyDE-Zp^w6MiCAnoB~J|Y&Mwn3;qZZ zfD5G(XwShvP{=KUIO_)=6#uTL1kX=n=?4&|5!jaaAS#P$7DBMeNl0BlK;MW3>Bt34 zokzfJjJbep6%$-PY{KnW=${z&gC!ON;sAmFh`{06lQlYy1FxLt*SMpxd;Ie5L4E!6 zN6YzaYm7_#(U*_CTF>+OFpGD=Rp%X~ezeJ=M<$yZw0zDww;Qf5-9hR{TPzEGWb$c| z;}7sLZfN^&U8Uz?JN>_dTzX;3(UZl)W(p9q?qT0ICzb5<8TQ?RJ8Difkl_2*Ram5& zCZ=kl7Ay=9D{W%mH?x%NeGjqk7GU9h2_9~HSj5Ju3{R9jUKTS~-D+Abi}S7o?}F=D zVcC`YuI`OB9*Px=#|y?21>@^lGgbkxR_It)!}k|ixtqi+g@wuoc~C)}$EOUSOXsK&{6>>(O-;${gWo)rr8k5u0B6cxlX3 f9%~+0E!}9lO3PZEzdMb$5ru_W>XY5(Ghj1PD+hNC_mx50R8aiKIR#i6Ui7wk1R{O!AHtB!Hk6 zpkykbp*TvcBu*@QGmYCsE}O=RozSf^p0xfc$4=|U%^1)iwgyu+pKhB;lc6R~$4xTR zzPAU8UrriNe|4rickkZ5efxIz?Y_6W@4opJ{`&L0^=lT3iG%dqryh(R-0`&4nK>bH z+!r~Slld4I=fiwl2n%sdSQ8h+B9C+-rj6^uI)-av`nVx%h#SMkxG8Lko5N;C7h_Uf z3QKWI*aCU2tczLWwy=%i`dCT4G+fGXL#!-r58D}TjFrbL!Yvh$;bc?H5w8qavI29= z8LtXgF!%Jfg z@y2i?t6UZf#+$-TJST7pe*-k|4O9b5;bx$@&u(4GE8A}HP!6w}!Xbe>$jSCAoLsKx zZfG)4sJU0z!U`*((1C^P%34`jC6qZgm9??5DkyU)+m)spFb&^;x7cJmE3Jl7_okZ1 zJ6NFy3TrkMcFMI^xNw*3RVpV1*>^<@?~v=1?Xq9#o)qN(mlE(G%xI=_i60+ zaqn5qY zSEOb*dE1AO+QIrM%ArjX`(Pw1r7fV1JxsT)pxd@hy4{Pdt6HHCX)K30dAqXrkJOhs zAMKmk?+2L!sQn&J?of zu={7&PEe#D)ppQlwQtk3?j^pKHtF}6JWxOoRMDiqS=Yd_G-T+bKh~Fb*I>35zzFdo zx&;B_X+*4f1-KCj-YaYd0aa8oCU*IdR)@e3prA>n2&u0$)pBUUAOB(J!ej?J|8rj!>>UhE2KRg^6I&}QR=>4ZQifl)b2m1$GmZ=ex#1?@8!X^CZaG6eL3VTL58h>}c_7R;7R zj7?7{u?SJl&nn53B98?1Wc_eR8wzEu1+a=Zo=C(t4rCYdAZVsPx13N1vWxDwzN#z3 zl5#59_Lkwy6m(NcDk6`QA;`4Thiy0A1wkI4xaa18tb-Rl+poMD`pccCX|%#BEQq)| z>0EU=RjMoK-rY7opXP}*&7VbfmWUp5`juR=G*wD#++MelOW9Y59Cwy&JebF-bgZJX zTKK)?dMwv!(!zonn+J2(uXjQ7Vp=2>sa0#?&RSO5N{fqz_1rv{!UbJSvMtJ`g|G6T z=TpuV8uZV$f=5bLuoNy)NCyzGYMMV#4K>cvIl5-e1E|OIIt%qHIw)7j^6T9_MB2y!J8e zMgAi9sOA)Rkq?$!+BKxaW)$L0CA_i3m<-=@%CvWqB;wwE1KzP|*?U1D(aFr5Hwh~_ znwa)pjHb?b4~~&|bUJjYVHhe!r>9mq=P;)huT%+zLa-KDjTiZXi8T>P&CMu3dm7f_ zrOG0=+VpfH<(*8-PRpSeg@QSm8cWS4BNLf~tQ2&zk{XM~;G2k#MW=&2xgW;O>O+@S z`p5bkRpihmKM3AbFfo?GQA~m$Meg;UfF2o?M<*iD>B+>UzyU@^ZH&#P&M4EVDDW%Z zi<#9y_j*T|Nd_GST`Vy*rI3P%l?z(1YcfZ0Q5B4n(O60$k@2~Lb}~wmsX`ejAay2^ zK)G!ZHr92t?JA9EvATFRnV2qUaYm8_4VZDJpk?(7#)-uAWOQnlD8zxBx`|j6x>=Cc z3MQ3Uq>_qq3+4xs3OPm+&@rW8-2do=GJ}dJXca;dB&Z{YSRHk8h%%8Na=}~_Dn~OD zsbX$>M2=3RnC1(-l0=W-Wr;AKQxm~XM_}PZ*hI`l)NvPQhnxfcZg`WQhN-{Iy(^lY zIP&)!i|df?#9wl2rQS z=t5UkYRXAXd8rAIcLOciKp#9eo7L~1@Zt_*f%3Yb0-tWDSw`AuVfBA|Oe0tgr4Kl=hps@0d*oESm3Bv>z~N z{?4EU+^6ogA_CC_=m<(cI1NYPjd z7O)*~vk}$CUliCWjLpPvoCB+ZSk)DWR?w*!aN{Y2wBq3-Xd@3`=0ODNF#)lC2=*gD zvn05SkP>>{UJ+IhokUOppkSJvk--l}&L+m$8RbR&>Uto!Rg!}U63A=7;Y7w}X2@B{ zCh?GM<;iGB>ca;syp@*^(_8fF~yo=EcFRI7nkQU&f#v=3me+ z3wq-)za%xiup=k6=cV?n)V}0y{L_&yk6b;vR9W*v!!1v?ax_;tny(zaI&?=We@6=3 zmI5!#-Ew55Ku#LUOG8=qyuV}vw;_PIwQxx)dusSI!`Z4GH~Rs=lasphQgt^G32H)UcJ@ER0taKzN9mz{a0NL;qEeph>iCM7w%iJow%kcNsP}Y`i#xp7j zW&II#e$@wxebvklc57a>^lJy~!neyX^KH9nuu=PMPd`8C*S=Px1=4GNEs$Po)F9q1 z4(`&wwuc9}!Nv{$Z{&Z3k#G4RBabU75}in{v2hZI=^--+&LbcQk^nx8kpg?c&J690 zG8Ri^&P0k0k@c2KW>Gr3R$KsVlQkA9$n5k=-3Swi-h`xq`J-52gMIeD;>ect!;=#S z^5Q^N99Sx?d%laFx4HipM%s8=YP{KV>%pwln3IO{(r}hN|F?{E`-d`8G6&kFe^auj zasv0yl?0Zi?x#*#Qu-}1%`fQKW$YiSE#+x3&7DOCmegH{wt6aQzM@(f;b@}FsU@RO zNS`a)n_FXE9bNXOjAI!3cF4`Y*-x15- z5k0p>&-ZIOZce^3mG2wPiO2Hdv8;HEc9x&!Kcp_|N3aJUM}XS6WFJBkIpaNAG{xhv zKat02xuYfX2`K*qsv%cU$R`n?L9D8SdR$n_EcP=NRW3J~-` z(7;i1Hqeu^^yV$Smk-eXO1mj1j^@SDtT_5E{g)~){{+>$9^eP*M_s-$HVb%EPvSAm z3wtWP=5lJEgv@8@OD+%JK;Lv#32#eqoo}RN)m}bL-wHVCk!nRVFR1vN)qQ%nXu};| zpe61Wy9Vy|n$^oX&^7dacj$gNqpi7ZuVn$;3@x&h7FHJ};H!6X%~|UT&!X`LtdehN z=?~q5yom-pevMg{=wZ)`8q1=U{)cCOg>Bxra`(nsX`@^sm(XK1A7LDSRWr7&WYN0* zWO)hr=1pl+T8GCMj=OoETskkoYmrOouG$t$S=#m`2;*MY$mNR_^wHWjeu#d(wt;uh z+qFS}zpM51RHmUFlmwhk#$T1T1MW(f!}-vFH8-9z#EK-!{I4n(F)j zLv{NNemTH;KtFD+r2p)<3UWRDN}a!UOS%eLXh=KO^uGcqjo0;Tyg#km2@<^iJ=!44 z%l>A*iC*(p^6m5me*@o4v;H0QEx(OA0@YL>D53oU*Wu9mG0fMdYd~g8x)!vdnfI=p zqqEKTM(&kc*K29shimOH-$oy+-d;Xmm#!nbQy*F3hxx6ekJQ&@wx1J`Mto^cx-Nn~ zi>0&=a`(b0EQhX)CAl5SFmlLJ+6tqGH8*daZb_Y5p@MvO+9P*BO`qJp*m*+-s}Ei^ z&HKTxqW|OQ0~%Wkm-eq8GtZ?hMX8pwRK%of{F9wfZ#(nH5Yo}{+%?UUhv~H~J=?m9 zv6=G49qZKqx0|VAtMHM#`GJv8R2r#l-Q6}{`*$9u`*PCD7p+_|>$E_pX> z0QYWRFe`m&3E-Zz1>oMa6=45$0dV1 z3qcM2T~m!sB8nV^fIuoTOQM$o^zybkdU4xUzUJkxZX4!#E4>@CQ)5fF&oDcU9#D}B z!nr93DEtKqybhos&Q3>Dtlk>YpK7>dFeCVEi~JGfY;H=o_4Xc)W1YWdaCqynmeC$ez}%@qGxN3 z$9KoGWqOWt@j;>oM)? z(!8z-?Q7G#-lhfo4gY}sK_B(Bvj)-51jqlFq-|Nh-TdfR6@>CwF1+~080sku|eL*|ZCtAP(~ zGdTEeT58#$leJ(GI(p7@sEmc}M_?iQP?h-qf<9#k*nWgL1Y3Ae>~3l?cQ`C1mmw7O zad9Fxc_CFrt$i;1fXBxV1QkTtz{nnrkr9r$NPTmz*PEhmywGW0$et~(ENx#=0+b)tHWBw-y zeu@AcbkMTKkeLmmov8NMNoNLJ{4eQe2fS9MKl0aDLr)IT_Xqq?;2!k*Eb%BrnWv{B z7;Z>zzOFdvH}^WsT{LTvI9@mu^VIb3sB>d<#1x$doOIx&b&blsi5CIP+i*! z(vrZ$slgX?V2K$P#`!NGj!^O7VfF_pxSu{TKuJ(#L;c#!Fm8>J+16}YgEmif)M^n8N=>sIH51k?_L&=?4`dQ3A9@FS<(+xHm%cQ5>cofuHR`*TIYeFo&w$?#fRBhG5ApuZ;Muvm wNPTa$NTRLB>VnlM2EenR(-oXy*!&2e=Z`K6NbjZTW3duW!}o*Q5i-~D-xFA)sQ>@~ diff --git a/FitnessSync/backend/src/api/__pycache__/sync.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/sync.cpython-313.pyc index e55386db2988d015048dc0197be28063432bf542..7874ddeeb5b915916927b94d2fb04296fbd81aa2 100644 GIT binary patch delta 8472 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 List[List[float]]: + """ + Extract [lon, lat] points from a FIT file content. + Returns a list of [lon, lat]. + """ + points = [] + try: + with io.BytesIO(file_content) as f: + with fitdecode.FitReader(f) as fit: + for frame in fit: + if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record': + # Check for position_lat and position_long + # Garmin stores lat/long as semicircles. Convert to degrees: semicircle * (180 / 2^31) + if frame.has_field('position_lat') and frame.has_field('position_long'): + lat_sc = frame.get_value('position_lat') + lon_sc = frame.get_value('position_long') + + if lat_sc is not None and lon_sc is not None: + lat = lat_sc * (180.0 / 2**31) + lon = lon_sc * (180.0 / 2**31) + points.append([lon, lat]) + except Exception as e: + logger.error(f"Error parsing FIT file: {e}") + # Return what we have or empty + return points + +def _extract_points_from_tcx(file_content: bytes) -> List[List[float]]: + """ + Extract [lon, lat] points from a TCX file content. + """ + points = [] + try: + # TCX is XML + # Namespace usually exists + root = ET.fromstring(file_content) + # Namespaces are annoying in ElementTree, usually {http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2} + # We can just iterate and ignore namespace or handle it. + # Let's try ignoring namespace by using local-name() in xpath if lxml, but this is stdlib ET. + # Just strip namespace for simplicity + + for trkpt in root.iter(): + if trkpt.tag.endswith('Trackpoint'): + lat = None + lon = None + for child in trkpt.iter(): + if child.tag.endswith('LatitudeDegrees'): + try: lat = float(child.text) + except: pass + elif child.tag.endswith('LongitudeDegrees'): + try: lon = float(child.text) + except: pass + + if lat is not None and lon is not None: + points.append([lon, lat]) + + except Exception as e: + logger.error(f"Error parsing TCX file: {e}") + return points + +@router.get("/activities/{activity_id}/geojson") +async def get_activity_geojson(activity_id: str, db: Session = Depends(get_db)): + """ + Return GeoJSON LineString for the activity track. + """ + try: + activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() + if not activity or not activity.file_content: + raise HTTPException(status_code=404, detail="Activity or file content not found") + + points = [] + if activity.file_type == 'fit': + points = _extract_points_from_fit(activity.file_content) + elif activity.file_type == 'tcx': + points = _extract_points_from_tcx(activity.file_content) + else: + # Try FIT or TCX anyway? + # Default to FIT check headers? + # For now just log warning + logger.warning(f"Unsupported file type for map: {activity.file_type}") + + if not points: + return {"type": "FeatureCollection", "features": []} + + return { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "properties": { + "color": "red" + }, + "geometry": { + "type": "LineString", + "coordinates": points + } + }] + } + + except Exception as e: + logger.error(f"Error generating GeoJSON: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +def _extract_streams_from_fit(file_content: bytes) -> Dict[str, List[Any]]: + streams = { + "time": [], + "heart_rate": [], + "power": [], + "altitude": [], + "speed": [], + "cadence": [] + } + try: + start_time = None + with io.BytesIO(file_content) as f: + with fitdecode.FitReader(f) as fit: + for frame in fit: + if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record': + timestamp = frame.get_value('timestamp') + if not start_time and timestamp: + start_time = timestamp + + if timestamp and start_time: + # Relative time in seconds + t = (timestamp - start_time).total_seconds() + + # Helper to safely get value with fallback + def get_val(frame, keys): + for k in keys: + if frame.has_field(k): + return frame.get_value(k) + return None + + streams["time"].append(t) + streams["heart_rate"].append(get_val(frame, ['heart_rate'])) + streams["power"].append(get_val(frame, ['power'])) + streams["altitude"].append(get_val(frame, ['enhanced_altitude', 'altitude'])) + streams["speed"].append(get_val(frame, ['enhanced_speed', 'speed'])) # m/s (enhanced is also m/s) + streams["cadence"].append(get_val(frame, ['cadence'])) + except Exception as e: + logger.error(f"Error extracting streams from FIT: {e}") + return streams + +def _extract_summary_from_fit(file_content: bytes) -> Dict[str, Any]: + summary = {} + try: + with io.BytesIO(file_content) as f: + with fitdecode.FitReader(f) as fit: + for frame in fit: + if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'session': + # Prefer enhanced fields + def get(keys): + for k in keys: + if frame.has_field(k): return frame.get_value(k) + return None + + summary['total_distance'] = get(['total_distance']) + summary['total_timer_time'] = get(['total_timer_time', 'total_elapsed_time']) + summary['total_calories'] = get(['total_calories']) + summary['avg_heart_rate'] = get(['avg_heart_rate']) + summary['max_heart_rate'] = get(['max_heart_rate']) + summary['avg_cadence'] = get(['avg_cadence']) + summary['max_cadence'] = get(['max_cadence']) + summary['avg_power'] = get(['avg_power']) + summary['max_power'] = get(['max_power']) + summary['total_ascent'] = get(['total_ascent']) + summary['total_descent'] = get(['total_descent']) + summary['enhanced_avg_speed'] = get(['enhanced_avg_speed', 'avg_speed']) + summary['enhanced_max_speed'] = get(['enhanced_max_speed', 'max_speed']) + summary['normalized_power'] = get(['normalized_power']) + summary['training_stress_score'] = get(['training_stress_score']) + summary['total_training_effect'] = get(['total_training_effect']) + summary['total_anaerobic_training_effect'] = get(['total_anaerobic_training_effect']) + + # Stop after first session message (usually only one per file, or first is summary) + # Actually FIT can have multiple sessions (multipsport). We'll take the first for now. + break + except Exception as e: + logger.error(f"Error extraction summary from FIT: {e}") + return summary + +def _extract_streams_from_tcx(file_content: bytes) -> Dict[str, List[Any]]: + streams = { + "time": [], + "heart_rate": [], + "power": [], + "altitude": [], + "speed": [], + "cadence": [] + } + try: + root = ET.fromstring(file_content) + # Namespace strip hack + start_time = None + + for trkpt in root.iter(): + if trkpt.tag.endswith('Trackpoint'): + timestamp_str = None + hr = None + pwr = None + alt = None + cad = None + spd = None + + for child in trkpt.iter(): + if child.tag.endswith('Time'): + timestamp_str = child.text + elif child.tag.endswith('AltitudeMeters'): + try: alt = float(child.text) + except: pass + elif child.tag.endswith('HeartRateBpm'): + for val in child: + if val.tag.endswith('Value'): + try: hr = int(val.text) + except: pass + elif child.tag.endswith('Cadence'): # Standard TCX cadence + try: cad = int(child.text) + except: pass + elif child.tag.endswith('Extensions'): + # TPX extensions for speed/power + for ext in child.iter(): + if ext.tag.endswith('Speed'): + try: spd = float(ext.text) + except: pass + elif ext.tag.endswith('Watts'): + try: pwr = int(ext.text) + except: pass + + if timestamp_str: + try: + # TCX time format is ISO8601 usually + ts = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + if not start_time: + start_time = ts + + streams["time"].append((ts - start_time).total_seconds()) + streams["heart_rate"].append(hr) + streams["power"].append(pwr) + streams["altitude"].append(alt) + streams["speed"].append(spd) + streams["cadence"].append(cad) + except: pass + + except Exception as e: + logger.error(f"Error extracting streams from TCX: {e}") + return streams + + +@router.get("/activities/{activity_id}/streams") +async def get_activity_streams(activity_id: str, db: Session = Depends(get_db)): + """ + Return time series data for charts. + """ + try: + activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() + if not activity or not activity.file_content: + raise HTTPException(status_code=404, detail="Activity or file content not found") + + streams = {} + if activity.file_type == 'fit': + streams = _extract_streams_from_fit(activity.file_content) + elif activity.file_type == 'tcx': + streams = _extract_streams_from_tcx(activity.file_content) + else: + logger.warning(f"Unsupported file type for streams: {activity.file_type}") + + return streams + except Exception as e: + logger.error(f"Error getting streams: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/activities/{activity_id}/navigation") +async def get_activity_navigation(activity_id: str, db: Session = Depends(get_db)): + """ + Return next/prev activity IDs. + """ + try: + current = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() + if not current: + raise HTTPException(status_code=404, detail="Activity not found") + + # Global Prev (Older) + prev_act = ( + db.query(Activity) + .filter(Activity.start_time < current.start_time) + .order_by(Activity.start_time.desc()) + .first() + ) + + # Global Next (Newer) + next_act = ( + db.query(Activity) + .filter(Activity.start_time > current.start_time) + .order_by(Activity.start_time.asc()) + .first() + ) + + # Same Type Prev + prev_type_act = ( + db.query(Activity) + .filter(Activity.start_time < current.start_time) + .filter(Activity.activity_type == current.activity_type) + .order_by(Activity.start_time.desc()) + .first() + ) + + # Same Type Next + next_type_act = ( + db.query(Activity) + .filter(Activity.start_time > current.start_time) + .filter(Activity.activity_type == current.activity_type) + .order_by(Activity.start_time.asc()) + .first() + ) + + return { + "prev_id": prev_act.garmin_activity_id if prev_act else None, + "next_id": next_act.garmin_activity_id if next_act else None, + "prev_type_id": prev_type_act.garmin_activity_id if prev_type_act else None, + "next_type_id": next_type_act.garmin_activity_id if next_type_act else None + } + + except Exception as e: + logger.error(f"Error getting navigation: {e}") + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/FitnessSync/backend/src/api/setup.py b/FitnessSync/backend/src/api/auth.py similarity index 51% rename from FitnessSync/backend/src/api/setup.py rename to FitnessSync/backend/src/api/auth.py index 2ec0b13..811b7cd 100644 --- a/FitnessSync/backend/src/api/setup.py +++ b/FitnessSync/backend/src/api/auth.py @@ -5,14 +5,17 @@ from typing import Optional from sqlalchemy.orm import Session import logging import traceback -import requests -import base64 +import json +from datetime import datetime, timedelta from ..services.garmin.client import GarminClient from ..services.fitbit_client import FitbitClient from ..services.postgresql_manager import PostgreSQLManager from ..utils.config import config +from ..models.api_token import APIToken +from ..models.config import Configuration from garth.exc import GarthException +import garth router = APIRouter() logger = logging.getLogger(__name__) @@ -39,11 +42,6 @@ class FitbitCallback(BaseModel): class GarminMFARequest(BaseModel): verification_code: str -from datetime import datetime, timedelta -from ..models.api_token import APIToken -from ..models.config import Configuration -import json - class GarminAuthStatus(BaseModel): token_stored: bool authenticated: bool @@ -80,8 +78,8 @@ def get_auth_status(db: Session = Depends(get_db)): authenticated=has_oauth1 and has_oauth2, garth_oauth1_token_exists=has_oauth1, garth_oauth2_token_exists=has_oauth2, - mfa_state_exists=False, # We don't store persistent MFA state in DB other than tokens - last_used=garmin_token.expires_at, # Using expires_at as proxy or null + mfa_state_exists=False, + last_used=garmin_token.expires_at, updated_at=garmin_token.updated_at ) else: @@ -96,7 +94,7 @@ def get_auth_status(db: Session = Depends(get_db)): if fitbit_token: response.fitbit = FitbitAuthStatus( authenticated=True, - client_id="Stored", # We don't store client_id in APIToken explicitly but could parse from file if needed + client_id="Stored", token_expires_at=fitbit_token.expires_at, last_login=fitbit_token.updated_at ) @@ -119,7 +117,6 @@ def clear_garmin_credentials(db: Session = Depends(get_db)): @router.post("/setup/garmin") def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)): - # Re-acquire logger to ensure correct config after startup logger = logging.getLogger(__name__) logger.info(f"Received Garmin credentials for user: {credentials.username}") @@ -129,7 +126,7 @@ def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depend status = garmin_client.login(db) if status == "mfa_required": - return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required.", "session_id": "session"}) # Added dummy session_id for frontend compat + return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required.", "session_id": "session"}) elif status == "error": logger.error("Garmin login returned 'error' status.") raise HTTPException(status_code=401, detail="Login failed. Check username/password.") @@ -146,11 +143,6 @@ def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get logger.info(f"Received MFA verification code: {'*' * len(mfa_request.verification_code)}") try: - # We need to reuse the client that was just used for login. - # In a real clustered app this would need shared state (Redis). - # For this single-instance app, we rely on Global Garth state or re-instantiation logic. - # But wait, handle_mfa logic in auth.py was loading from file/global. - # Let's ensure we are instantiating correctly. garmin_client = GarminClient() success = garmin_client.handle_mfa(db, mfa_request.verification_code) @@ -161,8 +153,6 @@ def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get except Exception as e: logger.error(f"MFA verification failed with exception: {e}", exc_info=True) - print("DEBUG: MFA verification failed. Traceback below:", flush=True) - traceback.print_exc() raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}") @router.post("/setup/garmin/test-token") @@ -177,180 +167,43 @@ def test_garmin_token(db: Session = Depends(get_db)): logger.warning("Test Token: No 'garmin' token record found in database.") return JSONResponse(status_code=400, content={"status": "error", "message": "No valid tokens found. Please login first."}) - logger.debug(f"Test Token: Token record found. ID: {token.id}, Updated: {token.updated_at}") - if not token.garth_oauth1_token: logger.warning("Test Token: garth_oauth1_token is empty or None.") return JSONResponse(status_code=400, content={"status": "error", "message": "No valid tokens found. Please login first."}) - logger.debug(f"Test Token: OAuth1 Token length: {len(token.garth_oauth1_token)}") - logger.debug(f"Test Token: OAuth2 Token length: {len(token.garth_oauth2_token) if token.garth_oauth2_token else 'None'}") - - import garth - # Manually load tokens into garth global state try: oauth1_data = json.loads(token.garth_oauth1_token) if token.garth_oauth1_token else None oauth2_data = json.loads(token.garth_oauth2_token) if token.garth_oauth2_token else None - if not isinstance(oauth1_data, dict) or not isinstance(oauth2_data, dict): - logger.error(f"Test Token: Parsed tokens are not dictionaries. OAuth1: {type(oauth1_data)}, OAuth2: {type(oauth2_data)}") - return JSONResponse(status_code=500, content={"status": "error", "message": "Stored tokens are invalid (not dictionaries)."}) - - logger.debug(f"Test Token: Parsed tokens. OAuth1 keys: {list(oauth1_data.keys())}, OAuth2 keys: {list(oauth2_data.keys())}") - - # Instantiate objects using the garth classes from garth.auth_tokens import OAuth1Token, OAuth2Token garth.client.oauth1_token = OAuth1Token(**oauth1_data) garth.client.oauth2_token = OAuth2Token(**oauth2_data) - logger.debug("Test Token: Tokens loaded into garth.client.") - except json.JSONDecodeError as e: - logger.error(f"Test Token: Failed to decode JSON tokens: {e}") - return JSONResponse(status_code=500, content={"status": "error", "message": "Stored tokens are corrupted."}) + except Exception as e: + logger.error(f"Test Token: Failed to decode/load tokens: {e}") + return JSONResponse(status_code=500, content={"status": "error", "message": "Stored tokens are invalid."}) - # Now test connection try: - logger.debug(f"Test Token: garth.client type: {type(garth.client)}") - logger.debug("Test Token: Attempting to fetch UserProfile...") - - # Using direct connectapi call as it was proven to work in debug script - # and avoids potential issues with UserProfile.get default args in this context profile = garth.client.connectapi("/userprofile-service/socialProfile") - - # success = True display_name = profile.get('fullName') or profile.get('displayName') logger.info(f"Test Token: Success! Connected as {display_name}") return {"status": "success", "message": f"Token valid! Connected as: {display_name}"} except GarthException as e: - logger.warning(f"Test Token: GarthException during profile fetch: {e}") + logger.warning(f"Test Token: GarthException: {e}") return JSONResponse(status_code=401, content={"status": "error", "message": "Token expired or invalid."}) except Exception as e: - # Capture missing token errors that might be wrapped - logger.warning(f"Test Token: Exception during profile fetch: {e}") + logger.warning(f"Test Token: Exception: {e}") if "OAuth1 token is required" in str(e): - return JSONResponse(status_code=400, content={"status": "error", "message": "No valid tokens found. Please login first."}) + return JSONResponse(status_code=400, content={"status": "error", "message": "No valid tokens found."}) return JSONResponse(status_code=500, content={"status": "error", "message": f"Connection test failed: {str(e)}"}) except Exception as e: logger.error(f"Test token failed with unexpected error: {e}", exc_info=True) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) -@router.post("/setup/load-consul-config") -def load_consul_config(db: Session = Depends(get_db)): - logger = logging.getLogger(__name__) - logger.info("Attempting to load configuration from Consul...") - try: - # User defined Consul URL - consul_host = "consul.service.dc1.consul" - consul_port = "8500" - app_prefix = "fitbit-garmin-sync/" - consul_url = f"http://{consul_host}:{consul_port}/v1/kv/{app_prefix}?recurse=true" - - logger.debug(f"Connecting to Consul at: {consul_url}") - - response = requests.get(consul_url, timeout=5) - if response.status_code == 404: - logger.warning(f"No configuration found in Consul under '{app_prefix}'") - raise HTTPException(status_code=404, detail="No configuration found in Consul") - response.raise_for_status() - - data = response.json() - - config_map = {} - - # Helper to decode Consul values - def decode_consul_value(val): - if not val: return None - try: - return base64.b64decode(val).decode('utf-8') - except Exception as e: - logger.warning(f"Failed to decode value: {e}") - return None - - # Pass 1: Load all raw keys - for item in data: - key = item['Key'].replace(app_prefix, '') - value = decode_consul_value(item.get('Value')) - if value: - config_map[key] = value - - # Pass 2: Check for special 'config' key (JSON blob) - # The user URL ended in /config/edit, suggesting a single config file pattern - if 'config' in config_map: - try: - json_config = json.loads(config_map['config']) - logger.debug("Found 'config' key with JSON content, merging...") - # Merge JSON config, preferring explicit keys if collision (or vice versa? Let's say JSON overrides) - config_map.update(json_config) - except json.JSONDecodeError: - logger.warning("'config' key found but is not valid JSON, ignoring as blob.") - - logger.debug(f"Resolved configuration keys: {list(config_map.keys())}") - - # Look for standard keys - username = config_map.get('garmin_username') or config_map.get('USERNAME') - password = config_map.get('garmin_password') or config_map.get('PASSWORD') - is_china = str(config_map.get('is_china', 'false')).lower() == 'true' - - # If missing, try nested 'garmin' object (common in config.json structure) - if not username and isinstance(config_map.get('garmin'), dict): - logger.debug("Found nested 'garmin' config object.") - garmin_conf = config_map['garmin'] - username = garmin_conf.get('username') - password = garmin_conf.get('password') - if 'is_china' in garmin_conf: - is_china = str(garmin_conf.get('is_china')).lower() == 'true' - - if not username or not password: - logger.error("Consul config resolved but missing 'garmin_username' or 'garmin_password'") - raise HTTPException(status_code=400, detail="Consul config missing credentials") - - # Extract Fitbit credentials - fitbit_client_id = config_map.get('fitbit_client_id') - fitbit_client_secret = config_map.get('fitbit_client_secret') - fitbit_redirect_uri = config_map.get('fitbit_redirect_uri') - - if isinstance(config_map.get('fitbit'), dict): - logger.debug("Found nested 'fitbit' config object.") - fitbit_conf = config_map['fitbit'] - fitbit_client_id = fitbit_conf.get('client_id') - fitbit_client_secret = fitbit_conf.get('client_secret') - - logger.info("Consul config loaded successfully. Returning to frontend.") - - return { - "status": "success", - "message": "Configuration loaded from Consul", - "garmin": { - "username": username, - "password": password, - "is_china": is_china - }, - "fitbit": { - "client_id": fitbit_client_id, - "client_secret": fitbit_client_secret, - "redirect_uri": fitbit_redirect_uri - } - } - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to connect to Consul: {e}") - raise HTTPException(status_code=502, detail=f"Failed to connect to Consul: {str(e)}") - except HTTPException: - raise - except Exception as e: - logger.error(f"Error loading from Consul: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal error loading config: {str(e)}") - @router.post("/setup/fitbit") def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depends(get_db)): - """ - Saves Fitbit credentials to the Configuration table and returns the authorization URL. - """ logger = logging.getLogger(__name__) - logger.info("Received Fitbit credentials to save.") - try: - # Check if config exists config_entry = db.query(Configuration).first() if not config_entry: config_entry = Configuration() @@ -361,54 +214,27 @@ def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depend config_entry.fitbit_redirect_uri = credentials.redirect_uri db.commit() - # Generate Auth URL - redirect_uri = credentials.redirect_uri - if not redirect_uri: - redirect_uri = None - + redirect_uri = credentials.redirect_uri or None fitbit_client = FitbitClient(credentials.client_id, credentials.client_secret, redirect_uri=redirect_uri) - auth_url = fitbit_client.get_authorization_url(redirect_uri) - return { - "status": "success", - "message": "Credentials saved.", - "auth_url": auth_url - } - + return {"status": "success", "message": "Credentials saved.", "auth_url": auth_url} except Exception as e: logger.error(f"Error saving Fitbit credentials: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to save credentials: {str(e)}") @router.post("/setup/fitbit/callback") def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)): - """ - Exchanges the authorization code for tokens and saves them. - """ logger = logging.getLogger(__name__) - logger.info("Received Fitbit callback code.") - try: - # Retrieve credentials config_entry = db.query(Configuration).first() - - if not config_entry or not config_entry.fitbit_client_id or not config_entry.fitbit_client_secret: - raise HTTPException(status_code=400, detail="Configuration not found or missing Fitbit credentials. Please save them first.") + if not config_entry or not config_entry.fitbit_client_id: + raise HTTPException(status_code=400, detail="Configuration missing Fitbit credentials.") - client_id = config_entry.fitbit_client_id - client_secret = config_entry.fitbit_client_secret - - # Must match the one used in get_authorization_url - redirect_uri = config_entry.fitbit_redirect_uri - if not redirect_uri: - redirect_uri = None - - fitbit_client = FitbitClient(client_id, client_secret, redirect_uri=redirect_uri) - + redirect_uri = config_entry.fitbit_redirect_uri or None + fitbit_client = FitbitClient(config_entry.fitbit_client_id, config_entry.fitbit_client_secret, redirect_uri=redirect_uri) token_data = fitbit_client.exchange_code_for_token(callback_data.code, redirect_uri) - # Save to APIToken - # Check if exists token_entry = db.query(APIToken).filter_by(token_type='fitbit').first() if not token_entry: token_entry = APIToken(token_type='fitbit') @@ -416,62 +242,36 @@ def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db) token_entry.access_token = token_data.get('access_token') token_entry.refresh_token = token_data.get('refresh_token') + if token_data.get('expires_in'): + token_entry.expires_at = datetime.now() + timedelta(seconds=token_data.get('expires_in')) - # Handle expires_in (seconds) -> expires_at (datetime) - expires_in = token_data.get('expires_in') - if expires_in: - token_entry.expires_at = datetime.now() + timedelta(seconds=expires_in) - - # Save other metadata if available (user_id, scope) - if 'scope' in token_data: - token_entry.scopes = str(token_data['scope']) # JSON or string list - db.commit() - - return { - "status": "success", - "message": "Fitbit authentication successful. Tokens saved.", - "user_id": token_data.get('user_id') - } + return {"status": "success", "message": "Fitbit authentication successful.", "user_id": token_data.get('user_id')} except HTTPException: raise except Exception as e: logger.error(f"Error in Fitbit callback: {e}", exc_info=True) - # Often oauth errors are concise, return detail raise HTTPException(status_code=500, detail=f"Authentication failed: {str(e)}") @router.post("/setup/fitbit/test-token") def test_fitbit_token(db: Session = Depends(get_db)): - """Tests if the stored Fitbit token is valid by fetching user profile.""" logger = logging.getLogger(__name__) - logger.info("Received request to test Fitbit token.") - try: - # Retrieve tokens and credentials token = db.query(APIToken).filter_by(token_type='fitbit').first() config_entry = db.query(Configuration).first() if not token or not token.access_token: - return JSONResponse(status_code=400, content={"status": "error", "message": "No Fitbit token found. Please authenticate first."}) + return JSONResponse(status_code=400, content={"status": "error", "message": "No Fitbit token found."}) - if not config_entry or not config_entry.fitbit_client_id or not config_entry.fitbit_client_secret: - return JSONResponse(status_code=400, content={"status": "error", "message": "Fitbit credentials missing."}) - - # Instantiate client with tokens - # Note: fitbit library handles token refresh automatically if refresh_token is provided and valid fitbit_client = FitbitClient( config_entry.fitbit_client_id, config_entry.fitbit_client_secret, access_token=token.access_token, refresh_token=token.refresh_token, - redirect_uri=config_entry.fitbit_redirect_uri # Optional but good practice + redirect_uri=config_entry.fitbit_redirect_uri ) - # Test call - if not fitbit_client.fitbit: - return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to initialize Fitbit client."}) - profile = fitbit_client.fitbit.user_profile_get() user = profile.get('user', {}) display_name = user.get('displayName') or user.get('fullName') @@ -479,13 +279,9 @@ def test_fitbit_token(db: Session = Depends(get_db)): return { "status": "success", "message": f"Token valid! Connected as: {display_name}", - "user": { - "displayName": display_name, - "avatar": user.get('avatar') - } + "user": {"displayName": display_name, "avatar": user.get('avatar')} } except Exception as e: logger.error(f"Test Fitbit token failed: {e}", exc_info=True) - # Check for specific token errors if possible, but generic catch is okay for now return JSONResponse(status_code=401, content={"status": "error", "message": f"Token invalid or expired: {str(e)}"}) diff --git a/FitnessSync/backend/src/api/bike_setups.py b/FitnessSync/backend/src/api/bike_setups.py new file mode 100644 index 0000000..dd880a2 --- /dev/null +++ b/FitnessSync/backend/src/api/bike_setups.py @@ -0,0 +1,110 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +import logging + +from ..models.bike_setup import BikeSetup +from ..models.base import Base +from ..services.postgresql_manager import PostgreSQLManager +from ..utils.config import config + +logger = logging.getLogger(__name__) + +# Reusing get_db logic (it should ideally be in a shared common module, but for now reproducing it to avoid circular imports or refactoring) +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + +class BikeSetupCreate(BaseModel): + frame: str + chainring: int + rear_cog: int + name: Optional[str] = None + +class BikeSetupUpdate(BaseModel): + frame: Optional[str] = None + chainring: Optional[int] = None + rear_cog: Optional[int] = None + name: Optional[str] = None + +class BikeSetupRead(BaseModel): + id: int + frame: str + chainring: int + rear_cog: int + name: Optional[str] = None + created_at: Optional[datetime] + updated_at: Optional[datetime] + + class Config: + from_attributes = True + +router = APIRouter(prefix="/api/bike-setups", tags=["bike-setups"]) + +@router.get("/", response_model=List[BikeSetupRead]) +def get_bike_setups(db: Session = Depends(get_db)): + """List all bike setups.""" + return db.query(BikeSetup).all() + +@router.post("/", response_model=BikeSetupRead, status_code=status.HTTP_201_CREATED) +def create_bike_setup(setup: BikeSetupCreate, db: Session = Depends(get_db)): + """Create a new bike setup.""" + new_setup = BikeSetup( + frame=setup.frame, + chainring=setup.chainring, + rear_cog=setup.rear_cog, + name=setup.name + ) + db.add(new_setup) + db.commit() + db.refresh(new_setup) + return new_setup + +@router.get("/{setup_id}", response_model=BikeSetupRead) +def get_bike_setup(setup_id: int, db: Session = Depends(get_db)): + """Get a specific bike setup.""" + # Assuming BikeSetup is imported correctly + setup = db.query(BikeSetup).filter(BikeSetup.id == setup_id).first() + if not setup: + raise HTTPException(status_code=404, detail="Bike setup not found") + return setup + +@router.put("/{setup_id}", response_model=BikeSetupRead) +def update_bike_setup(setup_id: int, setup_data: BikeSetupUpdate, db: Session = Depends(get_db)): + """Update a bike setup.""" + setup = db.query(BikeSetup).filter(BikeSetup.id == setup_id).first() + if not setup: + raise HTTPException(status_code=404, detail="Bike setup not found") + + if setup_data.frame is not None: + setup.frame = setup_data.frame + if setup_data.chainring is not None: + setup.chainring = setup_data.chainring + if setup_data.rear_cog is not None: + setup.rear_cog = setup_data.rear_cog + if setup_data.name is not None: + setup.name = setup_data.name + + db.commit() + db.refresh(setup) + return setup + +@router.delete("/{setup_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_bike_setup(setup_id: int, db: Session = Depends(get_db)): + """Delete a bike setup.""" + setup = db.query(BikeSetup).filter(BikeSetup.id == setup_id).first() + if not setup: + raise HTTPException(status_code=404, detail="Bike setup not found") + + db.delete(setup) + db.commit() + +@router.post("/match-all", status_code=status.HTTP_200_OK) +def trigger_matching(db: Session = Depends(get_db)): + """Trigger bike matching for all applicable activities.""" + from ..services.bike_matching import run_matching_for_all + run_matching_for_all(db) + return {"status": "success", "message": "Matching process completed."} diff --git a/FitnessSync/backend/src/api/config_routes.py b/FitnessSync/backend/src/api/config_routes.py new file mode 100644 index 0000000..faa18e2 --- /dev/null +++ b/FitnessSync/backend/src/api/config_routes.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +import logging +import requests +import base64 +import json + +from ..services.postgresql_manager import PostgreSQLManager +from ..utils.config import config + +router = APIRouter() +logger = logging.getLogger(__name__) + +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + +@router.post("/setup/load-consul-config") +def load_consul_config(db: Session = Depends(get_db)): + logger = logging.getLogger(__name__) + logger.info("Attempting to load configuration from Consul...") + try: + # User defined Consul URL + consul_host = "consul.service.dc1.consul" + consul_port = "8500" + app_prefix = "fitbit-garmin-sync/" + consul_url = f"http://{consul_host}:{consul_port}/v1/kv/{app_prefix}?recurse=true" + + logger.debug(f"Connecting to Consul at: {consul_url}") + + response = requests.get(consul_url, timeout=5) + if response.status_code == 404: + logger.warning(f"No configuration found in Consul under '{app_prefix}'") + raise HTTPException(status_code=404, detail="No configuration found in Consul") + response.raise_for_status() + + data = response.json() + + config_map = {} + + # Helper to decode Consul values + def decode_consul_value(val): + if not val: return None + try: + return base64.b64decode(val).decode('utf-8') + except Exception as e: + logger.warning(f"Failed to decode value: {e}") + return None + + # Pass 1: Load all raw keys + for item in data: + key = item['Key'].replace(app_prefix, '') + value = decode_consul_value(item.get('Value')) + if value: + config_map[key] = value + + # Pass 2: Check for special 'config' key (JSON blob) + if 'config' in config_map: + try: + json_config = json.loads(config_map['config']) + logger.debug("Found 'config' key with JSON content, merging...") + config_map.update(json_config) + except json.JSONDecodeError: + logger.warning("'config' key found but is not valid JSON, ignoring as blob.") + + logger.debug(f"Resolved configuration keys: {list(config_map.keys())}") + + # Look for standard keys + username = config_map.get('garmin_username') or config_map.get('USERNAME') + password = config_map.get('garmin_password') or config_map.get('PASSWORD') + is_china = str(config_map.get('is_china', 'false')).lower() == 'true' + + if not username and isinstance(config_map.get('garmin'), dict): + logger.debug("Found nested 'garmin' config object.") + garmin_conf = config_map['garmin'] + username = garmin_conf.get('username') + password = garmin_conf.get('password') + if 'is_china' in garmin_conf: + is_china = str(garmin_conf.get('is_china')).lower() == 'true' + + if not username or not password: + logger.error("Consul config resolved but missing 'garmin_username' or 'garmin_password'") + raise HTTPException(status_code=400, detail="Consul config missing credentials") + + # Extract Fitbit credentials + fitbit_client_id = config_map.get('fitbit_client_id') + fitbit_client_secret = config_map.get('fitbit_client_secret') + fitbit_redirect_uri = config_map.get('fitbit_redirect_uri') + + if isinstance(config_map.get('fitbit'), dict): + logger.debug("Found nested 'fitbit' config object.") + fitbit_conf = config_map['fitbit'] + fitbit_client_id = fitbit_conf.get('client_id') + fitbit_client_secret = fitbit_conf.get('client_secret') + + logger.info("Consul config loaded successfully. Returning to frontend.") + + return { + "status": "success", + "message": "Configuration loaded from Consul", + "garmin": { + "username": username, + "password": password, + "is_china": is_china + }, + "fitbit": { + "client_id": fitbit_client_id, + "client_secret": fitbit_client_secret, + "redirect_uri": fitbit_redirect_uri + } + } + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to connect to Consul: {e}") + raise HTTPException(status_code=502, detail=f"Failed to connect to Consul: {str(e)}") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error loading from Consul: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal error loading config: {str(e)}") diff --git a/FitnessSync/backend/src/api/metrics.py b/FitnessSync/backend/src/api/metrics.py index 856e735..80ad212 100644 --- a/FitnessSync/backend/src/api/metrics.py +++ b/FitnessSync/backend/src/api/metrics.py @@ -1,9 +1,11 @@ -from fastapi import APIRouter, Query, HTTPException, Depends +from fastapi import APIRouter, Query, HTTPException, Depends, BackgroundTasks from pydantic import BaseModel from typing import List, Optional, Dict, Any from sqlalchemy import func from ..models.health_metric import HealthMetric +from ..models.weight_record import WeightRecord import logging +import json from ..services.postgresql_manager import PostgreSQLManager from sqlalchemy.orm import Session from ..utils.config import config @@ -79,21 +81,62 @@ async def query_metrics( metric_type: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), - limit: int = Query(100, ge=1, le=1000), + source: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=10000), db: Session = Depends(get_db) ): """ Query health metrics with filters. """ try: - logger.info(f"Querying metrics - type: {metric_type}, start: {start_date}, end: {end_date}, limit: {limit}") + logger.info(f"Querying metrics - type: {metric_type}, source: {source}, start: {start_date}, end: {end_date}, limit: {limit}") - # Start building the query + # Special handling for Fitbit Weight queries -> Use WeightRecord table + if source == 'fitbit' and metric_type == 'weight': + query = db.query(WeightRecord) + + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + query = query.filter(WeightRecord.date >= start_dt) + + if end_date: + from datetime import datetime + end_dt = datetime.fromisoformat(end_date) + query = query.filter(WeightRecord.date <= end_dt) + + query = query.order_by(WeightRecord.date.desc()) + query = query.limit(limit) + + weight_records = query.all() + + metric_responses = [] + for wr in weight_records: + metric_responses.append( + HealthMetricResponse( + id=wr.id, + metric_type='weight', + metric_value=wr.weight, + unit=wr.unit, + timestamp=wr.timestamp.isoformat() if wr.timestamp else "", + date=wr.date.isoformat() if wr.date else "", + source='fitbit', + detailed_data={'fitbit_id': wr.fitbit_id, 'bmi': wr.bmi} + ) + ) + + logger.info(f"Returning {len(metric_responses)} Fitbit weight records from WeightRecord table") + return metric_responses + + # Default: Start building the query on HealthMetric query = db.query(HealthMetric) # Apply filters based on parameters if metric_type: query = query.filter(HealthMetric.metric_type == metric_type) + + if source: + query = query.filter(HealthMetric.source == source) if start_date: from datetime import datetime @@ -105,6 +148,9 @@ async def query_metrics( end_dt = datetime.fromisoformat(end_date) query = query.filter(HealthMetric.date <= end_dt.date()) + # Sort by Date Descending + query = query.order_by(HealthMetric.date.desc()) + # Apply limit query = query.limit(limit) @@ -123,7 +169,7 @@ async def query_metrics( timestamp=metric.timestamp.isoformat() if metric.timestamp else "", date=metric.date.isoformat() if metric.date else "", source=metric.source, - detailed_data=metric.detailed_data + detailed_data=json.loads(metric.detailed_data) if metric.detailed_data else None ) ) @@ -133,6 +179,24 @@ async def query_metrics( logger.error(f"Error in query_metrics: {str(e)}") raise HTTPException(status_code=500, detail=f"Error querying metrics: {str(e)}") + +# run_fitbit_sync_job moved to tasks.definitions + + +# ... + +@router.post("/metrics/sync/fitbit") +async def sync_fitbit_trigger( + background_tasks: BackgroundTasks, + days_back: int = Query(30, description="Number of days to sync back") +): + """Trigger background sync of Fitbit metrics""" + job_id = job_manager.create_job("sync_fitbit_metrics") + + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_fitbit_sync_job, job_id, days_back, db_manager.get_db_session) + return {"job_id": job_id, "status": "started"} + @router.get("/health-data/summary", response_model=HealthDataSummary) async def get_health_summary( start_date: Optional[str] = Query(None), @@ -220,9 +284,57 @@ async def get_health_summary( total_sleep_hours=round(total_sleep_hours, 2), avg_calories=round(avg_calories, 2) ) - logger.info(f"Returning health summary: steps={total_steps}, avg_hr={avg_heart_rate}, sleep_hours={total_sleep_hours}, avg_calories={avg_calories}") return summary except Exception as e: logger.error(f"Error in get_health_summary: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error getting health summary: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Error getting health summary: {str(e)}") + +# New Sync Endpoints + +from ..services.job_manager import job_manager +from ..models.health_state import HealthSyncState +from ..utils.config import config +from ..services.postgresql_manager import PostgreSQLManager +from ..tasks.definitions import run_health_scan_job, run_health_sync_job, run_fitbit_sync_job + +# Removed inline run_health_scan_job and run_health_sync_job + + +# Definitions moved to tasks/definitions.py + + +@router.post("/metrics/sync/scan") +async def scan_health_trigger(background_tasks: BackgroundTasks): + """Trigger background scan of health gaps""" + job_id = job_manager.create_job("scan_health_metrics") + + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_health_scan_job, job_id, db_manager.get_db_session) + return {"job_id": job_id, "status": "started"} + +@router.post("/metrics/sync/pending") +async def sync_pending_health_trigger( + background_tasks: BackgroundTasks, + limit: Optional[int] = Query(None, description="Limit number of days/metrics to sync") +): + """Trigger background sync of pending health metrics""" + job_id = job_manager.create_job("sync_pending_health_metrics") + + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_health_sync_job, job_id, limit, db_manager.get_db_session) + return {"job_id": job_id, "status": "started"} + +@router.get("/metrics/sync/status") +async def get_health_sync_status_summary(db: Session = Depends(get_db)): + """Get counts of health metrics by sync status""" + try: + stats = db.query( + HealthSyncState.sync_status, + func.count(HealthSyncState.id) + ).group_by(HealthSyncState.sync_status).all() + + return {s[0]: s[1] for s in stats} + except Exception as e: + logger.error(f"Error getting health sync status: {e}") + return {} \ No newline at end of file diff --git a/FitnessSync/backend/src/api/scheduling.py b/FitnessSync/backend/src/api/scheduling.py new file mode 100644 index 0000000..0b4c0e8 --- /dev/null +++ b/FitnessSync/backend/src/api/scheduling.py @@ -0,0 +1,131 @@ + +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime, timedelta +import json +import logging + +from ..models.scheduled_job import ScheduledJob +from ..services.postgresql_manager import PostgreSQLManager +from ..utils.config import config +from ..services.scheduler import scheduler + +router = APIRouter() +logger = logging.getLogger(__name__) + +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + +class ScheduledJobResponse(BaseModel): + id: int + job_type: str + name: str + interval_minutes: int + enabled: bool + last_run: Optional[datetime] + next_run: Optional[datetime] + params: Optional[str] + + class Config: + from_attributes = True + +class JobUpdateRequest(BaseModel): + interval_minutes: Optional[int] = None + enabled: Optional[bool] = None + params: Optional[dict] = None + +@router.get("/scheduling/jobs", response_model=List[ScheduledJobResponse]) +def list_scheduled_jobs(db: Session = Depends(get_db)): + """List all scheduled jobs.""" + jobs = db.query(ScheduledJob).order_by(ScheduledJob.id).all() + return jobs + +@router.put("/scheduling/jobs/{job_id}", response_model=ScheduledJobResponse) +def update_scheduled_job(job_id: int, request: JobUpdateRequest, db: Session = Depends(get_db)): + """Update a scheduled job's interval or enabled status.""" + job = db.query(ScheduledJob).filter(ScheduledJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if request.interval_minutes is not None: + if request.interval_minutes < 1: + raise HTTPException(status_code=400, detail="Interval must be at least 1 minute") + job.interval_minutes = request.interval_minutes + + # If enabled, update next_run based on new interval if it's far in future? + # Actually, standard behavior: next_run should be recalculated from last_run + new interval + # OR just leave it. If we shorten it, we might want it to run sooner. + # Let's recalculate next_run if it exists. + if job.last_run: + job.next_run = job.last_run + timedelta(minutes=job.interval_minutes) + else: + # If never run, next_run should be Now if enabled? + # Or keep existing next_run? + # If next_run is null and enabled, scheduler picks it up immediately. + pass + + if request.enabled is not None: + job.enabled = request.enabled + if job.enabled and job.next_run is None: + # If re-enabling and no next run, set to now + job.next_run = datetime.now() + + if request.params is not None: + job.params = json.dumps(request.params) + + db.commit() + db.refresh(job) + return job + +class JobCreateRequest(BaseModel): + job_type: str + name: str + interval_minutes: int + params: Optional[dict] = {} + enabled: Optional[bool] = True + +@router.post("/scheduling/jobs", response_model=ScheduledJobResponse) +def create_scheduled_job(request: JobCreateRequest, db: Session = Depends(get_db)): + """Create a new scheduled job.""" + # Validate job_type + from ..services.scheduler import scheduler + if request.job_type not in scheduler.TASK_MAP: + raise HTTPException(status_code=400, detail=f"Invalid job_type. Must be one of: {list(scheduler.TASK_MAP.keys())}") + + new_job = ScheduledJob( + job_type=request.job_type, + name=request.name, + interval_minutes=request.interval_minutes, + params=json.dumps(request.params) if request.params else "{}", + enabled=request.enabled, + next_run=datetime.now() if request.enabled else None + ) + + try: + db.add(new_job) + db.commit() + db.refresh(new_job) + return new_job + except Exception as e: + db.rollback() + logger.error(f"Failed to create job: {e}") + # Check for unique constraint on job_type if we enforced it? + # The model has job_type unique=True. This might be a problem if we want multiple of same type? + # User wants "new scheduled tasks" with "variables" -> implies multiple of same type (e.g. sync fitbit 10 days vs 30 days). + # We need to remove unique=True from ScheduledJob model if it exists! + raise HTTPException(status_code=400, detail=f"Failed to create job: {str(e)}") + +@router.delete("/scheduling/jobs/{job_id}", status_code=204) +def delete_scheduled_job(job_id: int, db: Session = Depends(get_db)): + """Delete a scheduled job.""" + job = db.query(ScheduledJob).filter(ScheduledJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + db.delete(job) + db.commit() + return None diff --git a/FitnessSync/backend/src/api/status.py b/FitnessSync/backend/src/api/status.py index 236842d..cbc5737 100644 --- a/FitnessSync/backend/src/api/status.py +++ b/FitnessSync/backend/src/api/status.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, BackgroundTasks from pydantic import BaseModel from typing import List, Optional, Dict, Any from sqlalchemy.orm import Session @@ -6,6 +6,7 @@ from ..services.postgresql_manager import PostgreSQLManager from ..utils.config import config from ..models.activity import Activity from ..models.sync_log import SyncLog +from ..services.job_manager import job_manager from datetime import datetime import json @@ -30,11 +31,28 @@ class SyncLogResponse(BaseModel): class Config: from_attributes = True + class Config: + from_attributes = True + +class JobStatusResponse(BaseModel): + id: str + operation: str + status: str + message: Optional[str] = None + start_time: datetime + progress: int = 0 + cancel_requested: bool = False + paused: bool = False + completed_at: Optional[datetime] = None + duration_s: Optional[float] = None + result: Optional[Dict[str, Any]] = None + class StatusResponse(BaseModel): total_activities: int downloaded_activities: int recent_logs: List[SyncLogResponse] last_sync_stats: Optional[List[Dict[str, Any]]] = None + active_jobs: List[Dict[str, Any]] = [] @router.get("/status", response_model=StatusResponse) def get_status(db: Session = Depends(get_db)): @@ -79,5 +97,65 @@ def get_status(db: Session = Depends(get_db)): total_activities=total_activities, downloaded_activities=downloaded_activities, recent_logs=recent_logs, - last_sync_stats=last_sync_stats if last_sync_stats else [] - ) \ No newline at end of file + last_sync_stats=last_sync_stats if last_sync_stats else [], + active_jobs=job_manager.get_active_jobs() + ) + +@router.get("/jobs/history", response_model=Dict[str, Any]) +def get_job_history(page: int = 1, limit: int = 10): + """Get history of completed jobs with pagination.""" + if page < 1: page = 1 + offset = (page - 1) * limit + return job_manager.get_job_history(limit=limit, offset=offset) + +@router.post("/jobs/{job_id}/pause") +def pause_job(job_id: str): + if job_manager.request_pause(job_id): + return {"status": "paused", "message": f"Pause requested for job {job_id}"} + raise HTTPException(status_code=404, detail="Job not found or cannot be paused") + +@router.post("/jobs/{job_id}/resume") +def resume_job(job_id: str): + if job_manager.resume_job(job_id): + return {"status": "resumed", "message": f"Job {job_id} resumed"} + raise HTTPException(status_code=404, detail="Job not found or cannot be resumed") + +@router.post("/jobs/{job_id}/cancel") +def cancel_job(job_id: str): + if job_manager.request_cancel(job_id): + return {"status": "cancelling", "message": f"Cancellation requested for job {job_id}"} + raise HTTPException(status_code=404, detail="Job not found") + +import time + +def run_test_job(job_id: str): + """Simulate a long running job with pause support.""" + try: + total_steps = 20 + i = 0 + while i < total_steps: + if job_manager.should_cancel(job_id): + job_manager.update_job(job_id, status="cancelled", message="Cancelled by user") + return + + if job_manager.should_pause(job_id): + time.sleep(1) + continue # Skip progress update + + # Normal work + progress = int(((i + 1) / total_steps) * 100) + job_manager.update_job(job_id, status="running", progress=progress, message=f"Processing... {i+1}/{total_steps}") + time.sleep(1) + i += 1 + + job_manager.complete_job(job_id) + except Exception as e: + job_manager.fail_job(job_id, str(e)) + +@router.post("/status/test-job") +def trigger_test_job(background_tasks: BackgroundTasks): + """Trigger a test job for queue verification.""" + job_id = job_manager.create_job("Test Job (5s)") + # Use run_serialized to enforce global lock + background_tasks.add_task(job_manager.run_serialized, job_id, run_test_job) + return {"job_id": job_id, "status": "started", "message": "Test job started"} \ No newline at end of file diff --git a/FitnessSync/backend/src/api/sync.py b/FitnessSync/backend/src/api/sync.py index 7f11030..448364b 100644 --- a/FitnessSync/backend/src/api/sync.py +++ b/FitnessSync/backend/src/api/sync.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query from pydantic import BaseModel from typing import Optional, List, Dict, Any from datetime import datetime, timedelta @@ -15,6 +15,7 @@ import garth import time from garth.auth_tokens import OAuth1Token, OAuth2Token from ..services.fitbit_client import FitbitClient +from fitbit import exceptions from ..models.weight_record import WeightRecord from ..models.config import Configuration from enum import Enum @@ -28,11 +29,21 @@ class SyncActivityRequest(BaseModel): class SyncMetricsRequest(BaseModel): days_back: int = 30 +class UploadWeightRequest(BaseModel): + limit: int = 50 + class SyncResponse(BaseModel): status: str message: str job_id: Optional[str] = None +class WeightComparisonResponse(BaseModel): + fitbit_total: int + garmin_total: int + missing_in_garmin: int + missing_dates: List[str] + message: str + class FitbitSyncScope(str, Enum): LAST_30_DAYS = "30d" ALL_HISTORY = "all" @@ -53,66 +64,27 @@ def get_db(): with db_manager.get_db_session() as session: yield session -def _load_and_verify_garth_session(db: Session): - """Helper to load token from DB and verify session with Garmin.""" - logger.info("Loading and verifying Garmin session...") - token_record = db.query(APIToken).filter_by(token_type='garmin').first() - if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token): - raise HTTPException(status_code=401, detail="Garmin token not found.") - - try: - oauth1_dict = json.loads(token_record.garth_oauth1_token) - oauth2_dict = json.loads(token_record.garth_oauth2_token) - - domain = oauth1_dict.get('domain') - if domain: - garth.configure(domain=domain) - - garth.client.oauth1_token = OAuth1Token(**oauth1_dict) - garth.client.oauth2_token = OAuth2Token(**oauth2_dict) - - garth.UserProfile.get() - logger.info("Garth session verified.") - except Exception as e: - logger.error(f"Garth session verification failed: {e}", exc_info=True) - raise HTTPException(status_code=401, detail=f"Failed to authenticate with Garmin: {e}") - -def run_activity_sync_task(job_id: str, days_back: int): - logger.info(f"Starting background activity sync task {job_id}") - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - try: - _load_and_verify_garth_session(session) - garmin_client = GarminClient() - sync_app = SyncApp(db_session=session, garmin_client=garmin_client) - sync_app.sync_activities(days_back=days_back, job_id=job_id) - except Exception as e: - logger.error(f"Background task failed: {e}") - job_manager.update_job(job_id, status="failed", message=str(e)) - -def run_metrics_sync_task(job_id: str, days_back: int): - logger.info(f"Starting background metrics sync task {job_id}") - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - try: - _load_and_verify_garth_session(session) - garmin_client = GarminClient() - sync_app = SyncApp(db_session=session, garmin_client=garmin_client) - sync_app.sync_health_metrics(days_back=days_back, job_id=job_id) - except Exception as e: - logger.error(f"Background task failed: {e}") - job_manager.update_job(job_id, status="failed", message=str(e)) +from ..services.garth_helper import load_and_verify_garth_session +from ..tasks.definitions import ( + run_activity_sync_task, + run_metrics_sync_task, + run_health_scan_job, + run_fitbit_sync_job, + run_garmin_upload_job, + run_health_sync_job +) @router.post("/sync/activities", response_model=SyncResponse) def sync_activities(request: SyncActivityRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): # Verify auth first before starting task try: - _load_and_verify_garth_session(db) + load_and_verify_garth_session(db) except Exception as e: raise HTTPException(status_code=401, detail=f"Garmin auth failed: {str(e)}") job_id = job_manager.create_job("Activity Sync") - background_tasks.add_task(run_activity_sync_task, job_id, request.days_back) + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_activity_sync_task, job_id, request.days_back, db_manager.get_db_session) return SyncResponse( status="started", @@ -123,12 +95,13 @@ def sync_activities(request: SyncActivityRequest, background_tasks: BackgroundTa @router.post("/sync/metrics", response_model=SyncResponse) def sync_metrics(request: SyncMetricsRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): try: - _load_and_verify_garth_session(db) + load_and_verify_garth_session(db) except Exception as e: raise HTTPException(status_code=401, detail=f"Garmin auth failed: {str(e)}") job_id = job_manager.create_job("Health Metrics Sync") - background_tasks.add_task(run_metrics_sync_task, job_id, request.days_back) + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_metrics_sync_task, job_id, request.days_back, db_manager.get_db_session) return SyncResponse( status="started", @@ -136,6 +109,22 @@ def sync_metrics(request: SyncMetricsRequest, background_tasks: BackgroundTasks, job_id=job_id ) +@router.post("/metrics/sync/scan", response_model=SyncResponse) +async def scan_health_trigger( + background_tasks: BackgroundTasks, + days_back: int = Query(30, description="Number of days to scan back") +): + """Trigger background scan of health gaps""" + job_id = job_manager.create_job("scan_health_metrics") + + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_health_scan_job, job_id, days_back, db_manager.get_db_session) + return SyncResponse( + status="started", + message="Health metrics scan started in background", + job_id=job_id + ) + @router.post("/sync/fitbit/weight", response_model=SyncResponse) def sync_fitbit_weight(request: WeightSyncRequest, db: Session = Depends(get_db)): # Keep functionality for now, ideally also background @@ -161,13 +150,37 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session): raise HTTPException(status_code=400, detail="Fitbit credentials missing.") # 2. Init Client + # Define callback to save new token + def refresh_cb(token_dict): + logger.info("Fitbit token refreshed via callback") + try: + # Re-query to avoid stale object errors if session closed? + # We have 'db' session from argument. + # We can use it. + # Convert token_dict to model fields + # The token_dict from fitbit library usually has access_token, refresh_token, expires_in/at + + # token is the APIToken object from line 197. Use it if attached, or query. + # It's better to query by ID or token_type again to be safe? + # Or just use the 'token' variable if it's still attached to session. + token.access_token = token_dict.get('access_token') + token.refresh_token = token_dict.get('refresh_token') + token.expires_at = datetime.fromtimestamp(token_dict.get('expires_at')) if token_dict.get('expires_at') else None + # scopes? + + db.commit() + logger.info("New Fitbit token saved to DB") + except Exception as e: + logger.error(f"Failed to save refreshed token: {e}") + try: fitbit_client = FitbitClient( config_entry.fitbit_client_id, config_entry.fitbit_client_secret, access_token=token.access_token, refresh_token=token.refresh_token, - redirect_uri=config_entry.fitbit_redirect_uri + redirect_uri=config_entry.fitbit_redirect_uri, + refresh_cb=refresh_cb ) except Exception as e: logger.error(f"Failed to initialize Fitbit client: {e}") @@ -245,6 +258,7 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session): # Structure: {'bmi': 23.5, 'date': '2023-01-01', 'logId': 12345, 'time': '23:59:59', 'weight': 70.5, 'source': 'API'} fitbit_id = str(log.get('logId')) weight_val = log.get('weight') + bmi_val = log.get('bmi') date_str = log.get('date') time_str = log.get('time') @@ -252,11 +266,15 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session): dt_str = f"{date_str} {time_str}" timestamp = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S') + # Check exist # Check exist existing = db.query(WeightRecord).filter_by(fitbit_id=fitbit_id).first() if existing: - if abs(existing.weight - weight_val) > 0.01: # Check for update + # Check for update (weight changed or BMI missing) + if abs(existing.weight - weight_val) > 0.01 or existing.bmi is None: existing.weight = weight_val + existing.bmi = bmi_val + existing.unit = 'kg' # Force unit update too existing.date = timestamp existing.timestamp = timestamp existing.sync_status = 'unsynced' # Mark for Garmin sync if we implement that direction @@ -265,6 +283,7 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session): new_record = WeightRecord( fitbit_id=fitbit_id, weight=weight_val, + bmi=bmi_val, unit='kg', date=timestamp, timestamp=timestamp, @@ -291,11 +310,7 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session): job_id=f"fitbit-weight-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}" ) -class WeightComparisonResponse(BaseModel): - fitbit_total: int - garmin_total: int - missing_in_garmin: int - message: str + @router.post("/sync/compare-weight", response_model=WeightComparisonResponse) def compare_weight_records(db: Session = Depends(get_db)): @@ -318,15 +333,24 @@ def compare_weight_records(db: Session = Depends(get_db)): garmin_date_set = {d[0].date() for d in garmin_dates if d[0]} # 3. Compare - missing_dates = fitbit_date_set - garmin_date_set + missing_dates_set = fitbit_date_set - garmin_date_set + missing_dates_list = sorted([d.isoformat() for d in missing_dates_set], reverse=True) return WeightComparisonResponse( fitbit_total=len(fitbit_date_set), garmin_total=len(garmin_date_set), - missing_in_garmin=len(missing_dates), - message=f"Comparison Complete. Fitbit has {len(fitbit_date_set)} unique days, Garmin has {len(garmin_date_set)}. {len(missing_dates)} days from Fitbit are missing in Garmin." + missing_in_garmin=len(missing_dates_set), + missing_dates=missing_dates_list, + message=f"Comparison Complete. Fitbit has {len(fitbit_date_set)} unique days, Garmin has {len(garmin_date_set)}. {len(missing_dates_set)} days from Fitbit are missing in Garmin." ) + limit = request.limit + job_id = job_manager.create_job("garmin_weight_upload") + + db_manager = PostgreSQLManager(config.DATABASE_URL) + background_tasks.add_task(run_garmin_upload_job, job_id, limit, db_manager.get_db_session) + return {"job_id": job_id, "status": "started"} + @router.get("/jobs/active", response_model=List[JobStatusResponse]) def get_active_jobs(): return job_manager.get_active_jobs() @@ -336,3 +360,11 @@ def stop_job(job_id: str): if job_manager.request_cancel(job_id): return {"status": "cancelled", "message": f"Cancellation requested for job {job_id}"} raise HTTPException(status_code=404, detail="Job not found") + +@router.get("/jobs/{job_id}", response_model=JobStatusResponse) +def get_job_status(job_id: str): + """Get status of a specific job.""" + job = job_manager.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job diff --git a/FitnessSync/backend/src/models/__init__.py b/FitnessSync/backend/src/models/__init__.py index 903e51a..722be08 100644 --- a/FitnessSync/backend/src/models/__init__.py +++ b/FitnessSync/backend/src/models/__init__.py @@ -6,5 +6,10 @@ from .api_token import APIToken from .auth_status import AuthStatus from .weight_record import WeightRecord from .activity import Activity +from .job import Job from .health_metric import HealthMetric -from .sync_log import SyncLog \ No newline at end of file +from .sync_log import SyncLog +from .activity_state import GarminActivityState +from .health_state import HealthSyncState +from .scheduled_job import ScheduledJob +from .bike_setup import BikeSetup \ No newline at end of file diff --git a/FitnessSync/backend/src/models/__pycache__/__init__.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/__init__.cpython-311.pyc index f97f5eae7fe0097f934f9e07fb9d00f8e638e41f..ee1a4dc8267a6684e33e7018bb72e4f24c002f3e 100644 GIT binary patch delta 480 zcmaFGvXq@~IWI340}veF9+%0^G?7n&anVHeG)IOM&K!YU!6-pSh7^Vrt{kCU;V5A+ zn>$A&S2Ri#%;w1v%N36j2eWx|ByuIAB!O(cUN^U^ZYi&z*YOEG%XGiT)|6>)+j zcr(Dxh|dMPqX^{dTO7qeE8=tV(~G!4T)sr8L*k1;E-B&x3GzVoL4_6As}*r0R>IAW>N{h!JvNu b72ROaynu>sFsNTZMK>4}FJMDOazN_<+^LWo delta 115 zcmZ3={)&ZfIWI340}zxH_-6VsPUMqdOq-~l#>yGYpvg7yT=--iMhRv=O@YaMjB)b3 z8L5dmB^mL#sU=03$wiz%ZMQgzEAx`$bMn)RxF#ntS#gO01sQ?3c<$t7Ocj$2n3cFM MFvtQ?kqA%}0Nk`4=>Px# diff --git a/FitnessSync/backend/src/models/__pycache__/__init__.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/__init__.cpython-313.pyc index f9c5d8e136ef3a8c749c1ab0cbd1512d5b3acc5b..4fb45ed244db6f423fc4096377b6e245bee742ac 100644 GIT binary patch delta 399 zcmXw!Pfo%>6vn5PfGuqi0VyUDT#;_{3hGAEs+%r!Wz&|ii~=REg9$6*&ZP|(aO(*? zf_G?A58##y_@=-lGx@#m{h9BjrMI%N)^(NO`T2fimA%w3rO&iaFI?g(&VgNrdXFHh z&Y|6aM!{95X}6$Nu;v`uZD<#)JEq-%PLD)}RZBnam$DrPmXv7&>W}&8mU3Vr#uaxl z4R7KIv2pFggiS9)&R!W`xZLLy<=Uo0)a@kA4M%i1AJgGxsB*<#s7v{Lc4r-DrNB=p z$hbyU4&&()i=aeSrZiZ}&v5{XNa&-&hBqmEfLJ=)Pw)$G97j+Uq2X_hc!_WVnh>>Z z-$v4lQOVAXC&n8>Qv)>QCBhNZQ1~Kid{^(%gW5xo@Fr)gKdEarbJEYrNlwmlVj=&M IQ|My)AIybyPXGV_ delta 128 zcmZoX(+R*!gMF#&IIavg{_YI}~l=*iK?6X@Ix^${_?To)y+4r5q_c z&cvaEhYs4JQIqQ;9y+E#h7`!qkpwshLE|d#{Y{we2<;Q^R3M&HA|?7{O7U?%#n1Vb02fe#Tu=#dAtlU(l?WG6 zXpZ&~J--}P7>;r2fE-ieTpZ}27?Kl8l1ut1KXsE5!#`1C1SPiu6!ijLZ*VD*en91; zZwZ^Ho$!sC+)z{}a$D6=6=5g*K*y3=b?8|^M@y1|oZu3Asynee0Au$3zQ{mdYni@Cwl6Z+7sOx;&4Nio-~j{s|{HemSvRp z;Xz2*iPVuQLVJRa5)sjg(2#W?X&w61vziKOPEtd74PpKeT2mW!5xUv44S zOd7f~P!+Husr-JP8$9?)d|=4DL#)10N0>B1htYHa>pV%sp~VJv8!3h>g926pP^|z^ z5#$<%rE|iBPTAXhd9?^y|xZFhU?HH8MTHL-lGFLc!;dkzU09$rb#C8 z6$#d~QiH2T)t#uzktJ|aVr^Z8D@EiXQ@x=%iLL;NyrAPanH^AT>kd=F2uew$4&#;8 z)gafblQ^g^EIW=78FL`}m3jvf>izk$P_LJlg~}>yrm}`BWvDiiwQ{#i{Q9P)l0S)rqVgkL~YSb6|Gd!P8GYF$7G4j=ELSq&`T7pMA1$ZjrmS5SvuDYHydr; zOpjUVF*`kG-0t*}4W4Q~Y|TM-gOk?aq&+xk-0Sp`4Gc9$TPGm9feCA1!XB6~7CXIU z@qx|x=5%|>j2EnU!HyS@Rrfg-(cBX9H@#0(G z&YJ0QD?M(f$Gb5flezXHFqbV`Y}sbZV2?>}jy4C|S(6#Hm{FSG#5`g2~QV?3~Tc84J7aew%JDv=_|sx7Nrtd*s@mSIq2vD|_G0-X|{4G*7oy zEw*5@1!IBy@Jx5h-^-_=Aw7`=x$+n(8wYL9ZE=F#BX{0jz~}RIs7L0fJ{{_!dGzT} gXU(HehZ;7IK6}B7zTzJB@n=*2he-eJ0lCTi2Y)e3-~a#s delta 462 zcmZpaSj)?|oR^o20SLP1`egR8OyrYbjGCyf?99lJ!jQt0!=B3##Q|h9r?BL3=5j@G zF)}bQxHF`%wlJiyrE)D}W?)zi#1Ih0ox&c>pvmzP#MNY*xX^6kFQtjHoJ^}2C#y4V zo&1eaYH|sq2)6(eLn=!udkXs+j>-B=qKw>=1(}2yg(mB=$TACOiA?roQD+oI;)zdQ z!y>>gmcj!xoOd!it1D2gFRLT7pC(X-b;@Keb~{GS$)4;All$0p1$cp$7l8;?ATBmy zoP3(SfAS5^g@O%U4PHfBFj+rMp2@qov?eoi3)XW21#hwC=BK3Q6c=&BR1|4~SYQp> zK-Mn~8=#8PoK(9aeIS<+h>NEKi4V+-jEpxJyf2V~ZZODSfTPJ9xg{O>7~L2@FkmM; ON diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc index f57e03257627a7fa7762ac8ad6cad3c0bae55143..461488d6ee101f9ddadadd5fb776197dacd78796 100644 GIT binary patch delta 1357 zcmZvb&2QUe9LFEWd2w9Fd2iEhVOd{RC0W}ItxT2B#u!b9Gs=XSxI~S-VDS9VtWxKDagi1yTLF};g%&|AJf@V7(k@LZGd{}tXx2w_E=@dh zKAQKaHy*g)Ydq(p^S;KskKXXn1z%smM;BdM9M4;Fi4ixww_hG3BS!IBthmIDOD}nZ z7oQf{EvmNq(LM7!>SDV1+IW7O!ww}k7p|3YS8S6a`vXXSWQ#_ZYil^lPoijDjH8WK zqL_4;TMb?A%KGt(u*n2&P_yPv;8x)?{(<}m)-|Xfb+mQ4=|tbfEE4=v(>-v)l@ zMX|rbG-XYf+70M14M|av3^gY#bzAj)W@L;5BV(sbC-1c#L%QXj~Tx(*#qYEO|nv7We!C2Ee z9ueuPZ`N_%yh`7sIP;J$tRV8yII&l+UzeL`hQ!!cF>^rYsJ!`vzMn|o6e71UzO5X^rcTPIN1A*?8Zc z*4cBz?a_Ny_NuXKPwP}}I5)axrLGuN+ts}>aH;(&NJ*&{wZjs zH$9&&_D`%~AM^9zZ9L~$W?^{!d*SKE&C}&~e!XU8?inB0$^7uqX|nW%fVL+qw_8k@ zPlKf%A_vM_e_lIOB&D$r502OIo_QNE9mILW;c|S&)FxCk#82E%o+#J6LJF9-amA4W wZ=fnh@>yt0`1Th~QPdw`?{~0w3ii&x>e(!~a0cf84X#q>=Y=A0Cb5iY!jDTE5ATE{%5+9fu85!?0cwcAmzRMtgmqGFi3m>B!V@JtX J1`rK4003Z7X#oHL diff --git a/FitnessSync/backend/src/models/__pycache__/activity_state.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/activity_state.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ec0ddfa3e15cfd2d7670c491eeb4431e1d9f8cc GIT binary patch literal 1243 zcma)4y>HV%6hD8&c9NzfAx#Ji9|EKZDQP6cgen0oRg2oC5kgkg!;P=S)OOOd(L5MqzutbNKD;|%G8NFJEcklh~KE+}YKqc3H zWfXY;ellkyp`mfMNR54<1D!L0#i^XdtGp$sf+eb=6;eYS(0N@jB}-Oi4)93PMhSvHXuY!_`JOr#YT8}=qqW;GX;3=0u)qh^;+ zXlEeD5%HPkpmI+Lt(59X@nh$9a{Q;mG?!J zuS66d3DauhIgw#b=ujG~7G(%sEKBZi^d!0hq0xzVv60uI#=*#;1YV6DDr2)u>S0Q0 zgj}E3uw~d!%dW9wxQ!K;GDxO2{UFqaQRuL6DaVs&6&sd@8*mFXNXW2tRHpu5u(nJ&XzdLMfg8`11Z2jLxr;SeQC#jaI#T6#HmjVKV>nuQ=F5eTh{ zUNf0J0pWH{GlxhFLYJKrxR|{V%$^%bj(2S6BzDn0+y0m9HmV4-CqN=}FziCsB8fT; zyX;R|bFj=%!LIC*u&FsNbP%#XgEJZh=edGbtrpg`@)ol7f`iKi+AT7j!o_($UHNJQ zN9lfFiPUhuQq{cp2C0Y5r61W_?es_K%j|d2%g*((bN%d`C#DCfo2?h^$K6>kRqUmT z{Zz5JbZIP+X{Fmr_mP*F?j@%CiRtFzAbF$ppq=hcdC5XAS?DJV&6k6Cx+S&ht$H`* zW%9jDzMsh-)sNic`nSE~J+HLtm0tHttG&`%zqIDXVJ{B*aoAj7AHFjhVmTM4jBT5m zS$>PGMnFHC%)bMl(SSH9AOwd;nfNxd{6crBb*?-KE_eptr!ITl&U>m(1&-qeV8;8u de*m)HzyGPIaBsL%aJg>Z`{g6QufY8V{{WznPyGM@ literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/models/__pycache__/activity_state.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/activity_state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd6d4ae837585f3bf048f764c77a7b820fb0aa57 GIT binary patch literal 1082 zcmah{&ubGw6rRm)b~j0*ZS4dbZf_Mmy+s!mxyP33aX4|Hx zg5ceQkfXgOTkT`!mXTiZH^)JSrPtlrxYin_!kLu*zA;of0Jm{JHazfdkgd9_n>g)V1f zN1>jSQ*un~P8rqWofCg1k=kQuvg`aGk8>)Qqc{(}^+Ksf9=qW~BV<XZlFZ_p1%_Wvs)tjXe`BE9_&C|WBNbW>$sX$3)a*0R7`EJ(!1 z930J>thp|8kwOzuO^bWy`^S@dYlW&gm)X74{0E{ zcRdsp@Pp+G+G3P*tZwoal!vmK$06{Pej0J_FyW*H=T=%!P+^lrw~B{qd5k^}oZY(l zZRF%L`^D@#t21(K#~Rul7;RtLp6v`wY~B3J?i+5q+ZQ{1=eNqc{m0uw+k>5cZ)<+H zcj(E+_CRO&+$-^REhJf`*!0Kk^@@VHjW0^hY%P1&#c)T;qY! L9{+{(KRNvy^Mw6;L*c1SrW6FICi`>OcuCpfVID54BA0fE3#aX9t>29y%a( z$p#}6rGlYbsl-4f)TN6fWw35S>cmDxC#K%T{D?nQ>hs;x_wKv<^zOa$r>-sqQ2X)q zO=X4$;1>%nEPf77UsJFSAb^|&Y)I>NX?a%P8k+{4KR4W9UrL9Ktp$3Up9EjeH)5=Ru-LlL% zi?u}ZXlveA9frGIgjWzYAeuMpmdDttQ?EhV!0DVyB*Sn_8yN;s48yL%y2WtHFqZ44 zbnzLmX8M|9b9K8n z$Y`yM*3M}DM40YfTWKzA$AfgCl`gc?1^;E3RM(2lkcrLQ-l#VmXU9O;hSLk7FfOw_HpeumkWLoiV9oUUM~3#ILFapG>vfK z2*SmNbI!_ZLAhP3JKLJnZCSjW#A%1g(lsV%n}RH20TF9U=Sm>xO4OlsL)xn;3%3oO zp~ynOX_(i_ZE-|)-Yf7FH4qpWfr$;UunBgfFpU~T)s|{(-hxub9bCZ0c@xT`XwKX} zSY@=+%63*WHl_|_;nKX(*mwKr?@i=*8l4+Cv7@R_{AEsA<ZYu0K-3AKOV(*u79!T750{ic4ol-WXvSJOM<@j!B~0UeoWgwy zq(xVQG!{CVuymuPbp8k3tRIjLAuQ-nLZnMbCuyaz##KV@r9nK3j1#gN0T*$^C`(j$ zj1Zwemd8O48EoCH8MPp5yRZtXsw{_VLB#Ue0u6wKNn6@F4fFt^q?0U`Jdkdv;)1qF zAf%f&nBgfxxClfPs(|`QnyH|i5SUbnJL*C5y0?~esK>dWOnA2-xl2RAy`@O3KoG8< z(TGvbahHW&Q+X(Bc^pDd9hS!2`=#&PMi(loWM=d!I9KKg`Z#%_cjHTWykFW}`FCVMZ!Qk1r+bUTiG%$ITT_DxuXk-YKGkn;6$ayTy{p4Yt$%9s?4UB+yYi)W z=$W%MGdS$Mvfnwcoj2~Fw%qfFd+rxAFK7Ro{r~i!w)ohGle=Bqth>_X_u?Q9*J!8v zOX*DIOhYx|z4)Ahxu$kgT^K0u*2(nB%g!jDbS~zjz67(1>PyCtDf7)T4C6Dp@Bv-; WgpPee2fy2{v1Ih^A4tEm0zUy+#U4cf literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/models/__pycache__/health_state.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/health_state.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..232f348b00d2a67e3e01b5a369a5433faeb843a0 GIT binary patch literal 1382 zcmb7Dzi-<{6h8hCe~6|fD~@az=+;C~4OpH$6)lhiPJ^OGT`NXG1UWFev#o|a$wxAg zJSjj1kJ{?dlbXUBGNwTOf=*VM? z>6j9b!CipV=KvM3IZ^<);43$$h_pRW*WZY=t~2d+$BTTz^m`=4hnO;LFQjd9$c#H~ zh?{L6GgU-PJ%~t)mG6`GafEMoNDxxDO~N-^IjBoaz2yeDm474m+AO*JND@cDz>yGe zWF$F?46up|T(CS=uE^6ePDkpvFyf^zMET4dBV)|>FvSlt){N1{`ju)6g5k^N8jKNF zc)?e099{grG|xsnxR@uru^;BfS6f}?yO&TAS!e;-!?_rt zLrj{uKHjNcV+MpGgpd^>^gAf>M7#juapZb4#)dEy+VYf&Ye+>4nA}EG7ukw%;=0pS z;nuig*1Lm80TuU%8Uj{G=P5HiHwa;XF`=da7iV99E@=&6mruxs{k(skt?{ zHL;dXw}zjbUyrR?V%1WsHn=mXtPXFURbSkQE1OAWGp%e6zM9zAPWOk~=l5fKJ+aqQ zdwsAy-*-K(G?Gdqtu*+)a&@Q;d&Ay~<#=TyS=mTeHeU5!g|B-*pS(Van-AjVcWLuM z(tMaUAI4>vlwn$igL_jAm}{}IdR9IE_@xnl@l9;(CdO`R>`oQAsttC;LvPLxxl^}T zF*x>IuXTj|{@(-1N09Y88;fXo-e4_n9JS^(=nWBNC2Kr-YkZau|8x2&PsQITc*fmS jktAsX_T&HmPQY6H?{}&$NZJ(q*A3Ah0{!znR3`B+_rh=h literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/models/__pycache__/health_state.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/health_state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fb0a2a2acc93a9c1a40557690244d73092f8a05 GIT binary patch literal 1199 zcmah{O>Epm6dwPqf7aP-vQ0u#sZtbys->bvh$2NoAhlIQ9T62~8*x}Ma=ep`sdt>b z8PjY}DljoLYnl=BE-gpExU8DK;vn2`lmWP=?wpb0kjV8%pnXENCwsQE6*W z+Peiylq~!R|NK zP~X9pdhB%z+!A}Zft_^|{1P>56#NIx)X2r2`m$A`S=FY6{dJ??{&=-M_oZzO-Kf$3 z#c@;BGT-m0d;y-pEj)|c>-O6QT*7m(Fr4ppWcvdeXJQYJS%QU9=FC>P<@{dB60T^% zHQ(!+GT4W76vLR@r(@Yj*?=ByD6u?NWLdnEQE4&yKoRbM?#4wXbWAACALfjz-)A|6 z&nb{PD!rT)`-5^Cr42?@4J#pHyicXW^8yko+rJ4DDx%yW#Q`N!{7b(k$cj6vM}}EncO@L=8s-IS)2w}C$~>K%g0NPUz>K`oNS)9 zE*)*0+?%$pO*SfiaoPzLKezPN;p2trE7!jf-yeQ^_}#;&y-%L?`akvhKleVJ&XLKT zKON++950<*dEz~N|L)ZL=(%MsIbUo+_oAe&ZXlcdAd9nPkB-Lw>R%~dN?T4r>s#sR zRBb|Vk)xEZO8L6tr1x$${P*Au1r7CbE$(y6FpS^O_A|6SMccokl^6Dm;T#SALTW32 F{~OJ#M+5)> literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/models/__pycache__/job.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/job.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc2d865c583e77c501d8911b74a862d26e7d5a25 GIT binary patch literal 1713 zcmbVMzi-<{6h2ZENztYxCW_=1XeSpiZCC<=XsZpw^^e+a-N8%APWw{O#wP(bCFJ&az|Q0t2iBUvkQ)5C>-FyDuBXI0OpZ$O7ox7nGItMid#UJUNKjfiTIUghmK36H8((e2orHD zz-@Pr$V*m$w%ZOO!Zvyw5NXl#Y-G7aSl{|?lL)(^+j_;ZBpeYIEg!YA0@?bmhd*h^ z6M%rhL16HZGjcpY0?Jdi{6%hrn|O^07x`13vZgZ|hBQQ=cp;7aOJ*3I1)5PlrS;Ak zt#rWmGb#TQ&A(T7EUh~Z3m3`=BQ-^ewB{(+Qi_ysK(rLOMOlqzyZu3=!Vo%d=#DB6Y_-%!za-(7>sr`>N~7D~s}p4eY{Dnx~5TxceR zW_NK=rq6nZXZfgHkIVI>T<@+8)Z51odMo|=PuC)KK33-wb-ueY&}zNviF_iTO-2)q zc%qR^G%ml0w1rq(NVJ9S`k-{@_^`L%+dmVc>TFz{O{%k(LR6ZIOLIwSuKRTsu-89` zN_t$uPtj;9rOn2qw4K$z2&xIHAAIb|Q(l%mkBhfY}-5UZgr!FU-P5nOo zY&z1GV{JLnmb>fh!qda;-PFgaCJt=NZtWxIC{q#J^HfTyW>Pt%2cJqIJ7ZyoeiiZf zFb&q3m1dox)*JcDOtka3PF?oN@_(kwRgUAh0oaN@_8x$0^xykRC~+&?6}Wjlp8PvS K{`ml&Qhp09D!BLn literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/models/__pycache__/job.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/job.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06a2e160899a859e6a63a0c73c5743a509fc3823 GIT binary patch literal 1358 zcmb7EO>E;t6duQp?YK?S(%&sYR8?1MK--8FJye1fD%~t~MOiW3!%C4xjx$ZY>x|Q9 zd)ab|goHSj!%A?=vD{YTQUMYO4qUM~Ku1Do#i=)>!ksrxQhshsl$rOfr9dMS(B3U8SSR%C8bT*J*4VdVdHS0o|HCvi*Mq%0&(%KOT?{JVNO&|~*X>WT8 z-w(Q68vFclBCV|`3b`kw;nH{Zq;Zf6|3XtFnl!e&nEQ%xJj{`Cxr`xCNRxm@2^6RX zIxT?FEUu8INp<`gn>tt{T%_h^0qiSeQ5(Ib96MNvPD`5_9Y#(3XIIa2C-dDhb?5{# zRdQ6tJjFYeqv;WPE=M!SrXpwmh30Z}^~C1a2Us#bs* zm1(v4ftQAf3QAD8_aniffS#HLbNM1GF^5MS*gqm8k@ngs^=D+S_Pn$i6JD1yCLPAQ zQ9BKlJ;~Tn>V+dum9c{W;v@_N7ZJM4j3w$sLqDiK1J%5;5VU0}>T&RrAQIAy6E8_) zP|YD71duRwP-%0~&MnBV!0tiR0UpO-sYt7friZ;*QpiLH774ZjQ@$sB9x~uZDUTE0 zmS)dOW8Mb!T1j0*?@7l8j`g*fmq=$=Yi`dmrp^=i=)jh5r3sH2gKL<0iGLz**AJsE zugAzMlKNeU9&kU2>y04U2hV?aQ&|F!MvsMGZ{ZGBQIEl|MP7EC&{B6zTEq_#VCw-+RaVz+ZDY z6~XxZ@el2V1VVrDWIB?U!8;v-1B4M4EMyD1V2iqFC-j6Z>5`q)lXgl^2^<$K*;aG~ z;slngw5{r@fJC&8aPnt_Q$#(28pp6EL(gD&3u($ZPqh?FeciEq+hOu!$0NIhvecGG z+s-aiR!omt}J9z2V+VDJa*%axE&?PNkr*92Swz4W9?ISd+ur*|wzF)>B?B6L-<$8fc{A@x zXO)UcaC~>?6Mr*L$e)rdMqv)l?jd+W9O5Vk#8yDDbC9$1khfJ(?E(~R4YX@=J$Inn z1{jF*j(SkEO)#&KRkB8$!fV9Q+U6GSAqSLtdebCTt(Pxa+gI*wERsKeuaT`eaY`6}=@os-=(FrpV5L`W)YjKkD#SGbc&|@cS+d=>s+t1wZuI<2}4$BTRz8?F8)fHaa$Qa9R569y@fC z01E;QX+~X)<@ziN8R9Bu@Q49@AreLuCIb(P`YpNJToneTq1$Ja3X{@)By&Stru1Rr z2J@8~rH4N7IPgOjMz~y|G?t0X)5BeekA4A>-T` zhFzB!dADk3xV|~(k)<}zQjdrHB zrSah;NozMo?@vqh@f(w^wA38!PFG(4s`spL4C%_wZ)#&&xia3J+)67OqrGXhG5&bs zrPb@B{gc|#mq$<4bos`yn%3UN82>k%)K|VXp01~>)=%oM#?QttW?DZO+0z&1hJNWHGX%R8I z?b!y`(I$-Bv%Y!Gu$;8MHDrFMl& zt-*P+3CnIqjmaNaBBa!T78YrM2u&dIi^C>2KczG$)vic)auREzBQK*H;|B)pLsVu2HDJ*N)mN79ftOjBT zh~kCFOs-*+^k+|DU&DbW&kvSoPhrB)DFBiIVjiFz=Q2j1sXz-Y<_ivau(1F!1VDuV diff --git a/FitnessSync/backend/src/models/__pycache__/weight_record.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/weight_record.cpython-313.pyc index 671b8a3a94c509f660d9c5ca052e3c3afb7c5eab..ab74471c0483847464c97ae57ae6bf45174ac881 100644 GIT binary patch delta 279 zcmey!If0A!GcPX}0}$jpMPy!>$a|8BiE-i$_j;~i=3sV9rXrpgRwafQj$l?Wj~C42 zgYx)McmhzB9KoDGeS%OP#1tWjN+kxMsa%$fF~UI_AUjck5`!sJAedVWXoM)Hrpe0~ z6{}9~61O;Pa`RJ4b5iY!^d~=Hkr(7) QbYq-g@|6KZ7lD)k02($kDF6Tf diff --git a/FitnessSync/backend/src/models/activity.py b/FitnessSync/backend/src/models/activity.py index 3bd260a..7fcf8fb 100644 --- a/FitnessSync/backend/src/models/activity.py +++ b/FitnessSync/backend/src/models/activity.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary +from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary, Float, ForeignKey +from sqlalchemy.orm import relationship from sqlalchemy.sql import func from ..models import Base @@ -11,9 +12,34 @@ class Activity(Base): activity_type = Column(String, nullable=True) # Type of activity (e.g., 'running', 'cycling') start_time = Column(DateTime, nullable=True) # Start time of the activity duration = Column(Integer, nullable=True) # Duration in seconds + duration = Column(Integer, nullable=True) # Duration in seconds + + # Extended Metrics + distance = Column(Float, nullable=True) # meters + calories = Column(Float, nullable=True) # kcal + avg_hr = Column(Integer, nullable=True) # bpm + max_hr = Column(Integer, nullable=True) # bpm + avg_speed = Column(Float, nullable=True) # m/s + max_speed = Column(Float, nullable=True) # m/s + elevation_gain = Column(Float, nullable=True) # meters + elevation_loss = Column(Float, nullable=True) # meters + avg_cadence = Column(Integer, nullable=True) # rpm/spm + max_cadence = Column(Integer, nullable=True) # rpm/spm + steps = Column(Integer, nullable=True) + aerobic_te = Column(Float, nullable=True) # 0-5 + anaerobic_te = Column(Float, nullable=True) # 0-5 + avg_power = Column(Integer, nullable=True) # watts + max_power = Column(Integer, nullable=True) # watts + norm_power = Column(Integer, nullable=True) # watts + tss = Column(Float, nullable=True) # Training Stress Score + vo2_max = Column(Float, nullable=True) # ml/kg/min + file_content = Column(LargeBinary, nullable=True) # Activity file content stored in database (base64 encoded) file_type = Column(String, nullable=True) # File type (.fit, .gpx, .tcx, etc.) download_status = Column(String, default='pending') # 'pending', 'downloaded', 'failed' downloaded_at = Column(DateTime, nullable=True) # When downloaded created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + bike_setup_id = Column(Integer, ForeignKey("bike_setups.id"), nullable=True) + bike_setup = relationship("BikeSetup") \ No newline at end of file diff --git a/FitnessSync/backend/src/models/activity_state.py b/FitnessSync/backend/src/models/activity_state.py new file mode 100644 index 0000000..c40d2c2 --- /dev/null +++ b/FitnessSync/backend/src/models/activity_state.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, DateTime, func +from ..models import Base + +class GarminActivityState(Base): + __tablename__ = "garmin_activity_state" + + garmin_activity_id = Column(String, primary_key=True, index=True) + activity_name = Column(String, nullable=True) + activity_type = Column(String, nullable=True) + start_time = Column(DateTime, nullable=True) + sync_status = Column(String, default='new') # 'new', 'updated', 'synced' + last_seen = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/FitnessSync/backend/src/models/bike_setup.py b/FitnessSync/backend/src/models/bike_setup.py new file mode 100644 index 0000000..f426ca6 --- /dev/null +++ b/FitnessSync/backend/src/models/bike_setup.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from .base import Base + +class BikeSetup(Base): + __tablename__ = "bike_setups" + + id = Column(Integer, primary_key=True, index=True) + frame = Column(String, nullable=False) + chainring = Column(Integer, nullable=False) + rear_cog = Column(Integer, nullable=False) + name = Column(String, nullable=True) # Optional, can be derived or user-set + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/FitnessSync/backend/src/models/health_state.py b/FitnessSync/backend/src/models/health_state.py new file mode 100644 index 0000000..5d08735 --- /dev/null +++ b/FitnessSync/backend/src/models/health_state.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, DateTime, Date, func, UniqueConstraint +from ..models import Base + +class HealthSyncState(Base): + __tablename__ = "health_sync_state" + + id = Column(Integer, primary_key=True, index=True) + date = Column(Date, nullable=False) + metric_type = Column(String, nullable=False) # 'steps', 'weight', 'sleep', etc. + source = Column(String, nullable=False) #'garmin', 'fitbit' + sync_status = Column(String, default='new') # 'new', 'updated', 'synced' + last_seen = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint('date', 'metric_type', 'source', name='uq_health_state'), + ) diff --git a/FitnessSync/backend/src/models/job.py b/FitnessSync/backend/src/models/job.py new file mode 100644 index 0000000..4a9935d --- /dev/null +++ b/FitnessSync/backend/src/models/job.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, JSON, func +from .base import Base + +class Job(Base): + __tablename__ = 'jobs' + + id = Column(String, primary_key=True, index=True) + operation = Column(String, nullable=False) + status = Column(String, nullable=False, default='running') + start_time = Column(DateTime(timezone=True), nullable=False) + end_time = Column(DateTime(timezone=True), nullable=True) + progress = Column(Integer, default=0) + message = Column(Text, nullable=True) + result = Column(JSON, nullable=True) + cancel_requested = Column(Boolean, default=False) + paused = Column(Boolean, default=False) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/FitnessSync/backend/src/models/scheduled_job.py b/FitnessSync/backend/src/models/scheduled_job.py new file mode 100644 index 0000000..f33f090 --- /dev/null +++ b/FitnessSync/backend/src/models/scheduled_job.py @@ -0,0 +1,20 @@ + +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text +from sqlalchemy.sql import func +from .base import Base + +class ScheduledJob(Base): + __tablename__ = 'scheduled_jobs' + + id = Column(Integer, primary_key=True, index=True) + job_type = Column(String, nullable=False) # e.g. 'fitbit_weight_sync' + name = Column(String, nullable=False) + interval_minutes = Column(Integer, nullable=False, default=60) + params = Column(Text, nullable=True) # JSON string + enabled = Column(Boolean, default=True) + + last_run = Column(DateTime(timezone=True), nullable=True) + next_run = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/FitnessSync/backend/src/models/weight_record.py b/FitnessSync/backend/src/models/weight_record.py index 9f43d79..2b8a0b2 100644 --- a/FitnessSync/backend/src/models/weight_record.py +++ b/FitnessSync/backend/src/models/weight_record.py @@ -8,6 +8,7 @@ class WeightRecord(Base): id = Column(Integer, primary_key=True, index=True) fitbit_id = Column(String, unique=True, nullable=False) # Original Fitbit ID to prevent duplicates weight = Column(Float, nullable=False) # Weight value + bmi = Column(Float, nullable=True) # BMI value unit = Column(String, nullable=False) # Unit (e.g., 'kg', 'lbs') date = Column(DateTime, nullable=False) # Date of measurement timestamp = Column(DateTime, nullable=False) # Exact timestamp diff --git a/FitnessSync/backend/src/routers/__init__.py b/FitnessSync/backend/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/FitnessSync/backend/src/routers/__pycache__/__init__.cpython-311.pyc b/FitnessSync/backend/src/routers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6dc326ca33f9a00757a673290ecaf61b50e2605d GIT binary patch literal 145 zcmZ3^%ge<81d{XOGC}lX5CH>>P{wCAAY(d13PUi1CZpd)5K;i>4 PBO~Jn1{hJq3={(Z7BL?G literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/routers/__pycache__/__init__.cpython-313.pyc b/FitnessSync/backend/src/routers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46024447bdcb0548d81ca2e29f3e1951aa68b3fc GIT binary patch literal 172 zcmey&%ge<81d{XOGC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iienx(7s(x{C zNorn+en3%vR%&udvA$boNk~y*a<&nOm6uvv99)@~te*rFNzF^qFD^>fFUl`1Ni8bY skB`sH%PfhH*DI*J#bJ}1pHiBWYFESxG#g}hF^KVznURsPh#ANN0C9OM!2kdN literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/routers/__pycache__/web.cpython-311.pyc b/FitnessSync/backend/src/routers/__pycache__/web.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12d223e67ad54223d285102e1da6e05fec9a41d8 GIT binary patch literal 2850 zcmcguO>7%Q6rNeH?bvI_j@z_gC&VDdRSwNc5mIRsA)p7EqS}c;s_Ki`cqh&h|1rC1 zNeEJaN(6~J960zC6qN(V9CPHji!2fCsS-kh15%JrRb0STsUTtYELD6}xgcXCA%YJ8 z{?xNY1wX9Gy6;E%0K+;=F$w|VZw9@m-n;JaN9qA0txJ0FiToV%*hq%4p$wq`hR{fc z@K6S2fFV4RAu^O9I>4ZeWEdaH5F22KjAV!pWtbRXh>m1P3^M3f$oRUfUmakH>67oH zxody2!<>`c(Az7^`TCYcs2j|akGF_v&0#lo&!~N*Eqp+#jf!Rw(+ydk3cDfQprmBg z>Gl~`-5?carEb*#vmfS{Fxzi|cn@KyNDDM?G<&h$B2=s{?IHsoKm|XADF#IA%kG4Y znod5M->|9`8iI{Oi3Z^2#=RKx#B9`SCOO1>-(()rXEUnSXsByiX_M4+)ubhr@?=f* zF#ZgvU%Bn4|dO5Be)|z4c zKRF6tFPdW?TI867{8*m!B_{YOMbtH8licJ7e#zfToOvn=PZym~FIL1p1tF(k4*y!- zIJ$jYwimOGl4~hBTh58>u6vDOTm7{2rEV7u{WDkTc`9|A8ql-$#arY%puBjC+(o<4 zCcl@4HOWNR*CT^G=9xpEWbKkpGr_yX3}_|`B%K!RqTfoqgEZ`OUfGyVf&A&jN`Y^^PC}R)1 zPSCvFe?1E&_Fw)w^-XFoa1u%F=bD>#WWk9nv?2?8p=UVwRq~7E{+-Wfp3Iy|7~jL+ zJvc?|-Yft1A!)(`oCc6I#;xuFZP(H+K6;|!i;3^%&S-hEzv{{kVlJh1=( literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/routers/__pycache__/web.cpython-313.pyc b/FitnessSync/backend/src/routers/__pycache__/web.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2427de45b2ff59fd8d37fca01cea66ed6d310847 GIT binary patch literal 2173 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 GXZSze*tNI- literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca91540da0b3eb1a1347b863736a5765f4824c18 GIT binary patch literal 4559 zcmb`KTWs6b8GsKdN}_I-CE1Q+=fXI?$&KyYFHYAac9kZ+r4^&Zg}ewdMafJh$~~mh z*lAY>MG$Ah=5$z556Cd*DGpE|dGubk0(G3!bq#J9Oq!}+l-;s)$?4`oXKSJRP#3P;>LYUV?7XNbPwW1(1`zz-_JS|M8O--qVtL9>;|b_LGtfSH#cALhl5$h;pJ=!9uSh z;wzFMD!mhMNwG{^6(%J))iX7tH43JJV%Z5I2G^u33D?4!9tA=*FVeS07q6{#_vd!@=UfA;%)m>KL>ir#(Aap%GG4n{VvPFL&cT7m)rQG{ zO{sj{I}rF$b(t`kWm7s-svif_FwBFu{12mHmXdK%VP?}3P%ZDyN6an^Cb>D85((Wz zqvR6x1kzRJt3Jn}l0(RJgyxxz1|J5~-khRtv&CdY7znjFnfj3}5zFg)*ym zyrH~1cI+mRh&G!2eSHY&>i`YLZuG%AdTjz$W#KF~nHiZx$b~o*VE_-d*`v-8;tBu( z&a_uf&2n*$xCy5am~j(v1(=YPH}Ip!so8}YAq_4xs@Wttk9l4|rn7NXa|n}hNyf00 z#$q5?Ovt7{U_#M+=g$p?!lwu<-VKEZLZjz{4oq~517wn#*WFpO>Bmtt=Y*)J1_CrT zBgwIZluT+?y;rNvh_Ie40Tc`hXbkCAh!JyU!o;|=2Av|Y%1N^^qBYzFo#e(83haLl z6xx>SS!{OZFaQ^W5eB1eF-oP`v_x903AeajT3xVETpf?}b zm$&aDJ+45(wH${gH3q2=uJtx2JEc9Q<^Uz{6Vx7RQF^7f{6 z80PUVjNKahjgFhKN(_^GQZr0MXKdDhc&Gn8bi&zp!v>2?#L{XT8 z&zdbMrB$FLEhT_`5>`|lC7MSMS#&&s_h`0sHkAS~21uI)K^)JBIzchfK5ixt9iog3 zJ(^9(W-^i*v>If5gLLb7wCab}>?vbA3h^i%a0cDBP8qtX@Xb^bhL;TLhri-90axo^ zxO(gA9X9XXRq%G^yq#Y)K4`w*yyD6Q2lC#x3*Nz;cQEfAo;&l@6&i}Vi&Ig)g- z4h8DJ?EJj*&au3|t>E92^Y8iUeZstzPjcPEdH+bk&*%Jn-XEE_KXv)n+q>^wzI%Co z?2%{pv-Ym}u{FpU+s(7r&)yi)NhfT?7r9m)Za;%oi1w^ zxQVAtcm5A!Nf^8-@fLIJA#z4Y|2F2Lcm`rQ3Zkj)5Yt#H(bia1xS5E8NQLID-LSq) zkAO>)*d<#2J`9&5BVz~T$=f0{pwWJyyuXsW_c_DU@+MbLr%fN1M1@l)<0_ZP${i{< zNt_)>Kq4UG?T)rmywf(zwiy9Wn;!8zCl@~J3qu5DI*3k@S@(vSdxGwYqvKu}f%lUK zVY6nH(trYum9wf|!k{3eI~m?fN(ac}AUuMM?!%h1^fHAlYgPduqe46cpFt$JCLU}! za2=kO%O5&109y=#Dop$~27l#u@X&)3f8CdjpEus=%=>m1d|f$T*JHM}!0yPgJC<5= zYQ&6wR~yHner**p6jqo;~o=&iHLVvdHedE_@~y9mwh}x{;@5F7&N;>%8SL zyKAZCUh-~ojqP4%J-254d5BLm224P8PM?zfHxE`X+`$*rYp>y_4SY|pczJ-UO zi&%!T0FR=DqNs0C%|FntC#dBKYJY-)&m6S{N7LUNO^ZkKj@CI_(T28eTSfKjb&adY zS7cb~ZED$CL?mAc8Tr@ZE4^N<3sMb>9~I%-x#3}_o~>N{#*sS_enpy!7pP{ccB!L? XAX~A#B3ZGohB~%*@dpH%zTtlXFG8z| literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/fitbit_client.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/fitbit_client.cpython-311.pyc index 91b84330ae9d7e671bba97d815b485371b310709..230c0998a90c334faff372065315f186bb7d6bc6 100644 GIT binary patch delta 1235 zcmZ{jO-z$X7{_Pc&$igo4}lh1Knz*l1w=($zYv3RP*xVHQ4DJ;wl5!1sF}AM+%!=S zCYX3J&n9c4CSF91#DHA9827dw_E60pni>!83FGA@;>FWG&p=&cbei|~e9SyE^PibM z-*L4oaNqCuF>IF)3{P&WUktP<)h5O3jrvslKFq_Q@~hsQS41-`Dvg1>k>MF-UY+wW z_Q(UTl}$a`z&jdVGLH5gA3A){GDHwV5h8@C!zb)9VLKD4^x2Y`o1C(jknv2!B{bvC?>38 zeJio9xfrc2)-<>wZ(U%8Ayx^qhpzX};?GsFP*#@#{aQRii%5|!I)AV{7aj1McEBg~ z>8MYKm*z%ooMkj9X`Nh$fWA9npS z!aY4_#K^6M24n9Jb>!0Fh!#Z2Gdh!-NX>}zC7yC{W!6Yd#NmpuKhlJ;a|3aixdz)1 zby)?xGMb{Kx56O3h*bjVb!)+E4*Xb8(`xB##!i#Oa{Da#U(BFnMRdXyvx>LCtk&UI z*6+5g{U@%0*ONryr#-y}sS=yuuBYqNni7fwgi1myp^ac6O5rg(m!3$Crqj0Lq;lDF z_Ozu5se3C8k}pG-c}K&*-{yt@$Z#zz2ejmUS7 z;A0S;c{_Ri(u%iVH+Ir$O-sF%io#>i7aBMAQl=Z8gjy@)e!3{$3Cp2Q9*1~%J5MhC z5^mSF|F4KPC}>-PE~n(3bh2YvF*TaAxRm73L!kS5T)mI~4)3c^nREe`HULLz+Pe-@ zWrEN_pi2<=|6yVW!5|D0P9h#ML8Um?d`;7(jXJ(t#NEx(ETpA*5s{{l8J8BH?(Iy-%KfQ`yYSd3(P&1L@eEo?{C4x$N6q Mk@?l{^vX&916b4>JOBUy delta 1162 zcmZ{jUu?@!6vyxH{{HmW(rU~4XYFj(7>(6fO@mAA$V<5rGL_apNNk+K)L0#gBApu)14&M^MWZ8R^87-MfrIS>8$$m@uh!K>+MSY&<*_qNh>zCPL9X!+OmT}phlEzg~ZAMUM ztJ#4%@0rcYYWQR}94S_hI|vtH1z|lQN~l51cu%Ht$x|nKlgV^8dm@+VPY+;^rPdfF z$sX%6p2g!>6`Babd3$qv0;kyvh~Lr;Z1LW)miM(y@4`)maMNsP&4Z?C_o`tR;=Q|i zx*|G_oww^@x|%RuP1rTmdA;Ym3%Db$geJE-+H@mM2UkvBUBS8HgKM5Xqmd%p;fH5K zu?_V!k3-BG$2RnO>y`bJ!`_(Y{v(cR6$yCj^DQSaS!y;FKJQB?H(`_ih(Udbs&LaE zYiOn9azZV^MxZ~2*i4|#h;~9JVw?$j6Sw;2ufFKj@nc0ij7ax1Dc#gc=?bYn=`F@C zD$RUxTzaKAU0<%_H-mVJJD_`wua-hfMvWf5%wmYKJ<`3%N*9?*6*>+Tx_b&8J#PkN z=x@}(^FY5-mh8m3qi*4YjlqM_E)pT{i^8ItP!xuvpJw7*CN+3Ay;B^A@!-g=cCI)q P_HWL~_`-iy<)*&?qSyf% diff --git a/FitnessSync/backend/src/services/__pycache__/fitbit_client.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/fitbit_client.cpython-313.pyc index 3c978afb5a6ac05f97aef4b3b95536a570e0cf11..2638d1f9a6ffdaf6e94068a00f35bf8761819407 100644 GIT binary patch delta 1084 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/job_manager.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/job_manager.cpython-311.pyc index 4d51c5d235563a696b2ed87a1441351888234fc0..492e064daa70dd4db540278f1c6f709f2655e21e 100644 GIT binary patch literal 16621 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 aJ|g8$l(lE|RC(R>*azWTVQB@(iv2fL%KK>m literal 3928 zcmbVP&2JmW6`%bgm!C^Y)Mx34mLSl|R6s@9KtR+qsa?bcct~9t=^@7)`v>%}G#2n;0RsVg>Wzh4Ajqk2W~tS#Bo{@O zyKiRRn>Qct{ob45pA(5Ff@k_y?^MS{g#Jz%hYvrfJl}=NeWW0T(NM`PnGyqSR%2^i ziL3D?z9y6ehR8TAR11|t3}TVWY=sX8Q^iLNLSMq?KBWkYevA};7b(Ic79{WuBvH_e zK7z)Vhb&ZzDa<;`hyFo0#XRTmYt@S3h(Bu@)keLnIs98y-N-YJsFV%WsMgeqrwf(H zpz-`KQ290NMMY%&C01cdTnBA13cD+oc!g61g;#|QUJ-WrQb-BWc32fBywN}w23Z99 zMFV_R5rH35QdBDfno%{b#9%a0iSG)f#9*`}jF5nylCPbFHr3l;6$$F;4*CUC8iF0C zo#;;+cit}7%bO}LRy^~;dWd^C_?~|Pu?oK2OYVhm33W~bpndq&UY6U%FX7&opR;pYF$~-ab-bQ@!e`g)fYff zuDRwZG(W%zP}m+iGFf3TQ4IL{GLUa~kR40z-7sUhXW6rVnmefUvWr%B@oDzjlkByx zFZHr3R(7Scy7#tyVU845eAbqR_C7Sl>~qjZPNV_bC==W{A*>oLT(5Z81v%Ltb<5wN zavvE!m-$_?<>>^G9*mL?**4o|6u_t9MG`_=p2>r{3a0>aABMpZ0!&VyHcNJG#0O0% zy1tQ)xJszaevXuIoB165kq@qdL2VYWC>EVCZq@76`sPhcmcJcaH_F(6cA-$nhaI-6 z;9=l9A#l0T(w$U=cwUpS`hH8*4OMZXAi;)ABGeI^xUq>r6)~BW4-v0D*Vy?X@+ zykAA!vL`NE;<70&+tNrUfrns9JOd;EDzbPAsw+?lMtau=Fc1!&HWPH=I>IE;KW1Hf zahs|E^>e_YlevL5FiseR)H41!8t|q-Lnqk~!blpnq^YOUxhK-O?#-SwZ%Ol}I8S$Q zdZH?VUN9m6sowItUPM4Q?+GIO)J+k8Ymoj2P`F#x0Bm`N?oKDe5B4RIJ^1p?_N34A z$7Iu>rjyNr`=#OiH7hgoG&BDsGvCX+Vr5?GNd-$Pm}0^8*En><-y{(FI4Im`#ruT1IBeQ0VynyyTViL>t zYuERAE0ybxTB!>MGhQ3C!6c!FA4d9fFGnrMBqoepS4pedFOLK&`3aKTYR zkY>ZhTbKY~JF!Z$r{?BY3a#y_0gs+PIWMo$LksPMM8xYu>D^io+ z#4p2~U_vi3IviM4Cy?<#pU~fHv^3?=dgDIp1tcU@>x)3Zc*!9v`O?$m^poUt_ew80 zXC>!499%0*X=z`-fA5of=G3cSz1ACBvc{H7ap|N{2&^b@FGA%M;6{Uif5QuNn)dPc zLF*(C;+00Nsi}hi9`kL)FSrJ}IswrM5Tr+}^x5utGo5$eKIcXtT|U^fSj-PQBHX~r zH5Kl9QCY4vl$J*7aan%9Rn}YyCbu(sb;D;!jU)|BLKTxO<2gb|=D`Gy4p*%k_*GKl z?=%`3zC497qUxbztK0X_Ta1(XtUQ`48=fcSO?_U8WVRA%QV zeV!N2^wB9K9v4Qtt9=B;fm_hJk7OC);)4pLAK_y6y#wQmI;|cjAx2o}X1dB3lSHy` zoP@#xWH2DzVOku(fYACYObLbg?rmBgCqp4&q`TfnP#oN%#c`5ig==K_P#mn$;y4jm zVXk|LaOeI9LLE(QnUCX_i1owdA_fV|2^k+Wp>#b+eMvn??`;t{Y~P|6HoOE%Zmy#L z3t|v$hu8VX$Q_>C#J&@kUFf`l*Puxbb^Uih`W(YBHj3@gpN*0`^k<`&%@@BmI%5Xk xZz7YOF)K3p;A$^2V?}0mLVf-+0~R<*{z-WMIud9Mk|sJ^ztQ$IcM){a{{cf0TBZO1 diff --git a/FitnessSync/backend/src/services/__pycache__/job_manager.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/job_manager.cpython-313.pyc index fcfa5ddbe0e0a449362f0b37aa8fa0d68d8569de..9c0da88ca9a476d27a21d3694a4627d21356f110 100644 GIT binary patch literal 14294 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 literal 3764 zcmbUkO>Y~=b(XtJE?X*;><-ZORUr+LB}0k?lr?&`PmfXwAThnJ%d1TBb{p z%IwmHRRmBEEhMynoVG@cwm=!62l-OuX!O({&;vIzKw{&j0eZ@ft`sE4z8NklQkHe- z2zqbky*INn@0&Y)eI5iO{;T&2Q30XfGC$$4X`ZYnjwb94*t^6e zo!^kve3G@eYX#M?gc~bHpaa&qi#c6a6rJ2wTC;AX@0ORfw5}Uk$w>bcm+xt+p{HjG#!Z}4e}0zC zN}8_A4@+u#5hz+IpVqOO)-`;;plW&=&R3RO3QVm$#KbSH(4JCL_I`b4Dbp*x7(Vvbu`SgczWD6i_VAVU>EB*&oElr7-n_nX{kd%b=ppw{{)Odg}#9ZT-nW}%~95V6NzOtkG(a%3DCMrLHk zZD(6*ftw0KqanGRIv6@MFZ>R zEe~+8p^#X%gcV#~!eCd+wG0rvf7LgQH3;>*1O}B>dFf6WOG&|Ebpu;mrBcXSJb~kw zXvAVmnlSHCx#XI(0h;J0~IE;$SrPqH@uIE)$`R;Tce-5w|-FzUwSUQ-4G+|eK-u?z$XCo zcu^rZ`d;kDKE0>ddxyx+a{aZ|F%`#KCD0{1_(PK>)7V9W1~lV}WGgeWP6> z_C1Ffw+L0iw)_2D5j-o&(0A!1*6bIFJHvI(1xLSi^$>ZMoV!H-lH5G*ec4lnDmcmSIm|u@ zom>tAsuNO`6;h>N38_8uC~VL_1VEB+upSvTBct2i(XYfvW8zXhAejNFI$#D)ZAHz% z*@id>9@cp4N;e~B2FAY<2O8(6x~W571kN>C6c}uJk?%yy8M~dV~vrP3S;c}$u~veahyIGlhJ1e^iT6X$+=o6<_m)=*ba^s-W9s|=vmSdB|p*00rl z!=`We$$O?RQ5${B^j&D2{ZS9K`o8H)zwm`N=QieQvB_sAo<(cXcb@yE8~uX~F?@)L zO7O-1FWr%HImL8)KPg)WrASgz+wCZnHFlord_vQNs$RoW=fc9sGGXnGaE_rFGs zKzQvZ-*Oy(r0MhV1J&s!0<<-~OHgx|;m|v<1?D{sHFzs=c56mf&W{$w#Vk69L+~xl2$p;NUNk&jH%Hwo6b`aPVW* zbKk*y?7sxG>J+(d(uXBLmWwn;kV!13@o)ujOMg_8RDZOWazjmgmo`y&8aB0(2sy>_ zLKL){Yy1x3(Ut|u3$lc_TgJSMZ@`l1O(zXYlVup@OXU3$`Tvbhn&{-e(2=j*fx0{P jg**1-!nQlH=KPKuXU1N_FSN0jMFxpOzbXFGOHckEOs)g0 diff --git a/FitnessSync/backend/src/services/__pycache__/postgresql_manager.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/postgresql_manager.cpython-311.pyc index 209aacdca44aa9e8511d78d28a6d9ac20afa9969..b823330cf8c8efdcc1653eee4947865c81f6dc0c 100644 GIT binary patch delta 887 zcmZuvL2DC16rR~8+1+e5sU|TMO}g5cxV4BPiq>kWpsgN62ngn2*ks3K>n2-gCu+gc zi-I=^bMc}ml!_>V;L$(e$(*F@Kj#W#IPQaC3`Ra@*nv$)w6V(I+0$2p5{1}+=O#y&+>>IACYDOzSS9%*&b(^y~ z^dgrG7k&IaC%^J0`N-;|-^C$7&>+~eukZ<4E z&q6QG{w6^WiEY(=Wlss7sLlq|f0Uv0JlFRX_;tUeIab!S(M5cP9r>iWAL!+{eN6&z YvJVQQdk`tX+yMLw-#Pc=KMDV$KX}pL-~a#s delta 688 zcmdlY{#}%BIWI340}yo2^~ua*o5&}@STRxE(}am3m3bLZcr{2445GN17~C0B*jpG< zIF>O1Wq=qe!;``q%%I6RaZc1`W=0z(My|;Y%!!OVlNT{}FfN&_%@P?vic zV=oYZv5@H+hAcrCe+}m{Mxa$d3;~P`DO^CSxF^41F=TqlG+CZC-JnPeDCegsbc;7P zKP5G%ST8w0FD)~@ND(M}O8_dCSdbZClAoQLSEMxg0jq?OGDuPcDp{VInVwM+UzD1h zUzAd$0+Ql~=}RujEXypZEK;4U#U|yVhAdZHnU@@&lb>Fs4iaT?N-R#j#gUs@l9-ZM zl6Z?NxhOTUBsD%UC#Ogfs6dmWNE%2LDNf$OrpRB+0c0ycL6OYlpKR)EQb3{NrIXdz zTPL4qS7DT!{Du9RJV@{sYjRG0ajGU0*o0e5DM`hkz*kTJYJACI%BV3}p0i&|7ARC? z1R~f$gc*=n$xtK*VuM1zh!aL|Prkuv#m~cP{DA>Z$V^t`QsD+G2C38JogBaw=PV9n z0zHzSSCU#$l9QPPFTHTx%*nu=l^(4juR zrmw}usYTcy*DqIk)abg-?q?r zV!-69*{8;yd@o zCY+ov>^wqxuWzduX+zw_b*80*BEKbk_}_}Y*Oxj#Hr6%Fc0VhyXtSy@>|wThXW>5^ zZ+(PUn3nam{=%*sYYpL}RKfxUQsVM_krFwMm&gWpQ1UFjS})6DPePgvTqX~=I(L8u ztyAPBSL9s&oXfA1w_Fic$Y<_-xkNQ5su@EI$sxSN{zBc3#a@Rb(fT*BRk%XF$F?|@ ziHl^DzmSm_U1JbqaE83#OSuZ8Wd?}=lH?;_IFyQ>m`H%j{RnZ<{4`A2lriiTuv|Gs zc;WVw0;Nuz-kyyf+lJop%$AtiJ||%a fp$n=mIO&4A3zokCf#NS7!1C`%4d#CW3ZBnj^6||G delta 597 zcmew%az~i&GcPX}0}yo2^~ua*o5&}@m^D$|Q!R#BiNO>k4g^Kq5)4WV!R%5D!5pSc zP%)l#R!z={Q=%sR&SB)K&*WXqC5+1^>$5~EM9V-_GcW|n!B_}7 zNOAH37ExIxgb0ik%xTG}z>v5@g_KzTn+p;i;Gkz|7VpFR|84&!=#f-GRrbcDvQ)7 z8?Z?!XdufKSLP+h=j5jsX@W#qoDz#uC(maS=am9-H93kvJ_6dfk4=%km;=aGfPx~~ z$^Y5Z*+8m_O(yHG%S`rTmtj&P*5r;05y7X zm@;ZiZr})I1F0;MntYqXSpXDZMJ6DE9Yk18*5i~FQ2;SzK?Em^;GP`MDW%NAYWzWl zflcHi6C11eXJ#PlGl*3L)&Nqg$vb%tXPmPHka>#>7;q)26(u>DNf5&gVTKhsf>;V5 z0%XfA4jW*ImFA?{6}bbsj6hthJ=u#(O7bHc1Bb+AcF6{o?<^{e;-46R1Xv#c9ut4J diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f5aa05f2768901d142c91da08b79405f7dd48e4 GIT binary patch literal 8353 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48a0fd682ef88f8773ac30f9a82f0369041b1a95 GIT binary patch literal 7535 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/sync_app.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/sync_app.cpython-311.pyc index 3436ad23e3ff07f6049cac98623e68e5234debb0..ce28f09c5923c93701a573502fbe7a9aebf4273e 100644 GIT binary patch literal 7002 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 BbwU6D literal 25497 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�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 DU~4Bo literal 23313 zcmd6PZBSd;mEhA8`b2<00!e&`hxim90vn708*B{5m`_^|*|foy1ri`5B>W_7!0B}F z{75;QY=utJ+wP>N;%u@t^d`0OY)xgD+N~OPl9?epJ-hEAB~o789oMAOiPM=W9CtRU z?o{ob`<~v@2f}T4yi@xS_ubEP?>*<-bKkw^-1|5uM~%Vr-P;$uArixWh62Kq756;e z0?(@$iII|FY)C>$2;|3y@gXTC<^0m&tRWdC1DyD^N&dQ2&w?;R}brk^prkc zzK|+3Vit_dF2+dB5j>u+XkKN_d5A(*39Ia#4S8>PLu=sI40>F?(88!EM0sZ+tRUC? zvm=3dlyt^LEqVQYKCj0gdWZmV>7HQF8}M6kR_S(yJR$FrhgBih?eT?NkRz+#I~y7U zPxiIIjC0B5cg=gKS-u^-%OM)PkG~7ftJpL~Nl1*sNeLw-b)ShJ@LBLV7rSSjwe}eNWHmUW&n|~$=U!1HMHre8flMchCA>uoM5^Gki+O|?405+h1x9jUK82715OM{7;%(1Q_yr${3*`xH9*eW(7UIxddEF55R*6*91K5Nb^Eq$V|jL6KoDbiqo~cY+GJK4MN?4 zR%qkhvk5V%Pr)&8UdCd@RiDp`mDNX-#k_mJs4QAkyIWNI?T*d*d%gGEf9d~of3$s+ zX&1|Ig5swFckpS1=2~HreDUo08BIhN*Sg<4U zz?4debx25Qr=$sZ%#K=;_K;Zum-wYm3;Sc>WIaOnb3CvQ6~@1^SlkkDF{0rWwET8S2WLDidHiltR5gDQyZbX2|1L zO_lF2sl(3TgD^j@fZSaERuZ<(q+p8>N+#Y2TSEOJjvKLO!J9ZC1H}p)GM^|bNgad= zgt`c5#n- zFl7~LaAeyX1b^a98im?~w?D^`BjCafG&q2+sJgU=Gzl;WZ1f*63^+G4-#4cfgy2+* z5GTN}w+b_qwD76FE0;>W~1)k#j))QlV9j zyo^{ZBh3Q510a+O5QNsbs-f2jdD||uA@K(8%-nhrmH=~HLJ|k$tsvDx%Sa1Z*(nDM zWu}VAxrr7ylBGhfy(5t#_{gfRn;7PT(MMMMwPcMB8(amh&4)USLY~Ciek|b^e1P+O zvbGcV7dQ$8+_+&RkJbS73LLpU@%zk?42r#+#k2w_J;;l(vF_ZJ;aT{KRRbW%6gnAU1YZ!P_cL4m;LcYYCZ05MJ#i2uE ziL56Nb>hifegIxzE>C|1S`k<|MiPEqukK9ND6D08|D0qF>z0;dbCR_7o_NOgws9@R zg_gp|PMYPD=2u-NDvK?Xj4h_O%i6u(f|eA7E50ZvU|H=V=BU6D!7spxP=N3SW?eoa z7z$9ZnzpSsc6WCZWXMH@VBtjY3l+Y&*ApaedP55UCBAUz{xFMx>~F%#9L0U8tbU=X zda0?}y{_qp1w28y{PUhJVm%Ah!{!Lha3ZLzB8V#SxZjkrs=%^`a)n^+&&nY-v=U_1 zT&feT{wY8?wq7#e3C%8`HpRi9=J>q9kj-X$D1#ht;;do`R$b5wR=G?C<|zoQTQU_( z!T^+BFCa5E@>+$&@=gbm?Y9BW3*uV= zLK(Dz(+GiywPjBtGzKePRuO`@Vb22x=`a1#6zp+q#y!Hp^1h%iSp&S0kALNaytb=3K1Unz?)gD6jtc0GC+A@UGDO+ z($MTGE1h3nh5o4KygrW;!COJo?mxZpB_f3|2|xt!ohwL8u<|(ZxcsjX16&Ys zn~<6KP}M7N|GJrQ`6!Rey(T7m+%`hMvuy=^3H;L=uMwvyDnJpyL;EQ8A>KtmQV&tHeI4cEqq0l}Ls|<#yIc`*Fc+YfTp_9-LCP0cLTJYttKm3qHsD7aIn)Tsl_TT=g1SPi>h$WYXPJ{sGLC|* zk_z~I+^$JJ<(Umo?x0h|9-Avf@wf#(@w+)0hIW`>gaz5GYeA55GWW{Ta*)*sT!Ii~ zEu`Rk2BQe%RG_xRWgx;N4~h42WQ7eYHczbBIU863JZbo9`4Dvx!O(IHB6JzJ05Ixa zO&Y%d(?y{L3Y#s+4kSqOJ*yyF0nAlwGcpGdrs7TeSTG zZMqmyPQF`Ye$Dqq-W!OyST@SGe%PZS{R!$!o{Ic>GH}QC&Tw ztKaMj>)LNCQ$XH1zNxw!l5h2hT*M>VC4rj#!0jc88Ah^i=Ym>~{FiC%{2-L4N47impdEG1`o z+h34$=TP{_Fx@snkId3#?uf?oZn=f7Yzvnkp*5xN)-=;Cz0sOejMlOteNSh4V=-zz zx@$hVC6AgwwvSiK3#Jt!x> z*}5rZ>Uy>M{?8DzRSKMuiT3Q)P>%Eb%r8jIowUPaf!6LS#meJSJb!Q^_!5`~O z_p%!En`3p2-^qJ3FIv~l)OG*2g>c=OXx(|H?mX>TropYd9<95{)ZGl%t(e__xXDf%>+d(V#hSZf^+%)i$C&zKTVvt+;aL6gX#Gj1{^Yi4=WMwC zLToM=ox91*-Guh8NzUTG!?1bD5dMgNeRDNh+r3-cy|onW8E1ON>2p`ZJ+4UY4CFqC zPvEiY!_n$?rn>#!`u3r4_32pSkw-a-refM?-P2;GvajX6o)?0lr}d1tg`=3U#y`y+R)83bVnP;nFgq5ik`nlgQsEf&d5*88}586 z);mfQhayBztf!ar9ET_SdtcL<>W-(5(pA&WrT) zRodmIuX^Z1a}i=b*40B3M}JIIKhj}UrzQWp2qTWhP7czgO_9>>SSj(f;n#R4zc%ufk*|*3Rz{Sj^pUrx z#Bz!rl-9(|HL;^ZZ_WL~Lip%~H%PkTaHOo2p1vG2H$76x@(cE)3S;h`RAo}%&iO!( zL2tXh(sg$!T+)WX8q>kPmz{rKV~A?1b~ROZFGMtrDWZce>DwNs`zGj#%k-6N^yNi* zF+iJ^Bg*TsvPLHFG_5@S`wz}aFk{7IObTo*jJ;|`UrpByY~R{mqDcpR;Uevrq-$QF z-3!3ajNTj56L)(d&&CKU!lAB_(Kkl*wy@sz(VhZeeFTlyngFb-MwAg{jHZkxPHi9B zuB6Qa^mzve5JqzWP;z_l^W*o+Eq4!n^~UW%5Ieh?QUpd-^I;H{VA%H>PM2QD#(tpe zFP&(`|FN!5124L*1>z#D>I+I)r2E7LsqBZx6%g|ysSIL%q?96mw)&!8`lG_Oi+R#$ zo(%jc3I_DSfIfR3ABV5FEBLF}6a@Ik4vYHqCrL?wzCgGKlmwEvk^rq0(w>AW36un4 z+LK}1p9}{n36vrz(UVcNAm!8`S;?CtNeTkc-#%@dNX5UhfHDucGL!V4(PmZ%%CaY8)!NxRIEYr-0N=p-(&RacG1EJz!REOI9Ey@~Ff-g-8hoo97zX z0zC)5TyFcjQh=6tldz3x7q`6<@enI+W+h_D{3BU>$e-s>lKE&*Ir6&kZcYtO7NV`e zpfrfrqIfwU_RA;#6y+m}GUpqGZ7Oc_?vKNG30e9?jF%;0JpTZUn^Q0@DpQ^bkhzpRSL$%O{-_ZxDGJx z1|HWRi1A8?-fl}nECe%BA#=`_{iVt36v|XS7s|{%m$6Vw);*Cj)+EXl9zdD;6v`Be zlI2;XPa#ld0Vq=hlqs?w-jBl>Sa!nfm`N^Kp+p9`G$rh_9sp5nXG02gtj~oy(3jC` z&xJxwr0t0mYEGgMC{Z)c4=pJaGKf8Y777`FLZ1c-8G%AZdj_>%w?tGV!S@w`!5nXb zoE`gVnIUJf!{jJN+kO6$1E_omU_F`2C1Sr0r%<%{xlr`w=R(akP~AL961FE%v-ALJ z9!;U9sN#JlNmv>mZ)HHuGCSIeO?#d&-rAwWlPQrAqDY;N6za4;7wSB}@zzBidm@F7 zCsD}!4BzYBNndDY0T-aE>Ph~BKY-#VQYbE#ekO{W<8xm*P`uoJJdNBCoFPHNW=tVZ zA|nq!alptt4y7{igM%P@Qz(7>xlq~-lJ)t_eSKvA6DfTsjw+!XXPN+O;v_Dt z5?fn{RNY(-Sb zpj;mY1*`ada-fO8h|qI8(ut%!UA2~DNCz+-fE9vYdq6D>Vi!dZ7`JJWaSUlBO>v_EZvE?5fSSG7ukF>IQH*ZGx|j2( z7y$e+wGvNJD@cjwTx-q8)+!(U{&TOj@hNJBRmF4eYwO3>DjR*{x!2nHv9)H6f}Y?x z_x03M)S3aqK^(>>?ys7|z?>zSo3qdwp&ZOh$NrFH@RFS4Sx4E~k8PoJ^t&I6mlx64 zo`P>7{&8ppX+Vy4O@eV17=a!z^5GjP{UOJM(ga41C(jS$XtHb<K#^}{5n{D}qb+zqkB9lQxoTo33_bFMZN=|f(Yrqe;IpczgG8taV1@KG+f+4>pS+OSoulGMmvRe zsn>Pq5~eOBq6Qw|pMsh5q2!ct5-yO`< zE|fulmTZMZa$JYNq1`O%-y?8xJ`y++EId5x=6x;WSycdMNb28Nfq4-`w0fgnLl9MB zJex1_w-e65t%SfPLlO6^SL~}v%$%vRL1B2H$_7wnceu*LctET#15j~T2|$w(a*-Qo z2qLH%FzR)1*0Ym(xL!i=W08nkyCh2(R?Zinhf)}J@(U+fd~rSl0^<;n7y<*q;w12= zV0;s&`0)*mpwR9h^%Zc|HOWb=HdzqBVZc=uY}UXg${$*<+J{Aymxv3Ec%LaWbx;$? zaUiD+IUEbLf^WSpgV|Q9jN$C7x(Lo>Y8`JRC=Ok(pu#c cG>MdVi!ajD0fR;F5K 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 float: + """ + Calculate gear ratio from speed and cadence. + Speed = (Cadence * Ratio * Circumference) / 60 + Ratio = (Speed * 60) / (Cadence * Circumference) + """ + if not cadence_rpm or cadence_rpm == 0: + return 0.0 + return (speed_mps * 60) / (cadence_rpm * WHEEL_CIRCUMFERENCE_M) + +def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetup]: + """ + Match an activity to a bike setup based on gear ratio. + """ + if not activity.activity_type: + return None + + type_lower = activity.activity_type.lower() + + # Generic "cycling" check covers most (cycling, gravel_cycling, indoor_cycling) + # But explicitly: 'road_biking', 'mountain_biking', 'gravel_cycling', 'cycling' + # User asked for "all types of cycling". + # We essentially want to filter OUT known non-cycling stuff if it doesn't match keys. + # But safer to be inclusive of keywords. + + is_cycling = ( + 'cycling' in type_lower or + 'road_biking' in type_lower or + 'mountain_biking' in type_lower or + 'mtb' in type_lower or + 'cyclocross' in type_lower + ) + + if not is_cycling: + # Not cycling + return None + + if 'indoor' in type_lower: + # Indoor cycling - ignore + return None + + if not activity.avg_speed or not activity.avg_cadence: + # Not enough data + return None + + observed_ratio = calculate_observed_ratio(activity.avg_speed, activity.avg_cadence) + if observed_ratio == 0: + return None + + setups = db.query(BikeSetup).all() + if not setups: + return None + + best_match = None + min_diff = float('inf') + + for setup in setups: + if not setup.chainring or not setup.rear_cog: + continue + + mechanical_ratio = setup.chainring / setup.rear_cog + diff = abs(observed_ratio - mechanical_ratio) + + # Check tolerance + # e.g., if ratio match is within 15% + if diff / mechanical_ratio <= TOLERANCE_PERCENT: + if diff < min_diff: + min_diff = diff + best_match = setup + + return best_match + +def process_activity_matching(db: Session, activity_id: int): + """ + Process matching for a specific activity and save result. + """ + activity = db.query(Activity).filter(Activity.id == activity_id).first() + if not activity: + return + + match = match_activity_to_bike(db, activity) + if match: + activity.bike_setup_id = match.id + logger.info(f"Matched Activity {activity.id} to Setup {match.frame} (Found Ratio: {calculate_observed_ratio(activity.avg_speed, activity.avg_cadence):.2f})") + else: + # Implicitly "Generic" if None, but user requested explicit default logic. + generic = db.query(BikeSetup).filter(BikeSetup.name == "GenericBike").first() + if generic: + activity.bike_setup_id = generic.id + else: + activity.bike_setup_id = None # Truly unknown + + db.commit() + +def run_matching_for_all(db: Session): + """ + Run matching for all activities that don't have a setup. + """ + from sqlalchemy import or_ + + activities = db.query(Activity).filter( + Activity.bike_setup_id == None, + or_( + Activity.activity_type.ilike('%cycling%'), + Activity.activity_type.ilike('%road_biking%'), + Activity.activity_type.ilike('%mountain%'), # catch mountain_biking + Activity.activity_type.ilike('%mtb%'), + Activity.activity_type.ilike('%cyclocross%') + ), + Activity.activity_type.notilike('%indoor%') + ).all() + + count = 0 + for act in activities: + process_activity_matching(db, act.id) + count += 1 + logger.info(f"Ran matching for {count} activities.") diff --git a/FitnessSync/backend/src/services/fitbit_client.py b/FitnessSync/backend/src/services/fitbit_client.py index fbfffec..e53c5ed 100644 --- a/FitnessSync/backend/src/services/fitbit_client.py +++ b/FitnessSync/backend/src/services/fitbit_client.py @@ -9,7 +9,7 @@ from ..utils.helpers import setup_logger logger = setup_logger(__name__) class FitbitClient: - def __init__(self, client_id: str, client_secret: str, access_token: str = None, refresh_token: str = None, redirect_uri: str = None): + def __init__(self, client_id: str, client_secret: str, access_token: str = None, refresh_token: str = None, redirect_uri: str = None, refresh_cb = None): self.client_id = client_id self.client_secret = client_secret self.access_token = access_token @@ -26,7 +26,9 @@ class FitbitClient: access_token=access_token, refresh_token=refresh_token, redirect_uri=redirect_uri, - timeout=10 + refresh_cb=refresh_cb, + timeout=10, + system='METRIC' ) def get_authorization_url(self, redirect_uri: str = None) -> str: @@ -41,7 +43,8 @@ class FitbitClient: self.client_id, self.client_secret, redirect_uri=redirect_uri, - timeout=10 + timeout=10, + system='METRIC' ) # The example calls self.fitbit.client.authorize_token_url() @@ -61,11 +64,12 @@ class FitbitClient: """Exchange authorization code for access and refresh tokens.""" # If redirect_uri is provided here, ensure we are using a client configured with it if redirect_uri and redirect_uri != self.redirect_uri: - self.fitbit = fitbit.Fitbit( + self.fitbit = fitbit.Fitbit( self.client_id, self.client_secret, redirect_uri=redirect_uri, - timeout=10 + timeout=10, + system='METRIC' ) logger.info(f"Exchanging authorization code for tokens") diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/auth.cpython-311.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/auth.cpython-311.pyc index 568b0a59eeab15da9f167430ef33ce9b51b90036..6bc30d0183efa7490cefd9264b23da78093ced8d 100644 GIT binary patch delta 2366 zcmZ`)YfMx}6uz@}@7)Kxu)?w|i!8gmWI@42fyk>ut%ZtEK>?$6v+iDCVY!QQFM_y; zZBx@G{xD1vOl=eWFrcfUDv z&Nnk>&YUxQTKHuo{gTycW@tM(+Zz#!Z=|0_x|@c?r$}bsl6_#%TAT?xF6tQOD*b5< zUtennqVf`{mcZ4 zGv{;3yCrr~#*G;Aw8hC7SCsQiPA!a>Fdt_EF7i3&MeE6Jt|lkHhoM{7rWoqhW79@B zafVyowxW+4$#y<3qd;z0qZ{T+kdyA6o#u%f&oQ6r!u%Ira-Gj6ErwEWVGsChJYk$b z&mFIJ+N2&2W){Uwt7o#N6DIOIS7g#=!)VM6A@(ri@~UeGDag}C?<+RNcL#+Dl03e-0dlbrc2G0=u$$sa;ki1 zFcKBQuokFNW?0&`c4obYl$mcLH~H1<;&2uX4S8xl%wao718LighM}Molx0k=rRBR# zZ%l@SF*y-bnWC<_72buypk&i0uVy(B~ja2gQwL$W-EUBqH3u=C(bKv0mN z=!8iLvfe{#EuFeNJvn8WLsi6W{U@gtF|~Cc?mc}VY2A>tRxhaE!`Db*di~5pp0VYe zk0Tla#kdr8Nw z>qif2>TexP)^wiP@$s&ty(DR`eo=GZ?waeE?O5=(EZSR>_SOaUd-Ncu!?VO7y2xr= zCing{u;A^b-=e)QY42Nj;d?~e5)1P|;K`x%n#9TUH_^-y8i?4YH0W4 z)|TvcGc#{ObTDr=x7V`sc4xbvo!`iT#?OJKwxyX4ZX3;A40}7X#lE|Vy;G|1VwpQl zR;s#dc6BzecZ)%Dw}GRYdkjnEdu&#hgH`xga9kepD}2Ib`;+6hQzx)%){$F)D?n_a zpy-bVv2+C~Z0zu`1l8`r4W!Xgi}oZW#~;X{GOK~Ulfp7%$k~hn<@;h0SMNWCVC`>MU8fUtI^fraR zAuD}IG0?Ibmj%g}hu8Y8u!DlER)<;vd!Iw|&n*bh(#08zjP0Q3Ow034#=GvbvX08=q*Pe6d)5gr8E5V_%X zW(BDz!A>iYAisG%=>5b~?>>au6Fo&U9IqXm+%G$x3I6$z<|ep~2Z`XjP#gtu6l|=v z^Rhw$AL;5eC(6rPQG$$?=kIE#x=x=S7tmfY2Lhw9@OV@R1QbgkaAZ6f6@#O6geR%_ z3WI^I-jHJ19SeTi;t%Qx~WBTftz8_*D`syJW`Q` z8Qxr&SI^a*H7qf-dmiHC(&oubm8zD8O*?xZ@%y*%`LK+O4Eysc-5{!tsPzKe)w|H) hV`HP149)l>lJI}>+71?FE-^31e^vc|udu4F{|}|FK4Aa= delta 1661 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 bqir%hI-Qc*$rT!ow&w5QaN%DF(75{_=GsAE delta 1801 zcmZvcO>7%Q6o6-T?Oo@`PLq%(cH`KN^TXD`ZJMU0Z75CCLW&_p38u7;FdKUtyV=@i zX47Ahv?W9!fhsXYKzjhh2@wvhRU`xw0tu-!;=lo@_R<68fViZz+$zMpS)5P_OaAul zdv9jmyq~?Ey*uf<>-E+m{u{fNDtr;W;=7EcCZ+Nz9+zDyG_J98JI7!r;H}~5p!0eJrys5E$j|0- z(ev*P&2=3qPjw6SmS;qTHJ)J)J6My%p~}ZKXC-#E{%SO6*X1p%9M75sqiET6Io;B; zrcS9rZN*Wt1@Dw6}yCXgPxX5Y!IQML-D zqE=(Os9_!<^`LjbF~rX<`9gKruqERpt>Jq1gKt(kEwQ}~C$Pr8YPj064Q)%j{@jK1 z8|h!Xy+3+;fAVg*H^3IwbxSKg*4a3~rW)h8k$u^CZFC>s;M?Yr`q6&$-O!#k{ zo9vOV?;KX1ipV#xE2*Iy5ieKWh&LxA^0Lq@N8DVq+#_@AvKD+=soZWFb}PI2ZalzP zn?Gu+d7bK(MaVE$+OFd|p`Tz|E=?V!&=O&?gJs*c;bi4f+Y(+6agct%lN_ralYw3M z5W5^0!AW*2@HK8>A8d#Ppb1DPAOeU2poK_3yS-sDvIRuQB#}96xmY@3*E$K$Wps-q znBLwIfZh^$B|{)R2G|ccz}|1~3qV^?Xz}pskL&<*AG_7wj-RO9Z+{!(MCC$9QN{uG zcO-)IEZjLXKMqb49KXYA4cqFmLUGnIsJ4MT7`QD@8y4vX{{TSbV46#|%Q8+{wmeHr zs6VBo09p@s&*TbOiyQ^_=K&tTLBI=uLuj7!Fo2n@uHRgWlN$D>cBTn#pke1Ca5*x={2PzpC3be><6UJ?ybK<)6XB|Y!d>{0ym@}z zxTotg{zhRo&Z3*)u8aF`hC1IFB}uLza~xxuLPBhRY!jYmr((eYVSpmq_0N>DGvfv= zmx}yyXSiyK|EbEci2jnK#;CK)BKtnpi%oVXmiFekQInif<=LUW&*xjXW=!)tWffic ze>D*`XyvX%G$s_scAF(VN9|hiMTm>JJ_Q~GAd0Ox4NFR(5ltbRMsclM?0Pb4(Ai@~ z#w<-2ilh(x#7wxkr1}0++7n=cg4wlHKW^kQ@P#7cJkfWH?B_?q4~sC@MnUwyk1whg z*m?5Acf`B#<;vdp3ZCD@?PFR!3Bd{H0OtX(0u(?GU^hU-7XwL5`2~;`0Iva7D??yt z1iW0J&vGoH6=|zG@t%4Q@yALT54v}LsIH(vH>+$;G>N5R)YduKIn3^F9ykz$ef%8u zx?`oBVbZu>E;!#?XJ|UWK?E+QQk?r>EVgHeFP$}wUF0I0>)+WhC*h_)5l{sD4`>jY A=>Px# diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-311.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-311.pyc index 761cd8fdcd8de52b47e1e2081bab3df172734733..86a598197e8e4a2aa8c45ff33c3cf6517b533970 100644 GIT binary patch literal 5703 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 delta 1254 zcmZuw&1(}u6rb6T>~7LD>LzKTwXITXwkm#5i^ckJQq&lPR?r*_F|)?VCXw0gMJuf+ zl1fF(9O^MAzXksW5%m})U|109Nd$$6o;>+xw??Z@c7E@@c{B6g@4d;ZtzTAm-w%g_ z2*%6e@zQ5$p*t~SfwhPTBG^J%U*aq#0ue2tEM`TFK$k43EN5km1Z3h10e%A?rg9G> z^cbc;vZ{a%BO+f%Bw&iBd`|)|%w?}e@B}iHPu$FW;-?I}u#^}o_fk5fO=97S^rsXv-d%T@|9T5y%|6#6Ak=w*{#R=c&elm)8v!^R!&5 zqwZkoaZGEtz4>h`mm=fsQwJC)jXd_~w^Gx6}9>VM14Nw+WFtDT3 zY(eee`KRy-5AAb0J%uiIx?PueU)V7%qE5g&h9@GhpwhoPW5^ysSGKCfQiVn!mK!>0 zoAiXw)D@u`2KMONJUvc&UKyF(0J?nuU=}S!^k!tBjWAH}T6O#A&7*Va`E)Ja3PqZs zUcSENy`E_5gYWdzTRrtgA8zWyjjrKVM6Wrt2ktP$Oy-cGvKRW`j4Rp>b)~b_szu}A zqrDt>xQEEwOk;e3{|P+c@5FWhW;?&O5SEYNHsV;XvN3cvFKPoh8+KeJ7afCU-|m?|2Vyf FKLL>~4IKag diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-313.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-313.pyc index 3d04eef2d460b4244efdbb7bbbc79a41c610b073..6742d583870d43e1898369fe70d92ca768689c62 100644 GIT binary patch literal 5420 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 iT=qAw`S zieypc`~N@AKruW`0w0Z};S5-^4pyir7UgN_z^_z@8Q$eaewW9Z#8cjEJ=_`0m4k=? z!w1IRTd1r<5wIAbf<>rk#n>b{pQ1`dFjY^TE78?z{c5f1)SJ5z_6eDc@v}ty_Fe#c zLQD+`P~)~gEP|HAGQx1o=YTsGRrb0tBx1Mh17YlTB8Wu)i9_v3T2g@|U`jab)PM?n zbhJY|iagXT`FN}u%3r(7{t{NsMX7=zlvhDx2wuG6_hOg*W<0g(+T}*Q?v$I(5_=?G zh#iY(KZ%_jx80iv9q6Tv`|)Ft8C#RKBYN+&FGtv#bc@_$ze^c*M^OYF*dNkVCL>+oyp^Tu+&XWY^5f;smoib%iF0dn>TK)&Gj^WBfF`MZ>r;e_XPptjk9ZS zZ>yPKwDdo21oPaxFa_T$vts$+C7}y|AL-xdSE#JxeJ*k@4mKMak_G?@>|ZGpGW-?R zQFNg+$~p-{SX91{^$)XvuTHp|nRA*(%XO%6(27y5FE{9Fsab8*&*x3q@7I%SjY_pn zF)Mhn0*1dIH13H=j!AqrTwWdL;8FNedkl4V8leL_Nv)eSaQ4)J&*wjzzkj_KOLk+Y zasH~MyIOWj%XYPiEp6hdmfKWwy`)A*a3`M4`S;&}Uq$dN_ywC{K7Y-OdJ)^d6WjKp zw!PX|YSkRnRoiB5?bNU*x=rdyw;PQb9p;%vIb_)rO>ch<{uwmIUHs4Tfs^fCMEDe* z3vu}!vZ3{X%WvI4{&-{GxBH`Ed1NE|5>V`)49SB*HlE0g1~i3{GUTk{Nu^eEg-W>O zNlT?>iOK*UgQp;K*D=JQbR5^{Ne+G`=eWih@T>D8GeKvw^i}k6lHEy!zDNjp2I@0N l{066R?kEFYMgLjRA76Q@WILgk(ko=}!E2AJ`+$m{@ITs(6wd$v diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/data.cpython-311.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/data.cpython-311.pyc index 2f125688699f77c95fbaea73b521c776fe121c99..37f6874da296a1533d41b657dd4a3123d8a0db4d 100644 GIT binary patch literal 19210 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 D-)|tr literal 12239 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 O_&I+5$1iz{Me%=2-rCbO5~N^}0Cxb& zCLG7!Tq;H1Rb8arbxE$>E6bOpOz+lJkh;4HQrW77Y{#K&-_=0i2H1(>=x(=Cl|QZu zij;HCs=K=PdN4x}0;S}xGA#D=^y_}_bx%+Cd%xFsSXO4B;Nrh^A^2aPr>K9%2kBDf zGY`K9nYSpGVijZ5D8*5u3Qob`vvN#1N^|t6ic^iMIrXT9(~xp{OgpOMbR@4D({p;L zs~$6q8aX2=(~Ox$%eXR<*N&M-Eu1A^*UDL^t&4ycE(HWVrgMSCsJ~4msK$elsGu4O&PD}#FtjS@&MZfR z;gEl^O(9s=;L`G9;Iw}!5Lx!m22MknVaOj`TxFwyINO;enLr8p(4;AmFKsn{x(o>sFe_*YMBSk1I{S~snp)^{t_&=CK- zb*$D+y`*|c!5LUL3shDQ|C(vzw29C)tRYV?lX)Svn2&}vVm;2xdf2jQ3u~UXvKIK) zOxr+)HP54*wN0A}xU1N5(#p=-iPQqF-73~WXpRE8NQXWU%}5`*HEac`RY5pZ=5^QS zb$1e4CF|mx3#PVeL60Li5qu>W`b`=1KKADZ^1#f8Ip&m~TMC94c$I!;J`kO~91JZm z;pG75$9EQCRw7ssy&PZ`{9N=hvlyJ={M>4%VK5R|ftLs*e^jI|hG+bX`D(L^!9Xa= zT=qwpnLr@K_*Z~MC>orFDKpn;5Dbwhu=C*z5_EykoS0KD2A6_SLBj>2D_m$+`Zn-& zYv7-T|A!w#@)k7(sd!CdY0TFPHbQ6W`<21bdtb!Ta>3S=v{WCti#6;chV<`whQ09{ATE85ht){{8o zjDI#7yc&$I!ste#%sdxflHSYla3~a*jdsSWNKJef`N}~Uds6dD6%!3Jv6d4Y2k%pU zhsD*z7v0aCZqo>w#qh#HfD=@~(0o|Xh%Za1TnI#cTg`kktKwVHpLk_9Kt5qX9Y7&% zYC#nVEY5RQ=rCu)q#P1K5s2UnWbhKsbX^WF1-c@UD7^Qsvt0Nk=trdMWH5S;^Uq${ zhq+K75@Azi^)EW}cZ_cu`O2=iVg2ig?xZde;~k@$y0M&6r!UVLD2MCDq3ee-_MVizC;n2} zKE|8Iet*y6dPpht<@ZeG-x+^%ocA0{?oICEt4?m3PGwED$2m0+zl>CXjDP1oNF@w| z41HT!HRz#ld(@B@jM5wR1?L3)d~h+~i>@vQW(()49$J$beF+Bb|8+)B;eeA1W*p9W zl{n{FSk3yBIOAC@s~dyeBSbTlm!2d$k<>=KPfF`)LGcbX%>`BR*&S-))F`@ksEJdj z=wkJq@U3{2tfBt`Mfsi7PMVjH5>lVBc~z76Zi+6i{56KuUUh4pI@e4Ut|lO>_+pJ@ zOy$-LRWRx%ktU7zLK&>_P==?o);|UX82^tRw}zEd^XPlQmCr=*bsN z);&pMt~#9`aWRcz~BsUB2H?W7*~ z|Lr!(g{EwnqNw`d#Xv| zxWxxXz_Wuk)si+|O=IC4EH$Dvl3v^TQ!8qWg*?54kd}Xt^KS>@)3}%e}?4xt$mtp@ajPedHlEabJtXx)F-cA z8VS^Sn_l`s{X!XN1@|qj+8SdEhN1Awp~bL&j`4#jB5k&~ESKDD4K4DD-NMX5lbHb1 z5}kdeWjE8ZuuN|A!6-bze%lh}f(t<~Zd*DHu?k5}!9Kmp#8eEJL9ync6UV%#`tv*c zabh5K&gB)8_Fbs0VQ{M$IQPCnP|q)}L@sk^eSBa4(MF zCUCfZ=*;nR7tfwxE=QLZ$+mwOb1_D03T90lohgrcOv$vycDFJ9<>i2%1N$+I z!#jL#Vw@qy1+(m52y`+-!N{zin}dPujI|eQB{6;(#C7q?Cgxy=cQq*xjc*AB6%6rfhtaRWfm-y$o$IU z;wlq(B?=Y|3W9exvlSg0BNLq1X9l%vJwla4xSOT7Do`o-1Bd zikX+&%m+hr1r1_lVg)jA@RD8I#L8wHP&}e_C{4F*^Ycq3RA|XJl^ZeW6%^0Tex|L0 ztAr8ea2*lU%Um!N70N_Q4c-dcsvIsqLgiroGn3}S$uPI%2aoXF@e2Zd>g)w@3nxcT zjhr4F7wF^ci!hx7Q4V)W4!2Li_JnLf9f@+m<-F?2R7O^JiOV>9~d=G^V+GLgR)lLJdw84XWdN3-J5dvX57!G z+|OUHyzj1mr~U2rTfww@&$Y^5+pF%`oHwekSM$vN_(=Q^Uwd@Z_WV6tC115K-WPA@ zokx;$cWq-?SM`0nQ#7Ubr0jd*9^Sr(w~wz4{o3xlXRY8X4<)t4Se;Uwep7fx|y#Z zOtvR$HfH!2F7aolauhYK7*@iK8Bso>DBGwK<+zS-4BfT9Aj^6E&DU?fma%lCEFH+l zc-_d?4kWH5zQT|C`0B51TKz=3zNF)>^<++Iv<)k=jjfr+qp8NDNmaV>#I=dfosHSL z-8WVDT($2Q-!}5i1Ajl8Z2U=<@BcO45@H(V=nDtiO;b%cCCAD?DvoKl&c;*4G?;-!gFK#`t+@dcthO5Hyqfk z=)G6r=4*Q5Gx2l0r$5>F^NLe2#A{=Zb9!v>7$%an>LEqzU9y`ZPMz)zeezX)5(e-^ z{@i)K>cXb!;ysg%cO3frp``66rxJa9`H78lcTJP`?9RtIEpYfUav5gme>m1V(m;LK z^cS)Y27)GC?@9gd#ZK6MEQbT^58>9ijXiPo)EhGSdDYi_4{ou3|7!ajQu5C__ zWEecf(MM@T*CaY(MVD7LABjOFJ569v!K$&%`A0;h=wj8y&Oew<8g%{_0FEjG0kpAZ z5d?TS5L~znkO@ElVQy92G;zZXFq}UGTVkxeORUn7_mK0C=pY9svC2^bs=zW77>Mbc z!JihhH8ONiECRP14n3tR>P_fORyoo?E9C4WT7Q%c$->W zXRW}S00aWP!Yzsms0N;#S` zjy);Ip13{j=;6zHB-elIo9a*OO@QlvWYct%d@kVn!{>tA-}f5_4b*LeVekNb+u<7A zOW)qBhWu#}4uyBIv@GcJx?fok=$WP9Hb_ecJjl$yNnlK`ViNAf7pstFH8{kjYbfO! z>M1l8$u%hh15yb^0YrcfnaWE;3NCPwxh;cWtmesj2qTDq0ywy+dC@gl0tEA_y-F|5 z>icLg=AW*aLFxn4+|t7+NnOx_b0NbN@D+B!SEwoAX(>_%9$AxDQ{>icaV`^S5`XwU z%^}U8W&t2vycf6+*4T={yG)#i{72yj8!ViMw3%ZQBWTQtra5yw57!wG5GOOBpM1jj z$b@GQ0yD@nqMw-uwowDji+q-8mH4(9zSK2c>}Xo*Xqt=Jq};it;r^zH{w6k7Evkfe zTwW_#2{7h#R~B|Nz;@tEHCabwyZ&jd&T)VbP;E3f3^zd|vgJnMAy%FrfGtB707=Q# z6vw8J)F3V(c1n=Uigj1u?rV|{ZPQE2DOM|od6-ubx zTA51GBfur{Ye-;iGEG0@GqR_&tBlo7kPi+u5Lc3vgoUFI;za} zov}B^GN#=r({3>8vQ8%JaOSjNF+QMFWo9rI%IsK!ckSLZb)Y#=-Vp~Ipg(JOii;kt zuaXUkfrLN7@>PSIrenl%*dHH^@8`?W#4!!sv%84ZQ1xX5P4(Lq{l~1-2Uf#zJ^g{Z z>9~siP^E^vpa%mSKxFWTX626`>dPDc29)B44-qaZ1koF~D?&6N8FZ<|%d0Gf(tG9Q zQCdKhuss9*S7K9_(6W%4Mbu4gr-s{M83CN9)Z!)8o-DP&%jQ8VbO^6G=*t9P6o?Wr z)ET9bVgQYise6I)|07S1VaOc+wDM%c?hlmbpr|Q%3HHjZSq;H&VJq^gSq1J$g=nWA z{4#h`7(Xbww$QVPk|lpqEQX;P5z{UzTTs?hyD3>$*hc4-3{?m3fT0$YVzdP|5E*IC zn{m1+&|ADFOW|VLVq6Ti2_>Rm3m_lw1>1x!#-0#y01^zF4{{L6SqxqY06-u^4g`>C z?F5i!H`56i4x|9$w22^;pFv;W&p`<8YCyyhItlc1I;O%VkU|qkp~-Kci&U&qTxav1 zFD|se0bF#GpbUluEj*w=L3=5`3bwT_nd+;2dw2!7LVQ2(s6{X$onQ!)D9tj%?!?t0y=^7q{Dy4Oe2^#?X>y@|c| zT~+TG-ZuQT>6$uQ+i=bJP(!)vt_^0Lt{X33fB7$0GtRb@vu(XOH(jf2yvB+O@m> z$l500{k_O~&v#?-7ry&?wxMNR@oww2v0vM3QP_dE58P@`dpf`9qskAaY`wVyl%wv8 zUUZCEhkEA_@6ZP~*M_nhX z>7Q1sA)!f>eg35#hLe$aHME~NZp0<>F8e~9+d(#P+A z3lO$MrW;3nI=JJ5~Nmd2Whv@(>o z;|P_qzvaylw?uh_R1n-g#+skXt<<8t#jPM@Wd@a7YsxdOM##gNErhu+rmpF`eJ-W zUn=ubj3uNP2aO=*JV=4uQ~Of(3@KH4DP<+3lns`F6b7U~?x|8N&yeEDORl*#*1QzRZAodXiFJy&ntyST@n3}yihl;;eWE)D zM`th;#Ngp3D4YYxtTR?2<79=EPJs95eR{wxhiPDafASnKwsvr0vV5~ z3Hr;cbKge-O(T>%i*2Pfc`;4mDAzM^2kVsTpFxhcOGc2ds; zW>yvi6B1#r5Q=z|iA);tnP8yu>=DZx*rFKS5DY<>vf{K6RC9R5QUm5c%o0I^0adsY z?Fwg}2*}z*>mAKFL7Si7f;Qg)!C0WTKu3IVC{I>ajky4X-EfkNx~U?IP+~69Sao?7 zN{u3LBvzzfh7#?}OuiXp@=-LjFJ!+!%7}ol3p)-UZ60}v_+WNn`+(2@A3^~vT&m7L z0N==AuuBWhezmU;uLA&lU{lw7zoIHzRg_!Ml_LuMX~q3Y z_l+ypuVgCsr7HKu52h;*ubukbQVGCuQ%AO;?Y;JQ+t(M<4TrMr`+jif{X=~3c)EQe z%e1^_eAl>sCe0jvpjS7RU(?>yrmRe^99!+_{gLs9M*i@N={@JOdk_A|^Ft3ma6Y~F zLbkc%y({lt;rAU&Hy?*~t=P_(vNqwwxJotWf}%j4FieG>4sC+#y)p8WOw)8 z(xqH&Ig`3<6g-!@cMiRMDC60m^6ZZfr#%DLbf4Qj+2+oh-s@k@8PsLtifm2&J5z5@ zWomj;HN6R4x@Pd2;d6UUwq@_lQz=IyRsw1DP49W$^#oK4W5#_T&RW}2-b%>j~_uCkJKMm z$PrK&w|FdBo*YUZ;A=-SwlN5NlKSHX^+6vgVtObsoaj$ZZm5!1`P$PN+nKvIq7RJp z<42WG@;{hxB@D@iB%3_3Gk#$lx1QL3TjjuxqR42F?@h#-1Kdgwb{gxaIVl72!RFpPH4 zpJ+X!t@I}iNd2T$jU^pO{bY|}jG;g2_l$YyPb-l6sYeYZpE5}ObeCb=PJi0r88_3P z=~PhinOO}bpV^W6S*2k@Lx0A2CKU94QK%umEkcRrIq`J=55N)IJl#TA6Dr}=0KN#s zZ1R*+KqhV$cx{VqmgWSY=pV#M#VYO8i|UAS4;6vjFlZe1c2Pd%gqETZiVSJs$Ri!n zUWoPs{Nn#Bu=cg3;M9U>lMdoMC46YFPVzrOdawRzz$xf4Mo}x!v*o|@8YPc|0Htt( zXiA1vy~Zhc7B8u{Qf)u@dKX?IzEdfWRQ8Y=YT}?=vy``4SaU)g6R&nk_9R)FRrM)4 zKqq;OK<^10CphR9Q8Mr!QqNOxWc%9&(X71c=Yn8bf$a!CUf5fn$)MsVOL3$xR%M=aF^2m!Aw`Z;o>iVH7x8aPCF z+H}MOBvu!P<`uEgIAX1#Jw;3;VhC~PvAP0m2LEcL&B)<=CgBx`W5EwNpcu3{gH8St zDQZw|DZ;&hc@+YM+&7W-uP_0Zk!mv)$9KU*hH$VzqY0w80tcYCHgGUxS2zw1f{|UE zAW4D|^pG}qfxZ%06=*mI6+!1yvIg)jS_C#68#2yvlY+xGPY~VknMVExa3Y6oY*iU+ z{atJQt>@NXN?ZFf)*~tFk+k*sHT6AnWyV~0*Iai?xBgt(+> zuKdQ`=GSi7ZZ`bIN~-y_H8@26jr-2(HB;8?PMhnq&RrSj?v!))`lYn<;My?kTjD_) z*jwF$Yh(ATTQb!hsp^iOS9e|4U88?xue`SZp0obeuJ1*~hFRCHl&dx4>Poq~;`Gm5 z-S>+N9vG>bJ+R}IRsGG#`d;9bGIgwt=bV(q^N;5GoSU+Gc%A!y{kEQRzOIM>3`gCz zn2z_W1fyu+J>|^#JbY7cA(#Y948iDOe!vG0EQ+sEzpp3&6AGcXZs38?8-A}v7b^#; zcc9bKva$nRcE?$Dr>c=!@POuGbm32}x%L^F$qV8Rn#(XCYmhZJk}>z}IOecy7hhXr z4(EK8tmzpXN-fW{0t8A62H}#qFz`C2Y|(Pn0qBank^0pY7#xH z&(NdP*0FsoY|r3OYD?M9q5K&fkf9l!nc*bn5cm`cQlg~Qk+3b2o-=95` zs-D5YxixB1ZfhRE*?IKK$u$YzRPn{S#ouU1J%gB_2Vgrq2ud!927*keyvdX}A?EyNU^7GX-YA_RU`~0#*gbv&c{S;HO*5;eCPz&Ypx3brSDz z9DQjfFeg~V$03N>!%hwbwrz9-gW6OgSoF6j^6wxK3}PK50+K*rg2-%`1JD0zcpsdr zeI-^Q9Xl5{CUU|Z&UnNuMeqwZflNCfX|s|;9^5Jx^k5Rhi zq@bH3;!uXh0rppXBoGbgBbgB*veSo55X={m63rG;!hIi0zl{m}nt>8Snz+x3Hi~GS zh)l&(BKSsys_igEelJE+H(Dm&0?QJPhvie(qht8(EUdT4Kzc=>oSlp6sd>(Lv>dG9YuBgn>l*0pOP|Is>8Pm4*r||7Q z^rNvKj`7c5N_S6Xw>`A&`GMzs58rbl-FC7hXI>~618bti%i^`a_)_{rk#g)>mei(g(bE&!fW)%2twSctt$FJp5;bXC zvrOERa_)({($0R~+AqnyUmBCZwGlyB);W=GEEfa+Xsgkc=)h}q(?CQs1DPgQVp2y!u-c7k~U%bGYx6BK?rvU;ZBna z2zQPcM)%O4=xRsX=}+3#kbfVXkKfFJz%~WfMsBCuT!PN$!;=U;pJ4F$0LET{-%0ue zlh5~OEB?hi#o~h#1#p;RF&GMj!a&ygd~@Mh$k5=H2s8lI92%S)ewQ2?P8_b@oDLE} zO^zRt^ZDFKEG2#q{HzAf-3h9haCnhpu^60U(evTHi@E;@iBRDKG#8f4Ss$RsTyO>< zb)S#>&p>+!|CD%|0bN%P6Et=rUGRZ_n|h$6)w%};qq= zBJm;<|U24xd2!~r@Ke;8lH)z!2 z#XFZaEUDuc9w9B)J&rws{^LEdOS~srH}>D|_=1#iA4nqzKP|!6LmW&voVSX56qW;w&7ma%2ss{H?!*PTlk8R;x}Aa+ ze-sTO1wCvUd}DZW81@LNTQhq1UA_C}!L+{d4b2yth+5(LErluJ{(k{> CNW)+N literal 10266 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 bool: """Check if the connection to Garmin is still valid.""" try: - profile = self.garmin_client.get_full_name() if self.garmin_client else None - return profile is not None + # We can check if we have a display name or valid session + return self.client.get_full_name() is not None except: self.is_connected = False return False @@ -33,7 +40,65 @@ class GarminClient(AuthMixin, DataMixin): def get_profile_info(self): """Get user profile information.""" if not self.is_connected: - self.login() + # Attempt to reload tokens first if not connected + # This requires a db session which we don't have here usually. + # Ideally expected to be connected via SyncApp. + pass + if self.is_connected: - return garth.UserProfile.get() + try: + return self.client.get_user_profile() + except Exception as e: + logger.error(f"Error fetching profile: {e}") + return None return None + + def load_tokens(self, db: Session): + """Load tokens from DB and populate the garminconnect client.""" + logger.info("Attempting to load Garmin tokens from DB...") + print("DEBUG: Entering load_tokens...", flush=True) + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + + if not token_record: + logger.warning("No tokens found in DB.") + print("DEBUG: No tokens found in DB.", flush=True) + return False + + try: + if not token_record.garth_oauth1_token or not token_record.garth_oauth2_token: + logger.warning("Tokens record exists but fields are empty.") + print("DEBUG: Token fields are empty.", flush=True) + return False + + logger.info("Found tokens in DB, loading into garth session...") + + # Helper for JSON loading + def load_json(data): + if isinstance(data, str): + return json.loads(data) + return data + + oauth1 = load_json(token_record.garth_oauth1_token) + oauth2 = load_json(token_record.garth_oauth2_token) + + # Populate garth session inside garminconnect client + # garminconnect exposes its internal garth client via self.client.garth + self.client.garth.oauth1_token = garth.auth_tokens.OAuth1Token(**oauth1) + self.client.garth.oauth2_token = garth.auth_tokens.OAuth2Token(**oauth2) + + # Also configure the global garth client just in case + garth.client.configure( + oauth1_token=self.client.garth.oauth1_token, + oauth2_token=self.client.garth.oauth2_token, + domain="garmin.cn" if self.is_china else "garmin.com" + ) + + self.is_connected = True + logger.info("Tokens loaded successfully. Client authenticated.") + print("DEBUG: Tokens loaded successfully via load_tokens.", flush=True) + return True + + except Exception as e: + logger.error(f"Failed to load tokens: {e}", exc_info=True) + print(f"DEBUG: Exception in load_tokens: {e}", flush=True) + return False diff --git a/FitnessSync/backend/src/services/garmin/data.py b/FitnessSync/backend/src/services/garmin/data.py index ba5ecf9..1d5bbf2 100644 --- a/FitnessSync/backend/src/services/garmin/data.py +++ b/FitnessSync/backend/src/services/garmin/data.py @@ -20,10 +20,11 @@ class DataMixin: """Fetch activity list from Garmin Connect.""" logger.info(f"Fetching activities from {start_date} to {end_date}") try: - return garth.client.connectapi( - "/activitylist-service/activities/search/activities", - params={"startDate": start_date, "endDate": end_date, "limit": limit} - ) + # garminconnect expects start and limit in get_activities_by_date + # It actually iterates internally. + # Actually get_activities_by_date takes (startdate, enddate, activitytype=None) + # If activitytype is None, it fetches all. + return self.client.get_activities_by_date(start_date, end_date) except Exception as e: logger.error(f"Error fetching activities from Garmin: {e}") raise @@ -34,150 +35,350 @@ class DataMixin: 'file_type' can be 'tcx', 'gpx', 'fit', or 'original'. """ logger.info(f"Downloading activity {activity_id} as {file_type}") + print(f"DEBUG: GarminClient.download_activity {activity_id} type={file_type}", flush=True) try: - path = f"/download-service/export/{file_type}/activity/{activity_id}" - data = garth.client.download(path) + # Map file_type to garminconnect format constants if needed, or it might take strings. + # The library uses specific constants usually but accepts strings in some versions. + # Based on docs: Garmin.ActivityDownloadFormat.TCX etc. + # We can try passing the string first as the library often handles it or we map it. + + from garminconnect import Garmin + + fmt_map = { + 'tcx': Garmin.ActivityDownloadFormat.TCX, + 'gpx': Garmin.ActivityDownloadFormat.GPX, + # 'fit': Garmin.ActivityDownloadFormat.FIT, # Not supported effectively? + 'original': Garmin.ActivityDownloadFormat.ORIGINAL, + 'csv': Garmin.ActivityDownloadFormat.CSV, + } + + # FIT not in ActivityDownloadFormat enum in this version + # Use ORIGINAL which is usually a ZIP containing the FIT file + if file_type == 'fit': + dl_fmt = Garmin.ActivityDownloadFormat.ORIGINAL + else: + dl_fmt = fmt_map.get(file_type) + + if not dl_fmt: + logger.error(f"Unknown file type: {file_type}") + print(f"DEBUG: Unknown file type {file_type}", flush=True) + return None + + data = self.client.download_activity(activity_id, dl_fmt=dl_fmt) if not data: + print("DEBUG: Download returned empty data", flush=True) return None # Validation: Check for HTML error pages masquerading as files - # HTML error pages often start with Optional[Dict[str, Any]]: + """Fetch weight history for a date range.""" + # Using the relative path supported by garth/connectapi + url = f"/weight-service/weight/dateRange?startDate={start_date}&endDate={end_date}" + logger.info(f"Fetching weight history from {start_date} to {end_date}") + try: + # self.client is GarminConnect, which has .garth property exposing the garth client + # .connectapi is a method on the garth client + return self.client.garth.connectapi(url) + except Exception as e: + logger.error(f"Error fetching weight history: {e}") + return None + + def upload_metric_weight(self, timestamp: datetime, weight_kg: float, bmi: float = None) -> bool: + """ + Upload weight and optional BMI to Garmin Connect. + timestamp: datetime object + weight_kg: float + bmi: float (optional) + """ + date_str = timestamp.strftime('%Y-%m-%d') + # Timestamp formatted as needed by Garmin (e.g. '2023-01-01T08:00:00') - library might handle just YYYY-MM-DD but timestamp is better for conflicts + # Using add_body_composition from garminconnect + # Signature: add_body_composition(self, timestamp: str | None, weight: float, ..., bmi: float | None = None) + + # NOTE: garminconnect library expects timestamp in specific format or None? + # Based on inspection, it takes timestamp string. + ts_str = timestamp.strftime('%Y-%m-%dT%H:%M:%S') + + logger.info(f"Uploading weight to Garmin: {weight_kg}kg, bmi={bmi} for {ts_str}") + print(f"DEBUG: Uploading weight to Garmin: {weight_kg}kg, bmi={bmi} for {ts_str}", flush=True) + + try: + # weight must be in kg + # The library likely handles the POST request + # We use self.client which is the Garmin object + self.client.add_body_composition( + timestamp=ts_str, + weight=weight_kg, + bmi=bmi + ) + logger.info("Upload successful.") + print("DEBUG: Upload successful.", flush=True) + return True + except Exception as e: + logger.error(f"Error uploading weight to Garmin: {e}") + print(f"DEBUG: Error uploading weight: {e}", flush=True) + return False + + def _extract_file_from_zip(self, zip_bytes: bytes) -> Optional[bytes]: + """Extract the first likely activity file (.fit, .tcx, .gpx) from a zip archive bytes.""" + import io + import zipfile + + try: + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z: + # Prioritize .fit, then .tcx, .gpx + files = z.namelist() + target_file = None + + # Look for .fit + for f in files: + if f.lower().endswith('.fit'): + target_file = f + break + + if not target_file: + for f in files: + if f.lower().endswith('.tcx'): + target_file = f + break + + if not target_file: + for f in files: + if f.lower().endswith('.gpx'): + target_file = f + break + + # Fallback: first file if only one? + if not target_file and len(files) == 1: + target_file = files[0] + + if target_file: + return z.read(target_file) + + except Exception as e: + logger.error(f"Error extracting zip: {e}") + return None + + def get_all_metrics_for_date(self, date_str: str) -> Dict[str, Any]: + """Fetch all available metrics for a single date.""" + logger.info(f"Fetching metrics for {date_str}") + metrics = { + "steps": None, + "intensity": None, + "stress": None, + "hrv": None, + "sleep": None, + "hydration": None, + "weight": [], + "body_battery": None + } + + try: + # Summary - Steps, Intensity, etc. + summary = self.client.get_user_summary(date_str) + if summary: + # Steps + metrics["steps"] = { + "calendarDate": date_str, + "totalSteps": summary.get("totalSteps"), + "totalDistanceMeters": summary.get("totalDistanceMeters"), + "stepGoal": summary.get("dailyStepGoal") + } + + # Intensity + metrics["intensity"] = { + "calendarDate": date_str, + "moderateIntensityMinutes": summary.get("moderateIntensityMinutes"), + "vigorousIntensityMinutes": summary.get("vigorousIntensityMinutes"), + "intensityGoal": summary.get("activeTimeGoal") + } + + # Stress + try: + stress_data = self.client.get_stress_data(date_str) + if stress_data: + metrics["stress"] = stress_data + except Exception as s_e: + logger.debug(f"Stress fetch failed for {date_str}: {s_e}") + + + # HRV + try: + hrv_data = self.client.get_hrv_data(date_str) + if hrv_data: + metrics["hrv"] = hrv_data + except Exception as hrv_e: + logger.debug(f"HRV fetch failed for {date_str}: {hrv_e}") + + # Sleep + try: + sleep_data = self.client.get_sleep_data(date_str) + if sleep_data: + metrics["sleep"] = sleep_data + except Exception as sl_e: + logger.debug(f"Sleep fetch failed for {date_str}: {sl_e}") + + # Hydration + try: + hydration_data = self.client.get_hydration_data(date_str) + if hydration_data: + metrics["hydration"] = hydration_data + except Exception as hy_e: + logger.debug(f"Hydration fetch failed for {date_str}: {hy_e}") + + # Weight (Body Composition) + try: + weight_data = self.client.get_body_composition(date_str) + if weight_data: + if isinstance(weight_data, dict) and 'dateWeightList' in weight_data: + metrics["weight"].extend(weight_data['dateWeightList']) + else: + metrics["weight"].append(weight_data) + except Exception as w_e: + logger.debug(f"Weight fetch failed for {date_str}: {w_e}") + + # Body Battery + try: + bb_data = self.client.get_body_battery(date_str) + if bb_data: + metrics["body_battery"] = bb_data + except Exception as bb_e: + logger.debug(f"Body Battery fetch failed for {date_str}: {bb_e}") + + except Exception as e: + logger.error(f"Error fetching daily metrics for {date_str}: {e}") + + return metrics + def get_daily_metrics(self, start_date: str, end_date: str) -> Dict[str, List[Dict]]: """ Fetch various daily metrics for a given date range. + DEPRECATED: Prefer iterating with get_all_metrics_for_date for granular control. """ start = datetime.strptime(start_date, '%Y-%m-%d').date() end = datetime.strptime(end_date, '%Y-%m-%d').date() - days = (end - start).days + 1 - - all_metrics = { - "steps": [], - "hrv": [], - "sleep": [], - "stress": [], - "intensity": [], - "hydration": [], - "weight": [], - "body_battery": [] - } - - # Steps - try: - logger.info(f"Fetching daily steps for {days} days ending on {end_date}") - all_metrics["steps"] = garth.stats.steps.DailySteps.list(end, period=days) - except Exception as e: - logger.error(f"Error fetching daily steps: {e}") - - # HRV - try: - logger.info(f"Fetching daily HRV for {days} days ending on {end_date}") - all_metrics["hrv"] = garth.stats.hrv.DailyHRV.list(end, period=days) - except Exception as e: - logger.error(f"Error fetching daily HRV: {e}") - - # Sleep - try: - logger.info(f"Fetching daily sleep for {days} days ending on {end_date}") - all_metrics["sleep"] = garth.data.sleep.SleepData.list(end, days=days) - except Exception as e: - logger.error(f"Error fetching daily sleep: {e}") - - # Stress - try: - logger.info(f"Fetching daily stress for {days} days ending on {end_date}") - all_metrics["stress"] = garth.stats.stress.DailyStress.list(end, period=days) - except Exception as e: - logger.error(f"Error fetching daily stress: {e}") - - # Intensity Minutes - try: - logger.info(f"Fetching daily intensity minutes for {days} days ending on {end_date}") - all_metrics["intensity"] = garth.stats.intensity_minutes.DailyIntensityMinutes.list(end, period=days) - except Exception as e: - logger.error(f"Error fetching daily intensity minutes: {e}") - - # Hydration - try: - logger.info(f"Fetching daily hydration for {days} days ending on {end_date}") - all_metrics["hydration"] = garth.stats.hydration.DailyHydration.list(end, period=days) - except Exception as e: - logger.error(f"Error fetching daily hydration: {e}") - # Weight - weight_success = False - try: - print(f"Fetching daily weight for {days} days ending on {end_date}", flush=True) - all_metrics["weight"] = garth.data.weight.WeightData.list(end, days=days) - print(f"Fetched {len(all_metrics['weight'])} weight records from Garmin (via garth class).", flush=True) - if len(all_metrics["weight"]) > 0: - weight_success = True - except Exception as e: - print(f"Error fetching daily weight via Garth: {e}", flush=True) + all_metrics = {k: [] for k in ["steps", "hrv", "sleep", "stress", "intensity", "hydration", "weight", "body_battery"]} + + current_date = start + while current_date <= end: + date_str = current_date.strftime('%Y-%m-%d') + day_metrics = self.get_all_metrics_for_date(date_str) - # Fallback: If Garth failed or returned 0, try Raw API - if not weight_success or len(all_metrics["weight"]) == 0: - try: - start_str = start.strftime('%Y-%m-%d') - end_str = end.strftime('%Y-%m-%d') - print(f"Attempting fallback raw weight fetch: {start_str} to {end_str}", flush=True) - - raw_weight = garth.client.connectapi( - f"/weight-service/weight/dateRange", - params={"startDate": start_str, "endDate": end_str} - ) - - raw_list = raw_weight.get('dateWeightList', []) - count = len(raw_list) - print(f"Fallback raw fetch returned {count} records.", flush=True) - - if raw_list: - print(f"Fallback successful: Found {len(raw_list)} records via raw API.", flush=True) - converted = [] - for item in raw_list: - try: - obj = SimpleNamespace() - # Weight in grams - obj.weight = item.get('weight') - - # Date handling (usually timestamps in millis for this endpoint) - d_val = item.get('date') - if isinstance(d_val, (int, float)): - # Garmin timestamps are millis - obj.calendar_date = datetime.fromtimestamp(d_val/1000).date() - elif isinstance(d_val, str): - obj.calendar_date = datetime.strptime(d_val, '%Y-%m-%d').date() - else: - # Attempt to use 'date' directly if it's already a date object (unlikely from JSON) - obj.calendar_date = d_val - - converted.append(obj) - except Exception as conv_e: - print(f"Failed to convert raw weight item: {conv_e}", flush=True) - - all_metrics["weight"] = converted - else: - print("Raw API also returned 0 records.", flush=True) - - except Exception as raw_e: - print(f"Fallback raw API fetch failed: {raw_e}", flush=True) - - # Body Battery - try: - logger.info(f"Fetching daily body battery for {days} days ending on {end_date}") - # Body Battery uses DailyBodyBatteryStress but stored in 'body_battery' naming usually? - # We use the class found: garth.data.body_battery.DailyBodyBatteryStress - all_metrics["body_battery"] = garth.data.body_battery.DailyBodyBatteryStress.list(end, period=days) - except Exception as e: - logger.error(f"Error fetching daily body battery: {e}") + for key, val in day_metrics.items(): + if val: + if isinstance(val, list): + all_metrics[key].extend(val) + else: + all_metrics[key].append(val) + + current_date += timedelta(days=1) return all_metrics + + def get_metric_data(self, date_str: str, metric_type: str) -> Optional[Any]: + """ + Fetch specific metric data for a single date. + """ + try: + if metric_type == 'steps': + summary = self.client.get_user_summary(date_str) + if summary: + return { + "calendarDate": date_str, + "totalSteps": summary.get("totalSteps"), + "totalDistanceMeters": summary.get("totalDistanceMeters"), + "stepGoal": summary.get("dailyStepGoal") + } + + elif metric_type == 'intensity': + summary = self.client.get_user_summary(date_str) + if summary: + return { + "calendarDate": date_str, + "moderateIntensityMinutes": summary.get("moderateIntensityMinutes"), + "vigorousIntensityMinutes": summary.get("vigorousIntensityMinutes"), + "intensityGoal": summary.get("activeTimeGoal") + } + + elif metric_type == 'stress': + return self.client.get_stress_data(date_str) + + elif metric_type == 'hrv': + return self.client.get_hrv_data(date_str) + + elif metric_type == 'sleep': + return self.client.get_sleep_data(date_str) + + elif metric_type == 'hydration': + return self.client.get_hydration_data(date_str) + + elif metric_type == 'weight': + data = self.client.get_body_composition(date_str) + # Normalize weight return if needed + return data + + elif metric_type == 'body_battery': + return self.client.get_body_battery(date_str) + + elif metric_type == 'respiration': + return self.client.get_respiration_data(date_str) + + elif metric_type == 'spo2': + return self.client.get_spo2_data(date_str) + + elif metric_type == 'floors': + # Floors are part of the daily summary usually + summary = self.client.get_user_summary(date_str) + if summary: + return { + "calendarDate": date_str, + "floorsClimbed": summary.get("floorsClimbed"), + "floorsDescended": summary.get("floorsDescended"), + "floorsGoal": summary.get("floorsClimbedGoal") + } + + elif metric_type == 'sleep_score': + # Sleep score is inside sleep data + return self.client.get_sleep_data(date_str) + + elif metric_type == 'vo2_max': + # VO2 Max is in max metrics or training status + return self.client.get_max_metrics(date_str) + + else: + logger.warning(f"Unknown metric type: {metric_type}") + return None + + except Exception as e: + logger.error(f"Error fetching {metric_type} for {date_str}: {e}") + return None diff --git a/FitnessSync/backend/src/services/garth_helper.py b/FitnessSync/backend/src/services/garth_helper.py new file mode 100644 index 0000000..28184af --- /dev/null +++ b/FitnessSync/backend/src/services/garth_helper.py @@ -0,0 +1,35 @@ + +import logging +import json +import garth +from fastapi import HTTPException +from sqlalchemy.orm import Session +from ..models.api_token import APIToken +from garth.auth_tokens import OAuth1Token, OAuth2Token + +logger = logging.getLogger(__name__) + +def load_and_verify_garth_session(db: Session): + """Helper to load token from DB and verify session with Garmin.""" + logger.info("Loading and verifying Garmin session...") + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token): + logger.warning("Garmin token not found in DB.") + raise Exception("Garmin token not found.") + + try: + oauth1_dict = json.loads(token_record.garth_oauth1_token) + oauth2_dict = json.loads(token_record.garth_oauth2_token) + + domain = oauth1_dict.get('domain') + if domain: + garth.configure(domain=domain) + + garth.client.oauth1_token = OAuth1Token(**oauth1_dict) + garth.client.oauth2_token = OAuth2Token(**oauth2_dict) + + garth.UserProfile.get() + logger.info("Garth session verified.") + except Exception as e: + logger.error(f"Garth session verification failed: {e}", exc_info=True) + raise Exception(f"Failed to authenticate with Garmin: {e}") diff --git a/FitnessSync/backend/src/services/job_manager.py b/FitnessSync/backend/src/services/job_manager.py index c812be5..e7914ef 100644 --- a/FitnessSync/backend/src/services/job_manager.py +++ b/FitnessSync/backend/src/services/job_manager.py @@ -1,7 +1,15 @@ import uuid import logging from typing import Dict, Optional, List -from datetime import datetime +from datetime import datetime, timedelta +import threading +import json +from sqlalchemy.orm import Session +from sqlalchemy import desc + +from ..services.postgresql_manager import PostgreSQLManager +from ..utils.config import config +from ..models.job import Job logger = logging.getLogger(__name__) @@ -11,52 +19,205 @@ class JobManager: def __new__(cls): if cls._instance is None: cls._instance = super(JobManager, cls).__new__(cls) - cls._instance.active_jobs = {} + cls._instance.db_manager = PostgreSQLManager(config.DATABASE_URL) + # We still keep active_jobs in memory for simple locking/status + # But the detailed state and history will be DB backed + cls._instance.job_lock = threading.Lock() return cls._instance + def _get_db(self): + return self.db_manager.get_db_session() + + def run_serialized(self, job_id: str, func, *args, **kwargs): + """Run a function with a global lock to ensure serial execution.""" + if self.should_cancel(job_id): + self.update_job(job_id, status="cancelled", message="Cancelled before start") + return + + self.update_job(job_id, message="Queued (Waiting for lock)...") + + with self.job_lock: + if self.should_cancel(job_id): + self.update_job(job_id, status="cancelled", message="Cancelled while queued") + return + + self.update_job(job_id, message="Starting...") + try: + func(job_id, *args, **kwargs) + except Exception as e: + logger.error(f"Error in serialized job {job_id}: {e}") + self.fail_job(job_id, str(e)) + + def request_pause(self, job_id: str) -> bool: + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + if job and job.status == 'running': + job.paused = True + job.status = 'paused' + job.message = "Paused..." + db.commit() + return True + return False + + def resume_job(self, job_id: str) -> bool: + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + if job and job.paused: + job.paused = False + job.status = 'running' + job.message = "Resuming..." + db.commit() + return True + return False + + def should_pause(self, job_id: str) -> bool: + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + return job.paused if job else False + def create_job(self, operation: str) -> str: job_id = str(uuid.uuid4()) - self.active_jobs[job_id] = { - "id": job_id, - "operation": operation, - "status": "running", - "cancel_requested": False, - "start_time": datetime.now(), - "progress": 0, - "message": "Starting..." - } + new_job = Job( + id=job_id, + operation=operation, + status="running", + start_time=datetime.now(), + progress=0, + message="Starting..." + ) + + with self._get_db() as db: + db.add(new_job) + db.commit() + logger.info(f"Created job {job_id} for {operation}") return job_id + def _cleanup_jobs(self): + """Delete jobs older than 30 days.""" + try: + with self._get_db() as db: + cutoff = datetime.now() - timedelta(days=30) + db.query(Job).filter(Job.start_time < cutoff).delete() + db.commit() + except Exception as e: + logger.error(f"Error cleaning up jobs: {e}") + + def get_job_history(self, limit: int = 10, offset: int = 0) -> Dict: + # self._cleanup_jobs() # Optional: Run periodically, running on every fetch is okay-ish but maybe expensive? + # Let's run it async or less frequently? For now, run it here is safe. + self._cleanup_jobs() + + with self._get_db() as db: + # Sort desc by start_time + query = db.query(Job).order_by(desc(Job.start_time)) + total = query.count() + jobs = query.offset(offset).limit(limit).all() + + # Convert to dict + items = [] + for j in jobs: + items.append({ + "id": j.id, + "operation": j.operation, + "status": j.status, + "start_time": j.start_time.isoformat() if j.start_time else None, + "end_time": j.end_time.isoformat() if j.end_time else None, + "completed_at": j.end_time.isoformat() if j.end_time else None, # Compatibility + "duration_s": round((j.end_time - j.start_time).total_seconds(), 2) if j.end_time and j.start_time else None, + "progress": j.progress, + "message": j.message, + "result": j.result + }) + + return {"total": total, "items": items} + def get_job(self, job_id: str) -> Optional[Dict]: - return self.active_jobs.get(job_id) + with self._get_db() as db: + j = db.query(Job).filter(Job.id == job_id).first() + if j: + return { + "id": j.id, + "operation": j.operation, + "status": j.status, + "start_time": j.start_time, + "progress": j.progress, + "message": j.message, + "paused": j.paused, + "cancel_requested": j.cancel_requested + } + return None def get_active_jobs(self) -> List[Dict]: - return list(self.active_jobs.values()) + with self._get_db() as db: + active_jobs = db.query(Job).filter(Job.status.in_(['running', 'queued', 'paused'])).all() + return [{ + "id": j.id, + "operation": j.operation, + "status": j.status, + "start_time": j.start_time, + "progress": j.progress, + "message": j.message, + "paused": j.paused, + "cancel_requested": j.cancel_requested + } for j in active_jobs] def update_job(self, job_id: str, status: str = None, progress: int = None, message: str = None): - if job_id in self.active_jobs: - if status: - self.active_jobs[job_id]["status"] = status - if progress is not None: - self.active_jobs[job_id]["progress"] = progress - if message: - self.active_jobs[job_id]["message"] = message + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + if job: + if status: + job.status = status + if status in ["completed", "failed", "cancelled"] and not job.end_time: + job.end_time = datetime.now() + if progress is not None: + job.progress = progress + if message: + job.message = message + db.commit() def request_cancel(self, job_id: str) -> bool: - if job_id in self.active_jobs: - self.active_jobs[job_id]["cancel_requested"] = True - self.active_jobs[job_id]["message"] = "Cancelling..." - logger.info(f"Cancellation requested for job {job_id}") - return True + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + if job and job.status in ['running', 'queued', 'paused']: + # If paused, it's effectively stopped, so cancel immediately + if job.status == 'paused': + job.status = 'cancelled' + job.message = "Cancelled (while paused)" + job.end_time = datetime.now() + else: + job.cancel_requested = True + job.message = "Cancelling..." + + db.commit() + logger.info(f"Cancellation requested for job {job_id}") + return True return False def should_cancel(self, job_id: str) -> bool: - job = self.active_jobs.get(job_id) - return job and job.get("cancel_requested", False) + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + return job.cancel_requested if job else False - def complete_job(self, job_id: str): - if job_id in self.active_jobs: - del self.active_jobs[job_id] + def complete_job(self, job_id: str, result: Dict = None): + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "completed" + job.progress = 100 + job.message = "Completed" + job.end_time = datetime.now() + if result: + job.result = result + db.commit() + + def fail_job(self, job_id: str, error: str): + with self._get_db() as db: + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.message = error + job.end_time = datetime.now() + db.commit() job_manager = JobManager() diff --git a/FitnessSync/backend/src/services/postgresql_manager.py b/FitnessSync/backend/src/services/postgresql_manager.py index 6184252..6381cb2 100644 --- a/FitnessSync/backend/src/services/postgresql_manager.py +++ b/FitnessSync/backend/src/services/postgresql_manager.py @@ -6,7 +6,8 @@ import os from contextlib import contextmanager # Create a base class for declarative models -Base = declarative_base() +# Import Base from the models package to ensure we share the same metadata +from ..models.base import Base class PostgreSQLManager: def __init__(self, database_url: str = None): @@ -33,6 +34,8 @@ class PostgreSQLManager: from ..models.activity import Activity from ..models.health_metric import HealthMetric from ..models.sync_log import SyncLog + from ..models.activity_state import GarminActivityState + from ..models.health_state import HealthSyncState # Create all tables Base.metadata.create_all(bind=self.engine) diff --git a/FitnessSync/backend/src/services/scheduler.py b/FitnessSync/backend/src/services/scheduler.py new file mode 100644 index 0000000..fd7eec3 --- /dev/null +++ b/FitnessSync/backend/src/services/scheduler.py @@ -0,0 +1,161 @@ + +import threading +import time +import json +import logging +from datetime import datetime, timedelta +from sqlalchemy import or_ +from ..services.postgresql_manager import PostgreSQLManager +from ..models.scheduled_job import ScheduledJob +from ..services.job_manager import job_manager +from ..utils.config import config +from ..tasks.definitions import ( + run_activity_sync_task, + run_metrics_sync_task, + run_health_scan_job, + run_fitbit_sync_job, + run_garmin_upload_job, + run_health_sync_job, + run_activity_backfill_job +) + +logger = logging.getLogger(__name__) + +class SchedulerService: + def __init__(self): + self._stop_event = threading.Event() + self._thread = None + self.db_manager = PostgreSQLManager(config.DATABASE_URL) + + # Map job_type string to (function, default_params) + self.TASK_MAP = { + 'activity_sync': run_activity_sync_task, + 'metrics_sync': run_metrics_sync_task, + 'health_scan': run_health_scan_job, + 'fitbit_weight_sync': run_fitbit_sync_job, + 'garmin_weight_upload': run_garmin_upload_job, + 'health_sync_pending': run_health_sync_job, + 'activity_backfill_full': run_activity_backfill_job + } + + def start(self): + """Start the scheduler background thread.""" + if self._thread and self._thread.is_alive(): + return + logger.info("Starting Scheduler Service...") + self.ensure_defaults() + self._stop_event.clear() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def stop(self): + """Stop the scheduler.""" + logger.info("Stopping Scheduler Service...") + self._stop_event.set() + if self._thread: + self._thread.join(timeout=5) + + def ensure_defaults(self): + """Ensure default schedules exist.""" + with self.db_manager.get_db_session() as session: + try: + # Default 1: Fitbit Weight Sync (30 days) every 6 hours + job_type = 'fitbit_weight_sync' + name = 'Fitbit Weight Sync (30d)' + + existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() + if not existing: + logger.info(f"Creating default schedule: {name}") + new_job = ScheduledJob( + job_type=job_type, + name=name, + interval_minutes=360, # 6 hours + params=json.dumps({"days_back": 30}), + enabled=True + ) + session.add(new_job) + session.commit() + except Exception as e: + logger.error(f"Error checking default schedules: {e}") + + def _run_loop(self): + logger.info("Scheduler loop started.") + while not self._stop_event.is_set(): + try: + self._check_and_run_jobs() + except Exception as e: + logger.error(f"Error in scheduler loop: {e}", exc_info=True) + + # Sleep for 60 seconds, checking for stop event + if self._stop_event.wait(60): + break + logger.info("Scheduler loop exited.") + + def _check_and_run_jobs(self): + with self.db_manager.get_db_session() as session: + now = datetime.now() + + # Find due jobs + # due if enabled AND (next_run <= now OR next_run is NULL) + # also, if last_run is NULL, we might want to run immediately or schedule for later? + # Let's run immediately if next_run is NULL (freshly created). + + jobs = session.query(ScheduledJob).filter( + ScheduledJob.enabled == True, + or_( + ScheduledJob.next_run <= now, + ScheduledJob.next_run == None + ) + ).all() + + for job in jobs: + # Double check locking? For now, simple single-instance app is assumed. + # If we had multiple workers, we'd need 'FOR UPDATE SKIP LOCKED' or similar. + + self._execute_job(session, job) + + session.commit() + + def _execute_job(self, session, job_record): + logger.info(f"Executing scheduled job: {job_record.name} ({job_record.job_type})") + + task_func = self.TASK_MAP.get(job_record.job_type) + if not task_func: + logger.error(f"Unknown job type: {job_record.job_type}") + # Disable to prevent spam loop? + # job_record.enabled = False + return + + # Parse params + params = {} + if job_record.params: + try: + params = json.loads(job_record.params) + except: + logger.error(f"Invalid params for job {job_record.id}") + + # Create Job via Manager + job_id = job_manager.create_job(f"{job_record.name} (Scheduled)") + + # Launch task in thread (don't block scheduler loop) + # We pass self.db_manager.get_db_session factory + # Note: We must duplicate the factory access b/c `run_*` definitions use `with factory() as db:` + # passing self.db_manager.get_db_session is correct. + + t = threading.Thread( + target=task_func, + kwargs={ + "job_id": job_id, + "db_session_factory": self.db_manager.get_db_session, + **params + } + ) + t.start() + + # Update next_run + job_record.last_run = datetime.now() + job_record.next_run = datetime.now() + timedelta(minutes=job_record.interval_minutes) + # session commit happens in caller loop + +# Global instance +scheduler = SchedulerService() diff --git a/FitnessSync/backend/src/services/sync/__init__.py b/FitnessSync/backend/src/services/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/__init__.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cd416f0bbcfa6f4ac4dc4e70846d960f80452af GIT binary patch literal 151 zcmZ3^%ge<81hsu}nIQTxh=2h`DC095kTIPhg&~+hlhJP_LlF~@{~09tOG`hopg=z< zF*!RmFGat&C|SQawWusJIki~7xH2zUKR!M)FS8^*Uaz3?7l%!5eoARhs$CH)P%Fsr VVtyd;ftit!@dE>lC}IYR0RT9&A(a3C literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/__init__.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..56a78c9e6d7a992b2be09ccd1f86d6fdce34df87 GIT binary patch literal 178 zcmey&%ge<81hsu}nIQTxh=2h`DC08=kTI1Zok5e)ZzV$!6Oi{ABy}rLKO;XkRlm5n zBsH%@KcFZ-D>b>KSl=zPB%~-YIok-t%1bRS4zA2g)=vV8q~@jQ7Z)Y#7pE4LWhSQ< w>lcG$;^Q;(GE3s)^$IF)aoFVMrp!Y+bCu>UB? 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 NBjx|G!dppV{|`^sT~Po4 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14114f2b70b1fb9d31a7fe327686e8d1d86fc79e GIT binary patch literal 14244 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 P`clgK^LG@%bLjs9k&t~3 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/health.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/health.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aad0af793a82e03eb14132aefcff22bcb205bd62 GIT binary patch literal 19505 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/utils.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34102524b16b7ee9a922cb418151cce065cd8462 GIT binary patch literal 2471 zcmZ`*O>7fK6rT0|c-Q}l9g>(pT@r8%CBafb2`#kHL_+1SZPWukU^(6)IPBVHc1=jM zsiLZf6d@5+NFY(9RH7*;9C~P_o_eS_^k^eXtX4vbRB_<86r>{5L*MLX?YL@ZXJ@{7 z^X9#o_cOacgu?*@<3=}JX_UEm2yF`_|MA#fQh?MD;J^nPhm|1(sLG8!#H#T7Zm;C zX{?h{(Gq3~L@kvqzvgmTLDiYa>+@A?aa57TUnwXx7}Uxo-4e7)jTEsJma$$aDOk<} zPr(uh)@!6(1P8PVbqiJc((qB^OUOX0t;wxGhk37bns0T>jK_hH!5BV+f5@zX^mVGi zDXT5yQE0yg?|O@{=r;tC(4`gu&GH^LgihUMM)oa=hM%0$TkZwj{@KXG1!DPIDNav z1Ck;hHhg;0!$6OEcs1sst$nB7HSoZC%L~-*hWJoegLAF7&bBClXInwNRhA)jS_e|c zKZOR7KGfzKLS)}!K;P4*tR~vHjbzba?jTt*0(a0|?%#6HK`Ji>zJMeH+c;>BnL}6E zb7+pq1Q&8Auu{b&rB_n5Dvjz?g`|oEQ&dP@bW$SahJK3RVui>fsn48{)>7#*o=d-z zO1leXfUq{LT`EO)sd`wkXm~XIR+4!nR35K(8aPRG#rRyWl&VvUp7AGCdZ5xrWI+p_yk% z7fOnbNq&0X;xCklrd!;sRw-M&TvMx>#TKSD(hH1^c1IDR9tCZcIV-Ta@q9riG|DW0 zv7%0w%GhG*b-~fSf(Ti*q{*vA?A$(k=&Ds_-aYh z;W%6F6K9hFIC9>#k;Pz*CPk`U-lRth)#~VUp?C?GR~Yl!{mz)#l5^(VAXT zw2|t(wbMOEzC!Y@xAU&AH)WF%5KO(Norf!X3E7Fa`QD9q^45`Wj(k7Rh>tYmBTwRE zkKXo8`|I@ev<+8%mmNZfK4R*9r|xp!23C(W z`m)WwY`ri0JGUI(2*z!ckouoRyI01V@!|Ui8}S2;Xto*6{y6zKI$n>Co5TB8#Aa-_ znHpLVZ-&jxAv)aibkE*<7r&dmH%mu@W^Za`-i!~dj6dt&Z4RW(9lOk){-?=Z&E($u z@BZBP)4-GL@yFTYjqF4-JJCo^Hj|UUVD|1XckZ*JVlwgqLGy|>%OBg_D4e))^4iJU zfktSs85*pI2F-Zi%E8x9g9#h?_}+H&RE*pRT?^HFKD>Vk8mPZ~>h=xsSW`S!7mu0I zZOe1s^BRiHg9zxgx4_O{9v_+z&~JN>C82vH1SZ~NAH`A=huBAlc)%GSp{_oYNsyh; zTYef*5FTy}3h+^)1A4&{dT$efG6l5DNmNO~FUWVHncy zz6L)9$YvRaF;Q^Ic}x^pavl@ysCQNq4cGs_ZlLY;&T67v_0GD14%9pAhLl|HZA!`8 zV-0DjDGe>Bu7u4~qJV<&>-%j##UcuI*%Czo6pYwG jib5zHv%?fcP-5Wr#Lc6wuow~~yNlAP*#BzCI9TN$+!0!V literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/utils.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14af4518e47f8d875994a97acd6915f6b57f9332 GIT binary patch literal 2257 zcmZ8iU2GIZ9G|`2z5Te)atDQ@l%=$^2ehRMNQt08tK=#l-g*hKr2i=kr)$=nh0;2n8>s8z$2widKpS25lnbHY=TC8@ISY=J!F#G|8M61Isf_1 z+)5~v@KCVVL$@o8+0pWssh5mF)% zQ-0!at_c%TN+xm>ixWypB`RRQE=>edK@!wZmP;qRbZyy#vf(|PJ13ED)Q$8GUCyey zl8x$W)|c*S+j>Y3WY_3H(1+j`&Wc$<1eG4iMt4W`Xada&vmA*Px#XHB%;k8JqjJG; zuw#~SlBdF$nRlpsqUx9x%P3JXV*vmoVY+7JK}tHdZCHB3gE!2BN3l_IW{zQpn0YGM zu9gPM&4rvZSH)Cd38;A1DAiz6vrLEj?MjX0F%1>4W0)me$bpSP{RBHTV&&loRtGy6 zmTcvukF~!WWl*}+lvNu5lc05n*RnL*`3ob%WqcV?=SKPT^Ep~pds@k2^oC|S?c6A zZzY}dP+RJ9h6^KI$*5uUve1sT8Lp_M9#V^{E2p_>bb#NCrnzL`!n;Rtsfvl_RJ2-^ zF;%M&El)5*hBnirx8~AWBRF3ng&{57q^Yg-Tlif6POaZ{EC2-Ge*3gpt>QxeP?9IC z>x2=2MB#J1`0vf9_l`}Se2q#iAGkBFc~WW`(_riBG_MPZfEQKR?X(@ETy2LU>{+5b z6mVhPI3bX?8PK$fUa=Q^`!yJ4@p;pBU^~yf5zQi1*mBP85#_MWLTT+m zj}OmO%6Qne9c($nCrM=%=N)_a4bypt82QsLF=W9jrO#RU;VD?bR${xJ;Hq``RR)qP8`f%(|LjXm?RT?>(s@5dJ+qxFHo#b{q$>%A7LC-*K!w=Hkm z{@u*iGvCZ!l^3I1>z!IX*7ayZU%jipzNx3acEfU_XDPA$USj)=-9N7Xsq2TXrIEw) zBZn78#ugIeps#mss(0^r8j%y>OR2^h6pCM&xIA%PT?}rm$JYO~9Eh(VpV+x9hpz-L z2k&+c-Z*{Zy<0uE-oAC@?%LNE`%!47(NZ29 pI~oc@Dhh-f0fvGo6m5hU3Zr<}^|4P5HdrJG>sOw203xGM(tmmj2<`v? literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08bea3a9fb4f1e5bded98970ac4c54dd4d5c6a83 GIT binary patch literal 13178 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 VH6AwVVD+pIpW^(_Hz>sn{C^J39TNZm literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d06e94d14d98d21a5296d7ea045d806107d4199 GIT binary patch literal 12350 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/sync/activity.py b/FitnessSync/backend/src/services/sync/activity.py new file mode 100644 index 0000000..bb737b7 --- /dev/null +++ b/FitnessSync/backend/src/services/sync/activity.py @@ -0,0 +1,305 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ...models.activity import Activity +from ...models.activity_state import GarminActivityState +from ...models.sync_log import SyncLog +from ...services.garmin.client import GarminClient +from ...services.job_manager import job_manager + +logger = logging.getLogger(__name__) + +class GarminActivitySync: + def __init__(self, db_session: Session, garmin_client: GarminClient): + self.db_session = db_session + self.garmin_client = garmin_client + self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def _check_pause(self, job_id: str) -> bool: + """ + Checks if job is paused. Blocks if paused until resumed/cancelled. + Returns False if job is cancelled, True otherwise. + """ + if not job_id: return True + + # Initial check to avoid import overhead if not paused + if job_manager.should_pause(job_id): + self.logger.info(f"Job {job_id} paused. Waiting...") + import time + while job_manager.should_pause(job_id): + if job_manager.should_cancel(job_id): + self.logger.info(f"Job {job_id} cancelled while paused.") + return False + time.sleep(1) + self.logger.info(f"Job {job_id} resumed.") + + # Also check cancel here for convenience + return not job_manager.should_cancel(job_id) + + def scan_activities(self, days_back: int = 30, job_id: str = None) -> Dict[str, int]: + """ + Fetches metadata from Garmin and updates GarminActivityState table. + Does NOT download activity files. + """ + start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d') + end_date = datetime.now().strftime('%Y-%m-%d') + + self.logger.info(f"Scanning activities from {start_date} to {end_date}") + if job_id: + job_manager.update_job(job_id, message=f"Fetching activities list ({days_back} days)...") + + try: + garmin_activities = self.garmin_client.get_activities(start_date, end_date) + self.logger.info(f"Fetched {len(garmin_activities)} activities from Garmin for scanning.") + except Exception as e: + self.logger.error(f"Error fetching activities for scan: {e}") + raise + + stats = {'new': 0, 'updated': 0, 'synced': 0, 'total': len(garmin_activities)} + total_count = len(garmin_activities) + + if job_id: + job_manager.update_job(job_id, message=f"Processing {total_count} activities...", progress=0) + + for idx, activity_data in enumerate(garmin_activities): + if job_id: + # Check for pause/cancel + if not self._check_pause(job_id): + return stats + + # Update progress every 10 items or so to avoid spamming DB? + # Or just every item since it's fast? DB write is fast. + if idx % 5 == 0: + progress = int((idx / total_count) * 100) + job_manager.update_job(job_id, progress=progress, message=f"Scanning {idx}/{total_count}") + + activity_id = str(activity_data.get('activityId')) + if not activity_id: continue + + # Check actual download status + existing_main = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first() + is_downloaded = existing_main and existing_main.download_status == 'downloaded' and existing_main.file_content is not None + + # Determine correct status + current_status = 'synced' if is_downloaded else 'new' + + # Check State Table + state = self.db_session.query(GarminActivityState).filter_by(garmin_activity_id=activity_id).first() + + if not state: + # Insert new state + activity_type = activity_data.get('activityType', {}).get('typeKey', 'unknown') + start_time_str = activity_data.get('startTimeLocal') + start_time = datetime.fromisoformat(start_time_str) if start_time_str else None + + state = GarminActivityState( + garmin_activity_id=activity_id, + activity_name=activity_data.get('activityName'), + activity_type=activity_type, + start_time=start_time, + sync_status=current_status + ) + self.db_session.add(state) + if current_status == 'new': stats['new'] += 1 + else: stats['synced'] += 1 + else: + # Update existing state + if state.sync_status != 'synced' and is_downloaded: + state.sync_status = 'synced' + stats['synced'] += 1 # Transitioned to synced + elif state.sync_status == 'synced' and not is_downloaded: + state.sync_status = 'new' + stats['new'] += 1 # Regression? or manual deletion + + # Update last seen + state.last_seen = datetime.now() + + # Backfill missing duration/metrics on main Activity record if available + if existing_main: + modified = False + if existing_main.duration is None and activity_data.get('duration'): + existing_main.duration = activity_data.get('duration') + modified = True + + if existing_main.distance is None and activity_data.get('distanceMeters'): + existing_main.distance = activity_data.get('distanceMeters') + modified = True + + if existing_main.avg_hr is None and activity_data.get('averageHR'): + existing_main.avg_hr = activity_data.get('averageHR') + modified = True + + if existing_main.avg_speed is None and activity_data.get('averageSpeed'): + existing_main.avg_speed = activity_data.get('averageSpeed') + modified = True + + if existing_main.avg_cadence is None: + # Try various cadence keys + cadence = ( + activity_data.get('averageRunningCadenceInStepsPerMinute') or + activity_data.get('averageBikingCadenceInRevPerMinute') or + activity_data.get('averageSwimCadenceInStrokesPerMinute') + ) + if cadence: + existing_main.avg_cadence = cadence + modified = True + + if modified: + stats['updated'] += 1 + + self.db_session.commit() + self.logger.info(f"Scan complete: {stats}") + return stats + + def sync_pending_activities(self, limit: int = None, job_id: str = None) -> Dict[str, int]: + """ + Syncs activities marked as 'new' or 'updated' in GarminActivityState. + """ + query = self.db_session.query(GarminActivityState).filter( + GarminActivityState.sync_status.in_(['new', 'updated']) + ).order_by(GarminActivityState.start_time.desc()) + + if limit: + query = query.limit(limit) + + pending_activities = query.all() + total_count = len(pending_activities) + processed_count = 0 + failed_count = 0 + + self.logger.info(f"Found {total_count} pending activities to sync (limit={limit})") + + if job_id: + job_manager.update_job(job_id, message=f"Starting sync for {total_count} activities...", progress=0) + + for idx, state in enumerate(pending_activities): + # Check for cancellation/pause + if job_id and not self._check_pause(job_id): + self.logger.info("Sync pending activities cancelled by user.") + break + + if job_id: + progress = int((idx / total_count) * 100) + job_manager.update_job(job_id, message=f"Syncing {idx+1}/{total_count}: {state.activity_name or state.garmin_activity_id}", progress=progress) + + try: + # 1. Ensure Activity record exists + activity = self.db_session.query(Activity).filter_by(garmin_activity_id=state.garmin_activity_id).first() + if not activity: + activity = Activity( + garmin_activity_id=state.garmin_activity_id, + activity_name=state.activity_name, + activity_type=state.activity_type, + start_time=state.start_time, + download_status='pending' + ) + self.db_session.add(activity) + + # Fetch full metadata to populate metrics (since state lacks them) + try: + # Accessing internal client for direct call - TODO: Add wrapper to GarminClient + full_details = self.garmin_client.client.get_activity(state.garmin_activity_id) + if full_details: + self._update_activity_metrics(activity, full_details) + except Exception as meta_e: + self.logger.warning(f"Failed to fetch metadata for {state.garmin_activity_id}: {meta_e}") + + self.db_session.flush() + + # 2. Download content (reuse redownload logic) + success = self.redownload_activity(state.garmin_activity_id) + + if success: + state.sync_status = 'synced' + state.last_seen = datetime.now() + processed_count += 1 + else: + failed_count += 1 + + self.db_session.commit() + + except Exception as e: + self.logger.error(f"Error syncing pending activity {state.garmin_activity_id}: {e}", exc_info=True) + failed_count += 1 + self.db_session.rollback() + + if job_id: + job_manager.complete_job(job_id) + + return {"processed": processed_count, "failed": failed_count} + + def redownload_activity(self, activity_id: str) -> bool: + """ + Force re-download of an activity file from Garmin. + """ + self.logger.info(f"Redownloading activity {activity_id}...") + try: + # Find the activity + activity = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first() + if not activity: + self.logger.error(f"Activity {activity_id} not found locally.") + return False + + # Attempt download with fallback order + downloaded = False + for fmt in ['fit', 'original', 'tcx', 'gpx']: + file_content = self.garmin_client.download_activity(activity_id, file_type=fmt) + if file_content: + activity.file_content = file_content + activity.file_type = fmt + activity.download_status = 'downloaded' + activity.downloaded_at = datetime.now() + self.logger.info(f"✓ Successfully redownloaded {activity_id} as {fmt}") + downloaded = True + break + + if not downloaded: + self.logger.warning(f"Failed to redownload {activity_id}") + return False + + self.db_session.commit() + return True + + except Exception as e: + self.logger.error(f"Error redownloading activity {activity_id}: {e}", exc_info=True) + self.db_session.rollback() + return False + + def _update_activity_metrics(self, activity: Activity, data: Dict[str, Any]): + """Populate extended metrics from Garmin JSON.""" + # Duration override if available (sometimes different from file/header) + if data.get('duration'): + activity.duration = data.get('duration') + + activity.distance = data.get('distanceMeters') + activity.calories = data.get('calories') + activity.avg_hr = data.get('averageHR') + activity.max_hr = data.get('maxHR') + activity.avg_speed = data.get('averageSpeed') + activity.max_speed = data.get('maxSpeed') + activity.elevation_gain = data.get('totalElevationGain') + activity.elevation_loss = data.get('totalElevationLoss') + activity.steps = data.get('steps') + activity.aerobic_te = data.get('trainingEffect') + activity.anaerobic_te = data.get('anaerobicTrainingEffect') + activity.vo2_max = data.get('vO2MaxValue') + activity.avg_power = data.get('avgPower') + activity.max_power = data.get('maxPower') + activity.norm_power = data.get('normalizedPower') + activity.tss = data.get('trainingStressScore') + + # Cadence handling (try various sport-specific keys) + activity.avg_cadence = ( + data.get('averageRunningCadenceInStepsPerMinute') or + data.get('averageBikingCadenceInRevPerMinute') or + data.get('averageSwimCadenceInStrokesPerMinute') + ) + activity.max_cadence = ( + data.get('maxRunningCadenceInStepsPerMinute') or + data.get('maxBikingCadenceInRevPerMinute') or + data.get('maxSwimCadenceInStrokesPerMinute') + ) diff --git a/FitnessSync/backend/src/services/sync/health.py b/FitnessSync/backend/src/services/sync/health.py new file mode 100644 index 0000000..06a6521 --- /dev/null +++ b/FitnessSync/backend/src/services/sync/health.py @@ -0,0 +1,392 @@ +import json +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional + +from sqlalchemy.orm import Session + +from ...models.health_metric import HealthMetric +from ...models.health_state import HealthSyncState +from ...models.sync_log import SyncLog +from ...services.garmin.client import GarminClient +from ...services.job_manager import job_manager +from .utils import update_or_create_health_metric + +logger = logging.getLogger(__name__) + +class GarminHealthSync: + def __init__(self, db_session: Session, garmin_client: GarminClient): + self.db_session = db_session + self.garmin_client = garmin_client + self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def _check_pause(self, job_id: str) -> bool: + """ + Checks if job is paused. Blocks if paused until resumed/cancelled. + Returns False if job is cancelled, True otherwise. + """ + if not job_id: return True + + if job_manager.should_pause(job_id): + self.logger.info(f"Job {job_id} paused. Waiting...") + import time + while job_manager.should_pause(job_id): + if job_manager.should_cancel(job_id): + self.logger.info(f"Job {job_id} cancelled while paused.") + return False + time.sleep(1) + self.logger.info(f"Job {job_id} resumed.") + + return not job_manager.should_cancel(job_id) + + def scan_health_metrics(self, days_back: int = 30) -> Dict[str, int]: + """ + Scans for gaps in health metrics for the last 'days_back' days. + Populates HealthSyncState with 'new' for missing dates. + Does NOT contact Garmin API (gap analysis only). + """ + self.logger.info(f"Scanning health metrics gaps for last {days_back} days") + + # Excluded 'weight' as it is handled by WeightSyncService + metrics = ['steps', 'hrv', 'sleep', 'stress', 'intensity', 'hydration', 'body_battery', + 'respiration', 'spo2', 'floors', 'sleep_score', 'vo2_max'] + stats = {m: 0 for m in metrics} + + today = datetime.now().date() + min_date = today - timedelta(days=days_back) + + for metric in metrics: + # Find last synced date for this metric + last_record = self.db_session.query(HealthMetric).filter_by( + metric_type=metric, + source='garmin' + ).order_by(HealthMetric.date.desc()).first() + + # Start form min_date or day after last record + start_scan = min_date + if last_record: + last_date = last_record.date + if isinstance(last_date, datetime): + last_date = last_date.date() + + if last_date >= min_date: + start_scan = last_date + timedelta(days=1) + + current = start_scan + while current < today: # Sync up to yesterday for completed days + # Check if state exists + state = self.db_session.query(HealthSyncState).filter_by( + date=current, metric_type=metric, source='garmin' + ).first() + + if not state: + state = HealthSyncState( + date=current, + metric_type=metric, + source='garmin', + sync_status='new' + ) + self.db_session.add(state) + stats[metric] += 1 + elif state.sync_status == 'synced': + state.sync_status = 'new' + stats[metric] += 1 + + current += timedelta(days=1) + + # Always check "today" + state_today = self.db_session.query(HealthSyncState).filter_by( + date=today, metric_type=metric, source='garmin' + ).first() + if not state_today: + state_today = HealthSyncState(date=today, metric_type=metric, source='garmin', sync_status='new') + self.db_session.add(state_today) + stats[metric] += 1 + else: + if state_today.sync_status == 'synced': + state_today.sync_status = 'updated' + stats[metric] += 1 + + self.db_session.commit() + self.logger.info(f"Health Scan Complete: found gaps {stats}") + return stats + + def sync_health_metrics(self, days_back: int = 30, job_id: str = None) -> Dict[str, int]: + """Sync health metrics from Garmin to local database.""" + start = (datetime.now() - timedelta(days=days_back)).date() + end = datetime.now().date() + + self.logger.info(f"=== Starting sync_health_metrics with days_back={days_back} ===") + sync_log = SyncLog(operation="health_metric_sync", status="started", start_time=datetime.now()) + self.db_session.add(sync_log) + self.db_session.commit() + + processed_count = 0 + failed_count = 0 + + # Excluded weight + metrics_breakdown = { + 'steps': {'new': 0, 'updated': 0}, 'hrv': {'new': 0, 'updated': 0}, + 'sleep': {'new': 0, 'updated': 0}, 'stress': {'new': 0, 'updated': 0}, + 'intensity': {'new': 0, 'updated': 0}, 'hydration': {'new': 0, 'updated': 0}, + 'body_battery': {'new': 0, 'updated': 0} + } + + stats_counters = {k: {"total": 0, "synced": 0} for k in metrics_breakdown.keys()} + stats_counters["floors"] = {"total": 0, "synced": 0} + stats_counters["spo2"] = {"total": 0, "synced": 0} + stats_counters["respiration"] = {"total": 0, "synced": 0} + stats_counters["sleep_score"] = {"total": 0, "synced": 0} + stats_counters["vo2_max"] = {"total": 0, "synced": 0} + + try: + total_days = (end - start).days + 1 + current_date = start + days_processed = 0 + + while current_date <= end: + # Check cancellation/pause + if job_id and not self._check_pause(job_id): + self.logger.info("Sync cancelled by user.") + sync_log.status = "cancelled" + sync_log.message = "Cancelled by user" + break + + date_str = current_date.strftime('%Y-%m-%d') + + if job_id: + progress = int((days_processed / total_days) * 100) + job_manager.update_job(job_id, message=f"Syncing metrics for {date_str}", progress=progress) + + # Fetch ALL metrics for this single day + day_metrics = self.garmin_client.get_all_metrics_for_date(date_str) + + # Check cancellation again after network call + if job_id and not self._check_pause(job_id): + sync_log.status = "cancelled" + break + + # Process specific metrics from the dict + self._process_day_metrics_dict(current_date, day_metrics, metrics_breakdown, stats_counters) + + current_date += timedelta(days=1) + days_processed += 1 + + # Optional: Sleep to prevent rate limit issues + # import time; time.sleep(0.5) + + if sync_log.status != "cancelled": + sync_log.status = "completed_with_errors" if failed_count > 0 else "completed" + processed_count = sum(v['synced'] for v in stats_counters.values()) + sync_log.records_processed = processed_count + sync_log.records_failed = failed_count + + # Save stats to message + stats_list = [] + for k, v in stats_counters.items(): + if v["total"] > 0: # Only show what we found + stats_list.append({"type": k.replace('_', ' ').title(), "source": "Garmin", "total": v["total"], "synced": v["synced"]}) + + sync_log.message = json.dumps({"summary": stats_list}) + + except Exception as e: + if str(e) == "Cancelled by user": + self.logger.info("Sync cancelled by user.") + sync_log.status = "cancelled" + sync_log.message = "Cancelled by user" + else: + self.logger.error(f"Major error during health metrics sync: {e}", exc_info=True) + sync_log.status = "failed" + sync_log.message = str(e) + + sync_log.end_time = datetime.now() + self.db_session.commit() + + if job_id: + job_manager.complete_job(job_id) + + self.logger.info(f"=== Finished sync_health_metrics ===") + return {"processed": processed_count, "failed": failed_count} + + def _process_day_metrics_dict(self, date: datetime.date, metrics: Dict[str, Any], breakdown: Dict, stats: Dict): + """Helper to process the dictionary returned by get_all_metrics_for_date""" + + def update_stat(key, status): + new = (status == 'new') + updated = (status == 'updated') + + if key in breakdown: + if new: breakdown[key]['new'] += 1 + if updated: breakdown[key]['updated'] += 1 + if key in stats: + stats[key]["total"] += 1 + if status != 'error': + stats[key]["synced"] += 1 + + # Steps + if metrics.get("steps"): + s = metrics["steps"] + status = update_or_create_health_metric(self.db_session, 'steps', date, float(s.get('totalSteps', 0)), 'steps') + update_stat('steps', status) + + # Intensity + if metrics.get("intensity"): + i = metrics["intensity"] + mod = i.get('moderateIntensityMinutes', 0) or 0 + vig = i.get('vigorousIntensityMinutes', 0) or 0 + status = update_or_create_health_metric(self.db_session, 'intensity_minutes', date, float(mod+vig), 'minutes') + update_stat('intensity', status) + + # Stress + if metrics.get("stress") and metrics["stress"].get("overallStressLevel"): + status = update_or_create_health_metric(self.db_session, 'stress', date, float(metrics["stress"]["overallStressLevel"]), 'score') + update_stat('stress', status) + + # HRV + if metrics.get("hrv") and metrics["hrv"].get("lastNightAvg"): + status = update_or_create_health_metric(self.db_session, 'hrv', date, float(metrics["hrv"]["lastNightAvg"]), 'ms') + update_stat('hrv', status) + + # Sleep + if metrics.get("sleep") and metrics["sleep"].get("dailySleepDTO"): + dto = metrics["sleep"]["dailySleepDTO"] + if dto.get("sleepTimeSeconds"): + status = update_or_create_health_metric(self.db_session, 'sleep', date, float(dto["sleepTimeSeconds"]), 'seconds') + update_stat('sleep', status) + + # Hydration + if metrics.get("hydration") and metrics["hydration"].get("valueInML"): + status = update_or_create_health_metric(self.db_session, 'hydration', date, float(metrics["hydration"]["valueInML"]), 'ml') + update_stat('hydration', status) + + # Body Battery + if metrics.get("body_battery") and metrics["body_battery"].get("bodyBatteryValuesArray"): + vals = [v[1] for v in metrics["body_battery"]["bodyBatteryValuesArray"] if v and len(v)>1 and isinstance(v[1], (int, float))] + if vals: + status = update_or_create_health_metric(self.db_session, 'body_battery_max', date, float(max(vals)), 'percent') + update_stat('body_battery', status) + + # Floors + if metrics.get("floors"): + val = metrics["floors"].get('floorsClimbed') + if val is not None: + status = update_or_create_health_metric(self.db_session, 'floors', date, float(val), 'floors') + update_stat('floors', status) + + # Respiration + if metrics.get("respiration"): + val = metrics["respiration"].get('avgRespirationValue') + if val: + status = update_or_create_health_metric(self.db_session, 'respiration', date, float(val), 'brpm') + update_stat('respiration', status) + + # SpO2 + if metrics.get("spo2"): + val = metrics["spo2"].get('averageSpO2') + if val: + status = update_or_create_health_metric(self.db_session, 'spo2', date, float(val), 'percent') + update_stat('spo2', status) + + # Sleep Score + if metrics.get("sleep_score"): + # Flatten logic already done in client usually? Or handled here? + # get_all_metrics_for_date in client populates this key + val = None + data = metrics["sleep_score"] + if hasattr(data, 'daily_sleep_dto') and data.daily_sleep_dto.sleep_scores: + val = data.daily_sleep_dto.sleep_scores.get('overall') + elif isinstance(data, dict): + # Try to find score + val = data.get('value') # If pre-flattened + if not val and 'dailySleepDTO' in data: + val = data['dailySleepDTO'].get('sleepScores', {}).get('overall') + + if val: + status = update_or_create_health_metric(self.db_session, 'sleep_score', date, float(val.get('value', val) if isinstance(val, dict) else val), 'score') + update_stat('sleep_score', status) + + # VO2 Max + if metrics.get("vo2_max"): + val = None + data = metrics["vo2_max"] + if isinstance(data, dict): + val = data.get('generic', {}).get('vo2MaxPreciseValue') + + if val: + status = update_or_create_health_metric(self.db_session, 'vo2_max', date, float(val), 'ml/kg/min', detailed_data=data) + update_stat('vo2_max', status) + + + def sync_pending_health_metrics(self, limit: int = None, job_id: str = None) -> Dict[str, int]: + """ + Syncs health metrics marked as 'new' or 'updated' in HealthSyncState. + """ + query = self.db_session.query(HealthSyncState).filter( + HealthSyncState.sync_status.in_(['new', 'updated']), + HealthSyncState.source == 'garmin' + ).order_by(HealthSyncState.date.desc()) + + if limit: + query = query.limit(limit) + + pending_items = query.all() + total_count = len(pending_items) + processed_count = 0 + failed_count = 0 + + self.logger.info(f"Found {total_count} pending health metrics to sync") + + if job_id: + job_manager.update_job(job_id, message=f"Starting health sync for {total_count} items...", progress=0) + + for idx, state in enumerate(pending_items): + # Check for cancellation/pause + if job_id and not self._check_pause(job_id): + break + + if job_id and idx % 5 == 0: + progress = int((idx / total_count) * 100) + job_manager.update_job(job_id, message=f"Syncing {state.metric_type} for {state.date}", progress=progress) + + try: + date_str = state.date.strftime('%Y-%m-%d') + data = self.garmin_client.get_metric_data(date_str, state.metric_type) + + if data: + # Create a pseudo-dict to reuse _process_day_metrics_dict? + # Or just call utils directly. _process_day_metrics_dict handles complex parsing (spo2, vo2 max etc). + # It's better to reuse _process_day_metrics_dict to avoid duplication of parsing logic. + # But _process expects a big dict keyed by metric type. + pseudo_dict = {state.metric_type: data} + + # stats/breakdown are required args + dummy_stats = {state.metric_type: {"total":0, "synced":0}} + dummy_breakdown = {} + + # Call helper + self._process_day_metrics_dict(state.date, pseudo_dict, dummy_breakdown, dummy_stats) + + # Check if it was actually synced (not error) + if dummy_stats[state.metric_type]["synced"] > 0: + state.sync_status = 'synced' + state.last_seen = datetime.now() + processed_count += 1 + else: + # Not synced (error) + failed_count += 1 + else: + # No data found (maybe user didn't wear device) + state.sync_status = 'synced' + processed_count += 1 + + self.db_session.commit() + + except Exception as e: + self.logger.error(f"Error syncing {state.metric_type} for {state.date}: {e}") + failed_count += 1 + self.db_session.rollback() + + if job_id: + job_manager.complete_job(job_id) + + return {"processed": processed_count, "failed": failed_count} diff --git a/FitnessSync/backend/src/services/sync/utils.py b/FitnessSync/backend/src/services/sync/utils.py new file mode 100644 index 0000000..8c35e7e --- /dev/null +++ b/FitnessSync/backend/src/services/sync/utils.py @@ -0,0 +1,56 @@ +import json +import logging +from datetime import datetime +from typing import Dict, Optional, Union + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ...models.health_metric import HealthMetric + +logger = logging.getLogger(__name__) + +def update_or_create_health_metric( + session: Session, + metric_type: str, + date: datetime.date, + value: float, + unit: str, + source: str = 'garmin', + detailed_data: Optional[Dict] = None +) -> str: + """Helper to update or create a health metric record. Returns 'new', 'updated', or 'skipped'.""" + try: + # Check for existing metric by type, date AND source (to separate garmin and fitbit weight if both exist) + existing = session.query(HealthMetric).filter_by( + metric_type=metric_type, + date=date, + source=source + ).first() + + detailed_json = json.dumps(detailed_data) if detailed_data else None + + if existing: + # Update if value changed + if abs(existing.metric_value - value) > 0.001: + existing.metric_value = value + existing.unit = unit + existing.detailed_data = detailed_json + existing.updated_at = func.now() + return 'updated' + return 'skipped' + else: + new_metric = HealthMetric( + metric_type=metric_type, + metric_value=value, + unit=unit, + timestamp=datetime.combine(date, datetime.min.time()), # Default to midnight if only date + date=date, + source=source, + detailed_data=detailed_json + ) + session.add(new_metric) + return 'new' + except Exception as e: + logger.error(f"Error updating metric {metric_type}: {e}") + return 'error' diff --git a/FitnessSync/backend/src/services/sync/weight.py b/FitnessSync/backend/src/services/sync/weight.py new file mode 100644 index 0000000..2d31417 --- /dev/null +++ b/FitnessSync/backend/src/services/sync/weight.py @@ -0,0 +1,274 @@ +import logging +import re +from datetime import datetime, timedelta, date +from typing import Dict, Optional, Any + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ...models.health_metric import HealthMetric +from ...models.weight_record import WeightRecord +from ...services.garmin.client import GarminClient +from ...services.job_manager import job_manager +from .utils import update_or_create_health_metric + +logger = logging.getLogger(__name__) + +class WeightSyncService: + def __init__(self, db_session: Session, garmin_client: GarminClient, fitbit_client: Any = None): + self.db_session = db_session + self.garmin_client = garmin_client + self.fitbit_client = fitbit_client + self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def _check_pause(self, job_id: str) -> bool: + """ + Checks if job is paused. Blocks if paused until resumed/cancelled. + Returns False if job is cancelled, True otherwise. + """ + if not job_id: return True + + if job_manager.should_pause(job_id): + self.logger.info(f"Job {job_id} paused. Waiting...") + import time + while job_manager.should_pause(job_id): + if job_manager.should_cancel(job_id): + self.logger.info(f"Job {job_id} cancelled while paused.") + return False + time.sleep(1) + self.logger.info(f"Job {job_id} resumed.") + + return not job_manager.should_cancel(job_id) + + def sync_fitbit_weight(self, days_back: int = 30, job_id: str = None) -> int: + """ + Sync weight logs from Fitbit. Handles chunking for large date ranges. + """ + if not self.fitbit_client: + self.logger.warning("Fitbit client not initialized") + return 0 + + final_end_date = datetime.now() + start_date = final_end_date - timedelta(days=days_back) + + count = 0 + current_start = start_date + + self.logger.info(f"Syncing Fitbit weight from {start_date.date()} to {final_end_date.date()}") + + retry_count = 0 + MAX_RETRIES = 5 + + while current_start < final_end_date: + # Check pause + if job_id and not self._check_pause(job_id): + self.logger.info("Fitbit sync cancelled by user.") + break + # Fitbit limits to 31 days usually, we'll use 30 to be safe + chunk_end = min(current_start + timedelta(days=30), final_end_date) + + start_str = current_start.strftime('%Y-%m-%d') + end_str = chunk_end.strftime('%Y-%m-%d') + + self.logger.info(f"Fetching Fitbit chunk: {start_str} to {end_str}") + + try: + logs = self.fitbit_client.get_weight_logs(start_str, end_str) + + # Reset retry count using success + retry_count = 0 + + for log in logs: + try: + weight_val = float(log.get('weight', 0)) + if weight_val <= 0: continue + + date_str = log.get('date') + time_str = log.get('time', '12:00:00') + log_dt = datetime.fromisoformat(f"{date_str}T{time_str}") + + res = update_or_create_health_metric( + self.db_session, + metric_type='weight', + date=log_dt.date(), + value=weight_val, + unit='kg', + source='fitbit', + detailed_data=log + ) + if res in ['new', 'updated']: + count += 1 + + except Exception as e: + self.logger.error(f"Error parsing fitbit weight log: {e}") + continue + + self.db_session.commit() + + # Advance to next chunk + current_start = chunk_end + timedelta(days=1) + + # Rate limit politeness + import time + time.sleep(1.0) + + except Exception as e: + err_msg = str(e) + if "Rate Limit" in err_msg or "Retry-After" in err_msg: + retry_count += 1 + if retry_count > MAX_RETRIES: + self.logger.error(f"Fitbit sync aborted: Max rate-limit retries ({MAX_RETRIES}) reached.") + break + + wait_time = 60 + # Try to parse seconds + match = re.search(r'Retry-After: (\d+)s?', err_msg) + if match: + wait_time = int(match.group(1)) + + self.logger.warning(f"Fitbit rate limit hit (Attempt {retry_count}/{MAX_RETRIES}). Sleeping {wait_time}s before retrying chunk...") + import time + time.sleep(wait_time + 5) # Add 5s buffer + # Continue loop WITHOUT updating current_start to retry same chunk + continue + + self.logger.error(f"Fitbit sync chunk failed ({start_str} to {end_str}): {e}") + # For non-rate limits, skip chunk to proceed + current_start = chunk_end + timedelta(days=1) + + self.logger.info(f"Synced {count} Fitbit weight records total") + return count + + def _sync_weight_range(self, start_date: date, end_date: date): + """Helper to fetch and save weight history for a range.""" + try: + s_str = start_date.strftime('%Y-%m-%d') + e_str = end_date.strftime('%Y-%m-%d') + data = self.garmin_client.get_weight_history(s_str, e_str) + + if not data or 'dateWeightList' not in data: + return + + count = 0 + for w_item in data['dateWeightList']: + # Parse date + d_str = w_item.get('calendarDate') + if not d_str: continue + d = datetime.strptime(d_str, '%Y-%m-%d').date() + + # Get weight + w_val = w_item.get('weight') + if w_val: + update_or_create_health_metric( + self.db_session, + 'weight', + d, + float(w_val)/1000.0, + 'kg', + detailed_data=w_item + ) + count += 1 + + self.logger.info(f"Optimistically synced {count} weight records via range fetch.") + self.db_session.commit() + + except Exception as e: + self.logger.error(f"Error in _sync_weight_range: {e}") + + def reconcile_and_tag_weights(self): + """ + Compare Fitbit (WeightRecord) vs Garmin (HealthMetric) and tag sync_status. + """ + self.logger.info("Reconciling weight records...") + + # 1. Fetch all Fitbit records + fitbit_records = self.db_session.query(WeightRecord).all() + + # 2. Fetch all Garmin records + garmin_metrics = self.db_session.query(HealthMetric).filter( + HealthMetric.metric_type == 'weight', + HealthMetric.source == 'garmin' + ).all() + + garmin_map = {} + for gm in garmin_metrics: + d_str = gm.date.strftime('%Y-%m-%d') if hasattr(gm.date, 'strftime') else str(gm.date) + garmin_map[d_str] = gm.metric_value + + updated_count = 0 + + for record in fitbit_records: + d_str = record.date.strftime('%Y-%m-%d') + + status = 'unsynced' + + if d_str in garmin_map: + g_val = garmin_map[d_str] + # Compare kg + if abs(record.weight - g_val) < 0.05: # 50g tolerance + status = 'synced' + else: + status = 'unsynced' + + if record.sync_status != status: + record.sync_status = status + updated_count += 1 + + self.db_session.commit() + self.logger.info(f"Reconciliation complete. Updated status for {updated_count} records.") + + def sync_weights_to_garmin(self, limit: int = 50, job_id: str = None) -> Dict[str, int]: + """ + Uploads 'unsynced' weight records from Fitbit to Garmin. + """ + # First, ensure our tags are correct + self.reconcile_and_tag_weights() + + # Query unsynced + # Sort by date ASC (oldest first) to fill history + unsynced = self.db_session.query(WeightRecord).filter( + WeightRecord.sync_status == 'unsynced' + ).order_by(WeightRecord.date.asc()).limit(limit).all() + + total = len(unsynced) + processed = 0 + failed = 0 + + self.logger.info(f"Found {total} unsynced weight records to upload.") + # print(f"DEBUG: Found {total} unsynced weight records.", flush=True) + + if job_id: + job_manager.update_job(job_id, message=f"Uploading {total} weight records...", progress=0) + + for idx, record in enumerate(unsynced): + # Check cancel/pause + if job_id and not self._check_pause(job_id): + break + + if job_id: + job_manager.update_job(job_id, progress=int((idx/total)*100)) + + # Upload + # record.timestamp is datetime + success = self.garmin_client.upload_metric_weight( + timestamp=record.timestamp, + weight_kg=record.weight, + bmi=record.bmi + ) + + if success: + record.sync_status = 'synced' + processed += 1 + else: + failed += 1 + + self.db_session.commit() + + # Rate limit sleep + import time + time.sleep(1.0) + + if job_id: + job_manager.complete_job(job_id) + + return {"processed": processed, "failed": failed} diff --git a/FitnessSync/backend/src/services/sync_app.py b/FitnessSync/backend/src/services/sync_app.py index d40c40c..9252a24 100644 --- a/FitnessSync/backend/src/services/sync_app.py +++ b/FitnessSync/backend/src/services/sync_app.py @@ -1,439 +1,85 @@ -from ..models.activity import Activity -from ..models.health_metric import HealthMetric -from ..models.sync_log import SyncLog -from ..services.garmin.client import GarminClient -from sqlalchemy.orm import Session -from datetime import datetime, timedelta -from typing import Dict import logging -import json +from typing import Dict, Any, Optional + +from sqlalchemy.orm import Session +from datetime import datetime + +from ..services.garmin.client import GarminClient +from .sync.activity import GarminActivitySync +from .sync.health import GarminHealthSync +from .sync.weight import WeightSyncService +from .sync.utils import update_or_create_health_metric logger = logging.getLogger(__name__) -from ..services.job_manager import job_manager -import math - class SyncApp: - def __init__(self, db_session: Session, garmin_client: GarminClient, fitbit_client=None): + def __init__(self, db_session: Session, garmin_client: GarminClient, fitbit_client: Any = None): self.db_session = db_session self.garmin_client = garmin_client self.fitbit_client = fitbit_client self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") - self.logger.info("SyncApp initialized") - - def sync_activities(self, days_back: int = 30, job_id: str = None) -> Dict[str, int]: - """Sync activity data from Garmin to local storage.""" - self.logger.info(f"=== Starting sync_activities with days_back={days_back} ===") - start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d') - end_date = datetime.now().strftime('%Y-%m-%d') - - self.logger.info(f"Date range: {start_date} to {end_date}") - - sync_log = SyncLog(operation="activity_sync", status="started", start_time=datetime.now()) - self.db_session.add(sync_log) - self.db_session.commit() - - processed_count = 0 - failed_count = 0 - - try: - if job_id: - job_manager.update_job(job_id, message="Fetching activities list...", progress=5) - - self.logger.info("Fetching activities from Garmin...") - garmin_activities = self.garmin_client.get_activities(start_date, end_date) - self.logger.info(f"Successfully fetched {len(garmin_activities)} activities from Garmin") - - total_activities = len(garmin_activities) - - for idx, activity_data in enumerate(garmin_activities): - # Check for cancellation - if job_id and job_manager.should_cancel(job_id): - self.logger.info("Sync cancelled by user.") - sync_log.status = "cancelled" - sync_log.message = "Cancelled by user" - break - - if job_id: - # Update progress (5% to 95%) - progress = 5 + int((idx / total_activities) * 90) - job_manager.update_job(job_id, message=f"Processing activity {idx + 1}/{total_activities}", progress=progress) - - activity_id = str(activity_data.get('activityId')) - if not activity_id: - self.logger.warning("Skipping activity with no ID.") - continue - - try: - existing_activity = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first() - - if not existing_activity: - activity_type_dict = activity_data.get('activityType', {}) - existing_activity = Activity( - garmin_activity_id=activity_id, - activity_name=activity_data.get('activityName'), - activity_type=activity_type_dict.get('typeKey', 'unknown'), - start_time=datetime.fromisoformat(activity_data.get('startTimeLocal')) if activity_data.get('startTimeLocal') else None, - duration=activity_data.get('duration', 0), - download_status='pending' - ) - self.db_session.add(existing_activity) - - if existing_activity.download_status != 'downloaded': - downloaded_successfully = False - # PRIORITIZE FIT FILE - for fmt in ['fit', 'original', 'tcx', 'gpx']: - file_content = self.garmin_client.download_activity(activity_id, file_type=fmt) - if file_content: - existing_activity.file_content = file_content - existing_activity.file_type = fmt - existing_activity.download_status = 'downloaded' - existing_activity.downloaded_at = datetime.now() - self.logger.info(f"✓ Successfully downloaded {activity_id} as {fmt}") - downloaded_successfully = True - break - - if not downloaded_successfully: - existing_activity.download_status = 'failed' - self.logger.warning(f"✗ Failed to download {activity_id}") - failed_count += 1 - else: - processed_count += 1 - else: - self.logger.info(f"Activity {activity_id} already downloaded. Skipping.") - processed_count += 1 - - self.db_session.commit() - - except Exception as e: - self.logger.error(f"✗ Error processing activity {activity_id}: {e}", exc_info=True) - failed_count += 1 - self.db_session.rollback() - - if sync_log.status != "cancelled": - sync_log.status = "completed_with_errors" if failed_count > 0 else "completed" - sync_log.records_processed = processed_count - sync_log.records_failed = failed_count - - except Exception as e: - self.logger.error(f"Major error during activity sync: {e}", exc_info=True) - sync_log.status = "failed" - sync_log.message = str(e) - - sync_log.end_time = datetime.now() - self.db_session.commit() - - # Create stats summary for message - stats_summary = { - "summary": [ - { - "type": "Activity", - "source": "Garmin", - "total": len(garmin_activities) if 'garmin_activities' in locals() else 0, - "synced": processed_count - } - ] - } - sync_log.message = json.dumps(stats_summary) - self.db_session.commit() - - if job_id: - job_manager.complete_job(job_id) - - self.logger.info(f"=== Finished sync_activities: processed={processed_count}, failed={failed_count} ===") - return {"processed": processed_count, "failed": failed_count} - - def sync_health_metrics(self, days_back: int = 30, job_id: str = None) -> Dict[str, int]: - """Sync health metrics from Garmin to local database.""" - start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d') - end_date = datetime.now().strftime('%Y-%m-%d') - - self.logger.info(f"=== Starting sync_health_metrics with days_back={days_back} ===") - sync_log = SyncLog(operation="health_metric_sync", status="started", start_time=datetime.now()) - self.db_session.add(sync_log) - self.db_session.commit() - - processed_count = 0 - failed_count = 0 - metrics_breakdown = { - 'steps': {'new': 0, 'updated': 0}, 'hrv': {'new': 0, 'updated': 0}, - 'sleep': {'new': 0, 'updated': 0}, 'stress': {'new': 0, 'updated': 0}, - 'intensity': {'new': 0, 'updated': 0}, 'hydration': {'new': 0, 'updated': 0}, - 'weight': {'new': 0, 'updated': 0}, 'body_battery': {'new': 0, 'updated': 0} - } - - stats_list = [] - - try: - if job_id: - job_manager.update_job(job_id, message="Fetching health metrics...", progress=10) - - daily_metrics = self.garmin_client.get_daily_metrics(start_date, end_date) - - # Helper to check cancellation - def check_cancel(): - if job_id and job_manager.should_cancel(job_id): - raise Exception("Cancelled by user") - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Steps...", progress=20) - - # Steps - steps_data_list = daily_metrics.get("steps", []) - stats_list.append({"type": "Steps", "source": "Garmin", "total": len(steps_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for steps_data in steps_data_list: - try: - status = self._update_or_create_metric('steps', steps_data.calendar_date, steps_data.total_steps, 'steps') - metrics_breakdown['steps'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing steps data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing HRV...", progress=30) - - # HRV - hrv_data_list = daily_metrics.get("hrv", []) - stats_list.append({"type": "HRV", "source": "Garmin", "total": len(hrv_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for hrv_data in hrv_data_list: - try: - status = self._update_or_create_metric('hrv', hrv_data.calendar_date, hrv_data.last_night_avg, 'ms') - metrics_breakdown['hrv'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing HRV data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Sleep...", progress=40) - - # Sleep - sleep_data_list = daily_metrics.get("sleep", []) - stats_list.append({"type": "Sleep", "source": "Garmin", "total": len(sleep_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for sleep_data in sleep_data_list: - try: - status = self._update_or_create_metric('sleep', sleep_data.daily_sleep_dto.calendar_date, sleep_data.daily_sleep_dto.sleep_time_seconds, 'seconds') - metrics_breakdown['sleep'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing sleep data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Stress...", progress=50) - - # Updated Sync Logic for new metrics - # Stress - stress_data_list = daily_metrics.get("stress", []) - stats_list.append({"type": "Stress", "source": "Garmin", "total": len(stress_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for stress_data in stress_data_list: - try: - if stress_data.overall_stress_level is not None: - status = self._update_or_create_metric('stress', stress_data.calendar_date, float(stress_data.overall_stress_level), 'score') - metrics_breakdown['stress'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing stress data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Intensity...", progress=60) - - # Intensity Minutes - intensity_data_list = daily_metrics.get("intensity", []) - stats_list.append({"type": "Intensity", "source": "Garmin", "total": len(intensity_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for intensity_data in intensity_data_list: - try: - mod = intensity_data.moderate_value or 0 - vig = intensity_data.vigorous_value or 0 - total_intensity = mod + vig - status = self._update_or_create_metric('intensity_minutes', intensity_data.calendar_date, float(total_intensity), 'minutes') - metrics_breakdown['intensity'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing intensity data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Hydration...", progress=70) - - # Hydration - hydration_data_list = daily_metrics.get("hydration", []) - stats_list.append({"type": "Hydration", "source": "Garmin", "total": len(hydration_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for hydration_data in hydration_data_list: - try: - if hydration_data.value_in_ml is not None: - status = self._update_or_create_metric('hydration', hydration_data.calendar_date, float(hydration_data.value_in_ml), 'ml') - metrics_breakdown['hydration'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing hydration data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Weight...", progress=80) - - # Weight - weight_records_from_garmin = daily_metrics.get("weight", []) - self.logger.info(f"Processing {len(weight_records_from_garmin)} weight records from Garmin") - stats_list.append({"type": "Weight", "source": "Garmin", "total": len(weight_records_from_garmin), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for weight_data in weight_records_from_garmin: - try: - if weight_data.weight is not None: - # Weight is usually in grams in Garmin API, converting to kg - weight_kg = weight_data.weight / 1000.0 - status = self._update_or_create_metric('weight', weight_data.calendar_date, weight_kg, 'kg') - metrics_breakdown['weight'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - except Exception as e: - self.logger.error(f"Error processing weight data: {e}", exc_info=True) - failed_count += 1 - - check_cancel() - if job_id: job_manager.update_job(job_id, message="Processing Body Battery...", progress=90) - - # Body Battery - bb_data_list = daily_metrics.get("body_battery", []) - stats_list.append({"type": "Body Battery", "source": "Garmin", "total": len(bb_data_list), "synced": 0}) - metric_idx = len(stats_list) - 1 - - for bb_data in bb_data_list: - try: - # Calculate max body battery from the values array if available - # body_battery_values_array is list[list[timestamp, value]] - max_bb = 0 - if bb_data.body_battery_values_array: - try: - # Filter out None values and find max - values = [v[1] for v in bb_data.body_battery_values_array if v and len(v) > 1 and isinstance(v[1], (int, float))] - if values: - max_bb = max(values) - except Exception: - pass # Keep 0 if extraction fails - - if max_bb > 0: - status = self._update_or_create_metric('body_battery_max', bb_data.calendar_date, float(max_bb), 'percent') - metrics_breakdown['body_battery'][status] += 1 - processed_count += 1 - stats_list[metric_idx]["synced"] += 1 - - except Exception as e: - self.logger.error(f"Error processing body battery data: {e}", exc_info=True) - failed_count += 1 - - sync_log.status = "completed_with_errors" if failed_count > 0 else "completed" - sync_log.records_processed = processed_count - sync_log.records_failed = failed_count - - # Save stats to message - sync_log.message = json.dumps({"summary": stats_list}) - - except Exception as e: - if str(e) == "Cancelled by user": - self.logger.info("Sync cancelled by user.") - sync_log.status = "cancelled" - sync_log.message = "Cancelled by user" + # Load tokens into the Garmin client using the provided session + if self.garmin_client and hasattr(self.garmin_client, 'load_tokens'): + print("DEBUG: SyncApp calling load_tokens...", flush=True) + is_loaded = self.garmin_client.load_tokens(self.db_session) + if is_loaded: + self.logger.info("Garmin tokens loaded successfully during SyncApp init.") + print("DEBUG: Tokens loaded successfully.", flush=True) else: - self.logger.error(f"Major error during health metrics sync: {e}", exc_info=True) - sync_log.status = "failed" - sync_log.message = str(e) - - sync_log.end_time = datetime.now() - self.db_session.commit() + self.logger.warning("Failed to load Garmin tokens during SyncApp init. Sync may fail if not logged in.") + print("DEBUG: Failed to load tokens.", flush=True) - if job_id: - job_manager.complete_job(job_id) + # Initialize sub-services + self.activity_sync = GarminActivitySync(db_session, garmin_client) + self.health_sync = GarminHealthSync(db_session, garmin_client) + self.weight_sync = WeightSyncService(db_session, garmin_client, fitbit_client) - breakdown_str = ", ".join([f"{k}: {v['new']} new/{v['updated']} updated" for k, v in metrics_breakdown.items()]) - self.logger.info(f"=== Finished sync_health_metrics: processed={processed_count}, failed={failed_count} ({breakdown_str}) ===") - return {"processed": processed_count, "failed": failed_count} + self.logger.info("SyncApp initialized (delegating to sub-services)") + + # --- Activity Sync --- + def scan_activities(self, days_back: int = 30): + self.logger.info("Delegating scan_activities to GarminActivitySync") + return self.activity_sync.scan_activities(days_back) + + def sync_pending_activities(self, limit: int = None, job_id: str = None) -> Dict[str, int]: + self.logger.info("Delegating sync_pending_activities to GarminActivitySync") + return self.activity_sync.sync_pending_activities(limit, job_id) + + def sync_activities(self, days_back: int = 30, job_id: str = None) -> Dict[str, int]: + """Coordinator for activity sync.""" + self.logger.info("Delegating sync_activities to GarminActivitySync") + # 1. Scan (fetch metadata) + self.scan_activities(days_back) + # 2. Sync Pending (download files) + return self.sync_pending_activities(job_id=job_id) + def redownload_activity(self, activity_id: str) -> bool: - """ - Force re-download of an activity file from Garmin. - """ - self.logger.info(f"Redownloading activity {activity_id}...") - try: - # Find the activity - activity = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first() - if not activity: - self.logger.error(f"Activity {activity_id} not found locally.") - return False + return self.activity_sync.redownload_activity(activity_id) - # Attempt download with fallback order - downloaded = False - for fmt in ['fit', 'original', 'tcx', 'gpx']: - file_content = self.garmin_client.download_activity(activity_id, file_type=fmt) - if file_content: - activity.file_content = file_content - activity.file_type = fmt - activity.download_status = 'downloaded' - activity.downloaded_at = datetime.now() - self.logger.info(f"✓ Successfully redownloaded {activity_id} as {fmt}") - downloaded = True - break - - if not downloaded: - self.logger.warning(f"Failed to redownload {activity_id}") - return False + # --- Health Sync --- + def sync_health_metrics(self, days_back: int = 30, job_id: str = None) -> Dict[str, int]: + self.logger.info("Delegating sync_health_metrics to GarminHealthSync") + return self.health_sync.sync_health_metrics(days_back, job_id) - self.db_session.commit() - return True + def scan_health_metrics(self, days_back: int = 30) -> Dict[str, int]: + return self.health_sync.scan_health_metrics(days_back) + + def sync_pending_health_metrics(self, limit: int = None, job_id: str = None) -> Dict[str, int]: + return self.health_sync.sync_pending_health_metrics(limit, job_id) - except Exception as e: - self.logger.error(f"Error redownloading activity {activity_id}: {e}", exc_info=True) - self.db_session.rollback() - return False + # --- Weight Sync --- + def sync_fitbit_weight(self, days_back: int = 30, job_id: str = None) -> int: + self.logger.info("Delegating sync_fitbit_weight to WeightSyncService") + return self.weight_sync.sync_fitbit_weight(days_back, job_id) - def _update_or_create_metric(self, metric_type: str, date: datetime.date, value: float, unit: str) -> str: - - """Helper to update or create a health metric record. Returns 'new' or 'updated'.""" - try: - existing = self.db_session.query(HealthMetric).filter_by(metric_type=metric_type, date=date).first() - if existing: - # Optional: Check if value is different before updating to truly 'skip' - # For now, we consider found as 'updated' (or skipped if we want to call it that in logs) - existing.metric_value = value - existing.updated_at = datetime.now() - self.db_session.commit() - return 'updated' - else: - metric = HealthMetric( - metric_type=metric_type, - metric_value=value, - unit=unit, - timestamp=datetime.combine(date, datetime.min.time()), - date=date, - source='garmin' - ) - self.db_session.add(metric) - self.db_session.commit() - return 'new' - except Exception as e: - self.logger.error(f"Error saving metric {metric_type} for {date}: {e}", exc_info=True) - self.db_session.rollback() - raise + def sync_weights_to_garmin(self, limit: int = 50, job_id: str = None) -> Dict[str, int]: + self.logger.info("Delegating sync_weights_to_garmin to WeightSyncService") + return self.weight_sync.sync_weights_to_garmin(limit, job_id) + + # --- Helpers for compatibility/tests --- + def _update_or_create_metric(self, metric_type: str, date: datetime.date, value: float, unit: str, source: str = 'garmin', detailed_data: Dict = None) -> str: + # Wrapper for tests that might mock/call this + return update_or_create_health_metric(self.db_session, metric_type, date, value, unit, source, detailed_data) diff --git a/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc b/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cde991ee82a32a546d5c33f338293f08126c5ab0 GIT binary patch literal 11658 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 365 else 3650 + + job_manager.update_job(job_id, status="running", progress=0, message=f"Starting backfill ({target_days} days)...") + + # Call scan_activities with job_id for progress + # We need to expose scan_activities in SyncApp wrapper first? + # Step 158 showed SyncApp has scan_activities wrapper: + # def scan_activities(self, days_back: int = 30): + # return self.activity_sync.scan_activities(days_back) + # It doesn't pass job_id! We need to update SyncApp wrapper too. + # Or access self.activity_sync directly: sync_app.activity_sync.scan_activities + + stats = sync_app.activity_sync.scan_activities(days_back=target_days, job_id=job_id) + + job_manager.complete_job(job_id, result=stats) + + except Exception as e: + logger.error(f"Backfill job failed: {e}") + job_manager.fail_job(job_id, str(e)) + +def run_fitbit_sync_job(job_id: str, days_back: int, db_session_factory): + """Background task wrapper for fitbit sync""" + logger.info(f"Starting run_fitbit_sync_job for {job_id}") + + with db_session_factory() as db: + try: + # Get tokens + token_record = db.query(APIToken).filter_by(token_type="fitbit").first() + access_token = token_record.access_token if token_record else None + refresh_token = token_record.refresh_token if token_record else None + + # Get API Credentials (Env Var > DB) + db_config = db.query(Configuration).first() + + client_id = config.FITBIT_CLIENT_ID + client_secret = config.FITBIT_CLIENT_SECRET + redirect_uri = config.FITBIT_REDIRECT_URI + + if not client_id and db_config: + client_id = db_config.fitbit_client_id + + if not client_secret and db_config: + client_secret = db_config.fitbit_client_secret + + if not redirect_uri and db_config and db_config.fitbit_redirect_uri: + redirect_uri = db_config.fitbit_redirect_uri + + def refresh_cb(token_dict): + """Callback to update tokens in DB upon refresh.""" + try: + logger.info("Refreshing Fitbit token in DB via callback") + # Re-query + tr = db.query(APIToken).filter_by(token_type="fitbit").first() + if tr: + tr.access_token = token_dict.get('access_token') + tr.refresh_token = token_dict.get('refresh_token') + if 'expires_at' in token_dict: + from datetime import datetime + tr.expires_at = datetime.fromtimestamp(token_dict['expires_at']) + db.commit() + logger.info("Fitbit token refreshed and saved.") + except Exception as e: + logger.error(f"Error in refresh_cb: {e}") + + fitbit_client = FitbitClient( + client_id=client_id, + client_secret=client_secret, + access_token=access_token, + refresh_token=refresh_token, + redirect_uri=redirect_uri, + refresh_cb=refresh_cb + ) + + garmin_client = GarminClient() + sync_app = SyncApp(db, garmin_client, fitbit_client) + + job_manager.update_job(job_id, status="running", progress=0) + count = sync_app.sync_fitbit_weight(days_back=days_back, job_id=job_id) + job_manager.complete_job(job_id, result={"count": count}) + + except Exception as e: + logger.error(f"Fitbit sync job failed: {e}") + job_manager.fail_job(job_id, str(e)) diff --git a/FitnessSync/backend/startup_dummy.db b/FitnessSync/backend/startup_dummy.db new file mode 100644 index 0000000000000000000000000000000000000000..946e3269f43674a1da3eeb2e549ac415d2b8a5de GIT binary patch literal 77824 zcmeI&-)`Gf9Ki8-U6UqV+N~Q3cXL-wO4I>s$0Q_#M7CwnktH-ur;QuSbxvBV`P11> zw;*w`bwlDMxZ@4D;VpOoZg>N(fJ=_;#L3B$4n(=Y`bMi}pL2ZT^ZA|Mu`Oq3yKH+x zZ+4xw>FKu;7ZaM6_*B;uiNqE8cUk@oKPz(M{BTG9uZ?Zb+Pso@|LdpO#orSv+N;EJ zcKN65uS-8?``W9;?=w4#`{_Rxep&ER7m`2De>cB6_ru)wtd8Pbe)1rh%B`+y-x{8| z*AnKDZFt>7(Q!vxsk^mOv0l>a#XIGaJ}TGqnY7)|w<`70XQi56sn+$%Zn=Cz&!mGc z46lDA^hd?o-Or0^wP{(xb;B>3+Q1P_N4N)S3%_IW{K%FyhMX_=RN9>!9cVKkQo_DbEW`AR~n{U>(OxH7du4qJG=J#kh!t_K#Ifr|t`^DXIUC(#A z&+>(W->i4km~3`KZ#PZDm1DQN9Yfj;aKdsqu3lRh@+ecSm@;Vp3%;yvJuKFq=wFnc z=y|(Q$P{WT$=v!?&9*xY@!WoHxc!c0w7UDQ{H|#G@*tdBm3yVf`a~>g6@(!W*AFcjrvB% zlsPeNDVDSUpQlWt=Y&Z(mJycO>N=8PJWCq3?7kzDISAofA{Cu$umC>TsaF1zl)9j% z)H7l4A9%(v)9r>@ke!U9T0MxRS{TOj-NhiKP7Z^3sx8#L3rbwox>I61J*C3Kr{v6D z+a85suiNMwO?eUhJApat6#6QY%zdz?J&jUGUYdhB^E8t@lr)ayvIksn9mTY-HBELvCoJy@Zl{yEtu-wXc)L$q@}Ap2-)^uUocU0HW6UD+C^fOt zMYo22l%k!5H-Z#Z4Pz7)1R+}fQ#*f&DnUW1t%dB?c?*w zT(vMO(bO>+8|wc2i-MNQt*>h@)8We1674c?9*m3{p4ISx4k}Vpp%_#Occz?3|a8ABrE^4 z<=+zWA0Gq|KmY**5I_I{1Q0*~0R#|;iNKO}?z$yzHg9fzv}ta9xS5gT;`u)&!DSc- zAb#p1^E4c z{A0`r5kLR|1Q0*~0R#|0009IL;Qc>y009ILKmY**5I_I{1Q0*~f%psX{y+XPW`qbJ zfB*srAb#p1$h3Ce~cL+0tg_000Iag zfB*srAbP)G{~!MtGeQIqKmY**5I_I{1Q0*~ z0R(ve&m2Gi0R#|0009ILKmY**5I`XQ0=)l^e~cL+0tg_000IagfB*srAb - +{% extends "base.html" %} - - Activity List - FitnessSync - - - - - - - -

-

Activities

- -
- - -
-