1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-04 12:45:17 +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', '')

View file

@ -0,0 +1 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="2500" height="2500"><path d="M0 0h16v16H0z" fill="#fc4c02"/><g fill="#fff" fill-rule="evenodd"><path d="M6.9 8.8l2.5 4.5 2.4-4.5h-1.5l-.9 1.7-1-1.7z" opacity=".6"/><path d="M7.2 2.5l3.1 6.3H4zm0 3.8l1.2 2.5H5.9z"/></g></svg>

After

Width:  |  Height:  |  Size: 291 B

View file

@ -53,7 +53,8 @@
icon: '',
user: ''
},
attachments: []
attachments: [],
trails: []
};
export let locationToEdit: Location | null = null;
@ -81,7 +82,7 @@
icon: '',
user: ''
},
trails: locationToEdit?.trails || [],
attachments: locationToEdit?.attachments || []
};
onMount(async () => {
@ -167,18 +168,24 @@
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5-5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div
<button
class="timeline-end timeline-box {step.selected
? 'bg-primary text-primary-content'
: 'bg-base-200'}"
: 'bg-base-200'} hover:bg-primary/80 transition-colors cursor-pointer"
on:click={() => {
// Reset all steps
steps.forEach((s) => (s.selected = false));
// Select clicked step
steps[index].selected = true;
}}
>
{step.name}
</div>
</button>
{#if index < steps.length - 1}
<hr />
{/if}
@ -263,6 +270,7 @@
<LocationMedia
bind:images={location.images}
bind:attachments={location.attachments}
bind:trails={location.trails}
on:back={() => {
steps[2].selected = false;
steps[1].selected = true;

View file

@ -397,7 +397,7 @@
});
</script>
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 pl-6 pr-6">
<div class="max-w-full mx-auto space-y-6">
<!-- Basic Information Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Attachment, ContentImage } from '$lib/types';
import type { Attachment, ContentImage, Trail } from '$lib/types';
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { deserialize } from '$app/forms';
@ -18,6 +18,8 @@
import ImageIcon from '~icons/mdi/image';
import AttachmentIcon from '~icons/mdi/attachment';
import SwapHorizontalVariantIcon from '~icons/mdi/swap-horizontal-variant';
import LinkIcon from '~icons/mdi/link';
import PlusIcon from '~icons/mdi/plus';
import { addToast } from '$lib/toasts';
import ImmichSelect from '../ImmichSelect.svelte';
@ -25,6 +27,7 @@
// Props
export let images: ContentImage[] = [];
export let attachments: Attachment[] = [];
export let trails: Trail[] = [];
export let itemId: string = '';
// Component state
@ -46,6 +49,18 @@
let attachmentToEdit: Attachment | null = null;
let editingAttachmentName: string = '';
// Trail state
let trailName: string = '';
let trailLink: string = '';
let trailWandererId: string = '';
let trailError: string = '';
let isTrailLoading: boolean = false;
let trailToEdit: Trail | null = null;
let editingTrailName: string = '';
let editingTrailLink: string = '';
let editingTrailWandererId: string = '';
let showAddTrailForm: boolean = false;
// Allowed file types for attachments
const allowedFileTypes = [
'.gpx',
@ -86,6 +101,10 @@
attachments = [...attachments, newAttachment];
}
function updateTrailsList(newTrail: Trail) {
trails = [...trails, newTrail];
}
// API calls
async function uploadImageToServer(file: File) {
const formData = new FormData();
@ -376,6 +395,160 @@
}
}
// Trail event handlers
function validateTrailForm(): boolean {
if (!trailName.trim()) {
trailError = 'Trail name is required';
return false;
}
const hasLink = trailLink.trim() !== '';
const hasWandererId = trailWandererId.trim() !== '';
if (hasLink && hasWandererId) {
trailError = 'Cannot have both a link and a Wanderer ID. Provide only one.';
return false;
}
if (!hasLink && !hasWandererId) {
trailError = 'You must provide either a link or a Wanderer ID.';
return false;
}
trailError = '';
return true;
}
async function createTrail() {
if (!validateTrailForm()) return;
isTrailLoading = true;
const trailData = {
name: trailName.trim(),
location: itemId,
link: trailLink.trim() || null,
wanderer_id: trailWandererId.trim() || null
};
try {
const res = await fetch('/api/trails/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(trailData)
});
if (res.ok) {
const newTrail = await res.json();
updateTrailsList(newTrail);
addToast('success', 'Trail created successfully');
resetTrailForm();
} else {
const errorData = await res.json();
throw new Error(errorData.message || 'Failed to create trail');
}
} catch (error) {
console.error('Trail creation error:', error);
trailError = error instanceof Error ? error.message : 'Failed to create trail';
addToast('error', 'Failed to create trail');
} finally {
isTrailLoading = false;
}
}
function resetTrailForm() {
trailName = '';
trailLink = '';
trailWandererId = '';
trailError = '';
showAddTrailForm = false;
}
function startEditingTrail(trail: Trail) {
trailToEdit = trail;
editingTrailName = trail.name;
editingTrailLink = trail.link || '';
editingTrailWandererId = trail.wanderer_id || '';
}
function cancelEditingTrail() {
trailToEdit = null;
editingTrailName = '';
editingTrailLink = '';
editingTrailWandererId = '';
}
function validateEditTrailForm(): boolean {
if (!editingTrailName.trim()) {
return false;
}
const hasLink = editingTrailLink.trim() !== '';
const hasWandererId = editingTrailWandererId.trim() !== '';
if (hasLink && hasWandererId) {
return false;
}
if (!hasLink && !hasWandererId) {
return false;
}
return true;
}
async function saveTrailEdit() {
if (!trailToEdit || !validateEditTrailForm()) return;
const trailData = {
name: editingTrailName.trim(),
link: editingTrailLink.trim() || null,
wanderer_id: editingTrailWandererId.trim() || null
};
try {
const res = await fetch(`/api/trails/${trailToEdit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(trailData)
});
if (res.ok) {
const updatedTrail = await res.json();
trails = trails.map((trail) => (trail.id === trailToEdit!.id ? updatedTrail : trail));
addToast('success', 'Trail updated successfully');
cancelEditingTrail();
} else {
throw new Error('Failed to update trail');
}
} catch (error) {
console.error('Error updating trail:', error);
addToast('error', 'Failed to update trail');
}
}
async function removeTrail(trailId: string) {
try {
const res = await fetch(`/api/trails/${trailId}`, {
method: 'DELETE'
});
if (res.status === 204) {
trails = trails.filter((trail) => trail.id !== trailId);
addToast('success', 'Trail removed successfully');
} else {
throw new Error('Failed to remove trail');
}
} catch (error) {
console.error('Error removing trail:', error);
addToast('error', 'Failed to remove trail');
}
}
// Navigation handlers
function handleBack() {
dispatch('back');
@ -629,17 +802,6 @@
</div>
{/if}
</div>
<!-- File Type Info -->
<div class="alert alert-info">
<InfoIcon class="h-5 w-5" />
<div>
<div class="text-sm font-medium">Supported file types:</div>
<div class="text-xs text-base-content/70 mt-1">
GPX, KML, PDF, DOC, TXT, JSON, CSV, XLSX and more
</div>
</div>
</div>
</div>
<!-- Attachment Gallery -->
@ -727,21 +889,208 @@
</div>
</div>
<!-- Trails Managment -->
<!-- Trails Management -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-accent/10 rounded-lg">
<SwapHorizontalVariantIcon class="w-5 h-5 text-accent" />
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-accent/10 rounded-lg">
<SwapHorizontalVariantIcon class="w-5 h-5 text-accent" />
</div>
<h2 class="text-xl font-bold">Trails Management</h2>
</div>
<h2 class="text-xl font-bold">Trails Management</h2>
<button
class="btn btn-accent btn-sm gap-2"
on:click={() => (showAddTrailForm = !showAddTrailForm)}
>
<PlusIcon class="w-4 h-4" />
Add Trail
</button>
</div>
<p class="text-base-content/70 mb-4">
You can manage trails associated with this location in the Trails section.
</p>
<p class="text-sm text-base-content/50">
Coming soon: Create, edit, and delete trails directly from this section.
</p>
<div class="text-sm text-base-content/60 mb-4">
Manage trails associated with this location. Trails can be linked to external services
like AllTrails or referenced by Wanderer ID.
</div>
<!-- Add Trail Form -->
{#if showAddTrailForm}
<div class="bg-accent/5 p-4 rounded-lg border border-accent/20 mb-6">
<h4 class="font-medium mb-3 text-accent">Add New Trail</h4>
<div class="grid gap-3">
<input
type="text"
bind:value={trailName}
class="input input-bordered"
placeholder="Trail name"
disabled={isTrailLoading}
/>
<input
type="url"
bind:value={trailLink}
class="input input-bordered"
placeholder="External link (e.g., AllTrails, Trailforks)"
disabled={isTrailLoading || trailWandererId.trim() !== ''}
/>
<div class="text-center text-sm text-base-content/60">OR</div>
<input
type="text"
bind:value={trailWandererId}
class="input input-bordered"
placeholder="Wanderer Trail ID"
disabled={isTrailLoading || trailLink.trim() !== ''}
/>
{#if trailError}
<div class="alert alert-error py-2">
<span class="text-sm">{trailError}</span>
</div>
{/if}
<div class="flex gap-2 justify-end">
<button
class="btn btn-ghost btn-sm"
disabled={isTrailLoading}
on:click={resetTrailForm}
>
Cancel
</button>
<button
class="btn btn-accent btn-sm"
class:loading={isTrailLoading}
disabled={isTrailLoading ||
!trailName.trim() ||
(!trailLink.trim() && !trailWandererId.trim())}
on:click={createTrail}
>
Create Trail
</button>
</div>
</div>
</div>
{/if}
<!-- Trails Gallery -->
{#if trails.length > 0}
<div class="divider">Current Trails</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each trails as trail (trail.id)}
<div class="relative group">
{#if trailToEdit?.id === trail.id}
<!-- Edit Mode -->
<div class="bg-warning/10 p-4 rounded-lg border border-warning/30">
<div class="flex items-center gap-2 mb-3">
<EditIcon class="w-4 h-4 text-warning" />
<span class="text-sm font-medium text-warning">Editing Trail</span>
</div>
<div class="grid gap-3">
<input
type="text"
bind:value={editingTrailName}
class="input input-bordered input-sm"
placeholder="Trail name"
/>
<input
type="url"
bind:value={editingTrailLink}
class="input input-bordered input-sm"
placeholder="External link"
disabled={editingTrailWandererId.trim() !== ''}
/>
<div class="text-center text-xs text-base-content/60">OR</div>
<input
type="text"
bind:value={editingTrailWandererId}
class="input input-bordered input-sm"
placeholder="Wanderer Trail ID"
disabled={editingTrailLink.trim() !== ''}
/>
</div>
<div class="flex gap-2 mt-3">
<button
class="btn btn-success btn-xs flex-1"
disabled={!validateEditTrailForm()}
on:click={saveTrailEdit}
>
<CheckIcon class="w-3 h-3" />
Save
</button>
<button class="btn btn-ghost btn-xs flex-1" on:click={cancelEditingTrail}>
<CloseIcon class="w-3 h-3" />
Cancel
</button>
</div>
</div>
{:else}
<!-- Normal Display -->
<div
class="bg-base-50 p-4 rounded-lg border border-base-200 hover:border-base-300 transition-colors"
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 bg-accent/10 rounded">
{#if trail.wanderer_id}
<Star class="w-4 h-4 text-accent" />
{:else}
<LinkIcon class="w-4 h-4 text-accent" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{trail.name}</div>
<div class="text-xs text-accent/70 mt-1">
{trail.provider || 'External'}
</div>
</div>
</div>
{#if trail.link}
<a
href={trail.link}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-accent hover:text-accent-focus mb-3 break-all block underline"
>
{trail.link}
</a>
{:else if trail.wanderer_id}
<div class="text-xs text-base-content/60 mb-3 break-all">
Wanderer ID: {trail.wanderer_id}
</div>
{:else}
<div class="text-xs text-base-content/40 mb-3 italic">
No external link available
</div>
{/if}
<!-- Trail Controls -->
<div class="flex gap-2 justify-end">
<button
type="button"
class="btn btn-warning btn-xs btn-square tooltip tooltip-top"
data-tip="Edit Trail"
on:click={() => startEditingTrail(trail)}
>
<EditIcon class="w-3 h-3" />
</button>
<button
type="button"
class="btn btn-error btn-xs btn-square tooltip tooltip-top"
data-tip="Remove Trail"
on:click={() => removeTrail(trail.id)}
>
<TrashIcon class="w-3 h-3" />
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="bg-base-200/50 rounded-lg p-8 text-center">
<div class="text-base-content/60 mb-2">No trails added yet</div>
<div class="text-sm text-base-content/40">
Add your first trail using the button above
</div>
</div>
{/if}
</div>
</div>

View file

@ -50,6 +50,7 @@ export type Location = {
city?: City | null;
region?: Region | null;
country?: Country | null;
trails: Trail[];
};
export type AdditionalLocation = Location & {
@ -312,4 +313,18 @@ export type CollectionInvite = {
collection: string; // UUID of the collection
name: string; // Name of the collection
created_at: string; // ISO 8601 date string
collection_owner_username: string; // Username of the collection owner
collection_user_first_name: string; // First name of the collection user
collection_user_last_name: string; // Last name of the collection user
};
export type Trail = {
id: string;
user: string;
name: string;
location: string; // UUID of the location
created_at: string; // ISO 8601 date string
link?: string | null; // Optional link to the trail
wanderer_id?: string | null; // Optional ID for integration with Wanderer
provider: string; // Provider of the trail data, e.g., 'wanderer', 'external'
};

View file

@ -316,7 +316,9 @@
"leave_collection": "Leave Collection",
"leave": "Leave",
"leave_collection_warning": "Are you sure you want to leave this collection? Any locations you added will be unlinked and remain in your account.",
"loading_collections": "Loading collections..."
"loading_collections": "Loading collections...",
"quick_start": "Quick Start",
"details": "Details"
},
"worldtravel": {
"country_list": "Country List",
@ -782,6 +784,7 @@
"decline": "Decline",
"accept": "Accept",
"invited_on": "Invited on",
"no_invites_desc": "Make sure your profile is public so users can invite you."
"no_invites_desc": "Make sure your profile is public so users can invite you.",
"by": "by"
}
}

View file

@ -415,6 +415,10 @@
<p class="text-xs text-base-content/50">
{$t('invites.invited_on')}
{formatDate(invite.created_at)}
{$t('invites.by')}
{invite.collection_owner_username || ''}
({invite.collection_user_first_name || ''}
{invite.collection_user_last_name || ''})
</p>
</div>
</div>

View file

@ -80,6 +80,7 @@ export const load: PageServerLoad = async (event) => {
}
let integrations = await integrationsFetch.json();
let googleMapsEnabled = integrations.google_maps as boolean;
let stravaEnabled = integrations.strava as boolean;
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = '';
@ -98,7 +99,8 @@ export const load: PageServerLoad = async (event) => {
immichIntegration,
publicUrl,
socialProviders,
googleMapsEnabled
googleMapsEnabled,
stravaEnabled
}
};
};

View file

@ -10,6 +10,7 @@
import { appTitle, appVersion, copyrightYear } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg';
import GoogleMapsLogo from '$lib/assets/google_maps.svg';
import StravaLogo from '$lib/assets/strava.svg';
export let data;
console.log(data);
@ -24,6 +25,7 @@
let public_url: string = data.props.publicUrl;
let immichIntegration = data.props.immichIntegration;
let googleMapsEnabled = data.props.googleMapsEnabled;
let stravaEnabled = data.props.stravaEnabled;
let activeSection: string = 'profile';
let acknowledgeRestoreOverride: boolean = false;
@ -896,7 +898,7 @@
</div>
<!-- Google maps integration - displayt only if its connected -->
<div class="p-6 bg-base-200 rounded-xl">
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={GoogleMapsLogo} alt="Google Maps" class="w-8 h-8" />
<div>
@ -922,6 +924,33 @@
</p>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl">
<div class="flex items-center gap-4 mb-4">
<img src={StravaLogo} alt="Strava" class="w-8 h-8 rounded-md" />
<div>
<h3 class="text-xl font-bold">Strava</h3>
<p class="text-sm text-base-content/70">
{$t('google_maps.google_maps_integration_desc')}
</p>
</div>
{#if stravaEnabled}
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
{/if}
</div>
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/google_maps_integration.html"
target="_blank">{$t('navbar.documentation')}</a
>
</p>
</div>
</div>
</div>
{/if}