1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 21:25: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

@ -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)