diff --git a/backend/server/.env.example b/backend/server/.env.example index 2c93208..d1af539 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -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 diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 365ed58..d033399 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -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' diff --git a/backend/server/adventures/migrations/0057_trail.py b/backend/server/adventures/migrations/0057_trail.py new file mode 100644 index 0000000..c535029 --- /dev/null +++ b/backend/server/adventures/migrations/0057_trail.py @@ -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', + }, + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 985103a..b21feb1 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -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 \ No newline at end of file + 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'})" \ No newline at end of file diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 73b6833..c5dd49d 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -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: diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index f15f1eb..6254beb 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -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'] \ No newline at end of file diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 241db16..e826552 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -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 diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 1ae7b6d..82ae140 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -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 * \ No newline at end of file +from .import_export_view import * +from .trail_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/trail_view.py b/backend/server/adventures/views/trail_view.py new file mode 100644 index 0000000..c1d2383 --- /dev/null +++ b/backend/server/adventures/views/trail_view.py @@ -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) \ No newline at end of file diff --git a/backend/server/integrations/admin.py b/backend/server/integrations/admin.py index d561cf4..1596a6e 100644 --- a/backend/server/integrations/admin.py +++ b/backend/server/integrations/admin.py @@ -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) \ No newline at end of file +admin.site.register(ImmichIntegration) +admin.site.register(StravaToken) \ No newline at end of file diff --git a/backend/server/integrations/migrations/0003_stravatoken.py b/backend/server/integrations/migrations/0003_stravatoken.py new file mode 100644 index 0000000..ec4e243 --- /dev/null +++ b/backend/server/integrations/migrations/0003_stravatoken.py @@ -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)), + ], + ), + ] diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py index 7b0400a..24cb802 100644 --- a/backend/server/integrations/models.py +++ b/backend/server/integrations/models.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/server/integrations/urls.py b/backend/server/integrations/urls.py index a15bbd0..049832f 100644 --- a/backend/server/integrations/urls.py +++ b/backend/server/integrations/urls.py @@ -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 = [ diff --git a/backend/server/integrations/utils.py b/backend/server/integrations/utils.py new file mode 100644 index 0000000..4337190 --- /dev/null +++ b/backend/server/integrations/utils.py @@ -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 \ No newline at end of file diff --git a/backend/server/integrations/views/__init__.py b/backend/server/integrations/views/__init__.py new file mode 100644 index 0000000..78e17d4 --- /dev/null +++ b/backend/server/integrations/views/__init__.py @@ -0,0 +1,3 @@ +from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet +from .integration_view import IntegrationView +from .strava_view import StravaIntegrationView \ No newline at end of file diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views/immich_view.py similarity index 96% rename from backend/server/integrations/views.py rename to backend/server/integrations/views/immich_view.py index 8bbb0ca..e5eb0f1 100644 --- a/backend/server/integrations/views.py +++ b/backend/server/integrations/views/immich_view.py @@ -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 diff --git a/backend/server/integrations/views/integration_view.py b/backend/server/integrations/views/integration_view.py new file mode 100644 index 0000000..013591d --- /dev/null +++ b/backend/server/integrations/views/integration_view.py @@ -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 + ) diff --git a/backend/server/integrations/views/strava_view.py b/backend/server/integrations/views/strava_view.py new file mode 100644 index 0000000..436b51b --- /dev/null +++ b/backend/server/integrations/views/strava_view.py @@ -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[^/.]+)') + 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) \ No newline at end of file diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 339c8f4..0763794 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -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', '') \ No newline at end of file +GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '') + +STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '') +STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '') \ No newline at end of file diff --git a/frontend/src/lib/assets/strava.svg b/frontend/src/lib/assets/strava.svg new file mode 100644 index 0000000..d94ac6a --- /dev/null +++ b/frontend/src/lib/assets/strava.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/components/NewLocationModal.svelte b/frontend/src/lib/components/NewLocationModal.svelte index 0c98d8c..60e2158 100644 --- a/frontend/src/lib/components/NewLocationModal.svelte +++ b/frontend/src/lib/components/NewLocationModal.svelte @@ -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 @@ > -
{ + // Reset all steps + steps.forEach((s) => (s.selected = false)); + // Select clicked step + steps[index].selected = true; + }} > {step.name} -
+ {#if index < steps.length - 1}
{/if} @@ -263,6 +270,7 @@ { steps[2].selected = false; steps[1].selected = true; diff --git a/frontend/src/lib/components/locations/LocationDetails.svelte b/frontend/src/lib/components/locations/LocationDetails.svelte index dac80a0..742be62 100644 --- a/frontend/src/lib/components/locations/LocationDetails.svelte +++ b/frontend/src/lib/components/locations/LocationDetails.svelte @@ -397,7 +397,7 @@ }); -
+
diff --git a/frontend/src/lib/components/locations/LocationMedia.svelte b/frontend/src/lib/components/locations/LocationMedia.svelte index f6f896b..164fa32 100644 --- a/frontend/src/lib/components/locations/LocationMedia.svelte +++ b/frontend/src/lib/components/locations/LocationMedia.svelte @@ -1,5 +1,5 @@