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:
parent
f5110ed640
commit
9bcada21dd
28 changed files with 991 additions and 72 deletions
|
@ -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'
|
||||
|
|
33
backend/server/adventures/migrations/0057_trail.py
Normal file
33
backend/server/adventures/migrations/0057_trail.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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'})"
|
|
@ -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:
|
||||
|
|
|
@ -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']
|
|
@ -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
|
||||
|
|
|
@ -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 *
|
40
backend/server/adventures/views/trail_view.py
Normal file
40
backend/server/adventures/views/trail_view.py
Normal 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)
|
Loading…
Add table
Add a link
Reference in a new issue