From 2da568dcc1489b5c27dd4841fee556caeee7b9ca Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 5 Aug 2025 20:59:37 -0400 Subject: [PATCH] feat: enhance import/export functionality to include trails and activities; update UI components and localization --- .../adventures/views/import_export_view.py | 166 +++++++++++++++++- .../components/locations/LocationMedia.svelte | 10 +- frontend/src/locales/en.json | 4 +- frontend/src/routes/settings/+page.svelte | 8 + 4 files changed, 174 insertions(+), 14 deletions(-) diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 4753777..aa4a684 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -19,7 +19,7 @@ from django.contrib.contenttypes.models import ContentType from adventures.models import ( Location, Collection, Transportation, Note, Checklist, ChecklistItem, - ContentImage, ContentAttachment, Category, Lodging, Visit + ContentImage, ContentAttachment, Category, Lodging, Visit, Trail, Activity ) from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country @@ -111,18 +111,69 @@ class BackupViewSet(viewsets.ViewSet): 'category_name': location.category.name if location.category else None, 'collection_export_ids': [collection_name_to_id[col_name] for col_name in location.collections.values_list('name', flat=True) if col_name in collection_name_to_id], 'visits': [], + 'trails': [], 'images': [], 'attachments': [] } # Add visits - for visit in location.visits.all(): - location_data['visits'].append({ + for visit_idx, visit in enumerate(location.visits.all()): + visit_data = { + 'export_id': visit_idx, # Add unique identifier for this visit 'start_date': visit.start_date.isoformat() if visit.start_date else None, 'end_date': visit.end_date.isoformat() if visit.end_date else None, 'timezone': visit.timezone, - 'notes': visit.notes - }) + 'notes': visit.notes, + 'activities': [] + } + + # Add activities for this visit + for activity in visit.activities.all(): + activity_data = { + 'name': activity.name, + 'type': activity.type, + 'sport_type': activity.sport_type, + 'distance': float(activity.distance) if activity.distance else None, + 'moving_time': activity.moving_time.total_seconds() if activity.moving_time else None, + 'elapsed_time': activity.elapsed_time.total_seconds() if activity.elapsed_time else None, + 'rest_time': activity.rest_time.total_seconds() if activity.rest_time else None, + 'elevation_gain': float(activity.elevation_gain) if activity.elevation_gain else None, + 'elevation_loss': float(activity.elevation_loss) if activity.elevation_loss else None, + 'elev_high': float(activity.elev_high) if activity.elev_high else None, + 'elev_low': float(activity.elev_low) if activity.elev_low else None, + 'start_date': activity.start_date.isoformat() if activity.start_date else None, + 'start_date_local': activity.start_date_local.isoformat() if activity.start_date_local else None, + 'timezone': activity.timezone, + 'average_speed': float(activity.average_speed) if activity.average_speed else None, + 'max_speed': float(activity.max_speed) if activity.max_speed else None, + 'average_cadence': float(activity.average_cadence) if activity.average_cadence else None, + 'calories': float(activity.calories) if activity.calories else None, + 'start_lat': float(activity.start_lat) if activity.start_lat else None, + 'start_lng': float(activity.start_lng) if activity.start_lng else None, + 'end_lat': float(activity.end_lat) if activity.end_lat else None, + 'end_lng': float(activity.end_lng) if activity.end_lng else None, + 'external_service_id': activity.external_service_id, + 'trail_name': activity.trail.name if activity.trail else None, # Link by trail name + 'gpx_filename': None + } + + # Handle GPX file + if activity.gpx_file: + activity_data['gpx_filename'] = activity.gpx_file.name.split('/')[-1] + + visit_data['activities'].append(activity_data) + + location_data['visits'].append(visit_data) + + # Add trails for this location + for trail in location.trails.all(): + trail_data = { + 'name': trail.name, + 'link': trail.link, + 'wanderer_id': trail.wanderer_id, + 'created_at': trail.created_at.isoformat() if trail.created_at else None + } + location_data['trails'].append(trail_data) # Add images for image in location.images.all(): @@ -242,7 +293,7 @@ class BackupViewSet(viewsets.ViewSet): # Add JSON data zip_file.writestr('data.json', json.dumps(export_data, indent=2)) - # Add images and attachments + # Add images, attachments, and GPX files files_added = set() for location in user.location_set.all(): @@ -267,6 +318,18 @@ class BackupViewSet(viewsets.ViewSet): files_added.add(attachment.file.name) except Exception as e: print(f"Error adding attachment {attachment.file.name}: {e}") + + # Add GPX files from activities + for visit in location.visits.all(): + for activity in visit.activities.all(): + if activity.gpx_file and activity.gpx_file.name not in files_added: + try: + gpx_content = default_storage.open(activity.gpx_file.name).read() + filename = activity.gpx_file.name.split('/')[-1] + zip_file.writestr(f'gpx/{filename}', gpx_content) + files_added.add(activity.gpx_file.name) + except Exception as e: + print(f"Error adding GPX file {activity.gpx_file.name}: {e}") # Return ZIP file as response with open(tmp_file.name, 'rb') as zip_file: @@ -341,6 +404,8 @@ class BackupViewSet(viewsets.ViewSet): def _clear_user_data(self, user): """Clear all existing user data before import""" # Delete in reverse order of dependencies + user.activity_set.all().delete() # Delete activities first + user.trail_set.all().delete() # Delete trails user.checklistitem_set.all().delete() user.checklist_set.all().delete() user.note_set.all().delete() @@ -363,13 +428,19 @@ class BackupViewSet(viewsets.ViewSet): def _import_data(self, backup_data, zip_file, user): """Import backup data and return summary""" + from datetime import timedelta + # Track mappings and counts category_map = {} collection_map = {} # Map export_id to actual collection object + location_map = {} # Map location export_id to actual location object + trail_name_map = {} # Map (location_id, trail_name) to trail object summary = { 'categories': 0, 'collections': 0, 'locations': 0, 'transportation': 0, 'notes': 0, 'checklists': 0, - 'checklist_items': 0, 'lodging': 0, 'images': 0, 'attachments': 0, 'visited_cities': 0, 'visited_regions': 0 + 'checklist_items': 0, 'lodging': 0, 'images': 0, + 'attachments': 0, 'visited_cities': 0, 'visited_regions': 0, + 'trails': 0, 'activities': 0, 'gpx_files': 0 } # Import Visited Cities @@ -468,21 +539,98 @@ class BackupViewSet(viewsets.ViewSet): category=category_map.get(adv_data.get('category_name')) ) location.save(_skip_geocode=True) # Skip geocoding for now + location_map[adv_data['export_id']] = location # Add to collections using export_ids - MUST be done after save() for collection_export_id in adv_data.get('collection_export_ids', []): if collection_export_id in collection_map: location.collections.add(collection_map[collection_export_id]) - # Import visits + # Import trails for this location first + for trail_data in adv_data.get('trails', []): + trail = Trail.objects.create( + user=user, + location=location, + name=trail_data['name'], + link=trail_data.get('link'), + wanderer_id=trail_data.get('wanderer_id'), + created_at=trail_data.get('created_at') + ) + trail_name_map[(location.id, trail_data['name'])] = trail + summary['trails'] += 1 + + # Import visits and their activities for visit_data in adv_data.get('visits', []): - Visit.objects.create( + visit = Visit.objects.create( location=location, start_date=visit_data.get('start_date'), end_date=visit_data.get('end_date'), timezone=visit_data.get('timezone'), notes=visit_data.get('notes') ) + + # Import activities for this visit + for activity_data in visit_data.get('activities', []): + # Find the trail if specified + trail = None + if activity_data.get('trail_name'): + trail = trail_name_map.get((location.id, activity_data['trail_name'])) + + # Convert time durations back from seconds + moving_time = None + if activity_data.get('moving_time') is not None: + moving_time = timedelta(seconds=activity_data['moving_time']) + + elapsed_time = None + if activity_data.get('elapsed_time') is not None: + elapsed_time = timedelta(seconds=activity_data['elapsed_time']) + + rest_time = None + if activity_data.get('rest_time') is not None: + rest_time = timedelta(seconds=activity_data['rest_time']) + + activity = Activity( + user=user, + visit=visit, + trail=trail, + name=activity_data['name'], + type=activity_data.get('type', 'general'), + sport_type=activity_data.get('sport_type'), + distance=activity_data.get('distance'), + moving_time=moving_time, + elapsed_time=elapsed_time, + rest_time=rest_time, + elevation_gain=activity_data.get('elevation_gain'), + elevation_loss=activity_data.get('elevation_loss'), + elev_high=activity_data.get('elev_high'), + elev_low=activity_data.get('elev_low'), + start_date=activity_data.get('start_date'), + start_date_local=activity_data.get('start_date_local'), + timezone=activity_data.get('timezone'), + average_speed=activity_data.get('average_speed'), + max_speed=activity_data.get('max_speed'), + average_cadence=activity_data.get('average_cadence'), + calories=activity_data.get('calories'), + start_lat=activity_data.get('start_lat'), + start_lng=activity_data.get('start_lng'), + end_lat=activity_data.get('end_lat'), + end_lng=activity_data.get('end_lng'), + external_service_id=activity_data.get('external_service_id') + ) + + # Handle GPX file + gpx_filename = activity_data.get('gpx_filename') + if gpx_filename: + try: + gpx_content = zip_file.read(f'gpx/{gpx_filename}') + gpx_file = ContentFile(gpx_content, name=gpx_filename) + activity.gpx_file = gpx_file + summary['gpx_files'] += 1 + except KeyError: + pass # GPX file not found in backup + + activity.save() + summary['activities'] += 1 # Import images content_type = ContentType.objects.get(model='location') diff --git a/frontend/src/lib/components/locations/LocationMedia.svelte b/frontend/src/lib/components/locations/LocationMedia.svelte index 7ff28cb..e1037e7 100644 --- a/frontend/src/lib/components/locations/LocationMedia.svelte +++ b/frontend/src/lib/components/locations/LocationMedia.svelte @@ -1141,9 +1141,11 @@ {/if} {:else} - {#each wandererFetchedTrails as trail (trail.id)} - - {/each} +
+ {#each wandererFetchedTrails as trail (trail.id)} + + {/each} +
{/if} {:else} @@ -1154,7 +1156,7 @@
+
+ 🥾 {$t('settings.trails')} + +
+
+ ⏱️ {$t('settings.activities')} + +
🌍 {$t('settings.world_travel_visits')}