Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions .github/workflows/build-push-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@ jobs:
packages: write

steps:
- uses: actions/checkout@v3
- name: Install dependencies
- uses: actions/checkout@v4
- uses: 'google-github-actions/auth@v2'
with:
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
- name: Setup Google Cloud SDK
uses: google-github-actions/setup-gcloud@v1

- name: Login
run: |
python -m pip install --upgrade pip
pip install uv
- name: Build the Docker image

gcloud auth configure-docker ${{secrets.GCP_REGION}}-docker.pkg.dev


- name: Build and push the Docker image
run: |
uv pip compile pyproject.toml -o requirements.txt
docker build . -t ghcr.io/${{ github.repository }}:latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push the Docker image
run: docker push ghcr.io/${{ github.repository }}:latest
docker build -t ${{secrets.GCP_REGION}}-docker.pkg.dev/${{secrets.GCP_PROJECT_ID}}/strava/strava:latest .
docker push ${{secrets.GCP_REGION}}-docker.pkg.dev/${{secrets.GCP_PROJECT_ID}}/strava/strava:latest
10 changes: 0 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,8 @@ FROM python:3.11-slim-bookworm

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libgeos-dev \
libopenblas-dev \
libatlas-base-dev \

&& rm -rf /var/lib/apt/lists/*

RUN pip install uv==0.6.3


ENV UV_PROJECT_ENVIRONMENT="/uv_venv/"
ENV PATH="/${UV_PROJECT_ENVIRONMENT}/bin:$PATH"

Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ services:
- PUSHBULLET_API_KEY=${PUSHBULLET_API_KEY}
- POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@postgres:5432/postgres
- STRAVA_VERIFY_TOKEN=${STRAVA_VERIFY_TOKEN}
image: ghcr.io/niklasvm/strava:latest
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
build:
context: .
dockerfile: Dockerfile
ports:
- 80:8000
depends_on:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"cryptography>=44.0.2",
"dill>=0.3.9",
"fastapi[standard]>=0.115.8",
"folium>=0.19.4",
Expand Down
3 changes: 2 additions & 1 deletion src/app/apis/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async def authorization() -> RedirectResponse:
url = client.authorization_url(
client_id=settings.strava_client_id,
redirect_uri=redirect_uri,
scope=["activity:read_all", "activity:write"],
scope=["activity:read", "activity:write"],
)

# redirect to strava authorization url
Expand All @@ -55,6 +55,7 @@ async def login(
client_id=settings.strava_client_id,
client_secret=settings.strava_client_secret,
postgres_connection_string=settings.postgres_connection_string,
encryption_key=settings.encryption_key,
)
uuid = athlete.uuid
except Exception:
Expand Down
3 changes: 3 additions & 0 deletions src/app/apis/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ async def handle_post_event(
settings.strava_client_id,
settings.strava_client_secret,
settings.postgres_connection_string,
settings.gemini_api_key,
settings.pushbullet_api_key,
settings.encryption_key,
)

return JSONResponse(content={"message": "Received webhook event"}, status_code=200)
Expand Down
6 changes: 6 additions & 0 deletions src/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class Settings(BaseModel):
strava_verify_token: str
application_url: str
postgres_connection_string: str
gemini_api_key: str
pushbullet_api_key: str
encryption_key: bytes

@field_validator("*")
def not_empty(cls, value):
Expand All @@ -26,6 +29,9 @@ def not_empty(cls, value):
strava_verify_token=os.environ["STRAVA_VERIFY_TOKEN"],
application_url=os.environ["APPLICATION_URL"],
postgres_connection_string=os.environ["POSTGRES_CONNECTION_STRING"],
gemini_api_key=os.environ["GEMINI_API_KEY"],
pushbullet_api_key=os.environ["PUSHBULLET_API_KEY"],
encryption_key=os.environ["ENCRYPTION_KEY"].encode(),
)
except ValueError as e:
print(f"Configuration error: {e}")
Expand Down
144 changes: 71 additions & 73 deletions src/app/db/adapter.py
Original file line number Diff line number Diff line change
@@ -1,93 +1,91 @@
import datetime
import logging
import uuid
from sqlalchemy import create_engine

from sqlalchemy.orm import sessionmaker
from cryptography.fernet import Fernet
import base64

from src.app.db.models import Auth, Base, Athlete
from stravalib.model import SummaryAthlete
from src.app.db.models import Auth, Base, User

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def encrypt_token(token: str, key: bytes) -> str:
"""Encrypts an access token."""
f = Fernet(key)
encrypted_token = f.encrypt(token.encode())
return base64.urlsafe_b64encode(encrypted_token).decode()


def decrypt_token(encrypted_token: str, key: bytes) -> str:
"""Decrypts an access token."""
f = Fernet(key)
encrypted_token_bytes = base64.urlsafe_b64decode(encrypted_token)
decrypted_token = f.decrypt(encrypted_token_bytes).decode()
return decrypted_token


class Database:
def __init__(self, connection_string: str):
def __init__(self, connection_string: str, encryption_key: bytes):
engine = create_engine(connection_string)
Base.metadata.create_all(engine) # create the tables.
Session = sessionmaker(bind=engine)
self.session = Session()

def add_athlete(self, athlete: SummaryAthlete):
found_athlete = (
self.session.query(Athlete).filter(Athlete.athlete_id == athlete.id).first()
)
if not found_athlete:
id = str(uuid.uuid4())
kwargs = {
k: v
for k, v in athlete.__dict__.items()
if k in Athlete.__table__.columns.keys()
}

kwargs["created_at"] = datetime.datetime.now()
kwargs["updated_at"] = datetime.datetime.now()
kwargs["athlete_id"] = athlete.id

kwargs["uuid"] = id

athlete_model = Athlete(**kwargs)
self.session.add(athlete_model)
self.session.commit()
logger.info(f"Added athlete {athlete.id} to the database")

return id
else:
logger.info(f"Athlete with id {athlete.id} already exists in the database")
return found_athlete.uuid

def get_athlete(self, uuid: str) -> Athlete:
return self.session.query(Athlete).filter(Athlete.uuid == uuid).first()

def add_auth(
self,
athlete_id: int,
access_token: str,
refresh_token: str,
expires_at: int,
scope: str,
):
if self.session.query(Auth).filter(Auth.athlete_id == athlete_id).first():
self.session.query(Auth).filter(Auth.athlete_id == athlete_id).update(
{
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
"scope": scope,
"updated_at": datetime.datetime.now(),
}
self.Session = sessionmaker(bind=engine)

self.encryption_key = encryption_key

def add_user(self, user: User):
with self.Session() as session:
existing_user = (
session.query(User).filter(User.athlete_id == user.athlete_id).first()
)
self.session.commit()
logger.info(f"Updated auth for athlete {athlete_id}")

else:
auth = Auth(
uuid=str(uuid.uuid4()),
athlete_id=athlete_id,
access_token=access_token,
refresh_token=refresh_token,
expires_at=expires_at,
scope=scope,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now(),
if not existing_user:
session.add(user)
session.commit()
logger.info(f"Added user {user.uuid} to the database")

return str(user.uuid)

logger.info(
f"User with id {existing_user.uuid} already exists in the database"
)
self.session.add(auth)
self.session.commit()
logger.info(f"Added auth for athlete {athlete_id}")
return str(existing_user.uuid)

def get_user(self, uuid: str) -> User:
with self.Session() as session:
return session.query(User).filter(User.uuid == uuid).first()

def get_auth(self, athlete_id: int) -> Auth:
return self.session.query(Auth).filter(Auth.athlete_id == athlete_id).first()
def add_auth(self, auth: Auth):
# encrypt tokens
auth.access_token = encrypt_token(auth.access_token, self.encryption_key)
auth.refresh_token = encrypt_token(auth.refresh_token, self.encryption_key)

with self.Session() as session:
existing_auth = (
session.query(Auth).filter(Auth.athlete_id == auth.athlete_id).first()
)
if existing_auth:
existing_auth.access_token = auth.access_token
existing_auth.refresh_token = auth.refresh_token
existing_auth.expires_at = auth.expires_at
existing_auth.scope = auth.scope
existing_auth.updated_at = datetime.datetime.now()

session.commit()
logger.info(f"Updated auth for athlete {auth.athlete_id}")

else:
session.add(auth)
session.commit()
logger.info(f"Added auth for athlete {auth.athlete_id}")

def get_auth_by_athlete_id(self, athlete_id: int) -> Auth:
with self.Session() as session:
auth = session.query(Auth).filter(Auth.athlete_id == athlete_id).first()
auth.access_token = decrypt_token(auth.access_token, self.encryption_key)
auth.refresh_token = decrypt_token(auth.refresh_token, self.encryption_key)
return auth

# def add_activity(self, activity: SummaryActivity):
# activity_dict = activity.model_dump()
Expand Down
33 changes: 11 additions & 22 deletions src/app/db/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations
import datetime
import uuid
from sqlalchemy import (
UUID,
Column,
Integer,
String,
DateTime,
Boolean,
ForeignKey,
)
from sqlalchemy.orm import declarative_base
Expand All @@ -15,40 +15,29 @@
Base = declarative_base()


class Athlete(Base):
__tablename__ = "athletes"
class User(Base):
__tablename__ = "user"

uuid = Column(UUID, primary_key=True, default=uuid.uuid4)
uuid = Column(UUID, primary_key=True, nullable=False, default=uuid.uuid4)
athlete_id = Column(Integer, unique=True)
resource_state = Column(Integer)
firstname = Column(String)
lastname = Column(String)
profile_medium = Column(String)
profile = Column(String)
city = Column(String)
state = Column(String)
country = Column(String)
sex = Column(String)
premium = Column(Boolean)
summit = Column(Boolean)
created_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now)
updated_at = Column(DateTime)

auth = relationship("Auth", uselist=False, back_populates="athlete")
auth = relationship("Auth", uselist=False, back_populates="user")


class Auth(Base):
__tablename__ = "auth"
uuid = Column(UUID, primary_key=True, default=uuid.uuid4)
uuid = Column(UUID, primary_key=True, nullable=False, default=uuid.uuid4)
access_token = Column(String)
refresh_token = Column(String)
expires_at = Column(Integer)
scope = Column(String)
athlete_id = Column(Integer, ForeignKey("athletes.athlete_id"))
athlete = relationship(Athlete.__name__, back_populates="auth")
athlete_id = Column(Integer, ForeignKey("user.athlete_id"))
user = relationship(User.__name__, back_populates="auth")

created_at = Column(DateTime)
updated_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.datetime.now)


class ToDictMixin:
Expand Down
22 changes: 12 additions & 10 deletions src/app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from dotenv import load_dotenv
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
Expand Down Expand Up @@ -31,17 +31,19 @@

@app.get("/welcome", response_class=HTMLResponse)
async def welcome(request: Request, uuid: str):
db = Database(settings.postgres_connection_string)
db = Database(
settings.postgres_connection_string, encryption_key=settings.encryption_key
)

try:
athlete = db.get_athlete(uuid)
if athlete is None:
user = db.get_user(uuid)
if user is None:
return templates.TemplateResponse(
request, "error.html", {"error": "Athlete not found"}
request, "error.html", {"error": "User not found"}
) # Provide error message
return templates.TemplateResponse(request, "welcome.html", {"athlete": athlete})
return templates.TemplateResponse(request, "welcome.html")
except Exception:
logger.exception(f"Error fetching athlete with UUID {uuid}:")
raise HTTPException(
status_code=500, detail="Failed to retrieve athlete data"
) # Use HTTPException
logger.exception(f"Error fetching User with UUID {uuid}:")
return templates.TemplateResponse(
request, "error.html", {"error": "User not found"}
) # Provide error message
Loading