1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-04 04:35:19 +02:00

feat: add Strava integration with OAuth flow and activity management

- Implemented IntegrationView for listing integrations including Immich, Google Maps, and Strava.
- Created StravaIntegrationView for handling OAuth authorization and token exchange.
- Added functionality to refresh Strava access tokens when needed.
- Implemented endpoints to fetch user activities from Strava and extract essential information.
- Added Strava logo asset and integrated it into the frontend settings page.
- Updated settings page to display Strava integration status.
- Enhanced location management to include trails with create, edit, and delete functionalities.
- Updated types and localization files to support new features.
This commit is contained in:
Sean Morley 2025-08-01 20:24:33 -04:00
parent f5110ed640
commit 9bcada21dd
28 changed files with 991 additions and 72 deletions

View file

@ -13,6 +13,9 @@ FRONTEND_URL='http://localhost:3000'
EMAIL_BACKEND='console'
# STRAVA_CLIENT_ID=''
# STRAVA_CLIENT_SECRET=''
# EMAIL_BACKEND='email'
# EMAIL_HOST='smtp.gmail.com'
# EMAIL_USE_TLS=False

View file

@ -1,7 +1,7 @@
import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login
@ -124,6 +124,16 @@ class VisitAdmin(admin.ModelAdmin):
image_display.short_description = 'Image Preview'
class CollectionInviteAdmin(admin.ModelAdmin):
list_display = ('collection', 'invited_user', 'created_at')
search_fields = ('collection__name', 'invited_user__username')
readonly_fields = ('created_at',)
def invited_user(self, obj):
return obj.invited_user.username if obj.invited_user else 'N/A'
invited_user.short_description = 'Invited User'
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'display_name', 'icon')
search_fields = ('name', 'display_name')
@ -153,7 +163,8 @@ admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)
admin.site.register(ContentAttachment)
admin.site.register(Lodging)
admin.site.register(CollectionInvite)
admin.site.register(CollectionInvite, CollectionInviteAdmin)
admin.site.register(Trail)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -0,0 +1,33 @@
# Generated by Django 5.2.2 on 2025-08-01 13:31
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0056_collectioninvite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Trail',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=200)),
('link', models.URLField(blank=True, max_length=2083, null=True, verbose_name='External Trail Link')),
('wanderer_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Wanderer Trail ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trails', to='adventures.location')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Trail',
'verbose_name_plural': 'Trails',
},
),
]

View file

@ -266,6 +266,9 @@ class CollectionInvite(models.Model):
def __str__(self):
return f"Invite for {self.invited_user.username} to {self.collection.name}"
class Meta:
verbose_name = "Collection Invite"
class Collection(models.Model):
#id = models.AutoField(primary_key=True)
@ -579,4 +582,40 @@ class Lodging(models.Model):
super().delete(*args, **kwargs)
def __str__(self):
return self.name
return self.name
class Trail(models.Model):
"""
Represents a trail associated with a user.
Supports referencing either a Wanderer trail ID or an external link (e.g., AllTrails).
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name='trails')
name = models.CharField(max_length=200)
# Either an external link (e.g., AllTrails, Trailforks) or a Wanderer ID
link = models.URLField("External Trail Link", max_length=2083, blank=True, null=True)
wanderer_id = models.CharField("Wanderer Trail ID", max_length=100, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Trail"
verbose_name_plural = "Trails"
def clean(self):
has_link = bool(self.link and str(self.link).strip())
has_wanderer_id = bool(self.wanderer_id and str(self.wanderer_id).strip())
if has_link and has_wanderer_id:
raise ValidationError("Cannot have both a link and a Wanderer ID. Provide only one.")
if not has_link and not has_wanderer_id:
raise ValidationError("You must provide either a link or a Wanderer ID.")
def save(self, *args, **kwargs):
self.full_clean() # Ensure clean() is called on save
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"

View file

@ -107,6 +107,10 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
"""
user = request.user
is_safe_method = request.method in permissions.SAFE_METHODS
# If the object has a location field, get that location and continue checking with that object, basically from the location's perspective. I am very proud of this line of code and that's why I am writing this comment.
if type(obj).__name__ == 'Trail':
obj = obj.location
# Anonymous users only get read access to public objects
if not user or not user.is_authenticated:

View file

@ -1,6 +1,6 @@
from django.utils import timezone
import os
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
from rest_framework import serializers
from main.utils import CustomModelSerializer
from users.serializers import CustomUserDetailsSerializer
@ -84,6 +84,28 @@ class CategorySerializer(serializers.ModelSerializer):
def get_num_locations(self, obj):
return Location.objects.filter(category=obj, user=obj.user).count()
class TrailSerializer(CustomModelSerializer):
provider = serializers.SerializerMethodField()
class Meta:
model = Trail
fields = ['id', 'user', 'name', 'location', 'created_at','link','wanderer_id', 'provider']
read_only_fields = ['id', 'created_at', 'user', 'provider']
def get_provider(self, obj):
if obj.wanderer_id:
return 'Wanderer'
# check the link to get the provider such as Strava, AllTrails, etc.
if obj.link:
if 'strava' in obj.link:
return 'Strava'
elif 'alltrails' in obj.link:
return 'AllTrails'
elif 'komoot' in obj.link:
return 'Komoot'
elif 'outdooractive' in obj.link:
return 'Outdooractive'
return 'External Link'
class VisitSerializer(serializers.ModelSerializer):
class Meta:
@ -105,13 +127,14 @@ class LocationSerializer(CustomModelSerializer):
queryset=Collection.objects.all(),
required=False
)
trails = TrailSerializer(many=True, read_only=True, required=False)
class Meta:
model = Location
fields = [
'id', 'name', 'description', 'rating', 'tags', 'location',
'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude',
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region', 'trails'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'is_visited']
@ -476,7 +499,11 @@ class CollectionSerializer(CustomModelSerializer):
class CollectionInviteSerializer(serializers.ModelSerializer):
name = serializers.CharField(source='collection.name', read_only=True)
collection_owner_username = serializers.CharField(source='collection.user.username', read_only=True)
collection_user_first_name = serializers.CharField(source='collection.user.first_name', read_only=True)
collection_user_last_name = serializers.CharField(source='collection.user.last_name', read_only=True)
class Meta:
model = CollectionInvite
fields = ['id', 'collection', 'created_at', 'name']
fields = ['id', 'collection', 'created_at', 'name', 'collection_owner_username', 'collection_user_first_name', 'collection_user_last_name']
read_only_fields = ['id', 'created_at']

View file

@ -20,6 +20,7 @@ router.register(r'attachments', AttachmentViewSet, basename='attachments')
router.register(r'lodging', LodgingViewSet, basename='lodging')
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
router.register(r'backup', BackupViewSet, basename='backup')
router.register(r'trails', TrailViewSet, basename='trails')
urlpatterns = [
# Include the router under the 'api/' prefix

View file

@ -14,4 +14,5 @@ from .global_search_view import *
from .attachment_view import *
from .lodging_view import *
from .recommendations_view import *
from .import_export_view import *
from .import_export_view import *
from .trail_view import *

View file

@ -0,0 +1,40 @@
from rest_framework import viewsets
from django.db.models import Q
from adventures.models import Location, Trail
from adventures.serializers import TrailSerializer
from adventures.permissions import IsOwnerOrSharedWithFullAccess
class TrailViewSet(viewsets.ModelViewSet):
serializer_class = TrailSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
def get_queryset(self):
"""
Returns trails based on location permissions.
Users can only see trails in locations they have access to for editing/updating/deleting.
This means they are either:
- The owner of the location
- The location is in a collection that is shared with the user
- The location is in a collection that the user owns
"""
user = self.request.user
if not user or not user.is_authenticated:
return Trail.objects.none()
# Build the filter for accessible locations
location_filter = Q(location__user=user) # User owns the location
# Location is in collections (many-to-many) that are shared with user
location_filter |= Q(location__collections__shared_with=user)
# Location is in collections (many-to-many) that user owns
location_filter |= Q(location__collections__user=user)
return Trail.objects.filter(location_filter).distinct()
def perform_create(self, serializer):
"""
Set the user when creating a trail.
"""
serializer.save(user=self.request.user)

View file

@ -1,9 +1,10 @@
from django.contrib import admin
from allauth.account.decorators import secure_admin_login
from .models import ImmichIntegration
from .models import ImmichIntegration, StravaToken
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
admin.site.register(ImmichIntegration)
admin.site.register(ImmichIntegration)
admin.site.register(StravaToken)

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2.2 on 2025-08-01 00:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0002_immichintegration_copy_locally'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StravaToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_token', models.CharField(max_length=255)),
('refresh_token', models.CharField(max_length=255)),
('expires_at', models.BigIntegerField()),
('athlete_id', models.BigIntegerField(blank=True, null=True)),
('scope', models.CharField(blank=True, max_length=255, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='strava_tokens', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -13,4 +13,14 @@ class ImmichIntegration(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
def __str__(self):
return self.user.username + ' - ' + self.server_url
return self.user.username + ' - ' + self.server_url
class StravaToken(models.Model):
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name='strava_tokens')
access_token = models.CharField(max_length=255)
refresh_token = models.CharField(max_length=255)
expires_at = models.BigIntegerField() # Unix timestamp
athlete_id = models.BigIntegerField(null=True, blank=True)
scope = models.CharField(max_length=255, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)

View file

@ -1,12 +1,14 @@
from integrations.views import *
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet
from integrations.views import IntegrationView, StravaIntegrationView
# Create the router and register the ViewSet
router = DefaultRouter()
router.register(r'immich', ImmichIntegrationView, basename='immich')
router.register(r'', IntegrationView, basename='integrations')
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
router.register(r'strava', StravaIntegrationView, basename='strava')
# Include the router URLs
urlpatterns = [

View file

@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class StandardResultsSetPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 1000

View file

@ -0,0 +1,3 @@
from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet
from .integration_view import IntegrationView
from .strava_view import StravaIntegrationView

View file

@ -1,42 +1,19 @@
import os
from rest_framework.response import Response
from rest_framework import viewsets, status
from .serializers import ImmichIntegrationSerializer
from .models import ImmichIntegration
from integrations.serializers import ImmichIntegrationSerializer
from integrations.models import ImmichIntegration
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
import requests
from rest_framework.pagination import PageNumberPagination
from django.conf import settings
from adventures.models import ContentImage
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from integrations.utils import StandardResultsSetPagination
import logging
logger = logging.getLogger(__name__)
class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""
RESTful GET method for listing all integrations.
"""
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
return Response(
{
'immich': immich_integrations.exists(),
'google_maps': google_map_integration
},
status=status.HTTP_200_OK
)
class StandardResultsSetPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 1000
class ImmichIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination

View file

@ -0,0 +1,26 @@
import os
from rest_framework.response import Response
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from integrations.models import ImmichIntegration
from django.conf import settings
class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""
RESTful GET method for listing all integrations.
"""
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
strava_integration = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
return Response(
{
'immich': immich_integrations.exists(),
'google_maps': google_map_integration,
'strava': strava_integration
},
status=status.HTTP_200_OK
)

View file

@ -0,0 +1,290 @@
from rest_framework.response import Response
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
import requests
import logging
import time
from django.conf import settings
from integrations.models import StravaToken
logger = logging.getLogger(__name__)
class StravaIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'], url_path='authorize')
def authorize(self, request):
"""
Redirects the user to Strava's OAuth authorization page.
"""
client_id = settings.STRAVA_CLIENT_ID
redirect_uri = f"{settings.PUBLIC_URL}/api/integrations/strava/callback/"
scope = 'activity:read_all'
auth_url = (
f'https://www.strava.com/oauth/authorize?client_id={client_id}'
f'&response_type=code'
f'&redirect_uri={redirect_uri}'
f'&approval_prompt=auto'
f'&scope={scope}'
)
return Response({'auth_url': auth_url}, status=status.HTTP_200_OK)
@action(detail=False, methods=['get'], url_path='callback')
def callback(self, request):
"""
Handles the OAuth callback from Strava and exchanges the code for an access token.
Saves or updates the StravaToken model instance for the authenticated user.
"""
code = request.query_params.get('code')
if not code:
return Response(
{
'message': 'Missing authorization code from Strava.',
'error': True,
'code': 'strava.missing_code'
},
status=status.HTTP_400_BAD_REQUEST
)
token_url = 'https://www.strava.com/oauth/token'
payload = {
'client_id': int(settings.STRAVA_CLIENT_ID),
'client_secret': settings.STRAVA_CLIENT_SECRET,
'code': code,
'grant_type': 'authorization_code'
}
try:
response = requests.post(token_url, data=payload)
response_data = response.json()
if response.status_code != 200:
logger.warning("Strava token exchange failed: %s", response_data)
return Response(
{
'message': 'Failed to exchange code for access token.',
'error': True,
'code': 'strava.exchange_failed',
'details': response_data.get('message', 'Unknown error')
},
status=status.HTTP_400_BAD_REQUEST
)
logger.info("Strava token exchange successful for user %s", request.user.username)
# Save or update tokens in DB
strava_token, created = StravaToken.objects.update_or_create(
user=request.user,
defaults={
'access_token': response_data.get('access_token'),
'refresh_token': response_data.get('refresh_token'),
'expires_at': response_data.get('expires_at'),
'athlete_id': response_data.get('athlete', {}).get('id'),
'scope': response_data.get('scope'),
}
)
return Response(
{
'message': 'Strava access token obtained and saved successfully.',
'access_token': response_data.get('access_token'),
'expires_at': response_data.get('expires_at'),
'refresh_token': response_data.get('refresh_token'),
'athlete': response_data.get('athlete'),
},
status=status.HTTP_200_OK
)
except requests.RequestException as e:
logger.error("Error during Strava OAuth token exchange: %s", str(e))
return Response(
{
'message': 'Failed to connect to Strava.',
'error': True,
'code': 'strava.connection_failed',
'details': str(e)
},
status=status.HTTP_502_BAD_GATEWAY
)
def refresh_strava_token_if_needed(self, user):
strava_token = StravaToken.objects.filter(user=user).first()
if not strava_token:
return None, Response({
'message': 'You need to authorize Strava first.',
'error': True,
'code': 'strava.not_authorized'
}, status=status.HTTP_403_FORBIDDEN)
now = int(time.time())
# If token expires in less than 5 minutes, refresh it
if strava_token.expires_at - now < 300:
logger.info(f"Refreshing Strava token for user {user.username}")
refresh_url = 'https://www.strava.com/oauth/token'
payload = {
'client_id': int(settings.STRAVA_CLIENT_ID),
'client_secret': settings.STRAVA_CLIENT_SECRET,
'grant_type': 'refresh_token',
'refresh_token': strava_token.refresh_token,
}
try:
response = requests.post(refresh_url, data=payload)
data = response.json()
if response.status_code == 200:
# Update token info
strava_token.access_token = data['access_token']
strava_token.refresh_token = data['refresh_token']
strava_token.expires_at = data['expires_at']
strava_token.save()
return strava_token, None
else:
logger.error(f"Failed to refresh Strava token: {data}")
return None, Response({
'message': 'Failed to refresh Strava token.',
'error': True,
'code': 'strava.refresh_failed',
'details': data.get('message', 'Unknown error')
}, status=status.HTTP_400_BAD_REQUEST)
except requests.RequestException as e:
logger.error(f"Error refreshing Strava token: {str(e)}")
return None, Response({
'message': 'Failed to connect to Strava for token refresh.',
'error': True,
'code': 'strava.connection_failed',
'details': str(e)
}, status=status.HTTP_502_BAD_GATEWAY)
return strava_token, None
def extract_essential_activity_info(self,activity):
# Pick only essential fields from a single activity dict
return {
"id": activity.get("id"),
"name": activity.get("name"),
"type": activity.get("type"),
"distance": activity.get("distance"),
"moving_time": activity.get("moving_time"),
"elapsed_time": activity.get("elapsed_time"),
"total_elevation_gain": activity.get("total_elevation_gain"),
"start_date": activity.get("start_date"),
"start_date_local": activity.get("start_date_local"),
"timezone": activity.get("timezone"),
"average_speed": activity.get("average_speed"),
"max_speed": activity.get("max_speed"),
"average_cadence": activity.get("average_cadence"),
"average_watts": activity.get("average_watts"),
"max_watts": activity.get("max_watts"),
"kilojoules": activity.get("kilojoules"),
"calories": activity.get("calories"),
"gear_id": activity.get("gear_id"),
"device_name": activity.get("device_name"),
"start_latlng": activity.get("start_latlng"),
"end_latlng": activity.get("end_latlng"),
'export_original': f'https://www.strava.com/activities/{activity.get("id")}/export_original',
'export_gpx': f'https://www.strava.com/activities/{activity.get("id")}/export_gpx',
}
@action(detail=False, methods=['get'], url_path='activities')
def activities(self, request):
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
if error_response:
return error_response
# Get date parameters from query string
start_date = request.query_params.get('start_date')
end_date = request.query_params.get('end_date')
# Build query parameters for Strava API
params = {}
if start_date:
try:
# Parse the date string and convert to Unix timestamp
from datetime import datetime
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
params['after'] = int(start_dt.timestamp())
except ValueError:
return Response({
'message': 'Invalid start_date format. Use ISO format (e.g., 2024-01-01T00:00:00Z)',
'error': True,
'code': 'strava.invalid_start_date'
}, status=status.HTTP_400_BAD_REQUEST)
if end_date:
try:
# Parse the date string and convert to Unix timestamp
from datetime import datetime
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
params['before'] = int(end_dt.timestamp())
except ValueError:
return Response({
'message': 'Invalid end_date format. Use ISO format (e.g., 2024-12-31T23:59:59Z)',
'error': True,
'code': 'strava.invalid_end_date'
}, status=status.HTTP_400_BAD_REQUEST)
headers = {'Authorization': f'Bearer {strava_token.access_token}'}
try:
response = requests.get('https://www.strava.com/api/v3/athlete/activities',
headers=headers, params=params)
if response.status_code != 200:
return Response({
'message': 'Failed to fetch activities from Strava.',
'error': True,
'code': 'strava.fetch_failed',
'details': response.json().get('message', 'Unknown error')
}, status=status.HTTP_400_BAD_REQUEST)
activities = response.json()
essential_activities = [self.extract_essential_activity_info(act) for act in activities]
return Response(essential_activities, status=status.HTTP_200_OK)
except requests.RequestException as e:
logger.error(f"Error fetching Strava activities: {str(e)}")
return Response({
'message': 'Failed to connect to Strava.',
'error': True,
'code': 'strava.connection_failed',
'details': str(e)
}, status=status.HTTP_502_BAD_GATEWAY)
@action(detail=False, methods=['get'], url_path='activities/(?P<activity_id>[^/.]+)')
def activity(self, request, activity_id=None):
if not activity_id:
return Response({
'message': 'Activity ID is required.',
'error': True,
'code': 'strava.activity_id_required'
}, status=status.HTTP_400_BAD_REQUEST)
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
if error_response:
return error_response
headers = {'Authorization': f'Bearer {strava_token.access_token}'}
try:
response = requests.get(f'https://www.strava.com/api/v3/activities/{activity_id}', headers=headers)
if response.status_code != 200:
return Response({
'message': 'Failed to fetch activity from Strava.',
'error': True,
'code': 'strava.fetch_failed',
'details': response.json().get('message', 'Unknown error')
}, status=status.HTTP_400_BAD_REQUEST)
activity = response.json()
essential_activity = self.extract_essential_activity_info(activity)
return Response(essential_activity, status=status.HTTP_200_OK)
except requests.RequestException as e:
logger.error(f"Error fetching Strava activity: {str(e)}")
return Response({
'message': 'Failed to connect to Strava.',
'error': True,
'code': 'strava.connection_failed',
'details': str(e)
}, status=status.HTTP_502_BAD_GATEWAY)

View file

@ -324,6 +324,8 @@ LOGGING = {
},
}
PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
# Major release version of AdventureLog, not including the patch version date.
@ -332,4 +334,7 @@ ADVENTURELOG_RELEASE_VERSION = 'v0.10.0'
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.6'
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')