Skip to content

Commit

Permalink
Merge pull request #122 from voynow/121-migrate-all-frontend-routes-t…
Browse files Browse the repository at this point in the history
…o-api

121 migrate all frontend routes to api
  • Loading branch information
voynow authored Nov 10, 2024
2 parents ffa1f66 + 235d87a commit ae5431e
Show file tree
Hide file tree
Showing 20 changed files with 2,232 additions and 474 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ celerybeat.pid
# Environments
.env
.venv
.*venv
env/
venv/
ENV/
Expand Down
1,281 changes: 1,133 additions & 148 deletions api/poetry.lock

Large diffs are not rendered by default.

35 changes: 24 additions & 11 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "trackflow-api"
name = "trackflow"
version = "0.1.0"
description = ""
authors = ["voynow <[email protected]>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
python = "^3.10"
stravalib = "^v1.7"
supabase = "*"
python-dotenv = "^1.0.1"
urllib3 = "2.2.2"
openai = "1.39.0"
sib-api-v3-sdk = "^7.6.0"
pydantic = "1.10.16"
postgrest = "^0.16.8"
jinja2 = "^3.1.4"
PyJWT = "^2.9.0"
pyperclip = "^1.9.0"
arrow = "^1.3.0"
httpx = "^0.27.2"
cryptography = "^43.0.3"
fastapi = "^0.115.4"
uvicorn = "^0.32.0"
supabase = "^2.10.0"
python-dotenv = "^1.0.1"
pyjwt = "^2.9.0"
stravalib = "^2.1"

[tool.poetry.group.dev.dependencies]
pytest = "^8.3.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
ipykernel = "^6.29.4"
freezegun = "^1.5.1"
pytest = "^8.3.2"
pytest-cov = "^6.0.0"
115 changes: 63 additions & 52 deletions api/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,52 +1,63 @@
aiohappyeyeballs==2.4.3 ; python_version >= "3.11" and python_version < "4.0"
aiohttp==3.10.10 ; python_version >= "3.11" and python_version < "4.0"
aiosignal==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
anyio==4.6.2.post1 ; python_version >= "3.11" and python_version < "4.0"
arrow==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
attrs==24.2.0 ; python_version >= "3.11" and python_version < "4.0"
certifi==2024.8.30 ; python_version >= "3.11" and python_version < "4.0"
charset-normalizer==3.4.0 ; python_version >= "3.11" and python_version < "4.0"
click==8.1.7 ; python_version >= "3.11" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows"
deprecation==2.1.0 ; python_version >= "3.11" and python_version < "4.0"
fastapi==0.115.4 ; python_version >= "3.11" and python_version < "4.0"
flexcache==0.3 ; python_version >= "3.11" and python_version < "4.0"
flexparser==0.4 ; python_version >= "3.11" and python_version < "4.0"
frozenlist==1.5.0 ; python_version >= "3.11" and python_version < "4.0"
gotrue==2.10.0 ; python_version >= "3.11" and python_version < "4.0"
h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0"
h2==4.1.0 ; python_version >= "3.11" and python_version < "4.0"
hpack==4.0.0 ; python_version >= "3.11" and python_version < "4.0"
httpcore==1.0.6 ; python_version >= "3.11" and python_version < "4.0"
httpx==0.27.2 ; python_version >= "3.11" and python_version < "4.0"
httpx[http2]==0.27.2 ; python_version >= "3.11" and python_version < "4.0"
hyperframe==6.0.1 ; python_version >= "3.11" and python_version < "4.0"
idna==3.10 ; python_version >= "3.11" and python_version < "4.0"
multidict==6.1.0 ; python_version >= "3.11" and python_version < "4.0"
packaging==24.2 ; python_version >= "3.11" and python_version < "4.0"
pint==0.24.4 ; python_version >= "3.11" and python_version < "4.0"
platformdirs==4.3.6 ; python_version >= "3.11" and python_version < "4.0"
postgrest==0.18.0 ; python_version >= "3.11" and python_version < "4.0"
propcache==0.2.0 ; python_version >= "3.11" and python_version < "4.0"
pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "4.0"
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "4.0"
pyjwt==2.9.0 ; python_version >= "3.11" and python_version < "4.0"
python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0"
python-dotenv==1.0.1 ; python_version >= "3.11" and python_version < "4.0"
pytz==2024.2 ; python_version >= "3.11" and python_version < "4.0"
realtime==2.0.6 ; python_version >= "3.11" and python_version < "4.0"
requests==2.32.3 ; python_version >= "3.11" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.11" and python_version < "4.0"
sniffio==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
starlette==0.41.2 ; python_version >= "3.11" and python_version < "4.0"
storage3==0.9.0 ; python_version >= "3.11" and python_version < "4.0"
stravalib==2.1 ; python_version >= "3.11" and python_version < "4.0"
supabase==2.10.0 ; python_version >= "3.11" and python_version < "4.0"
supafunc==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
types-python-dateutil==2.9.0.20241003 ; python_version >= "3.11" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.11" and python_version < "4.0"
urllib3==2.2.3 ; python_version >= "3.11" and python_version < "4.0"
uvicorn==0.32.0 ; python_version >= "3.11" and python_version < "4.0"
websockets==13.1 ; python_version >= "3.11" and python_version < "4.0"
yarl==1.17.1 ; python_version >= "3.11" and python_version < "4.0"
aiohappyeyeballs==2.4.3 ; python_version >= "3.10" and python_version < "4.0"
aiohttp==3.10.10 ; python_version >= "3.10" and python_version < "4.0"
aiosignal==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
anyio==4.6.2.post1 ; python_version >= "3.10" and python_version < "4.0"
arrow==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
async-timeout==4.0.3 ; python_version >= "3.10" and python_version < "3.11"
attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0"
certifi==2024.8.30 ; python_version >= "3.10" and python_version < "4.0"
cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
charset-normalizer==3.4.0 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
cryptography==43.0.3 ; python_version >= "3.10" and python_version < "4.0"
deprecation==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
distro==1.9.0 ; python_version >= "3.10" and python_version < "4.0"
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11"
fastapi==0.115.4 ; python_version >= "3.10" and python_version < "4.0"
flexcache==0.3 ; python_version >= "3.10" and python_version < "4.0"
flexparser==0.4 ; python_version >= "3.10" and python_version < "4.0"
frozenlist==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
gotrue==2.10.0 ; python_version >= "3.10" and python_version < "4.0"
h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
h2==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
hpack==4.0.0 ; python_version >= "3.10" and python_version < "4.0"
httpcore==1.0.6 ; python_version >= "3.10" and python_version < "4.0"
httpx==0.27.2 ; python_version >= "3.10" and python_version < "4.0"
httpx[http2]==0.27.2 ; python_version >= "3.10" and python_version < "4.0"
hyperframe==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
idna==3.10 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==3.0.2 ; python_version >= "3.10" and python_version < "4.0"
multidict==6.1.0 ; python_version >= "3.10" and python_version < "4.0"
openai==1.39.0 ; python_version >= "3.10" and python_version < "4.0"
packaging==24.2 ; python_version >= "3.10" and python_version < "4.0"
pint==0.24.4 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.3.6 ; python_version >= "3.10" and python_version < "4.0"
postgrest==0.16.11 ; python_version >= "3.10" and python_version < "4.0"
propcache==0.2.0 ; python_version >= "3.10" and python_version < "4.0"
pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
pydantic==1.10.16 ; python_version >= "3.10" and python_version < "4.0"
pyjwt==2.9.0 ; python_version >= "3.10" and python_version < "4.0"
pyperclip==1.9.0 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
pytz==2024.2 ; python_version >= "3.10" and python_version < "4.0"
realtime==2.0.6 ; python_version >= "3.10" and python_version < "4.0"
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
sib-api-v3-sdk==7.6.0 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
starlette==0.41.2 ; python_version >= "3.10" and python_version < "4.0"
storage3==0.8.2 ; python_version >= "3.10" and python_version < "4.0"
stravalib==1.7 ; python_version >= "3.10" and python_version < "4.0"
strenum==0.4.15 ; python_version >= "3.10" and python_version < "4.0"
supabase==2.8.0 ; python_version >= "3.10" and python_version < "4.0"
supafunc==0.6.2 ; python_version >= "3.10" and python_version < "4.0"
tqdm==4.67.0 ; python_version >= "3.10" and python_version < "4.0"
types-python-dateutil==2.9.0.20241003 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
urllib3==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
uvicorn==0.32.0 ; python_version >= "3.10" and python_version < "4.0"
websockets==13.1 ; python_version >= "3.10" and python_version < "4.0"
yarl==1.17.1 ; python_version >= "3.10" and python_version < "4.0"
158 changes: 158 additions & 0 deletions api/src/activities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
from collections import defaultdict
from datetime import datetime, timedelta
from typing import List

from src import constants
from src.types.activity import Activity, DailyMetrics, WeekSummary
from src.utils import datetime_now_est
from stravalib.client import Client



def add_missing_dates(
activities: List[Activity], start_date: datetime, end_date: datetime
) -> List[Activity]:
"""
Ensures that the list of activities includes placeholder activities for all dates
between the start and end date.
:param activities: List of Activity Pydantic models.
:param start_date: The start date of the range.
:param end_date: The end date of the range.
:return: A list of Activity objects, with missing dates filled in as placeholder activities.
"""
existing_dates = {activity.start_date_local.date() for activity in activities}
total_days = (end_date.date() - start_date.date()).days + 1
all_dates = {start_date.date() + timedelta(days=i) for i in range(total_days)}
missing_dates = all_dates - existing_dates

placeholders = [
Activity(
start_date=datetime.combine(date, datetime.min.time()),
start_date_local=datetime.combine(date, datetime.min.time()),
)
for date in missing_dates
]
return sorted(activities + placeholders, key=lambda x: x.start_date_local)


def aggregate_daily_metrics(activities: List[Activity]) -> List[DailyMetrics]:
"""
Aggregates and transforms activity data to calculate daily and weekly metrics.
:param activities: List of Activity Pydantic models containing activity data
:return: A list of DailyMetrics objects with aggregated and transformed metrics
"""

results = []
activities_by_date = defaultdict(list)
for activity in activities:
activities_by_date[activity.start_date_local.date()].append(activity)

for activity_date, daily_activities in activities_by_date.items():
total_distance = sum(a.distance for a in daily_activities)
total_elevation_gain = sum(a.total_elevation_gain for a in daily_activities)
total_moving_time = sum(a.moving_time.total_seconds() for a in daily_activities)
activity_count = len([a for a in daily_activities if a.id != -1])

if total_distance > 0:
pace_minutes_per_mile = (total_moving_time / 60) / (
total_distance / constants.METERS_PER_MILE
)
else:
pace_minutes_per_mile = None

results.append(
DailyMetrics(
date=activity_date,
day_of_week=activity_date.strftime("%a").lower(),
week_of_year=activity_date.isocalendar()[1],
year=activity_date.year,
distance_in_miles=total_distance / constants.METERS_PER_MILE,
elevation_gain_in_feet=total_elevation_gain * constants.FEET_PER_METER,
moving_time_in_minutes=total_moving_time / 60,
pace_minutes_per_mile=pace_minutes_per_mile,
activity_count=activity_count,
)
)

results = sorted(results, key=lambda x: x.date)
first_week = min(item.week_of_year for item in results)
results = [item for item in results if item.week_of_year != first_week]

return results


def get_daily_activity(strava_client: Client, num_weeks: int = 8) -> List[DailyMetrics]:
"""
Fetches activities for a given athlete ID and returns a DataFrame with daily aggregated activities
:param strava_client: The Strava client object to fetch data.
:param num_weeks: The number of weeks to fetch activities for.
:return: A cleaned and processed DataFrame of the athlete's daily aggregated activities.
"""
end_date = datetime_now_est()
start_date = end_date - timedelta(weeks=num_weeks)

all_strava_activities = strava_client.get_activities(
after=start_date, before=end_date
)

# filter and convert to our Activity type
activities = [
Activity(**activity.__dict__)
for activity in all_strava_activities
if activity.sport_type == "Run"
]

# add empty activities for missing dates
all_dates_activities = add_missing_dates(
activities=activities, start_date=start_date, end_date=end_date
)

# aggregate metrics
return aggregate_daily_metrics(all_dates_activities)


def get_weekly_summaries(strava_client: Client) -> List[WeekSummary]:
"""
Aggregate daily metrics by week of the year and calculate load for each week.
:param strava_client: The Strava client object to fetch data.
:return: A list of WeekSummary objects with summary statistics
"""
daily_metrics = get_daily_activity(strava_client)

weekly_aggregates = defaultdict(
lambda: {"total_distance": 0, "longest_run": 0, "start_of_week": None}
)

for metrics in daily_metrics:
key = (metrics.year, metrics.week_of_year)

# calculate total distance and longest run
weekly_aggregates[key]["total_distance"] += metrics.distance_in_miles
weekly_aggregates[key]["longest_run"] = max(
weekly_aggregates[key]["longest_run"], metrics.distance_in_miles
)

# update start of week
if (
weekly_aggregates[key]["start_of_week"] is None
or metrics.date < weekly_aggregates[key]["start_of_week"]
):
weekly_aggregates[key]["start_of_week"] = metrics.date

weekly_summaries = [
WeekSummary(
year=year,
week_of_year=week,
week_start_date=start_of_week,
longest_run=round(aggregate["longest_run"], 2),
total_distance=round(aggregate["total_distance"], 2),
)
for (year, week), aggregate in sorted(weekly_aggregates.items())
for start_of_week in [aggregate["start_of_week"]]
]

return weekly_summaries
28 changes: 27 additions & 1 deletion api/src/auth_manager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
import os
from typing import Optional
from datetime import datetime, timezone

import jwt
from fastapi import HTTPException, Security
Expand Down Expand Up @@ -133,3 +133,29 @@ async def validate_user(
status_code=401, detail="Invalid authentication credentials"
)
return supabase_client.get_user(athlete_id)


def authenticate_athlete(athlete_id: int) -> UserAuthRow:
"""
Authenticate athlete with valid token, refresh if necessary
:param athlete_id: strava internal identifier
:return: UserAuthRow
"""
user_auth = supabase_client.get_user_auth(athlete_id)
if datetime.now(timezone.utc) < user_auth.expires_at:
return user_auth
return refresh_and_update_user_token(athlete_id, user_auth.refresh_token)


def get_configured_strava_client(user_auth: UserAuthRow) -> Client:
strava_client.access_token = user_auth.access_token
strava_client.refresh_token = user_auth.refresh_token
strava_client.token_expires_at = user_auth.expires_at
return strava_client


def get_strava_client(athlete_id: int) -> Client:
"""Interface for retrieving a Strava client with valid authentication"""
user_auth = authenticate_athlete(athlete_id)
return get_configured_strava_client(user_auth)
3 changes: 3 additions & 0 deletions api/src/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
COACH_ROLE = "You are a talented running coach with years of experience. You have been hired by a client to help them improve their running performance. Note: convert pace values where applicable e.g. 7.5 -> 7m 30s."
METERS_PER_MILE = 1609.34
FEET_PER_METER = 3.28084
Loading

0 comments on commit ae5431e

Please sign in to comment.