diff --git a/README.md b/README.md index f6bf1bf01..2c249ab66 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ JAEGER_PROTOCOL | http | Yes JAEGER_HOST | jaeger | Yes JAGGER_PORT | 4317 | Yes STRAVA_DAYS_ACTIVITIES_ONLINK | 30 | Yes -API_ENDPOINT* | changeme | Yes +API_ENDPOINT* | backend | Yes FRONTEND_HOST* | frontend | Yes GEOCODES_MAPS_API** | changeme | `No` diff --git a/backend/.env b/backend/.env index ce1e95da1..5824e82d0 100644 --- a/backend/.env +++ b/backend/.env @@ -15,6 +15,6 @@ JAEGER_PROTOCOL=http JAEGER_HOST=jaeger JAGGER_PORT=4317 STRAVA_DAYS_ACTIVITIES_ONLINK=30 -API_ENDPOINT=changeme +API_ENDPOINT=backend FRONTEND_HOST=frontend GEOCODES_MAPS_API=changeme \ No newline at end of file diff --git a/backend/crud/crud_users.py b/backend/crud/crud_users.py index 4c328e5ba..1eb48f81b 100644 --- a/backend/crud/crud_users.py +++ b/backend/crud/crud_users.py @@ -44,6 +44,20 @@ def authenticate_user(username: str, password: str, db: Session): ) from err +def get_all_users(db: Session): + try: + # Get the number of users from the database + return db.query(models.User).all() + except Exception as err: + # Log the exception + logger.error(f"Error in get_all_number: {err}", exc_info=True) + # Raise an HTTPException with a 500 Internal Server Error status code + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal Server Error", + ) from err + + def get_users_number(db: Session): try: # Get the number of users from the database diff --git a/backend/main.py b/backend/main.py index 107b2e47d..0c46d6125 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,12 +19,13 @@ from routers import ( router_activity_streams, router_gear, router_followers, - router_strava + router_strava, ) from constants import API_VERSION from database import engine from schemas import schema_access_tokens from dependencies import dependencies_database +from processors import strava_processor import models models.Base.metadata.create_all(bind=engine) @@ -40,6 +41,8 @@ def startup_event(): # Job to remove expired tokens every 5 minutes logger.info("Added scheduler job to remove expired tokens every 5 minutes") scheduler.add_job(remove_expired_tokens_job, "interval", minutes=5) + logger.info("Added scheduler job to refresh strava user tokens every 60 minutes") + scheduler.add_job(remove_expired_tokens_job, "interval", minutes=60) def shutdown_event(): @@ -61,6 +64,17 @@ def remove_expired_tokens_job(): db.close() +def refresh_strava_tokens_job(): + # Get the first (and only) item from the generator + db = next(dependencies_database.get_db()) + try: + # Refresh Strava tokens + strava_processor.refresh_strava_tokens(db=db) + finally: + # Ensure the session is closed after use + db.close() + + # Create loggger logger = logging.getLogger("myLogger") logger.setLevel(logging.DEBUG) @@ -153,4 +167,4 @@ FastAPIInstrumentor.instrument_app(app) app.add_event_handler("startup", startup_event) # Register the shutdown event handler -app.add_event_handler("shutdown", shutdown_event) \ No newline at end of file +app.add_event_handler("shutdown", shutdown_event) diff --git a/backend/models.py b/backend/models.py index 27c4adee0..e2b42c2e1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -217,7 +217,7 @@ class Gear(Base): is_active = Column( Integer, nullable=False, comment="Is gear active (0 - not active, 1 - active)" ) - strava_gear_id = Column(BigInteger, nullable=True, comment="Strava gear ID") + strava_gear_id = Column(String(length=45), nullable=True, comment="Strava gear ID") # Define a relationship to the User model user = relationship("User", back_populates="gear") @@ -295,7 +295,7 @@ class Activity(Base): index=True, comment="Gear ID associated with this activity", ) - strava_gear_id = Column(BigInteger, nullable=True, comment="Strava gear ID") + strava_gear_id = Column(String(length=45), nullable=True, comment="Strava gear ID") strava_activity_id = Column(BigInteger, nullable=True, comment="Strava activity ID") # Define a relationship to the User model diff --git a/backend/processors/activity_processor.py b/backend/processors/activity_processor.py index 2d25f4b0f..276eb2e4c 100644 --- a/backend/processors/activity_processor.py +++ b/backend/processors/activity_processor.py @@ -215,9 +215,10 @@ def define_activity_type(activity_type): "cycling": 4, "Ride": 4, "GravelRide": 5, - "EBikeRide": 6, + "MountainBikeRide": 6, "VirtualRide": 7, "virtual_ride": 7, + "Swim": 8, "swimming": 8, "open_water_swimming": 8, "Walk": 9, diff --git a/backend/processors/strava_processor.py b/backend/processors/strava_processor.py index b33b9b303..26ebb6a60 100644 --- a/backend/processors/strava_processor.py +++ b/backend/processors/strava_processor.py @@ -1,21 +1,78 @@ import logging +import os +import requests from datetime import datetime, timedelta from fastapi import HTTPException, status from sqlalchemy.orm import Session from stravalib.client import Client -from concurrent.futures import ThreadPoolExecutor from pint import Quantity -from schemas import schema_activities, schema_activity_streams -from crud import crud_user_integrations, crud_activities, crud_activity_streams +from schemas import schema_activities, schema_activity_streams, schema_user_integrations +from crud import crud_user_integrations, crud_activities, crud_activity_streams, crud_users +from dependencies import dependencies_database from processors import activity_processor # Define a loggger created on main.py logger = logging.getLogger("myLogger") -def get_user_strava_activities_by_days(start_date: datetime, user_id: int, db: Session): +def refresh_strava_token(db: Session): + # Get all users + users = crud_users.get_all_users(db) + + # Iterate through all users + for user in users: + # Get the user integrations by user ID + user_integrations = crud_user_integrations.get_user_integrations_by_user_id(user.id, db) + + # Check if user_integrations strava token is not None + if user_integrations.strava_token is not None: + refresh_time = user_integrations.strava_token_expires_at - timedelta( + minutes=60 + ) + + if datetime.utcnow() > refresh_time: + # Strava token refresh endpoint + token_url = "https://www.strava.com/oauth/token" + # Parameters for the token refresh request + payload = { + "client_id": os.environ.get("STRAVA_CLIENT_ID"), + "client_secret": os.environ.get("STRAVA_CLIENT_SECRET"), + "refresh_token": user_integrations.strava_refresh_token, + "grant_type": "refresh_token", + } + + try: + # Send a POST request to the token URL + response = requests.post(token_url, data=payload) + + # Check if the response status code is not 200 + if response.status_code != 200: + # Raise an HTTPException with a 424 Failed Dependency status code + logger.error("Unable to retrieve tokens for refresh process from Strava") + + tokens = response.json() + except Exception as err: + # Log the exception + logger.error(f"Error in refresh_strava_token: {err}", exc_info=True) + + # Raise an HTTPException with a 500 Internal Server Error status code + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal Server Error", + ) from err + finally: + # Update the user integrations with the tokens + crud_user_integrations.link_strava_account(user_integrations, tokens, db) + #else: + # Log an informational event if the Strava access token is not found + #logger.info(f"User {user.id}: Strava access token not found") + + +def fetch_user_integrations_and_validate_token( + user_id: int, db: Session +) -> schema_user_integrations.UserIntegrations: # Get the user integrations by user ID user_integrations = crud_user_integrations.get_user_integrations_by_user_id( user_id, db @@ -35,12 +92,20 @@ def get_user_strava_activities_by_days(start_date: datetime, user_id: int, db: S detail="Strava access token not found", ) - # Log the start of the activities processing - logger.info(f"User {user_id}: Started activities processing") + # Return the user integrations + return user_integrations - # Create a Strava client with the user's access token - strava_client = Client(access_token=user_integrations.strava_token) +def create_strava_client( + user_integrations: schema_user_integrations.UserIntegrations, +) -> Client: + # Create a Strava client with the user's access token and return it + return Client(access_token=user_integrations.strava_token) + + +def fetch_and_process_activities( + strava_client: Client, start_date: datetime, user_id: int, db: Session +) -> int: # Fetch Strava activities after the specified start date strava_activities = list(strava_client.get_activities(after=start_date)) @@ -50,32 +115,39 @@ def get_user_strava_activities_by_days(start_date: datetime, user_id: int, db: S f"User {user_id}: No new activities found after {start_date}: strava_activities is None" ) - # Use ThreadPoolExecutor for parallel processing of activities - with ThreadPoolExecutor() as executor: - executor.map( - lambda activity: process_activity(activity, user_id, strava_client, db), - strava_activities, - ) + # Return 0 to indicate no activities were processed + return 0 - # Log an informational event for tracing - logger.info(f"User {user_id}: {len(strava_activities)} activities processed") + # Process the activities + for activity in strava_activities: + process_activity(activity, user_id, strava_client, db) + + # Return the number of activities processed + return len(strava_activities) -def process_activity(activity, user_id, strava_client, db: Session): +def fetch_and_validate_activity( + activity_id: int, user_id: int, db: Session +) -> schema_activities.Activity | None: # Get the activity by Strava ID from the user - activity = crud_activities.get_activity_by_strava_id_from_user_id( - activity.id, user_id, db + activity_db = crud_activities.get_activity_by_strava_id_from_user_id( + activity_id, user_id, db ) # Check if activity is None - if activity: + if activity_db: # Log an informational event if the activity already exists logger.info( - f"User {user_id}: Activity {activity.id} already exists. Will skip processing" + f"User {user_id}: Activity {activity_id} already exists. Will skip processing" ) + # Return None + return activity_db + else: return None + +def parse_activity(activity, user_id: int, strava_client: Client) -> dict: # Parse start and end dates start_date_parsed = activity.start_date @@ -183,8 +255,15 @@ def process_activity(activity, user_id, strava_client, db: Session): is_power_set = True for i in range(len(vel)): + # Append velocity to the velocity waypoints vel_waypoints.append({"time": time[i], "vel": vel[i]}) - pace_calculation = 1 / vel[i] + + # Calculate pace on-the-fly. If velocity is 0, pace is 0 + pace_calculation = 0 + if vel[i] != 0: + pace_calculation = 1 / vel[i] + + # Append pace to the pace waypoints pace_waypoints.append({"time": time[i], "pace": pace_calculation}) is_velocity_set = True @@ -203,37 +282,6 @@ def process_activity(activity, user_id, strava_client, db: Session): if activity.average_watts is not None: average_watts = activity.average_watts - # Create the activity in the database - created_activity = crud_activities.create_activity( - schema_activities.Activity( - user_id=user_id, - name=activity.name, - distance=( - round(float(activity.distance)) - if isinstance(activity.distance, Quantity) - else round(activity.distance) - ), - activity_type=activity_processor.define_activity_type(activity.sport_type), - start_time=start_date_parsed, - end_time=end_date_parsed, - city=city, - town=town, - country=country, - waypoints=waypoints, - elevation_gain=elevation_gain, - elevation_loss=elevation_loss, - pace=average_pace, - average_speed=average_speed, - average_power=average_watts, - calories=activity.calories, - strava_gear_id=activity.gear_id, - strava_activity_id=activity.id, - ), - db, - ) - - activity_streams = [] - # List of conditions, stream types, and corresponding waypoints stream_data = [ (is_heart_rate_set, 1, hr_waypoints), @@ -245,8 +293,48 @@ def process_activity(activity, user_id, strava_client, db: Session): (latitude is not None and longitude is not None, 7, lat_lon_waypoints), ] - for condition, stream_type, waypoints in stream_data: - if condition: + # Create the activity object + activity_to_store = schema_activities.Activity( + user_id=user_id, + name=activity.name, + distance=( + round(float(activity.distance)) + if isinstance(activity.distance, Quantity) + else round(activity.distance) + ), + activity_type=activity_processor.define_activity_type(activity.sport_type), + start_time=start_date_parsed.strftime("%Y-%m-%dT%H:%M:%S"), + end_time=end_date_parsed.strftime("%Y-%m-%dT%H:%M:%S"), + city=city, + town=town, + country=country, + waypoints=waypoints, + elevation_gain=elevation_gain, + elevation_loss=elevation_loss, + pace=average_pace, + average_speed=average_speed, + average_power=average_watts, + calories=activity.calories, + strava_gear_id=activity.gear_id, + strava_activity_id=int(activity.id), + ) + + # Return the activity and stream data + return {"activity_to_store": activity_to_store, "stream_data": stream_data} + + +def save_activity_and_streams( + activity: schema_activities.Activity, stream_data: list, db: Session +): + # Create the activity and get the ID + created_activity = crud_activities.create_activity(activity, db) + + # Create the empty array of activity streams + activity_streams = [] + + # Create the activity streams objects + for is_set, stream_type, waypoints in stream_data: + if is_set: activity_streams.append( schema_activity_streams.ActivityStreams( activity_id=created_activity.id, @@ -256,77 +344,53 @@ def process_activity(activity, user_id, strava_client, db: Session): ) ) + # Create the activity streams in the database crud_activity_streams.create_activity_streams(activity_streams, db) - """ activity_streams = [] - if is_heart_rate_set: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=1, - stream_waypoints=hr_waypoints, - strava_activity_stream_id=None, - ) - ) - - if is_power_set: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=2, - stream_waypoints=power_waypoints, - strava_activity_stream_id=None, - ) +def process_activity(activity, user_id: int, strava_client: Client, db: Session): + # Get the activity by Strava ID from the user + activity_db = fetch_and_validate_activity(activity.id, user_id, db) + + # Check if activity is None and return None if it is + if activity_db is not None: + return None + + # Log an informational event for activity processing + logger.info(f"User {user_id}: Activity {activity.id} will be processed") + + # Parse the activity and streams + parsed_activity = parse_activity(activity, user_id, strava_client) + + # Save the activity and streams to the database + save_activity_and_streams( + parsed_activity["activity_to_store"], parsed_activity["stream_data"], db + ) + + +def get_user_strava_activities_by_days(start_date: datetime, user_id: int): + # Get the first (and only) item from the generator + db = next(dependencies_database.get_db()) + + try: + # Get the user integrations by user ID + user_integrations = fetch_user_integrations_and_validate_token(user_id, db) + + # Log the start of the activities processing + logger.info(f"User {user_id}: Started activities processing") + + # Create a Strava client with the user's access token + strava_client = create_strava_client(user_integrations) + + # Fetch Strava activities after the specified start date + num_strava_activities_processed = fetch_and_process_activities( + strava_client, start_date, user_id, db ) - if is_cadence_set: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=3, - stream_waypoints=cad_waypoints, - strava_activity_stream_id=None, - ) + # Log an informational event for tracing + logger.info( + f"User {user_id}: {num_strava_activities_processed} activities processed" ) - - if is_elevation_set: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=4, - stream_waypoints=ele_waypoints, - strava_activity_stream_id=None, - ) - ) - - if is_velocity_set: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=5, - stream_waypoints=vel_waypoints, - strava_activity_stream_id=None, - ) - ) - - if is_velocity_set: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=6, - stream_waypoints=pace_waypoints, - strava_activity_stream_id=None, - ) - ) - - if latitude is not None and longitude is not None: - activity_streams.append( - schema_activity_streams.ActivityStreams( - activity_id=created_activity.id, - stream_type=7, - stream_waypoints=lat_lon_waypoints, - strava_activity_stream_id=None, - ) - ) """ - + finally: + # Ensure the session is closed after use + db.close() diff --git a/backend/routers/router_strava.py b/backend/routers/router_strava.py index 4ea9a2e1d..01cb21f8b 100644 --- a/backend/routers/router_strava.py +++ b/backend/routers/router_strava.py @@ -3,7 +3,7 @@ import requests import os from datetime import datetime, timedelta -from typing import Annotated, Callable +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi.security import OAuth2PasswordBearer from fastapi.responses import RedirectResponse @@ -80,7 +80,7 @@ async def strava_link( redirect_url = ( "https://" + os.environ.get("FRONTEND_HOST") - + "/settings/settings.php?profileSettings=1&stravaLinked=1" + + "/settings/settings.php?integrationsSettings=1&stravaLinked=1" ) # Return a RedirectResponse to the redirect URL @@ -106,7 +106,7 @@ async def strava_retrieve_activities_days( user_id: Annotated[ int, Depends(dependencies_session.validate_token_and_get_authenticated_user_id) ], - db: Annotated[Session, Depends(dependencies_database.get_db)], + #db: Annotated[Session, Depends(dependencies_database.get_db)], background_tasks: BackgroundTasks, ): # Process strava activities in the background @@ -114,13 +114,12 @@ async def strava_retrieve_activities_days( strava_processor.get_user_strava_activities_by_days, (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S"), user_id, - db, ) # Return success message and status code 202 - logger.info(f"Strava activities will be processed in the background for {user_id}") + logger.info(f"Strava activities will be processed in the background for user {user_id}") return { - "detail": f"Strava activities will be processed in the background for {user_id}" + "detail": f"Strava activities will be processed in the background for for {user_id}" } diff --git a/backend/schemas/schema_activities.py b/backend/schemas/schema_activities.py index 3b3003d48..542289d68 100644 --- a/backend/schemas/schema_activities.py +++ b/backend/schemas/schema_activities.py @@ -21,7 +21,7 @@ class Activity(BaseModel): calories: int | None = None visibility: int | None = None gear_id: int | None = None - strava_gear_id: int | None = None + strava_gear_id: str | None = None strava_activity_id: int | None = None class Config: diff --git a/backend/schemas/schema_gear.py b/backend/schemas/schema_gear.py index 9223f211a..9d8fa5733 100644 --- a/backend/schemas/schema_gear.py +++ b/backend/schemas/schema_gear.py @@ -9,7 +9,7 @@ class Gear(BaseModel): user_id: int | None = None created_at: str is_active: int | None = None - strava_gear_id: int | None = None + strava_gear_id: str | None = None class Config: orm_mode = True \ No newline at end of file diff --git a/frontend/activities/activity.php b/frontend/activities/activity.php index 555dd3173..560d90adb 100755 --- a/frontend/activities/activity.php +++ b/frontend/activities/activity.php @@ -240,19 +240,28 @@ if($activityUser["id"] == $_SESSION["id"]){ @@ -415,6 +424,15 @@ if($activityUser["id"] == $_SESSION["id"]){
+ + "; + # } function getStravaActivitiesLastDays($days) { - $response = callAPIRoute("/strava/activities/days/$days", 0, 0, NULL); + $response = callAPIRoute("/strava/activities/days/$days", 1, 0, NULL); if ($response[0] === false) { return -1; } else { - if ($response[1] === 200) { + if ($response[1] === 202) { return 0; } else { return -2; diff --git a/frontend/index.php b/frontend/index.php index 52fb4affa..5fdb8ec58 100755 --- a/frontend/index.php +++ b/frontend/index.php @@ -341,66 +341,79 @@ $thisMonthDistances = getUserActivitiesThisMonthDistances($_SESSION["id"]);
-
- alt="userPicture" class="rounded-circle" width="55" height="55"> -
- - - '; +
+
+ (Virtual)'; + echo ("../img/avatar/female1.png"); + } + } else { + echo ($_SESSION["photo_path"]); + } ?> alt="userPicture" class="rounded-circle" width="55" height="55"> +
+ + + '; } else { - if ($activity["activity_type"] == 4 || $activity["activity_type"] == 5 || $activity["activity_type"] == 6) { - echo ''; + if ($activity["activity_type"] == 3) { + echo ' (Virtual)'; } else { - if ($activity["activity_type"] == 7) { - echo ' (Virtual)'; + if ($activity["activity_type"] == 4 || $activity["activity_type"] == 5 || $activity["activity_type"] == 6) { + echo ''; } else { - if ($activity["activity_type"] == 8 || $activity["activity_type"] == 9) { - echo ''; + if ($activity["activity_type"] == 7) { + echo ' (Virtual)'; } else { - if ($activity["activity_type"] == 10) { - echo ''; + if ($activity["activity_type"] == 8 || $activity["activity_type"] == 9) { + echo ''; + } else { + if ($activity["activity_type"] == 10) { + echo ''; + } } } } } - } - } ?> - format("d/m/y"); ?>@ - format("H:i"); ?> - - - - + } ?> + format("d/m/y"); ?>@ + format("H:i"); ?> + + + + +
+
+
@@ -468,7 +481,7 @@ $thisMonthDistances = getUserActivitiesThisMonthDistances($_SESSION["id"]); echo "mb-3"; } ?>" id="map_" style="height: 300px">
- +