mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-05 21:25:19 +02:00
Add StravaActivity and Activity types to types.ts
- Introduced StravaActivity type to represent detailed activity data from Strava. - Added Activity type to encapsulate user activities, including optional trail and GPX file information. - Updated Location type to include an array of activities associated with each visit.
This commit is contained in:
parent
c7ff8f4bc7
commit
5046bd49f7
13 changed files with 1626 additions and 255 deletions
|
@ -1,8 +1,8 @@
|
|||
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, Trail
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
admin.autodiscover()
|
||||
|
@ -165,6 +165,7 @@ admin.site.register(ContentAttachment)
|
|||
admin.site.register(Lodging)
|
||||
admin.site.register(CollectionInvite, CollectionInviteAdmin)
|
||||
admin.site.register(Trail)
|
||||
admin.site.register(Activity)
|
||||
|
||||
admin.site.site_header = 'AdventureLog Admin'
|
||||
admin.site.site_title = 'AdventureLog Admin Site'
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -618,4 +618,59 @@ class Trail(models.Model):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
|
||||
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
|
||||
|
||||
class Activity(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
|
||||
visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name='activities')
|
||||
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, related_name='activities', blank=True, null=True)
|
||||
|
||||
# GPX File
|
||||
gpx_file = models.FileField(upload_to=PathAndRename('activities/'), validators=[validate_file_extension], blank=True, null=True)
|
||||
|
||||
# Descriptive
|
||||
name = models.CharField(max_length=200)
|
||||
type = models.CharField(max_length=100, default='general') # E.g., Run, Hike, Bike
|
||||
sport_type = models.CharField(max_length=100, blank=True, null=True) # Optional detailed type
|
||||
|
||||
# Time & Distance
|
||||
distance = models.FloatField(blank=True, null=True) # in meters
|
||||
moving_time = models.DurationField(blank=True, null=True)
|
||||
elapsed_time = models.DurationField(blank=True, null=True)
|
||||
rest_time = models.DurationField(blank=True, null=True)
|
||||
|
||||
# Elevation
|
||||
elevation_gain = models.FloatField(blank=True, null=True) # in meters
|
||||
elevation_loss = models.FloatField(blank=True, null=True) # estimated
|
||||
elev_high = models.FloatField(blank=True, null=True)
|
||||
elev_low = models.FloatField(blank=True, null=True)
|
||||
|
||||
# Timing
|
||||
start_date = models.DateTimeField(blank=True, null=True)
|
||||
start_date_local = models.DateTimeField(blank=True, null=True)
|
||||
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], blank=True, null=True)
|
||||
|
||||
# Speed
|
||||
average_speed = models.FloatField(blank=True, null=True) # in m/s
|
||||
max_speed = models.FloatField(blank=True, null=True) # in m/s
|
||||
|
||||
# Optional metrics
|
||||
average_cadence = models.FloatField(blank=True, null=True)
|
||||
calories = models.FloatField(blank=True, null=True)
|
||||
|
||||
# Coordinates
|
||||
start_lat = models.FloatField(blank=True, null=True)
|
||||
start_lng = models.FloatField(blank=True, null=True)
|
||||
end_lat = models.FloatField(blank=True, null=True)
|
||||
end_lng = models.FloatField(blank=True, null=True)
|
||||
|
||||
# Optional links
|
||||
external_service_id = models.CharField(max_length=100, blank=True, null=True) # E.g., Strava ID
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.type})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Activity"
|
||||
verbose_name_plural = "Activities"
|
|
@ -111,7 +111,12 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
|||
# 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
|
||||
|
||||
|
||||
if type(obj).__name__ == 'Activity':
|
||||
# If the object is an Activity, get its location
|
||||
if hasattr(obj, 'visit') and hasattr(obj.visit, 'location'):
|
||||
obj = obj.visit.location
|
||||
|
||||
# Anonymous users only get read access to public objects
|
||||
if not user or not user.is_authenticated:
|
||||
return is_safe_method and getattr(obj, 'is_public', False)
|
||||
|
|
|
@ -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, Trail
|
||||
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
|
||||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
from users.serializers import CustomUserDetailsSerializer
|
||||
|
@ -106,11 +106,34 @@ class TrailSerializer(CustomModelSerializer):
|
|||
return 'Outdooractive'
|
||||
return 'External Link'
|
||||
|
||||
class ActivitySerializer(CustomModelSerializer):
|
||||
trail = TrailSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
fields = [
|
||||
'id', 'user', 'visit', 'trail', 'gpx_file', 'name', 'type', 'sport_type',
|
||||
'distance', 'moving_time', 'elapsed_time', 'rest_time', 'elevation_gain',
|
||||
'elevation_loss', 'elev_high', 'elev_low', 'start_date', 'start_date_local',
|
||||
'timezone', 'average_speed', 'max_speed', 'average_cadence', 'calories',
|
||||
'start_lat', 'start_lng', 'end_lat', 'end_lng', 'external_service_id'
|
||||
]
|
||||
read_only_fields = ['id', 'user']
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
if instance.gpx_file:
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "")
|
||||
representation['gpx_file'] = f"{public_url}/media/{instance.gpx_file.name}"
|
||||
return representation
|
||||
|
||||
|
||||
class VisitSerializer(serializers.ModelSerializer):
|
||||
activities = ActivitySerializer(many=True, read_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Visit
|
||||
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes']
|
||||
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities']
|
||||
read_only_fields = ['id']
|
||||
|
||||
class LocationSerializer(CustomModelSerializer):
|
||||
|
|
|
@ -21,6 +21,7 @@ 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')
|
||||
router.register(r'activities', ActivityViewSet, basename='activities')
|
||||
|
||||
urlpatterns = [
|
||||
# Include the router under the 'api/' prefix
|
||||
|
|
|
@ -15,4 +15,5 @@ from .attachment_view import *
|
|||
from .lodging_view import *
|
||||
from .recommendations_view import *
|
||||
from .import_export_view import *
|
||||
from .trail_view import *
|
||||
from .trail_view import *
|
||||
from .activity_view import *
|
40
backend/server/adventures/views/activity_view.py
Normal file
40
backend/server/adventures/views/activity_view.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from rest_framework import viewsets
|
||||
from django.db.models import Q
|
||||
from adventures.models import Location, Activity
|
||||
from adventures.serializers import ActivitySerializer
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
|
||||
class ActivityViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ActivitySerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Returns activities based on location permissions.
|
||||
Users can only see activities 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 Activity.objects.none()
|
||||
|
||||
# Build the filter for accessible locations
|
||||
location_filter = Q(visit__location__user=user) # User owns the location
|
||||
|
||||
# Location is in collections (many-to-many) that are shared with user
|
||||
location_filter |= Q(visit__location__collections__shared_with=user)
|
||||
|
||||
# Location is in collections (many-to-many) that user owns
|
||||
location_filter |= Q(visit__location__collections__user=user)
|
||||
|
||||
return Activity.objects.filter(location_filter).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
Set the user when creating an activity.
|
||||
"""
|
||||
serializer.save(user=self.request.user)
|
Loading…
Add table
Add a link
Reference in a new issue