diff --git a/.gitignore b/.gitignore index cd0c11d0..00083fff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,8 @@ backend/*/__pycache__/ backend/*.pyc # Logs -backend/logs/ -backend/*.log \ No newline at end of file +backend/logs/*.log +backend/*.log + +# Frontend +frontend/img/users_img/*.* \ No newline at end of file diff --git a/README.md b/README.md index b89abd8a..630e0f35 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ JAEGER_PROTOCOL | http | Yes JAEGER_HOST | jaeger | Yes JAGGER_PORT | 4317 | Yes STRAVA_DAYS_ACTIVITIES_ONLINK | 30 | Yes +API_ENDPOINT* | changeme | Yes + +*API_ENDPOINT needs to be set if you want to enable Strava integration Table bellow shows the obligatory environemnt variables for mariadb container. You should set them based on what was also set for backend container. diff --git a/backend/.env b/backend/.env index 17c82fbb..3a20764c 100644 --- a/backend/.env +++ b/backend/.env @@ -14,4 +14,5 @@ JAEGER_ENABLED=true JAEGER_PROTOCOL=http JAEGER_HOST=jaeger JAGGER_PORT=4317 -STRAVA_DAYS_ACTIVITIES_ONLINK=30 \ No newline at end of file +STRAVA_DAYS_ACTIVITIES_ONLINK=30 +API_ENDPOINT=changeme \ No newline at end of file diff --git a/backend/controllers/activityController.py b/backend/controllers/activityController.py index e3ee0cb7..09b27acf 100644 --- a/backend/controllers/activityController.py +++ b/backend/controllers/activityController.py @@ -416,7 +416,15 @@ async def read_activities_useractivities_thisweek_distances( distances = calculate_activity_distances(activity_records) # Return the queried values using JSONResponse - return JSONResponse(content=distances) + #return JSONResponse(content=distances) + + # Include metadata in the response + metadata = {"total_records": 1} + + # Return the queried values using JSONResponse + return JSONResponse( + content={"metadata": metadata, "content": distances} + ) except JWTError: # Return an error response if the user is not authenticated return create_error_response("UNAUTHORIZED", "Unauthorized", 401) @@ -479,7 +487,15 @@ async def read_activities_useractivities_thismonth_distances( distances = calculate_activity_distances(activity_records) # Return the queried values using JSONResponse - return JSONResponse(content=distances) + #return JSONResponse(content=distances) + + # Include metadata in the response + metadata = {"total_records": 1} + + # Return the queried values using JSONResponse + return JSONResponse( + content={"metadata": metadata, "content": distances} + ) except JWTError: # Return an error response if the user is not authenticated return create_error_response("UNAUTHORIZED", "Unauthorized", 401) diff --git a/backend/controllers/followerController.py b/backend/controllers/followerController.py index 6bd875ab..07983173 100644 --- a/backend/controllers/followerController.py +++ b/backend/controllers/followerController.py @@ -36,7 +36,7 @@ """ import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from fastapi.security import OAuth2PasswordBearer from . import sessionController from jose import JWTError diff --git a/backend/controllers/sessionController.py b/backend/controllers/sessionController.py index f836d8f3..74c93f4f 100644 --- a/backend/controllers/sessionController.py +++ b/backend/controllers/sessionController.py @@ -1,58 +1,224 @@ +""" +Authentication and User Management Module + +This module defines FastAPI routes and functions for user authentication, access token management, +and CRUD operations on user records. It integrates with a relational database using SQLAlchemy +and provides endpoints for handling user login, token validation, user data retrieval, +and logout functionality. + +Endpoints: +- POST /token: Endpoint for user login to obtain an access token. +- GET /validate_token: Endpoint for validating the integrity and expiration of an access token. +- GET /users/me: Endpoint to retrieve user data based on the provided access token. +- DELETE /logout/{user_id}: Endpoint for user logout, revoking the associated access token. + +Dependencies: +- OAuth2PasswordBearer: FastAPI security scheme for handling OAuth2 password bearer tokens. +- get_db_session: Dependency function to get a database session. +- create_error_response: Function to create a standardized error response. + +Models: +- TokenBase: Base Pydantic model for token attributes. +- CreateTokenRequest: Pydantic model for creating token records. + +Functions: +- authenticate_user: Function to authenticate a user and generate an access token. +- create_access_token: Function to create and store a new access token. +- remove_expired_tokens: Function to remove expired access tokens from the database. +- get_user_data: Function to retrieve user data based on the provided access token. +- validate_token: Function to validate the integrity and expiration of an access token. +- validate_admin_access: Function to validate if a user has admin access based on the token. + +Logger: +- Logger named "myLogger" for logging errors and exceptions. +""" +# OS module for interacting with the operating system import os + +# Logging module for adding log statements to your code import logging -from fastapi import APIRouter, Depends, HTTPException + +# FastAPI framework imports +from fastapi import APIRouter, Depends + +# Datetime module for working with date and time from datetime import datetime, timedelta + +# JOSE (JavaScript Object Signing and Encryption) library for JWT (JSON Web Tokens) from jose import jwt, JWTError + +# FastAPI security module for handling OAuth2 password bearer authentication from fastapi.security import OAuth2PasswordBearer + +# SQLAlchemy module for working with relational databases from sqlalchemy.orm import Session -from db.db import ( - User, - AccessToken, -) # Import your SQLAlchemy session management from db.db and models + +# Importing User and AccessToken models from the 'db' module +from db.db import User, AccessToken + +# Importing UserResponse model from the 'controllers.userController' module from controllers.userController import UserResponse + +# Pydantic module for data validation and parsing from pydantic import BaseModel -from dependencies import get_db_session +# Custom dependencies for dependency injection in FastAPI +from dependencies import get_db_session, create_error_response + +# Define the API router router = APIRouter() +# Define a loggger created on main.py logger = logging.getLogger("myLogger") +# Define the OAuth2 scheme for handling bearer tokens oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") -def get_user_id_from_token(token: str): +class TokenBase(BaseModel): + """ + Base Pydantic model for representing token attributes. + + Attributes: + - username (str): The username of the user. + - password (str): The user password in hash format. + - neverExpires (str): True or false value to set the token to expire. + """ + username: str + password: str + neverExpires: bool + +class CreateTokenRequest(TokenBase): + """ + Pydantic model for creating token records. + + Inherits from TokenBase, which defines the base attributes for token. + + This class extends the TokenBase Pydantic model and is specifically tailored for + creating new records. + """ + pass + + +def decode_token(token: str): + """ + Decode a JSON Web Token (JWT) and extract its payload. + + Parameters: + - token (str): The JWT string to be decoded. + + Returns: + - dict: A dictionary containing the decoded payload of the JWT. + + This function decodes a given JWT using the provided secret key and algorithm. It extracts and returns the payload + of the JWT, which typically includes information such as user ID, access type, and expiration time. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + """ try: payload = jwt.decode( token, os.environ.get("SECRET_KEY"), algorithms=[os.environ.get("ALGORITHM")], ) - return payload.get("id") + return payload except JWTError: - raise HTTPException(status_code=401, detail="Unauthorized") + # Return an error response if the user is not authenticated + return ("UNAUTHORIZED", "Unauthorized", 401) -def get_exp_from_token(token: str = Depends(oauth2_scheme)): +def get_user_id_from_token(token: str): + """ + Extract the user ID from a decoded JSON Web Token (JWT) payload. + + Parameters: + - token (str): The decoded JWT string. + + Returns: + - Union[int, Tuple[str, str, int]]: The user ID extracted from the JWT payload, + or a tuple representing an error response if the token is invalid. + + This function retrieves the user ID from the decoded payload of a JWT. It is used for + obtaining the user ID associated with a valid token during user authentication. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the extraction process. + """ try: - payload = jwt.decode( - token, - os.environ.get("SECRET_KEY"), - algorithms=[os.environ.get("ALGORITHM")], + return decode_token(token).get("id") + except JWTError: + # Return an error response if the user is not authenticated + return ("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in get_user_id_from_token: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 ) - return payload.get("exp") + + +def get_exp_from_token(token: str = Depends(oauth2_scheme)): + """ + Extract the expiration time from a decoded JSON Web Token (JWT) payload. + + Parameters: + - token (str): The decoded JWT string. + + Returns: + - Union[int, Tuple[str, str, int]]: The expiration time (UNIX timestamp) extracted + from the JWT payload, or a tuple representing an error response if the token is invalid. + + This function retrieves the expiration time from the decoded payload of a JWT. + It is used to check the validity and expiration status of an access token. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the extraction process. + """ + try: + return decode_token(token).get("exp") except JWTError: - raise HTTPException(status_code=401, detail="Unauthorized") - + # Return an error response if the user is not authenticated + return ("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in get_exp_from_token: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) + + def get_access_type_from_token(token: str = Depends(oauth2_scheme)): + """ + Extract the access type from a decoded JSON Web Token (JWT) payload. + + Parameters: + - token (str): The decoded JWT string. + + Returns: + - Union[int, Tuple[str, str, int]]: The access type extracted from the JWT payload, + or a tuple representing an error response if the token is invalid. + + This function retrieves the access type from the decoded payload of a JWT. + It is used to determine the level of access associated with a user's token. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the extraction process. + """ try: - payload = jwt.decode( - token, - os.environ.get("SECRET_KEY"), - algorithms=[os.environ.get("ALGORITHM")], - ) - return payload.get("access_type") + return decode_token(token).get("access_type") except JWTError: - raise HTTPException(status_code=401, detail="Unauthorized") + # Return an error response if the user is not authenticated + return ("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in get_access_type_from_token: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) async def authenticate_user( @@ -61,6 +227,26 @@ async def authenticate_user( neverExpires: bool, db_session: Session, ): + """ + Authenticate a user and generate an access token. + + Parameters: + - username (str): The username of the user attempting to authenticate. + - password (str): The password of the user attempting to authenticate. + - neverExpires (bool): Flag indicating whether the access token should never expire. + - db_session (Session): SQLAlchemy database session. + + Returns: + - Union[str, Tuple[str, str, int]]: The generated access token, + or a tuple representing an error response if authentication fails. + + This function verifies the user's credentials, checks for an existing access token, + and generates a new access token if necessary. The token's expiration is determined + based on the 'neverExpires' flag. + + Raises: + - Exception: If an unexpected error occurs during the authentication process. + """ try: # Use SQLAlchemy ORM to query the database user = ( @@ -69,8 +255,8 @@ async def authenticate_user( .first() ) if not user: - raise HTTPException( - status_code=400, detail="Incorrect username or password" + return create_error_response( + "BAD_REQUEST", "Incorrect username or password", 400 ) # Check if there is an existing access token for the user @@ -93,9 +279,12 @@ async def authenticate_user( return access_token - except Exception as e: - logger.error(e) - return False + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in authenticate_user: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) async def create_access_token( @@ -104,34 +293,79 @@ async def create_access_token( db_session: Session, expires_delta: timedelta = None, ): - to_encode = data.copy() - if never_expire: - expire = datetime.utcnow() + timedelta(days=90) - elif expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode( - to_encode, os.environ.get("SECRET_KEY"), algorithm=os.environ.get("ALGORITHM") - ) + """ + Create and store a new access token. - # Insert the access token into the database using SQLAlchemy - access_token = AccessToken( - token=encoded_jwt, - user_id=data["id"], - created_at=datetime.utcnow(), - expires_at=expire, - ) + Parameters: + - data (dict): The payload data to be encoded in the access token. + - never_expire (bool): Flag indicating whether the access token should never expire. + - db_session (Session): SQLAlchemy database session. + - expires_delta (timedelta, optional): Duration until the access token expires. + + Returns: + - Union[str, Tuple[str, str, int]]: The generated access token, + or a tuple representing an error response if token creation fails. + + This function creates a new access token by encoding the provided payload data. + The token is then stored in the database, and the generated token string is returned. + + Raises: + - Exception: If an unexpected error occurs during the token creation process. + """ + try: + to_encode = data.copy() + if never_expire: + expire = datetime.utcnow() + timedelta(days=90) + elif expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, + os.environ.get("SECRET_KEY"), + algorithm=os.environ.get("ALGORITHM"), + ) - db_session.add(access_token) - db_session.commit() + # Insert the access token into the database using SQLAlchemy + access_token = AccessToken( + token=encoded_jwt, + user_id=data["id"], + created_at=datetime.utcnow(), + expires_at=expire, + ) - return encoded_jwt + db_session.add(access_token) + db_session.commit() + + return encoded_jwt + except Exception as err: + # Log the error, rollback the transaction, and return an error response + db_session.rollback() + logger.error(f"Error in create_access_token: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) def remove_expired_tokens(db_session: Session): + """ + Remove expired access tokens from the database. + + Parameters: + - db_session (Session): SQLAlchemy database session. + + Returns: + - Union[None, Tuple[str, str, int]]: None on successful removal, + or a tuple representing an error response if removal fails. + + This function deletes access tokens from the database that have exceeded their expiration time. + It helps maintain the database's integrity by regularly purging expired access tokens. + + Raises: + - Exception: If an unexpected error occurs during the removal process. + """ try: # Calculate the expiration time expiration_time = datetime.utcnow() - timedelta( @@ -148,24 +382,45 @@ def remove_expired_tokens(db_session: Session): logger.info(f"{rows_deleted} access tokens deleted from the database") except Exception as err: + # Log the error and return an error response logger.error(f"Error in remove_expired_tokens: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) def get_user_data(db_session: Session, token: str = Depends(oauth2_scheme)): + """ + Retrieve user data based on the provided access token. + + Parameters: + - db_session (Session): SQLAlchemy database session. + - token (str): The access token for which user data is requested. + + Returns: + - Union[dict, Tuple[str, str, int]]: A dictionary containing user data, + or a tuple representing an error response if retrieval fails. + + This function fetches user data from the database using the provided access token. + It validates the token, retrieves the associated user ID, and returns the user's details + in a dictionary format. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the data retrieval process. + """ try: validate_token(db_session=db_session, token=token) user_id = get_user_id_from_token(token) if user_id is None: - raise HTTPException( - status_code=401, detail="Invalid authentication credentials" - ) + return create_error_response("UNAUTHORIZED", "Unauthorized", 401) # Retrieve the user details from the database using the user ID user = db_session.query(User).filter(User.id == user_id).first() if not user: - raise HTTPException(status_code=404, detail="User not found") + return create_error_response("NOT_FOUND", "User not found", 404) if user.strava_token is None: is_strava_linked = 0 @@ -191,10 +446,35 @@ def get_user_data(db_session: Session, token: str = Depends(oauth2_scheme)): return user_data except JWTError: - raise HTTPException(status_code=401, detail="Unauthorized") + # Return an error response if the user is not authenticated + return ("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in get_user_data: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) def validate_token(db_session: Session, token: str): + """ + Validate the integrity and expiration of an access token. + + Parameters: + - db_session (Session): SQLAlchemy database session. + - token (str): The access token to be validated. + + Returns: + - Union[dict, Tuple[str, str, int]]: A dictionary with a success message if the token is valid, + or a tuple representing an error response if validation fails. + + This function checks the integrity and expiration of the provided access token. + It ensures that the token is associated with a valid user in the database and has not expired. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the validation process. + """ try: user_id = get_user_id_from_token(token) @@ -206,56 +486,159 @@ def validate_token(db_session: Session, token: str): .first() ) - if access_token: - expiration_datetime = datetime.fromtimestamp(exp) - current_time = datetime.utcnow() - if current_time > expiration_datetime: - raise JWTError("Token expired") - else: - return {"message": "Token is valid"} - else: + if not access_token or datetime.utcnow() > datetime.fromtimestamp(exp): raise JWTError("Token expired") - - # if 'exp' not in decoded_token: - # return {"message": "Token is valid"} - # else: - except JWTError: - raise JWTError("Invalid token") + else: + return {"message": "Token is valid"} + except JWTError as jwt_error: + raise jwt_error + except Exception as err: + logger.error(f"Error in token validation: {err}", exc_info=True) + raise JWTError("Token validation failed") def validate_admin_access(token: str): + """ + Validate if the user associated with the provided token has administrative access. + + Parameters: + - token (str): The access token to be validated. + + Returns: + - Union[None, Tuple[str, str, int]]: None if the user has admin access, + or a tuple representing an error response if validation fails. + + This function checks if the user associated with the provided access token has administrative access. + It verifies the access type stored in the token, allowing or denying access based on the user's privileges. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the validation process. + """ try: user_access_type = get_access_type_from_token(token) if user_access_type != 2: - raise HTTPException(status_code=401, detail="Unauthorized") + return create_error_response("UNAUTHORIZED", "Unauthorized", 401) except JWTError: raise JWTError("Invalid token") -class CreateTokenRequest(BaseModel): - username: str - password: str - neverExpires: bool - - @router.post("/token") async def login_for_access_token( token: CreateTokenRequest, db_session: Session = Depends(get_db_session) ): + """ + Endpoint for user login to obtain an access token. + + Parameters: + - token (CreateTokenRequest): The request model containing username, password, and neverExpires flag. + - db_session (Session, optional): SQLAlchemy database session. Obtained through dependency injection. + + Returns: + - Union[dict, Tuple[str, str, int]]: A dictionary containing the access token if login is successful, + or a tuple representing an error response if login fails. + + This endpoint handles user authentication by verifying the provided credentials. + If successful, it generates an access token and returns it to the user. + + Raises: + - Exception: If an unexpected error occurs during the authentication process. + """ access_token = await authenticate_user( token.username, token.password, token.neverExpires, db_session ) if not access_token: - raise HTTPException(status_code=400, detail="Unable to retrieve access token") + return create_error_response( + "BAD_REQUEST", "Unable to retrieve access token", 400 + ) return {"access_token": access_token, "token_type": "bearer"} +@router.get("/validate_token") +async def check_validate_token( + token: str = Depends(oauth2_scheme), db_session: Session = Depends(get_db_session) +): + """ + Endpoint for validating the integrity and expiration of an access token. + + Parameters: + - token (str): The access token to be validated. + - db_session (Session, optional): SQLAlchemy database session. Obtained through dependency injection. + + Returns: + - Union[dict, Tuple[str, str, int]]: A dictionary with a success message if the token is valid, + or a tuple representing an error response if validation fails. + + This endpoint checks the integrity and expiration of the provided access token. + If the token is valid, it returns a success message; otherwise, it returns an error response. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the validation process. + """ + try: + return validate_token(db_session, token) + except JWTError: + # Return an error response if the user is not authenticated + return ("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in get_user_data: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) + + +@router.get("/users/me", response_model=UserResponse) +async def read_users_me( + token: str = Depends(oauth2_scheme), db_session: Session = Depends(get_db_session) +): + """ + Endpoint to retrieve user data based on the provided access token. + + Parameters: + - token (str): The access token used to identify the user. + - db_session (Session, optional): SQLAlchemy database session. Obtained through dependency injection. + + Returns: + - Union[dict, Tuple[str, str, int]]: A dictionary containing user data, + or a tuple representing an error response if retrieval fails. + + This endpoint fetches and returns the user's data based on the provided access token. + It validates the token, retrieves the associated user ID, and returns the user's details + in a format consistent with the UserResponse Pydantic model. + + Raises: + - JWTError: If there is an issue with JWT decoding or the token is invalid. + - Exception: If an unexpected error occurs during the data retrieval process. + """ + return get_user_data(db_session, token) + + @router.delete("/logout/{user_id}") async def logout( user_id: int, token: str = Depends(oauth2_scheme), db_session: Session = Depends(get_db_session), ): + """ + Endpoint for user logout, revoking the associated access token. + + Parameters: + - user_id (int): The user ID for which logout is requested. + - token (str): The access token used to identify the user. + - db_session (Session, optional): SQLAlchemy database session. Obtained through dependency injection. + + Returns: + - Union[dict, Tuple[str, str, int]]: A dictionary with a success message if logout is successful, + or a tuple representing an error response if logout fails. + + This endpoint revokes the access token associated with the provided user ID, effectively logging the user out. + If the token is found and successfully revoked, it returns a success message; otherwise, it returns an error response. + + Raises: + - Exception: If an unexpected error occurs during the logout process. + """ try: access_token = ( db_session.query(AccessToken) @@ -267,24 +650,11 @@ async def logout( db_session.commit() return {"message": "Logged out successfully"} else: - raise HTTPException(status_code=404, detail="Token not found") - except Exception as e: - logger.error(e) - raise HTTPException(status_code=500, detail="Failed to invalidate access token") - - -@router.get("/validate_token") -async def check_validate_token( - token: str = Depends(oauth2_scheme), db_session: Session = Depends(get_db_session) -): - try: - return validate_token(db_session, token) - except JWTError: - raise HTTPException(status_code=401, detail="Unauthorized") - + return create_error_response("NOT_FOUND", "Token not found", 404) -@router.get("/users/me", response_model=UserResponse) -async def read_users_me( - token: str = Depends(oauth2_scheme), db_session: Session = Depends(get_db_session) -): - return get_user_data(db_session, token) + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in logout: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) diff --git a/backend/controllers/stravaController.py b/backend/controllers/stravaController.py index 8622b424..71807009 100644 --- a/backend/controllers/stravaController.py +++ b/backend/controllers/stravaController.py @@ -1,6 +1,40 @@ +""" +API Router for Strava Integration and Activity Processing. + +This module defines FastAPI routes and functions for integrating Strava accounts, +refreshing Strava tokens, and processing Strava activities. It includes endpoints +for handling Strava callback, setting/unsetting unique user states for Strava link logic, +and background tasks for fetching and processing Strava activities. + +Endpoints: +- GET /strava/strava-callback: Handle Strava callback to link user Strava accounts. +- PUT /strava/set-user-unique-state/{state}: Set unique state for user Strava link logic. +- PUT /strava/unset-user-unique-state: Unset unique state for user Strava link logic. + +Functions: +- refresh_strava_token: Refresh Strava tokens for all users in the database. +- get_strava_activities: Fetch and process Strava activities for all users. +- get_user_strava_activities: Fetch and process Strava activities for a specific user. +- process_activity: Process individual Strava activity and store in the database. +- update_strava_user_tokens: Common function to update Strava tokens and perform additional tasks. + +Dependencies: +- OAuth2PasswordBearer: FastAPI security scheme for handling OAuth2 password bearer tokens. +- get_db_session: Dependency function to get a database session. +- create_error_response: Function to create a standardized error response. +- BackgroundTasks: FastAPI class for handling background tasks. +- Session: SQLAlchemy session for database interactions. + +Models: +- User: SQLAlchemy model for user records. +- Activity: SQLAlchemy model for Strava activity records. + +Logger: +- Logger named "myLogger" for logging errors, exceptions, and informational events. +""" from datetime import datetime, timedelta from db.db import User, Activity -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError @@ -8,22 +42,457 @@ from pint import Quantity from concurrent.futures import ThreadPoolExecutor from fastapi import BackgroundTasks -from opentelemetry import trace from urllib.parse import urlencode from . import sessionController -from dependencies import get_db_session +from dependencies import get_db_session, create_error_response from sqlalchemy.orm import Session +from fastapi.responses import JSONResponse import logging import requests import os +# Define the API router router = APIRouter() +# Define a loggger created on main.py logger = logging.getLogger("myLogger") +# Define the OAuth2 scheme for handling bearer tokens oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +# Strava logic to refresh user Strava account refresh account +def refresh_strava_token(db_session: Session): + """ + Refresh Strava tokens for all users in the database. + + Parameters: + - db_session (Session): SQLAlchemy database session. + + Returns: + - None: The function updates Strava tokens in the database and logs the results. + + Raises: + - Exception: For unexpected errors during token refresh. + """ + # Strava token refresh endpoint + token_url = "https://www.strava.com/oauth/token" + + try: + # Query all users from the database + users = db_session.query(User).all() + + for user in users: + # expires_at = user.strava_token_expires_at + if user.strava_token_expires_at is not None: + refresh_time = user.strava_token_expires_at - timedelta(minutes=60) + + if datetime.utcnow() > refresh_time: + # 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.strava_refresh_token, + "grant_type": "refresh_token", + } + + try: + # Make a POST request to refresh the Strava token + response = requests.post(token_url, data=payload) + response.raise_for_status() # Raise an error for bad responses + + tokens = response.json() + + # Update the user in the database + db_user = ( + db_session.query(User) + .filter(User.id == user.id) + .first() + ) + + if db_user: + db_user.strava_token = tokens["access_token"] + db_user.strava_refresh_token = tokens["refresh_token"] + db_user.strava_token_expires_at = ( + datetime.fromtimestamp(tokens["expires_at"]) + ) + db_session.commit() # Commit the changes to the database + logger.info( + f"Token refreshed successfully for user {user.id}." + ) + else: + logger.error("User not found in the database.") + except requests.exceptions.RequestException as req_err: + logger.error( + f"Error refreshing token for user {user.id}: {req_err}" + ) + else: + logger.info( + f"Token not refreshed for user {user.id}. Will not expire in less than 60min" + ) + else: + logger.info(f"User {user.id} does not have strava linked") + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in refresh_strava_token: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) + + +def get_strava_activities(start_date: datetime, db_session: Session): + """ + Fetch and process Strava activities for all users. + + Parameters: + - start_date (datetime): The start date to retrieve activities after. + - db_session (Session): SQLAlchemy database session. + + Returns: + - None: The function fetches Strava activities, processes them, and stores in the database. + + Raises: + - Exception: For unexpected errors during activity fetching and processing. + """ + try: + # Query all users from the database + users = db_session.query(User).all() + + for user in users: + if user.strava_token_expires_at is not None: + + # Create a Strava client with the user's access token + stravaClient = Client(access_token=user.strava_token) + + # Fetch Strava activities after the specified start date + strava_activities = list( + stravaClient.get_activities(after=start_date) + ) + + if strava_activities: + # Initialize an empty list for results + all_results = [] + + # Use ThreadPoolExecutor for parallel processing of activities + with ThreadPoolExecutor() as executor: + results = list( + executor.map( + lambda activity: process_activity( + activity, user.id, stravaClient, db_session + ), + strava_activities, + ) + ) + + # Append non-empty and non-None results to the overall results list + all_results.extend(results) + + # Flatten the list of results + activities_to_insert = [ + activity for sublist in all_results for activity in sublist + ] + + # Bulk insert all activities into the database + db_session.bulk_save_objects(activities_to_insert) + db_session.commit() + + # Log an informational event for tracing + logger.info(f"User {user.id}: {len(strava_activities)} periodic activities processed") + else: + # Log an informational event if no activities were found + logger.info(f"User {user.id}: No new activities found after {start_date}") + else: + # Log an informational event if the user does not have Strava linked + logger.info(f"User {user.id} does not have Strava linked") + except Exception as err: + # Log the error, rollback the transaction, and return an error response + db_session.rollback() + logger.error(f"Error in strava_set_user_unique_state: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) + + +def get_user_strava_activities(start_date: datetime, user_id: int, db_session: Session): + """ + Fetch and process Strava activities for a specific user. + + Parameters: + - start_date (datetime): The start date to retrieve activities after. + - user_id (int): The user ID for whom to fetch and process Strava activities. + - db_session (Session): SQLAlchemy database session. + + Returns: + - None: The function fetches Strava activities for the specified user, + processes them, and stores in the database. + + Raises: + - Exception: For unexpected errors during user-specific activity fetching and processing. + """ + # Query user from the database + db_user = db_session.query(User).get(user_id) + + # Check if db returned an user object and variable is set + if db_user: + if db_user.strava_token_expires_at is not None: + # Log an informational event for tracing + logger.info(f"User {db_user.id}: Started initial activities processing") + + # Create a Strava client with the user's access token + stravaClient = Client(access_token=db_user.strava_token) + + # Fetch Strava activities after the specified start date + strava_activities = list(stravaClient.get_activities(after=start_date)) + + if strava_activities: + # Initialize an empty list for results + all_results = [] + + # Use ThreadPoolExecutor for parallel processing of activities + with ThreadPoolExecutor() as executor: + results = list( + executor.map( + lambda activity: process_activity( + activity, db_user.id, stravaClient, db_session + ), + strava_activities, + ) + ) + + # Append non-empty and non-None results to the overall results list + all_results.extend(results) + + # Flatten the list of results + activities_to_insert = [ + activity for sublist in all_results for activity in sublist + ] + + # Bulk insert all activities into the database + db_session.bulk_save_objects(activities_to_insert) + db_session.commit() + + # Log an informational event for tracing + logger.info(f"User {db_user.id}: {len(strava_activities)} initial activities processed") + + else: + # Log an informational event if no activities were found + logger.info(f"User {db_user.id}: No new activities found after {start_date}") + else: + # Log an informational event if the user does not have Strava linked + logger.info(f"User {db_user.id} does not have Strava linked") + + else: + # Log an informational event if the user is not found + logger.info(f"User with ID {user_id} not found.") + + +def process_activity(activity, user_id, stravaClient, db_session: Session): + """ + Process individual Strava activity and store in the database. + + Parameters: + - activity: Strava activity object obtained from the Strava API. + - user_id (int): The user ID associated with the Strava activity. + - stravaClient: Strava client instance for making API requests. + - db_session (Session): SQLAlchemy database session. + + Returns: + - List[Activity]: List of Activity objects constructed from the Strava activity. + + Raises: + - Exception: For unexpected errors during activity processing. + """ + activities_to_insert = [] + + # Check if the activity already exists in the database + activity_record = ( + db_session.query(Activity) + .filter(Activity.strava_activity_id == activity.id) + .first() + ) + + if activity_record: + # Skip existing activities + return activities_to_insert + + # Parse start and end dates + start_date_parsed = activity.start_date + # Ensure activity.elapsed_time is a numerical value + elapsed_time_seconds = ( + activity.elapsed_time.total_seconds() + if isinstance(activity.elapsed_time, timedelta) + else activity.elapsed_time + ) + end_date_parsed = start_date_parsed + timedelta(seconds=elapsed_time_seconds) + + # Initialize location variables + latitude, longitude = 0, 0 + + if hasattr(activity, "start_latlng") and activity.start_latlng is not None: + latitude = activity.start_latlng.lat + longitude = activity.start_latlng.lon + + city, town, country = None, None, None + + # Retrieve location details using reverse geocoding + if latitude != 0 and longitude != 0: + # Encode URL with query parameters to ensure proper encoding and protection against special characters. + url_params = {"lat": latitude, "lon": longitude} + url = f"https://geocode.maps.co/reverse?{urlencode(url_params)}" + # url = f"https://geocode.maps.co/reverse?lat={latitude}&lon={longitude}" + try: + # Make a GET request + response = requests.get(url) + + # Check if the request was successful (status code 200) + if response.status_code == 200: + # Parse the JSON response + data = response.json() + + # Extract the town and country from the address components + city = data.get("address", {}).get("city", None) + town = data.get("address", {}).get("town", None) + country = data.get("address", {}).get("country", None) + else: + logger.error(f"Error location: {response.status_code}") + logger.error(f"Error location: {url}") + except Exception as err: + # Log the error and return an error response + logger.error(f"Error in process_activity: {err}", exc_info=True) + + # List to store constructed waypoints + waypoints = [] + + # Initialize variables for elevation gain and loss + elevation_gain = 0 + elevation_loss = 0 + previous_elevation = None + + # Get streams for the activity + streams = stravaClient.get_activity_streams( + activity.id, + types=[ + "latlng", + "altitude", + "time", + "heartrate", + "cadence", + "watts", + "velocity_smooth", + ], + ) + + # Extract data from streams + latitudes = streams["latlng"].data if "latlng" in streams else [] + longitudes = streams["latlng"].data if "latlng" in streams else [] + elevations = streams["altitude"].data if "altitude" in streams else [] + times = streams["time"].data if "time" in streams else [] + heart_rates = streams["heartrate"].data if "heartrate" in streams else [] + cadences = streams["cadence"].data if "cadence" in streams else [] + powers = streams["watts"].data if "watts" in streams else [] + velocities = ( + streams["velocity_smooth"].data if "velocity_smooth" in streams else [] + ) + + # Iterate through stream data to construct waypoints + for i in range(len(heart_rates)): + waypoint = { + "lat": latitudes[i] if i < len(latitudes) else None, + "lon": longitudes[i] if i < len(longitudes) else None, + "ele": elevations[i] if i < len(elevations) else None, + "time": times[i] if i < len(times) else None, + "hr": heart_rates[i] if i < len(heart_rates) else None, + "cad": cadences[i] if i < len(cadences) else None, + "power": powers[i] if i < len(powers) else None, + "vel": velocities[i] if i < len(velocities) else None, + "pace": 1 / velocities[i] + if i < len(velocities) and velocities[i] != 0 + else None, + } + + # Calculate elevation gain and loss on-the-fly + current_elevation = elevations[i] if i < len(elevations) else None + + if current_elevation is not None: + if previous_elevation is not None: + elevation_change = current_elevation - previous_elevation + + if elevation_change > 0: + elevation_gain += elevation_change + else: + elevation_loss += abs(elevation_change) + + previous_elevation = current_elevation + + # Append the constructed waypoint to the waypoints list + waypoints.append(waypoint) + + # Calculate average speed, pace, and watts + average_speed = 0 + if activity.average_speed is not None: + average_speed = ( + float(activity.average_speed.magnitude) + if isinstance(activity.average_speed, Quantity) + else activity.average_speed + ) + + average_pace = 1 / average_speed if average_speed != 0 else 0 + + average_watts = 0 + if activity.average_watts is not None: + average_watts = activity.average_watts + + # Map activity type to a numerical value + auxType = 10 # Default value + type_mapping = { + "running": 1, + "Run": 1, + "trail running": 2, + "TrailRun": 2, + "VirtualRun": 3, + "cycling": 4, + "Ride": 4, + "GravelRide": 5, + "EBikeRide": 6, + "EMountainBikeRide": 6, + "VirtualRide": 7, + "virtual_ride": 7, + "MountainBikeRide": 8, + "swimming": 9, + "Swim": 9, + "open_water_swimming": 9, + "Workout": 10, + } + auxType = type_mapping.get(activity.sport_type, 10) + + # Create a new Activity record + newActivity = Activity( + user_id=user_id, + name=activity.name, + distance=round(float(activity.distance)) + if isinstance(activity.distance, Quantity) + else round(activity.distance), + activity_type=auxType, + start_time=start_date_parsed, + end_time=end_date_parsed, + city=city, + town=town, + country=country, + created_at=datetime.utcnow(), + waypoints=waypoints, + elevation_gain=elevation_gain, + elevation_loss=elevation_loss, + pace=average_pace, + average_speed=average_speed, + average_power=average_watts, + strava_activity_id=activity.id, + ) + + activities_to_insert.append(newActivity) + + return activities_to_insert + + # Strava route to link user Strava account @router.get("/strava/strava-callback") async def strava_callback( @@ -33,22 +502,20 @@ async def strava_callback( db_session: Session = Depends(get_db_session), ): """ - Callback endpoint for Strava OAuth2 authorization. + Handle Strava callback to link user Strava accounts. - This endpoint is used as a callback URL for Strava OAuth2 authorization. It receives - the authorization code from Strava and exchanges it for an access token and refresh - token. It then updates the user's Strava tokens in the database, triggers a background - task to fetch Strava activities, and redirects the user to a specified URL. + Parameters: + - state (str): Unique state associated with the user Strava link process. + - code (str): Authorization code received from Strava callback. + - background_tasks (BackgroundTasks): FastAPI class for handling background tasks. + - db_session (Session): SQLAlchemy database session. - Args: - - state (str): The unique state parameter sent during the initial OAuth2 authorization. - - code (str): The authorization code received from Strava. - - background_tasks (BackgroundTasks): A FastAPI BackgroundTasks instance used to - schedule background tasks. + Returns: + - RedirectResponse: Redirects to the main page or specified URL after processing. Raises: - - HTTPException: If there is an error retrieving tokens from Strava, if the user is - not found in the database, or if there is an authentication error. + - JWTError: If the user's access token is invalid or expired. + - Exception: For other unexpected errors during Strava callback processing. """ token_url = "https://www.strava.com/oauth/token" payload = { @@ -60,10 +527,7 @@ async def strava_callback( try: response = requests.post(token_url, data=payload) if response.status_code != 200: - raise HTTPException( - status_code=response.status_code, - detail="Error retrieving tokens from Strava.", - ) + return create_error_response("ERROR", "Error retrieving tokens from Strava.", response.status_code) tokens = response.json() @@ -92,99 +556,22 @@ async def strava_callback( ) # Redirect to the main page or any other desired page after processing - redirect_url = "https://gearguardian.jvslab.pt/settings/settings.php?profileSettings=1&stravaLinked=1" # Change this URL to your main page + redirect_url = "https://"+os.environ.get("API_ENDPOINT")+"/settings/settings.php?profileSettings=1&stravaLinked=1" # Change this URL to your main page return RedirectResponse(url=redirect_url) else: - raise HTTPException(status_code=404, detail="User not found.") + return create_error_response("NOT_FOUND", "User not found", 404) except JWTError: - raise HTTPException(status_code=401, detail="Unauthorized") - except NameError as err: - print(err) - - -# Strava logic to refresh user Strava account refresh account -def refresh_strava_token(db_session: Session): - """ - Refresh Strava access tokens for all users. - - This function iterates through all users in the database and checks if their - Strava access tokens are about to expire. If so, it makes a request to the Strava - token refresh endpoint to obtain a new access token. - - Note: The function assumes that the user model has the following fields: - - strava_refresh_token: Strava refresh token for each user - - strava_token_expires_at: Expiry timestamp of the Strava access token - - Raises: - - HTTPError: If there is an error in the token refresh request. - - Exception: If there is an unexpected error during the token refresh process. - """ - # Get the tracer from the main module - tracer = trace.get_tracer(__name__) - - with tracer.start_as_current_span("refresh_strava_token"): - # Strava token refresh endpoint - token_url = "https://www.strava.com/oauth/token" - - try: - # Query all users from the database - users = db_session.query(User).all() - - for user in users: - # expires_at = user.strava_token_expires_at - if user.strava_token_expires_at is not None: - refresh_time = user.strava_token_expires_at - timedelta(minutes=60) - - if datetime.utcnow() > refresh_time: - # 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.strava_refresh_token, - "grant_type": "refresh_token", - } - - try: - # Make a POST request to refresh the Strava token - response = requests.post(token_url, data=payload) - response.raise_for_status() # Raise an error for bad responses - - tokens = response.json() - - # Update the user in the database - db_user = ( - db_session.query(User) - .filter(User.id == user.id) - .first() - ) - - if db_user: - db_user.strava_token = tokens["access_token"] - db_user.strava_refresh_token = tokens["refresh_token"] - db_user.strava_token_expires_at = ( - datetime.fromtimestamp(tokens["expires_at"]) - ) - db_session.commit() # Commit the changes to the database - logger.info( - f"Token refreshed successfully for user {user.id}." - ) - else: - logger.error("User not found in the database.") - except requests.exceptions.RequestException as req_err: - logger.error( - f"Error refreshing token for user {user.id}: {req_err}" - ) - else: - logger.info( - f"Token not refreshed for user {user.id}. Will not expire in less than 60min" - ) - else: - logger.info(f"User {user.id} does not have strava linked") - except NameError as db_err: - logger.error(f"Database error: {db_err}") - - + # Return an error response if the user is not authenticated + return create_error_response("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error, rollback the transaction, and return an error response + db_session.rollback() + logger.error(f"Error in read_users_all: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) + # Define an HTTP PUT route set strava unique state for link logic @router.put("/strava/set-user-unique-state/{state}") async def strava_set_user_unique_state( @@ -193,19 +580,19 @@ async def strava_set_user_unique_state( db_session: Session = Depends(get_db_session), ): """ - Set the Strava unique state for a user. - - This route handles the HTTP PUT request to set the Strava unique state for a user. - The user is authenticated using the provided access token. If successful, the user's - Strava state is set to the provided state in the database. + Set unique state for user Strava link logic. Parameters: - - state (str): The Strava unique state to set for the user. - - token (str): The access token used for user authentication. + - state (str): Unique state associated with the user Strava link process. + - token (str): The access token for user authentication. + - db_session (Session): SQLAlchemy database session. Returns: - dict: A dictionary containing a success message if the operation is successful. - Raises appropriate HTTPExceptions for authentication or database errors. + - JSONResponse: JSON response indicating the success of setting the Strava user state. + + Raises: + - JWTError: If the user's access token is invalid or expired. + - Exception: For other unexpected errors during setting Strava user state. """ try: # Validate the user's access token using the oauth2_scheme @@ -219,26 +606,29 @@ async def strava_set_user_unique_state( # Check if the user with the given ID exists if not user: - raise HTTPException(status_code=404, detail="User not found") + return create_error_response("NOT_FOUND", "User not found", 404) # Set the user's photo paths to None to delete the photo user.strava_state = state # Commit the changes to the database db_session.commit() + + # Return a success message + return JSONResponse( + content={"message": f"Strava state for user {user_id} has been updated"}, status_code=200 + ) except JWTError: - # Handle JWT (JSON Web Token) authentication error - raise HTTPException(status_code=401, detail="Unauthorized") + # Return an error response if the user is not authenticated + return create_error_response("UNAUTHORIZED", "Unauthorized", 401) except Exception as err: - # Handle any other unexpected exceptions - print(err) - raise HTTPException( - status_code=500, detail="Failed to update user strava state" + # Log the error, rollback the transaction, and return an error response + db_session.rollback() + logger.error(f"Error in strava_set_user_unique_state: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 ) - # Return a success message - return {"message": f"Strava state for user {user_id} has been updated"} - # Define an HTTP PUT route set strava unique state for link logic @router.put("/strava/unset-user-unique-state") @@ -246,18 +636,18 @@ async def strava_unset_user_unique_state( token: str = Depends(oauth2_scheme), db_session: Session = Depends(get_db_session) ): """ - Unset the Strava unique state for a user. - - This route handles the HTTP PUT request to unset the Strava unique state for a user. - The user is authenticated using the provided access token. If successful, the user's - Strava state is set to None in the database. + Unset unique state for user Strava link logic. Parameters: - - token (str): The access token used for user authentication. + - token (str): The access token for user authentication. + - db_session (Session): SQLAlchemy database session. Returns: - dict: A dictionary containing a success message if the operation is successful. - Raises appropriate HTTPExceptions for authentication or database errors. + - JSONResponse: JSON response indicating the success of unsetting the Strava user state. + + Raises: + - JWTError: If the user's access token is invalid or expired. + - Exception: For other unexpected errors during unsetting Strava user state. """ try: # Validate the user's access token using the oauth2_scheme @@ -271,436 +661,25 @@ async def strava_unset_user_unique_state( # Check if the user with the given ID exists if not user: - raise HTTPException(status_code=404, detail="User not found") + return create_error_response("NOT_FOUND", "User not found", 404) # Set the user's photo paths to None to delete the photo user.strava_state = None # Commit the changes to the database db_session.commit() - except JWTError: - # Handle JWT (JSON Web Token) authentication error - raise HTTPException(status_code=401, detail="Unauthorized") - except Exception as err: - # Handle any other unexpected exceptions - print(err) - raise HTTPException( - status_code=500, detail="Failed to update user strava state" - ) - - # Return a success message - return {"message": f"Strava state for user {user_id} has been updated"} - - -def get_strava_activities(start_date: datetime, db_session: Session): - """ - Retrieve and process Strava activities for all users in the database. - - This function iterates over all users with linked Strava accounts, - retrieves their activities after the specified start date, and processes - and inserts those activities into the database. - - Parameters: - - start_date (datetime): The start date for retrieving activities. - - Returns: - None - """ - # Get the tracer from the main module - tracer = trace.get_tracer(__name__) - - with tracer.start_as_current_span("get_strava_activities"): - try: - # Query all users from the database - users = db_session.query(User).all() - for user in users: - if user.strava_token_expires_at is not None: - # Log an informational event for tracing - trace.get_current_span().add_event( - "InfoEvent", - { - "message": f"User {user.id}: Started periodic activities processing" - }, - ) - - # Create a Strava client with the user's access token - stravaClient = Client(access_token=user.strava_token) - - # Fetch Strava activities after the specified start date - strava_activities = list( - stravaClient.get_activities(after=start_date) - ) - - if strava_activities: - # Initialize an empty list for results - all_results = [] - - # Use ThreadPoolExecutor for parallel processing of activities - with ThreadPoolExecutor() as executor: - results = list( - executor.map( - lambda activity: process_activity( - activity, user.id, stravaClient, db_session - ), - strava_activities, - ) - ) - - # Append non-empty and non-None results to the overall results list - all_results.extend(results) - - # Flatten the list of results - activities_to_insert = [ - activity for sublist in all_results for activity in sublist - ] - - # Bulk insert all activities into the database - with get_db_session() as db_session: - db_session.bulk_save_objects(activities_to_insert) - db_session.commit() - - # Log an informational event for tracing - trace.get_current_span().add_event( - "InfoEvent", - { - "message": f"User {user.id}: {len(strava_activities)} periodic activities processed" - }, - ) - - else: - # Log an informational event if no activities were found - trace.get_current_span().add_event( - "InfoEvent", - { - "message": f"User {user.id}: No new activities found after {start_date}" - }, - ) - - else: - # Log an informational event if the user does not have Strava linked - logger.info(f"User {user.id} does not have Strava linked") - trace.get_current_span().add_event( - "InfoEvent", - {"message": f"User {user.id} does not have Strava linked"}, - ) - except NameError as db_err: - # Log an error event if a NameError occurs (e.g., undefined function or variable) - logger.error(f"Database error: {db_err}") - trace.get_current_span().add_event( - "ErrorEvent", - {"message": f"Database error: {db_err}"}, + # Return a success message + return JSONResponse( + content={"message": f"Strava state for user {user_id} has been updated"}, status_code=200 ) - - -def get_user_strava_activities(start_date: datetime, user_id: int, db_session: Session): - """ - Retrieve Strava activities for a user, process them, and store in the database. - - This function fetches Strava activities for a specified user after a given start date. - It processes the activities using parallel execution, creates corresponding database - records, and bulk inserts them into the database. - - Parameters: - - start_date (datetime): The start date for retrieving Strava activities. - - user_id (int): The ID of the user for whom activities are to be processed. - - Returns: - None - """ - - # Get the tracer from the main module - tracer = trace.get_tracer(__name__) - - with tracer.start_as_current_span("get_user_strava_activities"): - # Query user from the database - db_user = db_session.query(User).get(user_id) - - # Check if db returned an user object and variable is set - if db_user: - if db_user.strava_token_expires_at is not None: - # Log an informational event for tracing - trace.get_current_span().add_event( - "InfoEvent", - { - "message": f"User {db_user.id}: Started initial activities processing" - }, - ) - - # Create a Strava client with the user's access token - stravaClient = Client(access_token=db_user.strava_token) - - # Fetch Strava activities after the specified start date - strava_activities = list(stravaClient.get_activities(after=start_date)) - - if strava_activities: - # Initialize an empty list for results - all_results = [] - - # Use ThreadPoolExecutor for parallel processing of activities - with ThreadPoolExecutor() as executor: - results = list( - executor.map( - lambda activity: process_activity( - activity, db_user.id, stravaClient, db_session - ), - strava_activities, - ) - ) - - # Append non-empty and non-None results to the overall results list - all_results.extend(results) - - # Flatten the list of results - activities_to_insert = [ - activity for sublist in all_results for activity in sublist - ] - - # Bulk insert all activities into the database - with get_db_session() as db_session: - db_session.bulk_save_objects(activities_to_insert) - db_session.commit() - - # Log an informational event for tracing - trace.get_current_span().add_event( - "InfoEvent", - { - "message": f"User {db_user.id}: {len(strava_activities)} initial activities processed" - }, - ) - - else: - # Log an informational event if no activities were found - trace.get_current_span().add_event( - "InfoEvent", - { - "message": f"User {db_user.id}: No new activities found after {start_date}" - }, - ) - - else: - # Log an informational event if the user does not have Strava linked - logger.info(f"User {db_user.id} does not have Strava linked") - trace.get_current_span().add_event( - "InfoEvent", - {"message": f"User {db_user.id} does not have Strava linked"}, - ) - - else: - # Log an informational event if the user is not found - logger.info(f"User with ID {user_id} not found.") - trace.get_current_span().add_event( - "InfoEvent", - {"message": f"User with ID {user_id} not found"}, - ) - - -def process_activity(activity, user_id, stravaClient, db_session: Session): - """ - Process a Strava activity and create a corresponding database record. - - This function takes a Strava activity, retrieves relevant data such as - waypoints, elevation gain, and other details, and creates a new database - record for the activity. - - Parameters: - - activity: The Strava activity object. - - user_id (int): The ID of the user associated with the activity. - - stravaClient: The Strava client object for making API requests. - - Returns: - - newActivity: The newly created database record for the activity. - """ - - # Get the tracer from the main module - tracer = trace.get_tracer(__name__) - - with tracer.start_as_current_span("process_activity"): - activities_to_insert = [] - - # Check if the activity already exists in the database - activity_record = ( - db_session.query(Activity) - .filter(Activity.strava_activity_id == activity.id) - .first() - ) - - if activity_record: - # Skip existing activities - return activities_to_insert - - # Parse start and end dates - start_date_parsed = activity.start_date - # Ensure activity.elapsed_time is a numerical value - elapsed_time_seconds = ( - activity.elapsed_time.total_seconds() - if isinstance(activity.elapsed_time, timedelta) - else activity.elapsed_time - ) - end_date_parsed = start_date_parsed + timedelta(seconds=elapsed_time_seconds) - - # Initialize location variables - latitude, longitude = 0, 0 - - if hasattr(activity, "start_latlng") and activity.start_latlng is not None: - latitude = activity.start_latlng.lat - longitude = activity.start_latlng.lon - - city, town, country = None, None, None - - # Retrieve location details using reverse geocoding - if latitude != 0 and longitude != 0: - # Encode URL with query parameters to ensure proper encoding and protection against special characters. - url_params = {"lat": latitude, "lon": longitude} - url = f"https://geocode.maps.co/reverse?{urlencode(url_params)}" - # url = f"https://geocode.maps.co/reverse?lat={latitude}&lon={longitude}" - try: - # Make a GET request - response = requests.get(url) - - # Check if the request was successful (status code 200) - if response.status_code == 200: - # Parse the JSON response - data = response.json() - - # Extract the town and country from the address components - city = data.get("address", {}).get("city", None) - town = data.get("address", {}).get("town", None) - country = data.get("address", {}).get("country", None) - else: - print(f"Error location: {response.status_code}") - print(f"Error location: {url}") - except Exception as e: - print(f"An error occurred: {e}") - - # List to store constructed waypoints - waypoints = [] - - # Initialize variables for elevation gain and loss - elevation_gain = 0 - elevation_loss = 0 - previous_elevation = None - - # Get streams for the activity - streams = stravaClient.get_activity_streams( - activity.id, - types=[ - "latlng", - "altitude", - "time", - "heartrate", - "cadence", - "watts", - "velocity_smooth", - ], - ) - - # Extract data from streams - latitudes = streams["latlng"].data if "latlng" in streams else [] - longitudes = streams["latlng"].data if "latlng" in streams else [] - elevations = streams["altitude"].data if "altitude" in streams else [] - times = streams["time"].data if "time" in streams else [] - heart_rates = streams["heartrate"].data if "heartrate" in streams else [] - cadences = streams["cadence"].data if "cadence" in streams else [] - powers = streams["watts"].data if "watts" in streams else [] - velocities = ( - streams["velocity_smooth"].data if "velocity_smooth" in streams else [] - ) - - # Iterate through stream data to construct waypoints - for i in range(len(heart_rates)): - waypoint = { - "lat": latitudes[i] if i < len(latitudes) else None, - "lon": longitudes[i] if i < len(longitudes) else None, - "ele": elevations[i] if i < len(elevations) else None, - "time": times[i] if i < len(times) else None, - "hr": heart_rates[i] if i < len(heart_rates) else None, - "cad": cadences[i] if i < len(cadences) else None, - "power": powers[i] if i < len(powers) else None, - "vel": velocities[i] if i < len(velocities) else None, - "pace": 1 / velocities[i] - if i < len(velocities) and velocities[i] != 0 - else None, - } - - # Calculate elevation gain and loss on-the-fly - current_elevation = elevations[i] if i < len(elevations) else None - - if current_elevation is not None: - if previous_elevation is not None: - elevation_change = current_elevation - previous_elevation - - if elevation_change > 0: - elevation_gain += elevation_change - else: - elevation_loss += abs(elevation_change) - - previous_elevation = current_elevation - - # Append the constructed waypoint to the waypoints list - waypoints.append(waypoint) - - # Calculate average speed, pace, and watts - average_speed = 0 - if activity.average_speed is not None: - average_speed = ( - float(activity.average_speed.magnitude) - if isinstance(activity.average_speed, Quantity) - else activity.average_speed - ) - - average_pace = 1 / average_speed if average_speed != 0 else 0 - - average_watts = 0 - if activity.average_watts is not None: - average_watts = activity.average_watts - - # Map activity type to a numerical value - auxType = 10 # Default value - type_mapping = { - "running": 1, - "Run": 1, - "trail running": 2, - "TrailRun": 2, - "VirtualRun": 3, - "cycling": 4, - "Ride": 4, - "GravelRide": 5, - "EBikeRide": 6, - "EMountainBikeRide": 6, - "VirtualRide": 7, - "virtual_ride": 7, - "MountainBikeRide": 8, - "swimming": 9, - "Swim": 9, - "open_water_swimming": 9, - "Workout": 10, - } - auxType = type_mapping.get(activity.sport_type, 10) - - # Create a new Activity record - newActivity = Activity( - user_id=user_id, - name=activity.name, - distance=round(float(activity.distance)) - if isinstance(activity.distance, Quantity) - else round(activity.distance), - activity_type=auxType, - start_time=start_date_parsed, - end_time=end_date_parsed, - city=city, - town=town, - country=country, - created_at=datetime.utcnow(), - waypoints=waypoints, - elevation_gain=elevation_gain, - elevation_loss=elevation_loss, - pace=average_pace, - average_speed=average_speed, - average_power=average_watts, - strava_activity_id=activity.id, - ) - - activities_to_insert.append(newActivity) - - return activities_to_insert + except JWTError: + # Return an error response if the user is not authenticated + return create_error_response("UNAUTHORIZED", "Unauthorized", 401) + except Exception as err: + # Log the error, rollback the transaction, and return an error response + db_session.rollback() + logger.error(f"Error in strava_set_user_unique_state: {err}", exc_info=True) + return create_error_response( + "INTERNAL_SERVER_ERROR", "Internal Server Error", 500 + ) \ No newline at end of file diff --git a/backend/controllers/userController.py b/backend/controllers/userController.py index 121433ed..0546ffa5 100644 --- a/backend/controllers/userController.py +++ b/backend/controllers/userController.py @@ -38,7 +38,7 @@ - Logger named "myLogger" for logging errors and exceptions. """ import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from fastapi.security import OAuth2PasswordBearer from pydantic import BaseModel from typing import List, Optional diff --git a/backend/dependencies.py b/backend/dependencies.py index 0de822d0..d0c6d6d7 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -26,7 +26,7 @@ def configure_logger(): Returns: - logging.Logger: Configured logger instance. """ - logging.config.fileConfig('logging_config.ini') + logging.config.fileConfig('logs/logging_config.ini') return logging.getLogger('myLogger') diff --git a/backend/logs/__init__.py b/backend/logs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logging_config.ini b/backend/logs/logging_config.ini similarity index 100% rename from backend/logging_config.ini rename to backend/logs/logging_config.ini diff --git a/backend/main.py b/backend/main.py index 5a1c7aa6..675e514b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,6 +62,8 @@ # Import OS module for handling environment variables import os +import logging + # Import database-related functions and dependencies from db.db import create_database_tables from dependencies import get_db_session, configure_logger @@ -70,10 +72,20 @@ app = FastAPI() # Create loggger -logger = configure_logger() +#logger = configure_logger() +logger = logging.getLogger("myLogger") +logger.setLevel(logging.DEBUG) + +file_handler = logging.FileHandler("app.log") +file_handler.setLevel(logging.DEBUG) + +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +file_handler.setFormatter(formatter) + +logger.addHandler(file_handler) # Check for required environment variables -required_env_vars = ["DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_DATABASE", "SECRET_KEY", "ALGORITHM", "ACCESS_TOKEN_EXPIRE_MINUTES", "STRAVA_CLIENT_ID", "STRAVA_CLIENT_SECRET", "STRAVA_AUTH_CODE", "JAEGER_ENABLED", "JAEGER_PROTOCOL", "JAEGER_HOST", "JAGGER_PORT", "STRAVA_DAYS_ACTIVITIES_ONLINK"] +required_env_vars = ["DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_DATABASE", "SECRET_KEY", "ALGORITHM", "ACCESS_TOKEN_EXPIRE_MINUTES", "STRAVA_CLIENT_ID", "STRAVA_CLIENT_SECRET", "STRAVA_AUTH_CODE", "JAEGER_ENABLED", "JAEGER_PROTOCOL", "JAEGER_HOST", "JAGGER_PORT", "STRAVA_DAYS_ACTIVITIES_ONLINK", "API_ENDPOINT"] for var in required_env_vars: if var not in os.environ: @@ -96,6 +108,8 @@ def startup_event(): logger.info("Backend startup event") # Create the database and tables if they don't exist create_database_tables() + logger.info("Will check if there is expired tokens to remove") + sessionController.remove_expired_tokens(db_session=get_db_session()) def shutdown_event(): diff --git a/frontend/.env b/frontend/.env old mode 100644 new mode 100755 diff --git a/frontend/activities/activity.php b/frontend/activities/activity.php old mode 100644 new mode 100755 diff --git a/frontend/gear/gear.php b/frontend/gear/gear.php old mode 100644 new mode 100755 diff --git a/frontend/gear/gears.php b/frontend/gear/gears.php old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/bicycle1.png b/frontend/img/avatar/bicycle1.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/bicycle2.png b/frontend/img/avatar/bicycle2.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/female1.png b/frontend/img/avatar/female1.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/male1.png b/frontend/img/avatar/male1.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/running_shoe1.png b/frontend/img/avatar/running_shoe1.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/running_shoe2.png b/frontend/img/avatar/running_shoe2.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/wetsuit1.png b/frontend/img/avatar/wetsuit1.png old mode 100644 new mode 100755 diff --git a/frontend/img/avatar/wetsuit2.png b/frontend/img/avatar/wetsuit2.png old mode 100644 new mode 100755 diff --git a/frontend/img/logo/logo.png b/frontend/img/logo/logo.png old mode 100644 new mode 100755 diff --git a/frontend/img/strava/api_logo_cptblWith_strava_horiz_light.png b/frontend/img/strava/api_logo_cptblWith_strava_horiz_light.png old mode 100644 new mode 100755 diff --git a/frontend/img/strava/api_logo_cptblWith_strava_stack_light.png b/frontend/img/strava/api_logo_cptblWith_strava_stack_light.png old mode 100644 new mode 100755 diff --git a/frontend/img/strava/btn_strava_connectwith_orange.png b/frontend/img/strava/btn_strava_connectwith_orange.png old mode 100644 new mode 100755 diff --git a/frontend/inc/func/activities-funcs.php b/frontend/inc/func/activities-funcs.php index fe722260..0709528c 100755 --- a/frontend/inc/func/activities-funcs.php +++ b/frontend/inc/func/activities-funcs.php @@ -55,7 +55,7 @@ function getUserActivitiesThisWeekDistances($userID) return -1; } else { if ($response[1] === 200) { - return json_decode($response[0], true); + return json_decode($response[0], true)["content"]; } else { return -2; } @@ -70,7 +70,7 @@ function getUserActivitiesThisMonthDistances($userID) return -1; } else { if ($response[1] === 200) { - return json_decode($response[0], true); + return json_decode($response[0], true)["content"]; } else { return -2; } @@ -116,7 +116,7 @@ function getActivitiesPagination($pageNumber, $numRecords) return -1; } else { if ($response[1] === 200) { - return json_decode($response[0], true); + return json_decode($response[0], true)["content"]; } else { return -2; } diff --git a/frontend/inc/func/session-funcs.php b/frontend/inc/func/session-funcs.php old mode 100644 new mode 100755 diff --git a/frontend/inc/func/strava-funcs.php b/frontend/inc/func/strava-funcs.php old mode 100644 new mode 100755 diff --git a/frontend/inc/sqlFunctions.php b/frontend/inc/sqlFunctions.php old mode 100644 new mode 100755 diff --git a/frontend/lang/activities/en.php b/frontend/lang/activities/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/gear/gear/en.php b/frontend/lang/gear/gear/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/gear/gears/en.php b/frontend/lang/gear/gears/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/inc/Template-Top/en.php b/frontend/lang/inc/Template-Top/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/index/en.php b/frontend/lang/index/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/login/en.php b/frontend/lang/login/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/settings/en.php b/frontend/lang/settings/en.php old mode 100644 new mode 100755 diff --git a/frontend/lang/users/user/en.php b/frontend/lang/users/user/en.php old mode 100644 new mode 100755 diff --git a/frontend/settings/settings.php b/frontend/settings/settings.php old mode 100644 new mode 100755