Activities, Trails, Wanderer + Strava Integration, UI Refresh, Devops Improvments, and more (#785)

* Implement code changes to enhance functionality and improve performance

* Update nl.json

Fix Dutch translations.

* feat(security): add Trivy security scans for Docker images and source code

* feat(security): restructure Trivy scans for improved clarity and organization

* fix(dependencies): update Django version to 5.2.2

* style(workflows): standardize quotes and fix typo in frontend-test.yml

* feat(workflows): add job names for clarity in backend and frontend test workflows

* refactor(workflows): remove path filters from pull_request and push triggers in backend and frontend workflows

* feat(workflows): add paths to push and pull_request triggers for backend and frontend workflows

* refactor(workflows): simplify trigger paths for backend and frontend workflows
fix(dependencies): add overrides for esbuild in frontend package.json

* fix(package): add missing pnpm overrides for esbuild in package.json

* fix(workflows): add missing severity parameter for Trivy filesystem scan

* fix(workflows): add missing severity parameter for Docker image scans in Trivy workflow

* fix(workflows): remove MEDIUM severity from Trivy scans in security workflow

* added-fix-image-deletion (#681)

* added-fix-image-deletion

* feat(commands): add image cleanup command to find and delete unused files

* fix(models): ensure associated AdventureImages are deleted and files cleaned up on Adventure deletion

* fix(models): ensure associated Attachment files are deleted and their filesystem cleaned up on Adventure deletion

---------

Co-authored-by: ferdousahmed <taninme@gmail.com>
Co-authored-by: Sean Morley

* Rename Adventures to Locations (#696)

* Refactor user_id to user in adventures and related models, views, and components

- Updated all instances of user_id to user in the adventures app, including models, serializers, views, and frontend components.
- Adjusted queries and filters to reflect the new user field naming convention.
- Ensured consistency across the codebase for user identification in adventures, collections, notes, and transportation entities.
- Modified frontend components to align with the updated data structure, ensuring proper access control and rendering based on user ownership.

* Refactor adventure-related views and components to use "Location" terminology

- Updated GlobalSearchView to replace AdventureSerializer with LocationSerializer.
- Modified IcsCalendarGeneratorViewSet to use LocationSerializer instead of AdventureSerializer.
- Created new LocationImageViewSet for managing location images, including primary image toggling and image deletion.
- Introduced LocationViewSet for managing locations with enhanced filtering, sorting, and sharing capabilities.
- Updated ReverseGeocodeViewSet to utilize LocationSerializer.
- Added ActivityTypesView to retrieve distinct activity types from locations.
- Refactored user views to replace AdventureSerializer with LocationSerializer.
- Updated frontend components to reflect changes from "adventure" to "location", including AdventureCard, AdventureLink, AdventureModal, and others.
- Adjusted API endpoints in frontend routes to align with new location-based structure.
- Ensured all references to adventures are replaced with locations across the codebase.

* refactor: rename adventures to locations across the application

- Updated localization files to replace adventure-related terms with location-related terms.
- Refactored TypeScript types and variables from Adventure to Location in various routes and components.
- Adjusted UI elements and labels to reflect the change from adventures to locations.
- Ensured all references to adventures in the codebase are consistent with the new location terminology.

* Refactor code structure for improved readability and maintainability

* feat: Implement location details page with server-side loading and deletion functionality

- Added +page.server.ts to handle server-side loading of additional location info.
- Created +page.svelte for displaying location details, including images, visits, and maps.
- Integrated GPX file handling and rendering on the map.
- Updated map route to link to locations instead of adventures.
- Refactored profile and search routes to use LocationCard instead of AdventureCard.

* docs: Update terminology from "Adventure" to "Location" and enhance project overview

* docs: Clarify collection examples in usage documentation

* feat: Enable credentials for GPX file fetch and add CORS_ALLOW_CREDENTIALS setting

* Refactor adventure references to locations across the backend and frontend

- Updated CategoryViewSet to reflect location context instead of adventures.
- Modified ChecklistViewSet to include locations in retrieval logic.
- Changed GlobalSearchView to search for locations instead of adventures.
- Adjusted IcsCalendarGeneratorViewSet to handle locations instead of adventures.
- Refactored LocationImageViewSet to remove unused import.
- Updated LocationViewSet to clarify public access for locations.
- Changed LodgingViewSet to reference locations instead of adventures.
- Modified NoteViewSet to prevent listing all locations.
- Updated RecommendationsViewSet to handle locations in parsing and response.
- Adjusted ReverseGeocodeViewSet to search through user locations.
- Updated StatsViewSet to count locations instead of adventures.
- Changed TagsView to reflect activity types for locations.
- Updated TransportationViewSet to reference locations instead of adventures.
- Added new translations for search results related to locations in multiple languages.
- Updated dashboard and profile pages to reflect location counts instead of adventure counts.
- Adjusted search routes to handle locations instead of adventures.

* Update banner image

* style: Update stats component background and border for improved visibility

* refactor: Rename AdventureCard and AdventureModal to LocationCard and LocationModal for consistency

* Import and Export Functionality (#698)

* feat(backup): add BackupViewSet for data export and import functionality

* Fixed frontend returning corrupt binary data

* feat(import): enhance import functionality with confirmation check and improved city/region/country handling

* Potential fix for code scanning alert no. 29: Information exposure through an exception

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Refactor response handling to use arrayBuffer instead of bytes

* Refactor image cleanup command to use LocationImage model and update import/export view to include backup and restore functionality

* Update backup export versioning and improve data restore warning message

* Enhance image navigation and localization support in modal components

* Refactor location handling in Immich integration components for consistency

* Enhance backup and restore functionality with improved localization and error handling

* Improve accessibility by adding 'for' attribute to backup file input label

---------

Co-authored-by: Christian Zäske <blitzdose@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* refactor(serializers): rename Location to Adventure and update related fields

* refactor(serializers): rename Adventure to Location and update related fields

* chore(requirements): update pillow version to 11.3.0

* Add PT-BR translations (#739)

* Fixed frontend returning corrupt binary data

* fix(adventure): enhance collection ownership validation in AdventureSerializer (#723)

* Add PT-BR translations

Add translation for Brazilian Portuguese to the project;

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>

---------

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>
Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>
Co-authored-by: Christian Zäske <blitzdose@gmail.com>

* fix: update date formatting for adventure items to include timezone

* Image/attachment overhaul, activities, trails and integrations with Strava and Wanderer (#726)

* refactor(models, views, serializers): rename LocationImage and Attachment to ContentImage and ContentAttachment, update related references

* feat: Enhance collection sharing and location management features

- Implemented unsharing functionality in CollectionViewSet, including removal of user-owned locations from collections.
- Refactored ContentImageViewSet to support multiple content types and improved permission checks for image uploads.
- Added user ownership checks in LocationViewSet for delete operations.
- Enhanced collection management in the frontend to display both owned and shared collections separately.
- Updated Immich integration to handle access control based on location visibility and user permissions.
- Improved UI components to show creator information and manage collection links more effectively.
- Added loading states and error handling in collection fetching logic.

* feat: enhance transportation card and modal with image handling

- Added CardCarousel component to TransportationCard for image display.
- Implemented privacy indicator with Eye and EyeOff icons.
- Introduced image upload functionality in TransportationModal, allowing users to upload multiple images.
- Added image management features: remove image and set primary image.
- Updated Transportation and Location types to include images as ContentImage array.
- Enhanced UI for image upload and display in modal, including selected images preview and current images management.

* feat: update CardCarousel component to handle images, name, and icon props across various cards

* feat: add Discord link to AboutModal and update appVersion in config

* feat: add LocationQuickStart and LocationVisits components for enhanced location selection and visit management

- Implemented LocationQuickStart.svelte for searching and selecting locations on a map with reverse geocoding.
- Created LocationVisits.svelte to manage visit dates and notes for locations, including timezone handling and validation.
- Updated types to remove location property from Attachment type.
- Modified locations page to integrate NewLocationModal for creating and editing locations, syncing updates with adventures.

* feat: update button styles and add back and close functionality in location components

* Collection invite system

* feat: update CollectionSerializer to include 'shared_with' as a read-only field; update app version; add new background images and localization strings for invites

* 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.

* feat: enhance Strava integration with user-specific settings and management options; update localization strings

* feat: update Strava integration settings and add Wanderer logo; enhance user experience with active section management

* 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.

* feat: streamline location and activity management; enhance Strava import functionality and add activity handling in server actions

* feat: add ActivityCard component and update LocationVisits to use it; modify Activity type to reference trail as string

* feat: add geojson support to ActivitySerializer and ActivityCard; enhance location page with activity summaries and GPS tracks

* feat: add trails property to recommendation object in collection page

* feat: add Wanderer integration with authentication and management features

* feat: implement Wanderer integration with trail management and UI components; enhance settings for reauthentication

* feat: add measurement system field to CustomUser model and update related serializers, migrations, and UI components

* feat: add measurement system support across ActivityCard, StravaActivityCard, NewLocationModal, LocationVisits, and related utility functions

* feat: enhance Wanderer integration with trail data fetching and UI updates; add measurement system support

* feat: add TrailCard component for displaying trail details with measurement system support

* feat: add wanderer link support in TrailSerializer and TrailCard; update measurement system handling in location page

* feat: integrate memcached for caching in Wanderer services; update Docker, settings, and supervisord configurations

* feat: add activity statistics to user profile; include distance, moving time, elevation, and total activities

* feat: enhance import/export functionality to include trails and activities; update UI components and localization

* feat: integrate NewLocationModal across various components; update location handling and state management

* Refactor Location and Visit types: Replace visits structure in Location with Visit type and add location, created_at, and updated_at fields to Visit

* feat: enhance permissions and validation in activity, trail, and visit views; add unique constraint to CollectionInvite model

* feat: sync visits when updating adventures in collection page

* feat: add geojson support for attachments and refactor GPX handling in location page

* chore: remove unused dependencies from pnpm-lock.yaml

* feat: add Strava and Wanderer integration documentation and configuration options

* Add support for Japanese and Arabic languages in localization

* Add new localization strings for Russian, Swedish, and Chinese languages

- Updated translations in ru.json, sv.json, and zh.json to include new phrases related to collections, activities, and integrations.
- Added strings for leaving collections, loading collections, and quick start instructions.
- Included new sections for invites and Strava integration with relevant messages.
- Enhanced Google Maps integration descriptions for clarity.

* Add localization support for activity-related features and update UI labels

- Added new Russian, Swedish, and Chinese translations for activity statistics, achievements, and related terms.
- Updated UI components to use localized strings for activity statistics, distance, moving time, and other relevant fields.
- Enhanced user experience by ensuring all relevant buttons and labels are translated and accessible.

* fix: update appVersion to reflect the latest development version

* feat: add getActivityColor function and integrate activity color coding in map and location pages

* feat: add support for showing activities and visited cities on the map

* feat: update map page to display counts for visited cities and activities

* fix: remove debug print statement from IsOwnerOrSharedWithFullAccess permission class

* feat: add MapStyleSelector component and integrate basemap selection in map page

* feat: enhance basemap functions with 3D terrain support and update XYZ style handling

* feat: add management command to recalculate elevation data from GPX files and update activity view to handle elevation data extraction

* feat: update MapStyleSelector component and enhance basemap options for improved user experience

* feat: refactor activity model and admin to use sport_type, update serializers and components for improved activity handling

* feat: update Activity model string representation to use sport_type instead of type

* feat: update activity handling to use sport_type for color determination in map and location components

* feat: Add attachments support to Transportation and Lodging types

- Updated Transportation and Lodging types to include attachments array.
- Enhanced localization files for multiple languages to include new strings related to attachments, lodging, and transportation.
- Added error and success messages for attachment removal and upload information.
- Included new prompts for creating and updating lodging and transportation details across various languages.

* feat: Enhance activity statistics and breakdown by category in user profile

* feat: Add SPORT_CATEGORIES for better organization of sports types and update StatsViewSet to use it

* feat: Enhance CategoryDropdown for mobile responsiveness and add category creation functionality

* feat: Update inspirational quote in adventure log

* feat: Localize navigation labels in Navbar and add translation to en.json

* feat: Update navigation elements to use anchor tags for better accessibility and add new fields to signup form

* Translate login button text to support internationalization

* feat: Refactor location visit status logic and add utility function for visited locations count

* chore: Upgrade GitHub Actions and remove unused timezone import

* fix: Update Docker image tags in GitHub Actions workflow for consistency

* fix: Update Docker image build process to use BuildKit cache for improved performance

* chore: Remove unused imports from stats_view.py for cleaner code

* Increase background image opacity on login and signup pages for improved visibility

* fix: Add postgresql-client to runtime dependencies in Dockerfile

* fix: Update workflow files to include permissions for GitHub Actions

* fix: Update esbuild version to ^0.25.9 in package.json and pnpm-lock.yaml for compatibility

* chore: improve Chinese translation (#796)

* fix: update adventure log quote and remove unused activity type field

* fix: optimize import process by using get_or_create for visited cities and regions

* fix: update README to reflect changes from adventures to locations and enhance feature descriptions

* fix: update documentation to reflect changes from adventures to locations and enhance feature descriptions

* Update google_maps_integration.md (#743)

* Update google_maps_integration.md

Explain APIs needed for AdventureLogs versions.

Fixes #731 and #727

* Fix a typo google_maps_integration.md

---------

Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

* fix: update appVersion to reflect the main branch version

* fix: update image source for satellite map in documentation

* Update frontend/src/lib/components/NewLocationModal.svelte

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add localization updates for multiple languages

- Japanese (ja.json): Added new activity-related phrases and checklist terms.
- Korean (ko.json): Included activity breakdown and checklist enhancements.
- Dutch (nl.json): Updated activity descriptions and added checklist functionalities.
- Norwegian (no.json): Enhanced activity and checklist terminology.
- Polish (pl.json): Added new phrases for activities and checklist management.
- Brazilian Portuguese (pt-br.json): Updated activity-related terms and checklist features.
- Russian (ru.json): Included new phrases for activities and checklist management.
- Swedish (sv.json): Enhanced activity descriptions and checklist functionalities.
- Chinese (zh.json): Added new activity-related phrases and checklist terms.

* fix: enhance image upload handling to support immich_id

* Add "not_enabled" message for Strava integration in multiple languages

- Updated Spanish, French, Italian, Japanese, Korean, Dutch, Norwegian, Polish, Brazilian Portuguese, Russian, Swedish, and Chinese locale files to include a new message indicating that Strava integration is not enabled in the current instance.

---------

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>
Co-authored-by: Ycer0n <37674033+Ycer0n@users.noreply.github.com>
Co-authored-by: taninme <5262715+taninme@users.noreply.github.com>
Co-authored-by: ferdousahmed <taninme@gmail.com>
Co-authored-by: Christian Zäske <blitzdose@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lucas Zampieri <lcasmz54@gmail.com>
Co-authored-by: pplulee <pplulee@live.cn>
Co-authored-by: Cathelijne Hornstra <github@hornstra.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sean Morley
2025-08-19 08:50:45 -04:00
committed by GitHub
parent 4e96e529f4
commit a3f0eda63f
220 changed files with 27763 additions and 6653 deletions

View File

@@ -1,8 +1,8 @@
import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Lodging
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()
@@ -11,19 +11,19 @@ admin.site.login = secure_admin_login(admin.site.login)
@admin.action(description="Trigger geocoding")
def trigger_geocoding(modeladmin, request, queryset):
count = 0
for adventure in queryset:
for location in queryset:
try:
adventure.save() # Triggers geocoding logic in your model
location.save() # Triggers geocoding logic in your model
count += 1
except Exception as e:
modeladmin.message_user(request, f"Error geocoding {adventure}: {e}", level='error')
modeladmin.message_user(request, f"Geocoding triggered for {count} adventures.", level='success')
modeladmin.message_user(request, f"Error geocoding {location}: {e}", level='error')
modeladmin.message_user(request, f"Geocoding triggered for {count} locations.", level='success')
class AdventureAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
list_filter = ( 'user_id', 'is_public')
class LocationAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user', 'is_public')
list_filter = ( 'user', 'is_public')
search_fields = ('name',)
readonly_fields = ('city', 'region', 'country')
actions = [trigger_geocoding]
@@ -82,11 +82,11 @@ from users.models import CustomUser
class CustomUserAdmin(UserAdmin):
model = CustomUser
list_display = ['username', 'is_staff', 'is_active', 'image_display']
list_display = ['username', 'is_staff', 'is_active', 'image_display', 'measurement_system']
readonly_fields = ('uuid',)
search_fields = ('username',)
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password')}),
(None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password', 'measurement_system')}),
)
def image_display(self, obj):
if obj.profile_pic:
@@ -96,8 +96,8 @@ class CustomUserAdmin(UserAdmin):
else:
return
class AdventureImageAdmin(admin.ModelAdmin):
list_display = ('user_id', 'image_display')
class ContentImageImageAdmin(admin.ModelAdmin):
list_display = ('user', 'image_display')
def image_display(self, obj):
if obj.image:
@@ -109,7 +109,7 @@ class AdventureImageAdmin(admin.ModelAdmin):
class VisitAdmin(admin.ModelAdmin):
list_display = ('adventure', 'start_date', 'end_date', 'notes')
list_display = ('location', 'start_date', 'end_date', 'notes')
list_filter = ('start_date', 'end_date')
search_fields = ('notes',)
@@ -124,20 +124,30 @@ 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_id', 'display_name', 'icon')
list_display = ('name', 'user', 'display_name', 'icon')
search_fields = ('name', 'display_name')
class CollectionAdmin(admin.ModelAdmin):
list_display = ('name', 'user_id', 'is_public')
list_display = ('name', 'user', 'is_public')
class ActivityAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'visit__location', 'sport_type', 'distance', 'elevation_gain', 'moving_time')
admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Adventure, AdventureAdmin)
admin.site.register(Location, LocationAdmin)
admin.site.register(Collection, CollectionAdmin)
admin.site.register(Visit, VisitAdmin)
admin.site.register(Country, CountryAdmin)
@@ -147,12 +157,15 @@ admin.site.register(Transportation)
admin.site.register(Note)
admin.site.register(Checklist)
admin.site.register(ChecklistItem)
admin.site.register(AdventureImage, AdventureImageAdmin)
admin.site.register(ContentImage, ContentImageImageAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)
admin.site.register(Attachment)
admin.site.register(ContentAttachment)
admin.site.register(Lodging)
admin.site.register(CollectionInvite, CollectionInviteAdmin)
admin.site.register(Trail)
admin.site.register(Activity, ActivityAdmin)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

View File

@@ -167,7 +167,7 @@ def extractIsoCode(user, data):
return {"error": "No region found"}
region = Region.objects.filter(id=iso_code).first()
visited_region = VisitedRegion.objects.filter(region=region, user_id=user).first()
visited_region = VisitedRegion.objects.filter(region=region, user=user).first()
region_visited = False
city_visited = False
@@ -177,7 +177,7 @@ def extractIsoCode(user, data):
if town_city_or_county:
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
visited_city = VisitedCity.objects.filter(city=city, user_id=user).first()
visited_city = VisitedCity.objects.filter(city=city, user=user).first()
if visited_region:
region_visited = True
@@ -196,7 +196,11 @@ def is_host_resolvable(hostname: str) -> bool:
def reverse_geocode(lat, lon, user):
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
return reverse_geocode_google(lat, lon, user)
google_result = reverse_geocode_google(lat, lon, user)
if "error" not in google_result:
return google_result
# If Google fails, fallback to OSM
return reverse_geocode_osm(lat, lon, user)
return reverse_geocode_osm(lat, lon, user)
def reverse_geocode_osm(lat, lon, user):

View File

@@ -0,0 +1,243 @@
"""
Django management command to recalculate elevation data for all activities with GPX files.
Usage:
python manage.py recalculate_elevation
python manage.py recalculate_elevation --dry-run
python manage.py recalculate_elevation --activity-id 123
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from adventures.models import Activity
import gpxpy
from typing import Tuple
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Recalculate elevation data for activities with GPX files'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be updated without making changes',
)
parser.add_argument(
'--activity-id',
type=int,
help='Recalculate elevation for a specific activity ID only',
)
parser.add_argument(
'--batch-size',
type=int,
default=100,
help='Number of activities to process in each batch (default: 100)',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
activity_id = options.get('activity_id')
batch_size = options['batch_size']
if dry_run:
self.stdout.write(
self.style.WARNING('DRY RUN MODE - No changes will be made')
)
# Build queryset
queryset = Activity.objects.filter(gpx_file__isnull=False).exclude(gpx_file='')
if activity_id:
queryset = queryset.filter(id=activity_id)
if not queryset.exists():
raise CommandError(f'Activity with ID {activity_id} not found or has no GPX file')
total_count = queryset.count()
if total_count == 0:
self.stdout.write(
self.style.WARNING('No activities found with GPX files')
)
return
self.stdout.write(f'Found {total_count} activities with GPX files to process')
updated_count = 0
error_count = 0
# Process in batches to avoid memory issues with large datasets
for i in range(0, total_count, batch_size):
batch = queryset[i:i + batch_size]
for activity in batch:
try:
if self._process_activity(activity, dry_run):
updated_count += 1
# Progress indicator
if (updated_count + error_count) % 50 == 0:
self.stdout.write(
f'Processed {updated_count + error_count}/{total_count} activities...'
)
except Exception as e:
error_count += 1
self.stdout.write(
self.style.ERROR(
f'Error processing activity {activity.id}: {str(e)}'
)
)
# Summary
self.stdout.write('\n' + '='*50)
if dry_run:
self.stdout.write(
self.style.SUCCESS(
f'DRY RUN COMPLETE: Would update {updated_count} activities'
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f'Successfully updated {updated_count} activities'
)
)
if error_count > 0:
self.stdout.write(
self.style.WARNING(f'Encountered errors with {error_count} activities')
)
def _process_activity(self, activity, dry_run=False):
"""Process a single activity and return True if it was updated."""
try:
# Get elevation data from GPX file
elevation_gain, elevation_loss, elevation_high, elevation_low = \
self._get_elevation_data_from_gpx(activity.gpx_file)
# Check if values would actually change
current_values = (
getattr(activity, 'elevation_gain', None) or 0,
getattr(activity, 'elevation_loss', None) or 0,
getattr(activity, 'elev_high', None) or 0,
getattr(activity, 'elev_low', None) or 0,
)
new_values = (elevation_gain, elevation_loss, elevation_high, elevation_low)
# Only update if values are different (with small tolerance for floating point)
if self._values_significantly_different(current_values, new_values):
if dry_run:
self.stdout.write(
f'Activity {activity.id}: '
f'gain: {current_values[0]:.1f}{new_values[0]:.1f}, '
f'loss: {current_values[1]:.1f}{new_values[1]:.1f}, '
f'high: {current_values[2]:.1f}{new_values[2]:.1f}, '
f'low: {current_values[3]:.1f}{new_values[3]:.1f}'
)
return True
else:
# Update the activity
with transaction.atomic():
activity.elevation_gain = elevation_gain
activity.elevation_loss = elevation_loss
activity.elev_high = elevation_high
activity.elev_low = elevation_low
activity.save(update_fields=[
'elevation_gain', 'elevation_loss', 'elev_high', 'elev_low'
])
self.stdout.write(
f'Updated activity {activity.id}: '
f'gain: {elevation_gain:.1f}m, loss: {elevation_loss:.1f}m, '
f'high: {elevation_high:.1f}m, low: {elevation_low:.1f}m'
)
return True
else:
# Values are the same, skip
return False
except Exception as e:
logger.error(f'Error processing activity {activity.id}: {str(e)}')
raise
def _values_significantly_different(self, current, new, tolerance=0.1):
"""Check if elevation values are significantly different."""
for c, n in zip(current, new):
if abs(c - n) > tolerance:
return True
return False
def _get_elevation_data_from_gpx(self, gpx_file) -> Tuple[float, float, float, float]:
"""
Extract elevation data from a GPX file.
Returns: (elevation_gain, elevation_loss, elevation_high, elevation_low)
"""
try:
# Parse the GPX file
gpx_file.seek(0) # Reset file pointer if needed
gpx = gpxpy.parse(gpx_file)
elevations = []
# Extract all elevation points from tracks and track segments
for track in gpx.tracks:
for segment in track.segments:
for point in segment.points:
if point.elevation is not None:
elevations.append(point.elevation)
# Also check waypoints for elevation data
for waypoint in gpx.waypoints:
if waypoint.elevation is not None:
elevations.append(waypoint.elevation)
# If no elevation data found, return zeros
if not elevations:
return 0.0, 0.0, 0.0, 0.0
# Calculate basic stats
elevation_high = max(elevations)
elevation_low = min(elevations)
# Calculate gain and loss by comparing consecutive points
elevation_gain = 0.0
elevation_loss = 0.0
# Apply simple smoothing to reduce GPS noise (optional)
smoothed_elevations = self._smooth_elevations(elevations)
for i in range(1, len(smoothed_elevations)):
diff = smoothed_elevations[i] - smoothed_elevations[i-1]
if diff > 0:
elevation_gain += diff
else:
elevation_loss += abs(diff)
return elevation_gain, elevation_loss, elevation_high, elevation_low
except Exception as e:
logger.error(f"Error parsing GPX file: {e}")
raise
def _smooth_elevations(self, elevations, window_size=3):
"""
Apply simple moving average smoothing to reduce GPS elevation noise.
"""
if len(elevations) < window_size:
return elevations
smoothed = []
half_window = window_size // 2
for i in range(len(elevations)):
start = max(0, i - half_window)
end = min(len(elevations), i + half_window + 1)
smoothed.append(sum(elevations[start:end]) / (end - start))
return smoothed

View File

@@ -0,0 +1,94 @@
import os
from django.core.management.base import BaseCommand
from django.conf import settings
from adventures.models import ContentImage, ContentAttachment
from users.models import CustomUser
class Command(BaseCommand):
help = 'Find and prompt for deletion of unused image files and attachments in filesystem'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show files that would be deleted without actually deleting them',
)
def handle(self, **options):
dry_run = options['dry_run']
# Get all image and attachment file paths from database
used_files = set()
# Get ContentImage file paths
for img in ContentImage.objects.all():
if img.image and img.image.name:
used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name))
# Get Attachment file paths
for attachment in ContentAttachment.objects.all():
if attachment.file and attachment.file.name:
used_files.add(os.path.join(settings.MEDIA_ROOT, attachment.file.name))
# Get user profile picture file paths
for user in CustomUser.objects.all():
if user.profile_pic and user.profile_pic.name:
used_files.add(os.path.join(settings.MEDIA_ROOT, user.profile_pic.name))
# Find all files in media/images and media/attachments directories
media_root = settings.MEDIA_ROOT
all_files = []
# Scan images directory
images_dir = os.path.join(media_root, 'images')
# Scan attachments directory
attachments_dir = os.path.join(media_root, 'attachments')
if os.path.exists(attachments_dir):
for root, _, files in os.walk(attachments_dir):
for file in files:
all_files.append(os.path.join(root, file))
# Scan profile-pics directory
profile_pics_dir = os.path.join(media_root, 'profile-pics')
if os.path.exists(profile_pics_dir):
for root, _, files in os.walk(profile_pics_dir):
for file in files:
all_files.append(os.path.join(root, file))
attachments_dir = os.path.join(media_root, 'attachments')
if os.path.exists(attachments_dir):
for root, _, files in os.walk(attachments_dir):
for file in files:
all_files.append(os.path.join(root, file))
# Find unused files
unused_files = [f for f in all_files if f not in used_files]
if not unused_files:
self.stdout.write(self.style.SUCCESS('No unused files found.'))
return
self.stdout.write(f'Found {len(unused_files)} unused files:')
for file_path in unused_files:
self.stdout.write(f' {file_path}')
if dry_run:
self.stdout.write(self.style.WARNING('Dry run mode - no files were deleted.'))
return
# Prompt for deletion
confirm = input('\nDo you want to delete these files? (yes/no): ')
if confirm.lower() in ['yes', 'y']:
deleted_count = 0
for file_path in unused_files:
try:
os.remove(file_path)
self.stdout.write(f'Deleted: {file_path}')
deleted_count += 1
except OSError as e:
self.stdout.write(self.style.ERROR(f'Error deleting {file_path}: {e}'))
self.stdout.write(self.style.SUCCESS(f'Successfully deleted {deleted_count} files.'))
else:
self.stdout.write('Operation cancelled.')

View File

@@ -2,7 +2,7 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from adventures.models import Adventure
from adventures.models import Location
class Command(BaseCommand):
@@ -38,8 +38,8 @@ class Command(BaseCommand):
]
for name, location, type_ in adventures:
Adventure.objects.create(
user_id=user,
Location.objects.create(
user=user,
name=name,
location=location,
type=type_,

View File

@@ -1,15 +1,15 @@
from django.db import models
from django.db.models import Q
class AdventureManager(models.Manager):
def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False):
class LocationManager(models.Manager):
def retrieve_locations(self, user, include_owned=False, include_shared=False, include_public=False):
query = Q()
if include_owned:
query |= Q(user_id=user)
query |= Q(user=user)
if include_shared:
query |= Q(collections__shared_with=user)
query |= Q(collections__shared_with=user) | Q(collections__user=user)
if include_public:
query |= Q(is_public=True)

View File

@@ -0,0 +1,91 @@
# Generated by Django 5.2.1 on 2025-06-19 20:29
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
replaces = [('adventures', '0036_rename_adventure_location'), ('adventures', '0037_rename_adventure_visit_location'), ('adventures', '0038_rename_adventureimage_locationimage'), ('adventures', '0039_rename_adventure_locationimage_location'), ('adventures', '0040_rename_adventure_attachment_location'), ('adventures', '0041_rename_user_id_location_user'), ('adventures', '0042_rename_user_id_locationimage_user'), ('adventures', '0043_rename_user_id_attachment_user'), ('adventures', '0044_rename_user_id_collection_user'), ('adventures', '0045_rename_user_id_transportation_user'), ('adventures', '0046_rename_user_id_note_user'), ('adventures', '0047_rename_user_id_checklist_user'), ('adventures', '0048_rename_user_id_checklistitem_user'), ('adventures', '0049_rename_user_id_category_user'), ('adventures', '0050_rename_user_id_lodging_user')]
dependencies = [
('adventures', '0035_remove_adventure_collection_adventure_collections'),
('worldtravel', '0016_remove_city_insert_id_remove_country_insert_id_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel(
old_name='Adventure',
new_name='Location',
),
migrations.RenameField(
model_name='visit',
old_name='adventure',
new_name='location',
),
migrations.RenameModel(
old_name='AdventureImage',
new_name='LocationImage',
),
migrations.RenameField(
model_name='locationimage',
old_name='adventure',
new_name='location',
),
migrations.RenameField(
model_name='attachment',
old_name='adventure',
new_name='location',
),
migrations.RenameField(
model_name='location',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='locationimage',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='attachment',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='collection',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='transportation',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='note',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='checklist',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='checklistitem',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='category',
old_name='user_id',
new_name='user',
),
migrations.RenameField(
model_name='lodging',
old_name='user_id',
new_name='user',
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.1 on 2025-06-20 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0036_rename_adventure_location_squashed_0050_rename_user_id_lodging_user'),
]
operations = [
migrations.RenameField(
model_name='location',
old_name='activity_types',
new_name='tags',
),
migrations.AlterField(
model_name='location',
name='collections',
field=models.ManyToManyField(blank=True, related_name='locations', to='adventures.collection'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.1 on 2025-07-10 14:40
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0051_rename_activity_types_location_tags_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel(
old_name='Attachment',
new_name='ContentAttachment',
),
migrations.RenameModel(
old_name='LocationImage',
new_name='ContentImage',
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.1 on 2025-07-10 15:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0052_rename_attachment_contentattachment_and_more'),
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='contentattachment',
options={'verbose_name': 'Content Attachment', 'verbose_name_plural': 'Content Attachments'},
),
migrations.AlterModelOptions(
name='contentimage',
options={'verbose_name': 'Content Image', 'verbose_name_plural': 'Content Images'},
),
migrations.AddField(
model_name='contentattachment',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='contentattachment',
name='object_id',
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name='contentimage',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='contentimage',
name='object_id',
field=models.UUIDField(blank=True, null=True),
),
migrations.AddIndex(
model_name='contentattachment',
index=models.Index(fields=['content_type', 'object_id'], name='adventures__content_e42b72_idx'),
),
migrations.AddIndex(
model_name='contentimage',
index=models.Index(fields=['content_type', 'object_id'], name='adventures__content_aa4984_idx'),
),
]

View File

@@ -0,0 +1,73 @@
# Custom migrations to migrate LocationImage and Attachment models to generic ContentImage and ContentAttachment models
from django.db import migrations, models
from django.utils import timezone
def migrate_images_and_attachments_forward(apps, schema_editor):
"""
Migrate existing LocationImage and Attachment records to the new generic ContentImage and ContentAttachment models
"""
# Get the models
ContentImage = apps.get_model('adventures', 'ContentImage')
ContentAttachment = apps.get_model('adventures', 'ContentAttachment')
# Get the ContentType for Location
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
location_ct = ContentType.objects.get(app_label='adventures', model='location')
except ContentType.DoesNotExist:
return
# Update existing ContentImages (which were previously LocationImages)
ContentImage.objects.filter(content_type__isnull=True).update(
content_type=location_ct
)
# Set object_id from location_id for ContentImages
for content_image in ContentImage.objects.filter(object_id__isnull=True):
if hasattr(content_image, 'location_id') and content_image.location_id:
content_image.object_id = content_image.location_id
content_image.save()
# Update existing ContentAttachments (which were previously Attachments)
ContentAttachment.objects.filter(content_type__isnull=True).update(
content_type=location_ct
)
# Set object_id from location_id for ContentAttachments
for content_attachment in ContentAttachment.objects.filter(object_id__isnull=True):
if hasattr(content_attachment, 'location_id') and content_attachment.location_id:
content_attachment.object_id = content_attachment.location_id
content_attachment.save()
def migrate_images_and_attachments_reverse(apps, schema_editor):
"""
Reverse migration to restore location_id fields from object_id
"""
ContentImage = apps.get_model('adventures', 'ContentImage')
ContentAttachment = apps.get_model('adventures', 'ContentAttachment')
# Restore location_id from object_id for ContentImages
for content_image in ContentImage.objects.all():
if content_image.object_id and hasattr(content_image, 'location_id'):
content_image.location_id = content_image.object_id
content_image.save()
# Restore location_id from object_id for ContentAttachments
for content_attachment in ContentAttachment.objects.all():
if content_attachment.object_id and hasattr(content_attachment, 'location_id'):
content_attachment.location_id = content_attachment.object_id
content_attachment.save()
class Migration(migrations.Migration):
dependencies = [
('adventures', '0053_alter_contentattachment_options_and_more'),
]
operations = [
migrations.RunPython(
migrate_images_and_attachments_forward,
migrate_images_and_attachments_reverse,
elidable=True
)
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.1 on 2025-07-10 15:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0054_migrate_location_images_generic_relation'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.RemoveField(
model_name='contentattachment',
name='location',
),
migrations.RemoveField(
model_name='contentimage',
name='location',
),
migrations.AlterField(
model_name='contentattachment',
name='content_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_attachments', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='contentattachment',
name='object_id',
field=models.UUIDField(),
),
migrations.AlterField(
model_name='contentimage',
name='content_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='contentimage',
name='object_id',
field=models.UUIDField(),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.2 on 2025-07-30 12:54
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0055_alter_contentattachment_content_type_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CollectionInvite',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='adventures.collection')),
('invited_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_invites', to=settings.AUTH_USER_MODEL)),
],
),
]

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',
},
),
]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.2 on 2025-08-04 16:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0058_alter_collectioninvite_options_activity'),
]
operations = [
migrations.AlterModelOptions(
name='activity',
options={'verbose_name': 'Activity', 'verbose_name_plural': 'Activities'},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.2 on 2025-08-07 16:00
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0059_alter_activity_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterUniqueTogether(
name='collectioninvite',
unique_together={('collection', 'invited_user')},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.2 on 2025-08-12 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0060_alter_collectioninvite_unique_together'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='sport_type',
field=models.CharField(choices=[('General', 'General'), ('Run', 'Run'), ('TrailRun', 'Trail Run'), ('Walk', 'Walk'), ('Hike', 'Hike'), ('VirtualRun', 'Virtual Run'), ('Ride', 'Ride'), ('MountainBikeRide', 'Mountain Bike Ride'), ('GravelRide', 'Gravel Ride'), ('EBikeRide', 'E-Bike Ride'), ('EMountainBikeRide', 'E-Mountain Bike Ride'), ('Velomobile', 'Velomobile'), ('VirtualRide', 'Virtual Ride'), ('Canoeing', 'Canoe'), ('Kayaking', 'Kayak'), ('Kitesurfing', 'Kitesurf'), ('Rowing', 'Rowing'), ('StandUpPaddling', 'Stand Up Paddling'), ('Surfing', 'Surf'), ('Swim', 'Swim'), ('Windsurfing', 'Windsurf'), ('Sailing', 'Sail'), ('IceSkate', 'Ice Skate'), ('AlpineSki', 'Alpine Ski'), ('BackcountrySki', 'Backcountry Ski'), ('NordicSki', 'Nordic Ski'), ('Snowboard', 'Snowboard'), ('Snowshoe', 'Snowshoe'), ('Handcycle', 'Handcycle'), ('InlineSkate', 'Inline Skate'), ('RockClimbing', 'Rock Climb'), ('RollerSki', 'Roller Ski'), ('Golf', 'Golf'), ('Skateboard', 'Skateboard'), ('Soccer', 'Football (Soccer)'), ('Wheelchair', 'Wheelchair'), ('Badminton', 'Badminton'), ('Tennis', 'Tennis'), ('Pickleball', 'Pickleball'), ('Crossfit', 'Crossfit'), ('Elliptical', 'Elliptical'), ('StairStepper', 'Stair Stepper'), ('WeightTraining', 'Weight Training'), ('Yoga', 'Yoga'), ('Workout', 'Workout'), ('HIIT', 'HIIT'), ('Pilates', 'Pilates'), ('TableTennis', 'Table Tennis'), ('Squash', 'Squash'), ('Racquetball', 'Racquetball'), ('VirtualRow', 'Virtual Rowing')], default='General', max_length=100),
preserve_default=False,
),
migrations.AlterField(
model_name='activity',
name='type',
field=models.CharField(choices=[('General', 'General'), ('Run', 'Run'), ('TrailRun', 'Trail Run'), ('Walk', 'Walk'), ('Hike', 'Hike'), ('VirtualRun', 'Virtual Run'), ('Ride', 'Ride'), ('MountainBikeRide', 'Mountain Bike Ride'), ('GravelRide', 'Gravel Ride'), ('EBikeRide', 'E-Bike Ride'), ('EMountainBikeRide', 'E-Mountain Bike Ride'), ('Velomobile', 'Velomobile'), ('VirtualRide', 'Virtual Ride'), ('Canoeing', 'Canoe'), ('Kayaking', 'Kayak'), ('Kitesurfing', 'Kitesurf'), ('Rowing', 'Rowing'), ('StandUpPaddling', 'Stand Up Paddling'), ('Surfing', 'Surf'), ('Swim', 'Swim'), ('Windsurfing', 'Windsurf'), ('Sailing', 'Sail'), ('IceSkate', 'Ice Skate'), ('AlpineSki', 'Alpine Ski'), ('BackcountrySki', 'Backcountry Ski'), ('NordicSki', 'Nordic Ski'), ('Snowboard', 'Snowboard'), ('Snowshoe', 'Snowshoe'), ('Handcycle', 'Handcycle'), ('InlineSkate', 'Inline Skate'), ('RockClimbing', 'Rock Climb'), ('RollerSki', 'Roller Ski'), ('Golf', 'Golf'), ('Skateboard', 'Skateboard'), ('Soccer', 'Football (Soccer)'), ('Wheelchair', 'Wheelchair'), ('Badminton', 'Badminton'), ('Tennis', 'Tennis'), ('Pickleball', 'Pickleball'), ('Crossfit', 'Crossfit'), ('Elliptical', 'Elliptical'), ('StairStepper', 'Stair Stepper'), ('WeightTraining', 'Weight Training'), ('Yoga', 'Yoga'), ('Workout', 'Workout'), ('HIIT', 'HIIT'), ('Pilates', 'Pilates'), ('TableTennis', 'Table Tennis'), ('Squash', 'Squash'), ('Racquetball', 'Racquetball'), ('VirtualRow', 'Virtual Rowing')], default='General', max_length=100),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.2 on 2025-08-12 12:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0061_alter_activity_sport_type_alter_activity_type'),
]
operations = [
migrations.RemoveField(
model_name='activity',
name='type',
),
migrations.AlterField(
model_name='activity',
name='sport_type',
field=models.CharField(choices=[('General', 'General'), ('Run', 'Run'), ('TrailRun', 'Trail Run'), ('Walk', 'Walk'), ('Hike', 'Hike'), ('VirtualRun', 'Virtual Run'), ('Ride', 'Ride'), ('MountainBikeRide', 'Mountain Bike Ride'), ('GravelRide', 'Gravel Ride'), ('EBikeRide', 'E-Bike Ride'), ('EMountainBikeRide', 'E-Mountain Bike Ride'), ('Velomobile', 'Velomobile'), ('VirtualRide', 'Virtual Ride'), ('Canoeing', 'Canoe'), ('Kayaking', 'Kayak'), ('Kitesurfing', 'Kitesurf'), ('Rowing', 'Rowing'), ('StandUpPaddling', 'Stand Up Paddling'), ('Surfing', 'Surf'), ('Swim', 'Swim'), ('Windsurfing', 'Windsurf'), ('Sailing', 'Sail'), ('IceSkate', 'Ice Skate'), ('AlpineSki', 'Alpine Ski'), ('BackcountrySki', 'Backcountry Ski'), ('NordicSki', 'Nordic Ski'), ('Snowboard', 'Snowboard'), ('Snowshoe', 'Snowshoe'), ('Handcycle', 'Handcycle'), ('InlineSkate', 'Inline Skate'), ('RockClimbing', 'Rock Climb'), ('RollerSki', 'Roller Ski'), ('Golf', 'Golf'), ('Skateboard', 'Skateboard'), ('Soccer', 'Football (Soccer)'), ('Wheelchair', 'Wheelchair'), ('Badminton', 'Badminton'), ('Tennis', 'Tennis'), ('Pickleball', 'Pickleball'), ('Crossfit', 'Crossfit'), ('Elliptical', 'Elliptical'), ('StairStepper', 'Stair Stepper'), ('WeightTraining', 'Weight Training'), ('Yoga', 'Yoga'), ('Workout', 'Workout'), ('HIIT', 'HIIT'), ('Pilates', 'Pilates'), ('TableTennis', 'Table Tennis'), ('Squash', 'Squash'), ('Racquetball', 'Racquetball'), ('VirtualRow', 'Virtual Rowing')], default='General', max_length=100),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@ class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
# obj.user_id is FK to User, compare with request.user
return obj.user_id == request.user
# obj.user is FK to User, compare with request.user
return obj.user == request.user
class IsPublicReadOnly(permissions.BasePermission):
@@ -17,8 +17,8 @@ class IsPublicReadOnly(permissions.BasePermission):
"""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return obj.is_public or obj.user_id == request.user
return obj.user_id == request.user
return obj.is_public or obj.user == request.user
return obj.user == request.user
class CollectionShared(permissions.BasePermission):
@@ -33,13 +33,20 @@ class CollectionShared(permissions.BasePermission):
# Anonymous: only read public
return request.method in permissions.SAFE_METHODS and obj.is_public
# Special case for accept_invite and decline_invite actions
# Allow access if user has a pending invite for this collection
if hasattr(view, 'action') and view.action in ['accept_invite', 'decline_invite']:
if hasattr(obj, 'invites'):
if obj.invites.filter(invited_user=user).exists():
return True
# Check if user is in shared_with of any collections related to the obj
# If obj is a Collection itself:
if hasattr(obj, 'shared_with'):
if obj.shared_with.filter(id=user.id).exists():
return True
# If obj is an Adventure (has collections M2M)
# If obj is a Location (has collections M2M)
if hasattr(obj, 'collections'):
# Check if user is in shared_with of any related collection
shared_collections = obj.collections.filter(shared_with=user)
@@ -48,10 +55,10 @@ class CollectionShared(permissions.BasePermission):
# Read permission if public or owner
if request.method in permissions.SAFE_METHODS:
return obj.is_public or obj.user_id == user
return obj.is_public or obj.user == user
# Write permission only if owner or shared user via collections
if obj.user_id == user:
if obj.user == user:
return True
if hasattr(obj, 'collections'):
@@ -64,37 +71,177 @@ class CollectionShared(permissions.BasePermission):
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
"""
Full access for owners and users shared via collections,
read-only for others if public.
Permission class that provides access control based on ownership and sharing.
Access Rules:
- Object owners have full access (read/write)
- Users shared via collections have full access (read/write)
- Collection owners have full access to objects in their collections
- Users with direct sharing have full access
- Anonymous users get read-only access to public objects
- Authenticated users get read-only access to public objects
Supports multiple sharing patterns:
- obj.collections (many-to-many collections)
- obj.collection (single collection foreign key)
- obj.shared_with (direct sharing many-to-many)
- obj.is_public (public access flag)
"""
def has_object_permission(self, request, view, obj):
"""
Check if the user has permission to access the object.
Args:
request: The HTTP request
view: The view being accessed
obj: The object being accessed
Returns:
bool: True if access is granted, False otherwise
"""
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
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
if type(obj).__name__ == 'Visit':
print("Checking permissions for Visit object", obj)
# If the object is a Visit, get its location
if hasattr(obj, 'location'):
obj = obj.location
# Anonymous users only get read access to public objects
if not user or not user.is_authenticated:
return request.method in permissions.SAFE_METHODS and obj.is_public
# If safe method (read), allow if:
if request.method in permissions.SAFE_METHODS:
if obj.is_public:
return True
if obj.user_id == user:
return True
# If user in shared_with of any collection related to obj
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
return True
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
return True
return False
# For write methods, allow if owner or shared user
if obj.user_id == user:
return is_safe_method and getattr(obj, 'is_public', False)
# Owner always has full access
if self._is_owner(obj, user):
return True
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
# Check collection-based access (both ownership and sharing)
if self._has_collection_access(obj, user):
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
# Check direct sharing
if self._has_direct_sharing_access(obj, user):
return True
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
# For safe methods, check if object is public
if is_safe_method and getattr(obj, 'is_public', False):
return True
return False
def _is_owner(self, obj, user):
"""
Check if the user is the owner of the object.
Args:
obj: The object to check
user: The user to check ownership for
Returns:
bool: True if user owns the object
"""
return hasattr(obj, 'user') and obj.user == user
def _has_collection_access(self, obj, user):
"""
Check if user has access via collections (either as owner or shared user).
Handles both many-to-many collections and single collection foreign keys.
Args:
obj: The object to check
user: The user to check access for
Returns:
bool: True if user has collection-based access
"""
# Check many-to-many collections (obj.collections)
if hasattr(obj, 'collections'):
collections = obj.collections.all()
if collections.exists():
# User is shared with any collection containing this object
if collections.filter(shared_with=user).exists():
return True
# User owns any collection containing this object
if collections.filter(user=user).exists():
return True
# Check single collection foreign key (obj.collection)
if hasattr(obj, 'collection') and obj.collection:
collection = obj.collection
# User is shared with the collection
if hasattr(collection, 'shared_with') and collection.shared_with.filter(id=user.id).exists():
return True
# User owns the collection
if hasattr(collection, 'user') and collection.user == user:
return True
return False
def _has_direct_sharing_access(self, obj, user):
"""
Check if user has direct sharing access to the object.
Args:
obj: The object to check
user: The user to check access for
Returns:
bool: True if user has direct sharing access
"""
return (hasattr(obj, 'shared_with') and
obj.shared_with.filter(id=user.id).exists())
def has_permission(self, request, view):
"""
Check if the user has permission to access the view.
This is called before has_object_permission and provides a way to
deny access at the view level (e.g., for unauthenticated users).
Args:
request: The HTTP request
view: The view being accessed
Returns:
bool: True if access is granted at the view level
"""
# Allow authenticated users and anonymous users for safe methods
# Individual object permissions are handled in has_object_permission
return (request.user and request.user.is_authenticated) or \
request.method in permissions.SAFE_METHODS
class ContentImagePermission(IsOwnerOrSharedWithFullAccess):
"""
Specialized permission for ContentImage objects that checks permissions
on the related content object.
"""
def has_object_permission(self, request, view, obj):
"""
For ContentImage objects, check permissions on the related content object.
"""
if not request.user or not request.user.is_authenticated:
return False
# Get the related content object
content_object = obj.content_object
if not content_object:
return False
# Use the parent permission class to check access to the content object
return super().has_object_permission(request, view, content_object)

View File

@@ -1,25 +1,30 @@
from django.utils import timezone
import os
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Lodging
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
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer
from geopy.distance import geodesic
from integrations.models import ImmichIntegration
from adventures.utils.geojson import gpx_to_geojson
import gpxpy
import geojson
import logging
logger = logging.getLogger(__name__)
class AdventureImageSerializer(CustomModelSerializer):
class ContentImageSerializer(CustomModelSerializer):
class Meta:
model = AdventureImage
fields = ['id', 'image', 'adventure', 'is_primary', 'user_id', 'immich_id']
read_only_fields = ['id', 'user_id']
model = ContentImage
fields = ['id', 'image', 'is_primary', 'user', 'immich_id']
read_only_fields = ['id', 'user']
def to_representation(self, instance):
# If immich_id is set, check for user integration once
integration = None
if instance.immich_id:
integration = ImmichIntegration.objects.filter(user=instance.user_id).first()
integration = ImmichIntegration.objects.filter(user=instance.user).first()
if not integration:
return None # Skip if Immich image but no integration
@@ -40,10 +45,11 @@ class AdventureImageSerializer(CustomModelSerializer):
class AttachmentSerializer(CustomModelSerializer):
extension = serializers.SerializerMethodField()
geojson = serializers.SerializerMethodField()
class Meta:
model = Attachment
fields = ['id', 'file', 'adventure', 'extension', 'name', 'user_id']
read_only_fields = ['id', 'user_id']
model = ContentAttachment
fields = ['id', 'file', 'extension', 'name', 'user', 'geojson']
read_only_fields = ['id', 'user']
def get_extension(self, obj):
return obj.file.name.split('.')[-1]
@@ -57,13 +63,18 @@ class AttachmentSerializer(CustomModelSerializer):
public_url = public_url.replace("'", "")
representation['file'] = f"{public_url}/media/{instance.file.name}"
return representation
def get_geojson(self, obj):
if obj.file and obj.file.name.endswith('.gpx'):
return gpx_to_geojson(obj.file)
return None
class CategorySerializer(serializers.ModelSerializer):
num_adventures = serializers.SerializerMethodField()
num_locations = serializers.SerializerMethodField()
class Meta:
model = Category
fields = ['id', 'name', 'display_name', 'icon', 'num_adventures']
read_only_fields = ['id', 'num_adventures']
fields = ['id', 'name', 'display_name', 'icon', 'num_locations']
read_only_fields = ['id', 'num_locations']
def validate_name(self, value):
return value.lower()
@@ -71,7 +82,7 @@ class CategorySerializer(serializers.ModelSerializer):
def create(self, validated_data):
user = self.context['request'].user
validated_data['name'] = validated_data['name'].lower()
return Category.objects.create(user_id=user, **validated_data)
return Category.objects.create(user=user, **validated_data)
def update(self, instance, validated_data):
for attr, value in validated_data.items():
@@ -81,23 +92,128 @@ class CategorySerializer(serializers.ModelSerializer):
instance.save()
return instance
def get_num_adventures(self, obj):
return Adventure.objects.filter(category=obj, user_id=obj.user_id).count()
def get_num_locations(self, obj):
return Location.objects.filter(category=obj, user=obj.user).count()
class TrailSerializer(CustomModelSerializer):
provider = serializers.SerializerMethodField()
wanderer_data = serializers.SerializerMethodField()
wanderer_link = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._wanderer_integration_cache = {}
class Meta:
model = Trail
fields = ['id', 'user', 'name', 'location', 'created_at','link','wanderer_id', 'provider', 'wanderer_data', 'wanderer_link']
read_only_fields = ['id', 'created_at', 'user', 'provider']
def _get_wanderer_integration(self, user):
"""Cache wanderer integration to avoid multiple database queries"""
if user.id not in self._wanderer_integration_cache:
from integrations.models import WandererIntegration
self._wanderer_integration_cache[user.id] = WandererIntegration.objects.filter(user=user).first()
return self._wanderer_integration_cache[user.id]
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'
def get_wanderer_data(self, obj):
if not obj.wanderer_id:
return None
# Use cached integration
integration = self._get_wanderer_integration(obj.user)
if not integration:
return None
# Fetch the Wanderer trail data
from integrations.wanderer_services import fetch_trail_by_id
try:
trail_data = fetch_trail_by_id(integration, obj.wanderer_id)
if not trail_data:
return None
# Cache the trail data and link on the object to avoid refetching
obj._wanderer_data = trail_data
base_url = integration.server_url.rstrip('/')
obj._wanderer_link = f"{base_url}/trails/{obj.wanderer_id}"
return trail_data
except Exception as e:
logger.error(f"Error fetching Wanderer trail data for {obj.wanderer_id}: {e}")
return None
def get_wanderer_link(self, obj):
if not obj.wanderer_id:
return None
# Use cached integration
integration = self._get_wanderer_integration(obj.user)
if not integration:
return None
base_url = integration.server_url.rstrip('/')
return f"{base_url}/trail/view/@{integration.username}/{obj.wanderer_id}"
class ActivitySerializer(CustomModelSerializer):
geojson = serializers.SerializerMethodField()
class Meta:
model = Activity
fields = [
'id', 'user', 'visit', 'trail', 'gpx_file', 'name', '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', 'geojson'
]
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
def get_geojson(self, obj):
return gpx_to_geojson(obj.gpx_file)
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']
read_only_fields = ['id']
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities','location', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
def create(self, validated_data):
if not validated_data.get('end_date') and validated_data.get('start_date'):
validated_data['end_date'] = validated_data['start_date']
return super().create(validated_data)
class AdventureSerializer(CustomModelSerializer):
class LocationSerializer(CustomModelSerializer):
images = serializers.SerializerMethodField()
visits = VisitSerializer(many=True, read_only=False, required=False)
attachments = AttachmentSerializer(many=True, read_only=True)
category = CategorySerializer(read_only=False, required=False)
is_visited = serializers.SerializerMethodField()
user = serializers.SerializerMethodField()
country = CountrySerializer(read_only=True)
region = RegionSerializer(read_only=True)
city = CitySerializer(read_only=True)
@@ -106,32 +222,89 @@ class AdventureSerializer(CustomModelSerializer):
queryset=Collection.objects.all(),
required=False
)
trails = TrailSerializer(many=True, read_only=True, required=False)
class Meta:
model = Adventure
model = Location
fields = [
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location',
'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_id', 'is_visited', 'user']
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'is_visited']
# Makes it so the whole user object is returned in the serializer instead of just the user uuid
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user'] = CustomUserDetailsSerializer(instance.user, context=self.context).data
return representation
def get_images(self, obj):
serializer = AdventureImageSerializer(obj.images.all(), many=True, context=self.context)
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
def validate_collections(self, collections):
"""Validate that collections belong to the same user"""
"""Validate that collections are compatible with the location being created/updated"""
if not collections:
return collections
user = self.context['request'].user
for collection in collections:
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
# Get the location being updated (if this is an update operation)
location_owner = getattr(self.instance, 'user', None) if self.instance else user
# For updates, we need to check if collections are being added or removed
current_collections = set(self.instance.collections.all()) if self.instance else set()
new_collections_set = set(collections)
collections_to_add = new_collections_set - current_collections
collections_to_remove = current_collections - new_collections_set
# Validate collections being added
for collection in collections_to_add:
# Check if user has permission to use this collection
user_has_shared_access = collection.shared_with.filter(id=user.id).exists()
if collection.user != user and not user_has_shared_access:
raise serializers.ValidationError(
f"Collection '{collection.name}' does not belong to the current user."
f"The requested collection does not belong to the current user."
)
# Check location owner compatibility - both directions
if collection.user != location_owner:
# If user owns the collection but not the location, location owner must have shared access
if collection.user == user:
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
if not location_owner_has_shared_access:
raise serializers.ValidationError(
f"Locations must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user.username} Location owner: {location_owner.username if location_owner else 'None'}"
)
# If using someone else's collection, location owner must have shared access
else:
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
if not location_owner_has_shared_access:
raise serializers.ValidationError(
"Location cannot be added to collection unless the location owner has shared access to the collection."
)
# Validate collections being removed - allow if user owns the collection OR owns the location
for collection in collections_to_remove:
user_owns_collection = collection.user == user
user_owns_location = location_owner == user if location_owner else False
user_has_shared_access = collection.shared_with.filter(id=user.id).exists()
if not (user_owns_collection or user_owns_location or user_has_shared_access):
raise serializers.ValidationError(
"You don't have permission to remove this location from one of the collections it's linked to."
)
return collections
def validate_category(self, category_data):
@@ -140,7 +313,7 @@ class AdventureSerializer(CustomModelSerializer):
if category_data:
user = self.context['request'].user
name = category_data.get('name', '').lower()
existing_category = Category.objects.filter(user_id=user, name=name).first()
existing_category = Category.objects.filter(user=user, name=name).first()
if existing_category:
return existing_category
category_data['name'] = name
@@ -162,7 +335,7 @@ class AdventureSerializer(CustomModelSerializer):
icon = category_data.icon
category, created = Category.objects.get_or_create(
user_id=user,
user=user,
name=name,
defaults={
'display_name': display_name,
@@ -171,41 +344,31 @@ class AdventureSerializer(CustomModelSerializer):
)
return category
def get_user(self, obj):
user = obj.user_id
return CustomUserDetailsSerializer(user).data
def get_is_visited(self, obj):
return obj.is_visited_status()
def create(self, validated_data):
visits_data = validated_data.pop('visits', None)
category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', [])
print(category_data)
adventure = Adventure.objects.create(**validated_data)
# Handle visits
for visit_data in visits_data:
Visit.objects.create(adventure=adventure, **visit_data)
location = Location.objects.create(**validated_data)
# Handle category
if category_data:
category = self.get_or_create_category(category_data)
adventure.category = category
location.category = category
# Handle collections - set after adventure is saved
# Handle collections - set after location is saved
if collections_data:
adventure.collections.set(collections_data)
location.collections.set(collections_data)
adventure.save()
location.save()
return adventure
return location
def update(self, instance, validated_data):
has_visits = 'visits' in validated_data
visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', None)
@@ -214,9 +377,9 @@ class AdventureSerializer(CustomModelSerializer):
for attr, value in validated_data.items():
setattr(instance, attr, value)
# Handle category - ONLY allow the adventure owner to change categories
# Handle category - ONLY allow the location owner to change categories
user = self.context['request'].user
if category_data and instance.user_id == user:
if category_data and instance.user == user:
# Only the owner can set categories
category = self.get_or_create_category(category_data)
instance.category = category
@@ -226,45 +389,36 @@ class AdventureSerializer(CustomModelSerializer):
if collections_data is not None:
instance.collections.set(collections_data)
# Handle visits
if has_visits:
current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True))
updated_visit_ids = set()
for visit_data in visits_data:
visit_id = visit_data.get('id')
if visit_id and visit_id in current_visit_ids:
visit = current_visits.get(id=visit_id)
for attr, value in visit_data.items():
setattr(visit, attr, value)
visit.save()
updated_visit_ids.add(visit_id)
else:
new_visit = Visit.objects.create(adventure=instance, **visit_data)
updated_visit_ids.add(new_visit.id)
visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete()
# call save on the adventure to update the updated_at field and trigger any geocoding
# call save on the location to update the updated_at field and trigger any geocoding
instance.save()
return instance
class TransportationSerializer(CustomModelSerializer):
distance = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
attachments = serializers.SerializerMethodField()
class Meta:
model = Transportation
fields = [
'id', 'user_id', 'type', 'name', 'description', 'rating',
'id', 'user', 'type', 'name', 'description', 'rating',
'link', 'date', 'flight_number', 'from_location', 'to_location',
'is_public', 'collection', 'created_at', 'updated_at', 'end_date',
'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude',
'start_timezone', 'end_timezone', 'distance' # ✅ Add distance here
'start_timezone', 'end_timezone', 'distance', 'images', 'attachments'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'distance']
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance']
def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
def get_attachments(self, obj):
serializer = AttachmentSerializer(obj.attachments.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [attachment for attachment in serializer.data if attachment is not None]
def get_distance(self, obj):
if (
@@ -280,33 +434,45 @@ class TransportationSerializer(CustomModelSerializer):
return None
class LodgingSerializer(CustomModelSerializer):
images = serializers.SerializerMethodField()
attachments = serializers.SerializerMethodField()
class Meta:
model = Lodging
fields = [
'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
'collection', 'created_at', 'updated_at', 'type', 'timezone'
'id', 'user', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
'collection', 'created_at', 'updated_at', 'type', 'timezone', 'images', 'attachments'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
read_only_fields = ['id', 'created_at', 'updated_at', 'user']
def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
def get_attachments(self, obj):
serializer = AttachmentSerializer(obj.attachments.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [attachment for attachment in serializer.data if attachment is not None]
class NoteSerializer(CustomModelSerializer):
class Meta:
model = Note
fields = [
'id', 'user_id', 'name', 'content', 'date', 'links',
'id', 'user', 'name', 'content', 'date', 'links',
'is_public', 'collection', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
read_only_fields = ['id', 'created_at', 'updated_at', 'user']
class ChecklistItemSerializer(CustomModelSerializer):
class Meta:
model = ChecklistItem
fields = [
'id', 'user_id', 'name', 'is_checked', 'checklist', 'created_at', 'updated_at'
'id', 'user', 'name', 'is_checked', 'checklist', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'checklist']
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'checklist']
class ChecklistSerializer(CustomModelSerializer):
items = ChecklistItemSerializer(many=True, source='checklistitem_set')
@@ -314,21 +480,21 @@ class ChecklistSerializer(CustomModelSerializer):
class Meta:
model = Checklist
fields = [
'id', 'user_id', 'name', 'date', 'is_public', 'collection', 'created_at', 'updated_at', 'items'
'id', 'user', 'name', 'date', 'is_public', 'collection', 'created_at', 'updated_at', 'items'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
read_only_fields = ['id', 'created_at', 'updated_at', 'user']
def create(self, validated_data):
items_data = validated_data.pop('checklistitem_set')
checklist = Checklist.objects.create(**validated_data)
for item_data in items_data:
# Remove user_id from item_data to avoid constraint issues
item_data.pop('user_id', None)
# Set user_id from the parent checklist
# Remove user from item_data to avoid constraint issues
item_data.pop('user', None)
# Set user from the parent checklist
ChecklistItem.objects.create(
checklist=checklist,
user_id=checklist.user_id,
user=checklist.user,
**item_data
)
return checklist
@@ -348,8 +514,8 @@ class ChecklistSerializer(CustomModelSerializer):
# Update or create items
updated_item_ids = set()
for item_data in items_data:
# Remove user_id from item_data to avoid constraint issues
item_data.pop('user_id', None)
# Remove user from item_data to avoid constraint issues
item_data.pop('user', None)
item_id = item_data.get('id')
if item_id:
@@ -363,14 +529,14 @@ class ChecklistSerializer(CustomModelSerializer):
# If ID is provided but doesn't exist, create new item
ChecklistItem.objects.create(
checklist=instance,
user_id=instance.user_id,
user=instance.user,
**item_data
)
else:
# If no ID is provided, create new item
ChecklistItem.objects.create(
checklist=instance,
user_id=instance.user_id,
user=instance.user,
**item_data
)
@@ -391,7 +557,7 @@ class ChecklistSerializer(CustomModelSerializer):
return data
class CollectionSerializer(CustomModelSerializer):
adventures = AdventureSerializer(many=True, read_only=True)
locations = LocationSerializer(many=True, read_only=True)
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
notes = NoteSerializer(many=True, read_only=True, source='note_set')
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')
@@ -399,8 +565,8 @@ class CollectionSerializer(CustomModelSerializer):
class Meta:
model = Collection
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
fields = ['id', 'description', 'user', 'name', 'is_public', 'locations', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with']
def to_representation(self, instance):
representation = super().to_representation(instance)
@@ -409,4 +575,15 @@ class CollectionSerializer(CustomModelSerializer):
for user in instance.shared_with.all():
shared_uuids.append(str(user.uuid))
representation['shared_with'] = shared_uuids
return representation
return representation
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', 'collection_owner_username', 'collection_user_first_name', 'collection_user_last_name']
read_only_fields = ['id', 'created_at']

View File

@@ -1,12 +1,15 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from adventures.models import Adventure
from adventures.models import Location
@receiver(m2m_changed, sender=Adventure.collections.through)
@receiver(m2m_changed, sender=Location.collections.through)
def update_adventure_publicity(sender, instance, action, **kwargs):
"""
Signal handler to update adventure publicity when collections are added/removed
This function checks if the adventure's collections contain any public collection.
"""
if not isinstance(instance, Location):
return
# Only process when collections are added or removed
if action in ('post_add', 'post_remove', 'post_clear'):
collections = instance.collections.all()

View File

@@ -3,22 +3,26 @@ from rest_framework.routers import DefaultRouter
from adventures.views import *
router = DefaultRouter()
router.register(r'adventures', AdventureViewSet, basename='adventures')
router.register(r'locations', LocationViewSet, basename='locations')
router.register(r'collections', CollectionViewSet, basename='collections')
router.register(r'stats', StatsViewSet, basename='stats')
router.register(r'generate', GenerateDescription, basename='generate')
router.register(r'activity-types', ActivityTypesView, basename='activity-types')
router.register(r'tags', ActivityTypesView, basename='tags')
router.register(r'transportations', TransportationViewSet, basename='transportations')
router.register(r'notes', NoteViewSet, basename='notes')
router.register(r'checklists', ChecklistViewSet, basename='checklists')
router.register(r'images', AdventureImageViewSet, basename='images')
router.register(r'images', ContentImageViewSet, basename='images')
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
router.register(r'categories', CategoryViewSet, basename='categories')
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
router.register(r'search', GlobalSearchView, basename='search')
router.register(r'attachments', AttachmentViewSet, basename='attachments')
router.register(r'lodging', LodgingViewSet, basename='lodging')
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations')
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')
router.register(r'visits', VisitViewSet, basename='visits')
urlpatterns = [
# Include the router under the 'api/' prefix

View File

@@ -1,4 +1,6 @@
from adventures.models import AdventureImage, Attachment
from adventures.models import ContentImage, ContentAttachment
from adventures.models import Visit
protected_paths = ['images/', 'attachments/']
@@ -9,40 +11,76 @@ def checkFilePermission(fileId, user, mediaType):
try:
# Construct the full relative path to match the database field
image_path = f"images/{fileId}"
# Fetch the AdventureImage object
adventure = AdventureImage.objects.get(image=image_path).adventure
if adventure.is_public:
# Fetch the ContentImage object
content_image = ContentImage.objects.get(image=image_path)
# Get the content object (could be Location, Transportation, Note, etc.)
content_object = content_image.content_object
# handle differently when content_object is a Visit, get the location instead
if isinstance(content_object, Visit):
# check visit.location
if content_object.location:
# continue with the location check
content_object = content_object.location
# Check if content object is public
if hasattr(content_object, 'is_public') and content_object.is_public:
return True
elif adventure.user_id == user:
# Check if user owns the content object
if hasattr(content_object, 'user') and content_object.user == user:
return True
elif adventure.collections.exists():
# Check if the user is in any collection's shared_with list
for collection in adventure.collections.all():
if collection.shared_with.filter(id=user.id).exists():
# Check collection-based permissions
if hasattr(content_object, 'collections') and content_object.collections.exists():
# For objects with multiple collections (like Location)
for collection in content_object.collections.all():
if collection.user == user or collection.shared_with.filter(id=user.id).exists():
return True
return False
elif hasattr(content_object, 'collection') and content_object.collection:
# For objects with single collection (like Transportation, Note, etc.)
if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists():
return True
return False
else:
return False
except AdventureImage.DoesNotExist:
except ContentImage.DoesNotExist:
return False
elif mediaType == 'attachments/':
try:
# Construct the full relative path to match the database field
attachment_path = f"attachments/{fileId}"
# Fetch the Attachment object
attachment = Attachment.objects.get(file=attachment_path)
adventure = attachment.adventure
if adventure.is_public:
# Fetch the ContentAttachment object
content_attachment = ContentAttachment.objects.get(file=attachment_path)
# Get the content object (could be Location, Transportation, Note, etc.)
content_object = content_attachment.content_object
# Check if content object is public
if hasattr(content_object, 'is_public') and content_object.is_public:
return True
elif adventure.user_id == user:
# Check if user owns the content object
if hasattr(content_object, 'user') and content_object.user == user:
return True
elif adventure.collections.exists():
# Check if the user is in any collection's shared_with list
for collection in adventure.collections.all():
if collection.shared_with.filter(id=user.id).exists():
# Check collection-based permissions
if hasattr(content_object, 'collections') and content_object.collections.exists():
# For objects with multiple collections (like Location)
for collection in content_object.collections.all():
if collection.user == user or collection.shared_with.filter(id=user.id).exists():
return True
return False
elif hasattr(content_object, 'collection') and content_object.collection:
# For objects with single collection (like Transportation, Note, etc.)
if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists():
return True
return False
else:
return False
except Attachment.DoesNotExist:
except ContentAttachment.DoesNotExist:
return False

View File

@@ -0,0 +1,39 @@
import gpxpy
import geojson
def gpx_to_geojson(gpx_file):
"""
Convert a GPX file to GeoJSON format.
Args:
gpx_file: Django FileField or file-like object containing GPX data
Returns:
dict: GeoJSON FeatureCollection or error dict
"""
if not gpx_file:
return None
try:
with gpx_file.open('r') as f:
gpx = gpxpy.parse(f)
features = []
for track in gpx.tracks:
track_name = track.name or "GPX Track"
for segment in track.segments:
coords = [(point.longitude, point.latitude) for point in segment.points]
if coords:
feature = geojson.Feature(
geometry=geojson.LineString(coords),
properties={"name": track_name}
)
features.append(feature)
return geojson.FeatureCollection(features)
except Exception as e:
return {
"error": str(e),
"message": "Failed to convert GPX to GeoJSON"
}

View File

@@ -0,0 +1,25 @@
from django.utils import timezone
def is_location_visited(location):
"""
Check if a location has been visited based on its visits.
Args:
location: Location instance with visits relationship
Returns:
bool: True if location has been visited, False otherwise
"""
current_date = timezone.now().date()
for visit in location.visits.all():
start_date = visit.start_date.date() if isinstance(visit.start_date, timezone.datetime) else visit.start_date
end_date = visit.end_date.date() if isinstance(visit.end_date, timezone.datetime) else visit.end_date
if start_date and end_date and (start_date <= current_date):
return True
elif start_date and not end_date and (start_date <= current_date):
return True
return False

View File

@@ -0,0 +1,77 @@
SPORT_TYPE_CHOICES = [
# General Sports
('General', 'General'),
# Foot Sports
('Run', 'Run'),
('TrailRun', 'Trail Run'),
('Walk', 'Walk'),
('Hike', 'Hike'),
('VirtualRun', 'Virtual Run'),
# Cycle Sports
('Ride', 'Ride'),
('MountainBikeRide', 'Mountain Bike Ride'),
('GravelRide', 'Gravel Ride'),
('EBikeRide', 'E-Bike Ride'),
('EMountainBikeRide', 'E-Mountain Bike Ride'),
('Velomobile', 'Velomobile'),
('VirtualRide', 'Virtual Ride'),
# Water Sports
('Canoeing', 'Canoe'),
('Kayaking', 'Kayak'),
('Kitesurfing', 'Kitesurf'),
('Rowing', 'Rowing'),
('StandUpPaddling', 'Stand Up Paddling'),
('Surfing', 'Surf'),
('Swim', 'Swim'),
('Windsurfing', 'Windsurf'),
('Sailing', 'Sail'),
# Winter Sports
('IceSkate', 'Ice Skate'),
('AlpineSki', 'Alpine Ski'),
('BackcountrySki', 'Backcountry Ski'),
('NordicSki', 'Nordic Ski'),
('Snowboard', 'Snowboard'),
('Snowshoe', 'Snowshoe'),
# Other Sports
('Handcycle', 'Handcycle'),
('InlineSkate', 'Inline Skate'),
('RockClimbing', 'Rock Climb'),
('RollerSki', 'Roller Ski'),
('Golf', 'Golf'),
('Skateboard', 'Skateboard'),
('Soccer', 'Football (Soccer)'),
('Wheelchair', 'Wheelchair'),
('Badminton', 'Badminton'),
('Tennis', 'Tennis'),
('Pickleball', 'Pickleball'),
('Crossfit', 'Crossfit'),
('Elliptical', 'Elliptical'),
('StairStepper', 'Stair Stepper'),
('WeightTraining', 'Weight Training'),
('Yoga', 'Yoga'),
('Workout', 'Workout'),
('HIIT', 'HIIT'),
('Pilates', 'Pilates'),
('TableTennis', 'Table Tennis'),
('Squash', 'Squash'),
('Racquetball', 'Racquetball'),
('VirtualRow', 'Virtual Rowing'),
]
SPORT_CATEGORIES = {
'running': ['Run', 'TrailRun', 'VirtualRun'],
'walking_hiking': ['Walk', 'Hike'],
'cycling': ['Ride', 'MountainBikeRide', 'GravelRide', 'EBikeRide', 'EMountainBikeRide', 'Velomobile', 'VirtualRide'],
'water_sports': ['Canoeing', 'Kayaking', 'Kitesurfing', 'Rowing', 'StandUpPaddling', 'Surfing', 'Swim', 'Windsurfing', 'Sailing', 'VirtualRow'],
'winter_sports': ['IceSkate', 'AlpineSki', 'BackcountrySki', 'NordicSki', 'Snowboard', 'Snowshoe'],
'fitness_gym': ['Crossfit', 'Elliptical', 'StairStepper', 'WeightTraining', 'Yoga', 'Workout', 'HIIT', 'Pilates'],
'racket_sports': ['Badminton', 'Tennis', 'Pickleball', 'TableTennis', 'Squash', 'Racquetball'],
'climbing_adventure': ['RockClimbing'],
'team_sports': ['Soccer'],
'other_sports': ['Handcycle', 'InlineSkate', 'RollerSki', 'Golf', 'Skateboard', 'Wheelchair', 'General']
}

View File

@@ -0,0 +1,419 @@
TIMEZONES = [
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/La_Rioja",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Ciudad_Juarez",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Monticello",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Ojinaga",
"America/Panama",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zurich",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis"
]

View File

@@ -1,6 +1,6 @@
from .activity_types_view import *
from .adventure_image_view import *
from .adventure_view import *
from .tags_view import *
from .location_image_view import *
from .location_view import *
from .category_view import *
from .checklist_view import *
from .collection_view import *
@@ -13,4 +13,8 @@ from .transportation_view import *
from .global_search_view import *
from .attachment_view import *
from .lodging_view import *
from .recommendations_view import *
from .recommendations_view import *
from .import_export_view import *
from .trail_view import *
from .activity_view import *
from .visit_view import *

View File

@@ -0,0 +1,150 @@
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
from rest_framework.exceptions import PermissionDenied
import gpxpy
from typing import Tuple
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.
"""
visit = serializer.validated_data.get('visit')
location = visit.location
if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location):
raise PermissionDenied("You do not have permission to add an activity to this location.")
# if there is a GPX file, use it to get elevation data
gpx_file = serializer.validated_data.get('gpx_file')
if gpx_file:
elevation_gain, elevation_loss, elevation_high, elevation_low = self._get_elevation_data_from_gpx(gpx_file)
serializer.validated_data['elevation_gain'] = elevation_gain
serializer.validated_data['elevation_loss'] = elevation_loss
serializer.validated_data['elev_high'] = elevation_high
serializer.validated_data['elev_low'] = elevation_low
serializer.save(user=location.user)
def perform_update(self, serializer):
instance = serializer.instance
new_visit = serializer.validated_data.get('visit')
# Prevent changing visit/location after creation
if new_visit and new_visit != instance.visit:
raise PermissionDenied("Cannot change activity visit after creation. Create a new activity instead.")
# Check permission for updates to the existing location
location = instance.visit.location if instance.visit else None
if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location):
raise PermissionDenied("You do not have permission to update this activity.")
serializer.save()
def perform_destroy(self, instance):
location = instance.visit.location if instance.visit else None
if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location):
raise PermissionDenied("You do not have permission to delete this activity.")
instance.delete()
def _get_elevation_data_from_gpx(self, gpx_file) -> Tuple[float, float, float, float]:
"""
Extract elevation data from a GPX file.
Returns: (elevation_gain, elevation_loss, elevation_high, elevation_low)
"""
try:
# Parse the GPX file
gpx_file.seek(0) # Reset file pointer if needed
gpx = gpxpy.parse(gpx_file)
elevations = []
# Extract all elevation points from tracks and track segments
for track in gpx.tracks:
for segment in track.segments:
for point in segment.points:
if point.elevation is not None:
elevations.append(point.elevation)
# Also check waypoints for elevation data
for waypoint in gpx.waypoints:
if waypoint.elevation is not None:
elevations.append(waypoint.elevation)
# If no elevation data found, return zeros
if not elevations:
return 0.0, 0.0, 0.0, 0.0
# Calculate basic stats
elevation_high = max(elevations)
elevation_low = min(elevations)
# Calculate gain and loss by comparing consecutive points
elevation_gain = 0.0
elevation_loss = 0.0
# Apply simple smoothing to reduce GPS noise (optional)
smoothed_elevations = self._smooth_elevations(elevations)
for i in range(1, len(smoothed_elevations)):
diff = smoothed_elevations[i] - smoothed_elevations[i-1]
if diff > 0:
elevation_gain += diff
else:
elevation_loss += abs(diff)
return elevation_gain, elevation_loss, elevation_high, elevation_low
except Exception as e:
# Log the error and return zeros
print(f"Error parsing GPX file: {e}")
return 0.0, 0.0, 0.0, 0.0
def _smooth_elevations(self, elevations, window_size=3):
"""
Apply simple moving average smoothing to reduce GPS elevation noise.
"""
if len(elevations) < window_size:
return elevations
smoothed = []
half_window = window_size // 2
for i in range(len(elevations)):
start = max(0, i - half_window)
end = min(len(elevations), i + half_window + 1)
smoothed.append(sum(elevations[start:end]) / (end - start))
return smoothed

View File

@@ -1,215 +0,0 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Q
from django.core.files.base import ContentFile
from adventures.models import Adventure, AdventureImage
from adventures.serializers import AdventureImageSerializer
from integrations.models import ImmichIntegration
import uuid
import requests
class AdventureImageViewSet(viewsets.ModelViewSet):
serializer_class = AdventureImageSerializer
permission_classes = [IsAuthenticated]
@action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
@action(detail=True, methods=['post'])
def toggle_primary(self, request, *args, **kwargs):
# Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user_id != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
# Check if the image is already the primary image
if instance.is_primary:
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
# Set the current primary image to false
AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
# Set the new image to true
instance.is_primary = True
instance.save()
return Response({"success": "Image set as primary image"})
def create(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
adventure_id = request.data.get('adventure')
try:
adventure = Adventure.objects.get(id=adventure_id)
except Adventure.DoesNotExist:
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user:
# Check if the adventure has any collections
if adventure.collections.exists():
# Check if the user is in the shared_with list of any of the adventure's collections
user_has_access = False
for collection in adventure.collections.all():
if collection.shared_with.filter(id=request.user.id).exists():
user_has_access = True
break
if not user_has_access:
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
# Handle Immich ID for shared users by downloading the image
if (request.user != adventure.user_id and
'immich_id' in request.data and
request.data.get('immich_id')):
immich_id = request.data.get('immich_id')
# Get the shared user's Immich integration
try:
user_integration = ImmichIntegration.objects.get(user_id=request.user)
except ImmichIntegration.DoesNotExist:
return Response({
"error": "No Immich integration found for your account. Please set up Immich integration first.",
"code": "immich_integration_not_found"
}, status=status.HTTP_400_BAD_REQUEST)
# Download the image from the shared user's Immich server
try:
immich_response = requests.get(
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
headers={'x-api-key': user_integration.api_key},
timeout=10
)
immich_response.raise_for_status()
# Create a temporary file with the downloaded content
content_type = immich_response.headers.get('Content-Type', 'image/jpeg')
if not content_type.startswith('image/'):
return Response({
"error": "Invalid content type returned from Immich server.",
"code": "invalid_content_type"
}, status=status.HTTP_400_BAD_REQUEST)
# Determine file extension from content type
ext_map = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif'
}
file_ext = ext_map.get(content_type, '.jpg')
filename = f"immich_{immich_id}{file_ext}"
# Create a Django ContentFile from the downloaded image
image_file = ContentFile(immich_response.content, name=filename)
# Modify request data to use the downloaded image instead of immich_id
request_data = request.data.copy()
request_data.pop('immich_id', None) # Remove immich_id
request_data['image'] = image_file # Add the image file
# Create the serializer with the modified data
serializer = self.get_serializer(data=request_data)
serializer.is_valid(raise_exception=True)
# Save with the downloaded image
adventure = serializer.validated_data['adventure']
serializer.save(user_id=adventure.user_id, image=image_file)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except requests.exceptions.RequestException:
return Response({
"error": f"Failed to fetch image from Immich server",
"code": "immich_fetch_failed"
}, status=status.HTTP_502_BAD_GATEWAY)
except Exception:
return Response({
"error": f"Unexpected error processing Immich image",
"code": "immich_processing_error"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return super().create(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
adventure_id = request.data.get('adventure')
try:
adventure = Adventure.objects.get(id=adventure_id)
except Adventure.DoesNotExist:
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
return super().update(request, *args, **kwargs)
def perform_destroy(self, instance):
print("perform_destroy")
return super().perform_destroy(instance)
def destroy(self, request, *args, **kwargs):
print("destroy")
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user_id != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
return super().destroy(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user_id != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
return super().partial_update(request, *args, **kwargs)
@action(detail=False, methods=['GET'], url_path='(?P<adventure_id>[0-9a-f-]+)')
def adventure_images(self, request, adventure_id=None, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
try:
adventure_uuid = uuid.UUID(adventure_id)
except ValueError:
return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST)
# Updated queryset to include images from adventures the user owns OR has shared access to
queryset = AdventureImage.objects.filter(
Q(adventure__id=adventure_uuid) & (
Q(adventure__user_id=request.user) | # User owns the adventure
Q(adventure__collections__shared_with=request.user) # User has shared access via collection
)
).distinct()
serializer = self.get_serializer(queryset, many=True, context={'request': request})
return Response(serializer.data)
def get_queryset(self):
# Updated to include images from adventures the user owns OR has shared access to
return AdventureImage.objects.filter(
Q(adventure__user_id=self.request.user) | # User owns the adventure
Q(adventure__collections__shared_with=self.request.user) # User has shared access via collection
).distinct()
def perform_create(self, serializer):
# Always set the image owner to the adventure owner, not the current user
adventure = serializer.validated_data['adventure']
serializer.save(user_id=adventure.user_id)

View File

@@ -1,56 +1,205 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from rest_framework.response import Response
from adventures.models import Adventure, Attachment
from django.contrib.contenttypes.models import ContentType
from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentAttachment
from adventures.serializers import AttachmentSerializer
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.permissions import ContentImagePermission
class AttachmentViewSet(viewsets.ModelViewSet):
serializer_class = AttachmentSerializer
permission_classes = [IsAuthenticated]
permission_classes = [ContentImagePermission]
def get_queryset(self):
return Attachment.objects.filter(user_id=self.request.user)
"""Get all images the user has access to"""
if not self.request.user.is_authenticated:
return ContentAttachment.objects.none()
# Import here to avoid circular imports
from adventures.models import Location, Transportation, Note, Lodging, Visit
# Build a single query with all conditions
return ContentAttachment.objects.filter(
# User owns the image directly (if user field exists on ContentImage)
Q(user=self.request.user) |
# Or user has access to the content object
(
# Locations owned by user
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Shared locations
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(collections__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Collections owned by user containing locations
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(collections__user=self.request.user).values_list('id', flat=True))
) |
(
# Transportation owned by user
Q(content_type=ContentType.objects.get_for_model(Transportation)) &
Q(object_id__in=Transportation.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Notes owned by user
Q(content_type=ContentType.objects.get_for_model(Note)) &
Q(object_id__in=Note.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Lodging owned by user
Q(content_type=ContentType.objects.get_for_model(Lodging)) &
Q(object_id__in=Lodging.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Notes shared with user
Q(content_type=ContentType.objects.get_for_model(Note)) &
Q(object_id__in=Note.objects.filter(collection__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Lodging shared with user
Q(content_type=ContentType.objects.get_for_model(Lodging)) &
Q(object_id__in=Lodging.objects.filter(collection__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Transportation shared with user
Q(content_type=ContentType.objects.get_for_model(Transportation)) &
Q(object_id__in=Transportation.objects.filter(collection__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through location's user
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__user=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through shared locations
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__collections__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through collections owned by user
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__collections__user=self.request.user).values_list('id', flat=True))
)
).distinct()
@action(detail=True, methods=['post'])
def attachment_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
adventure_id = request.data.get('adventure')
try:
adventure = Adventure.objects.get(id=adventure_id)
except Adventure.DoesNotExist:
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user:
# Check if the adventure has any collections
if adventure.collections.exists():
# Check if the user is in the shared_with list of any of the adventure's collections
user_has_access = False
for collection in adventure.collections.all():
if collection.shared_with.filter(id=request.user.id).exists():
user_has_access = True
break
if not user_has_access:
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
# Get content type and object ID from request
content_type_name = request.data.get('content_type')
object_id = request.data.get('object_id')
# For backward compatibility, also check for 'location' parameter
location_id = request.data.get('location')
if location_id and not (content_type_name and object_id):
# Handle legacy location-specific requests
content_type_name = 'location'
object_id = location_id
if not content_type_name or not object_id:
return Response({"error": "content_type and object_id are required"}, status=status.HTTP_400_BAD_REQUEST)
# Get and validate the content object
content_object = self._get_and_validate_content_object(content_type_name, object_id)
if isinstance(content_object, Response): # Error response
return content_object
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):
adventure_id = self.request.data.get('adventure')
adventure = Adventure.objects.get(id=adventure_id)
def _get_and_validate_content_object(self, content_type_name, object_id):
"""Get and validate the content object exists and user has access"""
# Map content type names to model classes
content_type_map = {
'location': Location,
'transportation': Transportation,
'note': Note,
'lodging': Lodging,
'visit': Visit,
}
# If the adventure belongs to collections, set the owner to the collection owner
if adventure.collections.exists():
# Get the first collection's owner (assuming all collections have the same owner)
collection = adventure.collections.first()
serializer.save(user_id=collection.user_id)
else:
# Otherwise, set the owner to the request user
serializer.save(user_id=self.request.user)
if content_type_name not in content_type_map:
return Response({
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
}, status=status.HTTP_400_BAD_REQUEST)
# Get the content object
try:
content_object = content_type_map[content_type_name].objects.get(id=object_id)
except (ValueError, content_type_map[content_type_name].DoesNotExist):
return Response({
"error": f"{content_type_name} not found"
}, status=status.HTTP_404_NOT_FOUND)
# Check permissions using the permission class
permission_checker = IsOwnerOrSharedWithFullAccess()
if not permission_checker.has_object_permission(self.request, self, content_object):
return Response({
"error": "User does not have permission to access this content"
}, status=status.HTTP_403_FORBIDDEN)
return content_object
def perform_create(self, serializer):
content_type_name = self.request.data.get('content_type')
object_id = self.request.data.get('object_id')
# Handle legacy location parameter
location_id = self.request.data.get('location')
if location_id and not (content_type_name and object_id):
content_type_name = 'location'
object_id = location_id
# Get the content object (we know it exists from create validation)
content_type_map = {
'location': Location,
'transportation': Transportation,
'note': Note,
'lodging': Lodging,
'visit': Visit,
}
model_class = content_type_map[content_type_name]
content_object = model_class.objects.get(id=object_id)
content_type = ContentType.objects.get_for_model(model_class)
# Determine the appropriate user to assign
attachment_user = self._get_attachment_user(content_object)
serializer.save(
user=attachment_user,
content_type=content_type,
object_id=object_id
)
def _get_attachment_user(self, content_object):
"""
Determine which user should own the attachment based on the content object.
This preserves the original logic for shared collections.
"""
# Handle Location objects
if isinstance(content_object, Location):
if content_object.collections.exists():
# Get the first collection's owner (assuming all collections have the same owner)
collection = content_object.collections.first()
return collection.user
else:
return self.request.user
# Handle other content types with collections
elif hasattr(content_object, 'collection') and content_object.collection:
return content_object.collection.user
# Handle content objects with a user field
elif hasattr(content_object, 'user'):
return content_object.user
# Default to request user
return self.request.user

View File

@@ -2,21 +2,19 @@ from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from adventures.models import Category, Adventure
from adventures.models import Category, Location
from adventures.serializers import CategorySerializer
class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Category.objects.filter(user_id=self.request.user)
return Category.objects.filter(user=self.request.user)
@action(detail=False, methods=['get'])
def categories(self, request):
def list(self, request, *args, **kwargs):
"""
Retrieve a list of distinct categories for adventures associated with the current user.
Retrieve a list of distinct categories for locations associated with the current user.
"""
categories = self.get_queryset().distinct()
serializer = self.get_serializer(categories, many=True)
@@ -24,19 +22,19 @@ class CategoryViewSet(viewsets.ModelViewSet):
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.user_id != request.user:
if instance.user != request.user:
return Response({"error": "User does not own this category"}, status
=400)
if instance.name == 'general':
return Response({"error": "Cannot delete the general category"}, status=400)
# set any adventures with this category to a default category called general before deleting the category, if general does not exist create it for the user
general_category = Category.objects.filter(user_id=request.user, name='general').first()
# set any locations with this category to a default category called general before deleting the category, if general does not exist create it for the user
general_category = Category.objects.filter(user=request.user, name='general').first()
if not general_category:
general_category = Category.objects.create(user_id=request.user, name='general', icon='🌍', display_name='General')
general_category = Category.objects.create(user=request.user, name='general', icon='🌍', display_name='General')
Adventure.objects.filter(category=instance).update(category=general_category)
Location.objects.filter(category=instance).update(category=general_category)
return super().destroy(request, *args, **kwargs)

View File

@@ -6,32 +6,22 @@ from adventures.models import Checklist
from adventures.serializers import ChecklistSerializer
from rest_framework.exceptions import PermissionDenied
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from rest_framework.permissions import IsAuthenticated
class ChecklistViewSet(viewsets.ModelViewSet):
queryset = Checklist.objects.all()
serializer_class = ChecklistSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
permission_classes = [IsAuthenticated, IsOwnerOrSharedWithFullAccess]
filterset_fields = ['is_public', 'collection']
# return error message if user is not authenticated on the root endpoint
def list(self, request, *args, **kwargs):
# Prevent listing all adventures
return Response({"detail": "Listing all checklists is not allowed."},
status=status.HTTP_403_FORBIDDEN)
@action(detail=False, methods=['get'])
def all(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Checklist.objects.filter(
Q(user_id=request.user.id)
Q(user=request.user)
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def get_queryset(self):
# if the user is not authenticated return only public transportations for retrieve action
# if the user is not authenticated return only public checklists for retrieve action
if not self.request.user.is_authenticated:
if self.action == 'retrieve':
return Checklist.objects.filter(is_public=True).distinct().order_by('-updated_at')
@@ -39,14 +29,14 @@ class ChecklistViewSet(viewsets.ModelViewSet):
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
# For individual adventure retrieval, include public locations
return Checklist.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
Q(is_public=True) | Q(user=self.request.user) | Q(collection__shared_with=self.request.user)
).distinct().order_by('-updated_at')
else:
# For other actions, include user's own adventures and shared adventures
# For other actions, include user's own locations and shared locations
return Checklist.objects.filter(
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
Q(user=self.request.user) | Q(collection__shared_with=self.request.user)
).distinct().order_by('-updated_at')
def partial_update(self, request, *args, **kwargs):
@@ -65,11 +55,11 @@ class ChecklistViewSet(viewsets.ModelViewSet):
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
if new_collection.user != user or instance.user != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
if instance.collection is not None and instance.collection.user != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
@@ -94,11 +84,11 @@ class ChecklistViewSet(viewsets.ModelViewSet):
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
if new_collection.user != user or instance.user != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
if instance.collection is not None and instance.collection.user != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
@@ -119,12 +109,12 @@ class ChecklistViewSet(viewsets.ModelViewSet):
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
if collection.user != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
serializer.save(user=collection.user)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)

View File

@@ -4,19 +4,18 @@ from django.db import transaction
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from adventures.models import Collection, Adventure, Transportation, Note, Checklist
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite
from adventures.permissions import CollectionShared
from adventures.serializers import CollectionSerializer
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer
from users.models import CustomUser as User
from adventures.utils import pagination
from users.serializers import CustomUserDetailsSerializer as UserSerializer
class CollectionViewSet(viewsets.ModelViewSet):
serializer_class = CollectionSerializer
permission_classes = [CollectionShared]
pagination_class = pagination.StandardResultsSetPagination
# def get_queryset(self):
# return Collection.objects.filter(Q(user_id=self.request.user.id) & Q(is_archived=False))
def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'name')
@@ -47,15 +46,13 @@ class CollectionViewSet(viewsets.ModelViewSet):
if order_direction == 'asc':
ordering = '-updated_at'
#print(f"Ordering by: {ordering}") # For debugging
return queryset.order_by(ordering)
def list(self, request, *args, **kwargs):
# make sure the user is authenticated
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(user_id=request.user.id, is_archived=False)
queryset = Collection.objects.filter(user=request.user, is_archived=False)
queryset = self.apply_sorting(queryset)
collections = self.paginate_and_respond(queryset, request)
return collections
@@ -66,7 +63,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
Q(user_id=request.user.id)
Q(user=request.user)
)
queryset = self.apply_sorting(queryset)
@@ -80,7 +77,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
Q(user_id=request.user.id) & Q(is_archived=True)
Q(user=request.user.id) & Q(is_archived=True)
)
queryset = self.apply_sorting(queryset)
@@ -88,7 +85,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# this make the is_public field of the collection cascade to the adventures
# this make the is_public field of the collection cascade to the locations
@transaction.atomic
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
@@ -99,7 +96,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
if 'collection' in serializer.validated_data:
new_collection = serializer.validated_data['collection']
# if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error
if new_collection != instance.collection and new_collection.user_id != request.user:
if new_collection != instance.collection and new_collection.user != request.user:
return Response({"error": "User does not own the new collection"}, status=400)
# Check if the 'is_public' field is present in the update data
@@ -107,29 +104,29 @@ class CollectionViewSet(viewsets.ModelViewSet):
new_public_status = serializer.validated_data['is_public']
# if is_public has changed and the user is not the owner of the collection return an error
if new_public_status != instance.is_public and instance.user_id != request.user:
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}")
if new_public_status != instance.is_public and instance.user != request.user:
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user}")
return Response({"error": "User does not own the collection"}, status=400)
# Get all adventures in this collection
adventures_in_collection = Adventure.objects.filter(collections=instance)
# Get all locations in this collection
locations_in_collection = Location.objects.filter(collections=instance)
if new_public_status:
# If collection becomes public, make all adventures public
adventures_in_collection.update(is_public=True)
# If collection becomes public, make all locations public
locations_in_collection.update(is_public=True)
else:
# If collection becomes private, check each adventure
# Only set an adventure to private if ALL of its collections are private
# Collect adventures that do NOT belong to any other public collection (excluding the current one)
adventure_ids_to_set_private = []
# If collection becomes private, check each location
# Only set a location to private if ALL of its collections are private
# Collect locations that do NOT belong to any other public collection (excluding the current one)
location_ids_to_set_private = []
for adventure in adventures_in_collection:
has_public_collection = adventure.collections.filter(is_public=True).exclude(id=instance.id).exists()
for location in locations_in_collection:
has_public_collection = location.collections.filter(is_public=True).exclude(id=instance.id).exists()
if not has_public_collection:
adventure_ids_to_set_private.append(adventure.id)
location_ids_to_set_private.append(location.id)
# Bulk update those adventures
Adventure.objects.filter(id__in=adventure_ids_to_set_private).update(is_public=False)
# Bulk update those locations
Location.objects.filter(id__in=location_ids_to_set_private).update(is_public=False)
# Update transportations, notes, and checklists related to this collection
# These still use direct ForeignKey relationships
@@ -150,7 +147,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# make an action to retreive all adventures that are shared with the user
# make an action to retreive all locations that are shared with the user
@action(detail=False, methods=['get'])
def shared(self, request):
if not request.user.is_authenticated:
@@ -162,7 +159,8 @@ class CollectionViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# Adds a new user to the shared_with field of an adventure
# Created a custom action to share a collection with another user by their UUID
# This action will create a CollectionInvite instead of directly sharing the collection
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
def share(self, request, pk=None, uuid=None):
collection = self.get_object()
@@ -176,20 +174,140 @@ class CollectionViewSet(viewsets.ModelViewSet):
if user == request.user:
return Response({"error": "Cannot share with yourself"}, status=400)
# Check if user is already shared with the collection
if collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Adventure is already shared with this user"}, status=400)
return Response({"error": "Collection is already shared with this user"}, status=400)
collection.shared_with.add(user)
collection.save()
return Response({"success": f"Shared with {user.username}"})
# Check if there's already a pending invite for this user
if CollectionInvite.objects.filter(collection=collection, invited_user=user).exists():
return Response({"error": "Invite already sent to this user"}, status=400)
# Create the invite instead of directly sharing
invite = CollectionInvite.objects.create(
collection=collection,
invited_user=user
)
return Response({"success": f"Invite sent to {user.username}"})
# Custom action to list all invites for a user
@action(detail=False, methods=['get'], url_path='invites')
def invites(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
invites = CollectionInvite.objects.filter(invited_user=request.user)
serializer = CollectionInviteSerializer(invites, many=True)
return Response(serializer.data)
# Add these methods to your CollectionViewSet class
@action(detail=True, methods=['post'], url_path='revoke-invite/(?P<uuid>[^/.]+)')
def revoke_invite(self, request, pk=None, uuid=None):
"""Revoke a pending invite for a collection"""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
if not uuid:
return Response({"error": "User UUID is required"}, status=400)
try:
user = User.objects.get(uuid=uuid, public_profile=True)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=404)
# Only collection owner can revoke invites
if collection.user != request.user:
return Response({"error": "Only collection owner can revoke invites"}, status=403)
try:
invite = CollectionInvite.objects.get(collection=collection, invited_user=user)
invite.delete()
return Response({"success": f"Invite revoked for {user.username}"})
except CollectionInvite.DoesNotExist:
return Response({"error": "No pending invite found for this user"}, status=404)
@action(detail=True, methods=['post'], url_path='accept-invite')
def accept_invite(self, request, pk=None):
"""Accept a collection invite"""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
try:
invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user)
except CollectionInvite.DoesNotExist:
return Response({"error": "No pending invite found for this collection"}, status=404)
# Add user to collection's shared_with
collection.shared_with.add(request.user)
# Delete the invite
invite.delete()
return Response({"success": f"Successfully joined collection: {collection.name}"})
@action(detail=True, methods=['post'], url_path='decline-invite')
def decline_invite(self, request, pk=None):
"""Decline a collection invite"""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
try:
invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user)
invite.delete()
return Response({"success": f"Declined invite for collection: {collection.name}"})
except CollectionInvite.DoesNotExist:
return Response({"error": "No pending invite found for this collection"}, status=404)
# Action to list all users a collection **can** be shared with, excluding those already shared with and those with pending invites
@action(detail=True, methods=['get'], url_path='can-share')
def can_share(self, request, pk=None):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
# Get users with pending invites and users already shared with
users_with_pending_invites = set(str(uuid) for uuid in CollectionInvite.objects.filter(collection=collection).values_list('invited_user__uuid', flat=True))
users_already_shared = set(str(uuid) for uuid in collection.shared_with.values_list('uuid', flat=True))
# Get all users with public profiles excluding only the owner
all_users = User.objects.filter(public_profile=True).exclude(id=request.user.id)
# Return fully serialized user data with status
serializer = UserSerializer(all_users, many=True)
result_data = []
for user_data in serializer.data:
user_data.pop('has_password', None)
user_data.pop('disable_password', None)
# Add status field
if user_data['uuid'] in users_with_pending_invites:
user_data['status'] = 'pending'
elif user_data['uuid'] in users_already_shared:
user_data['status'] = 'shared'
else:
user_data['status'] = 'available'
result_data.append(user_data)
return Response(result_data)
@action(detail=True, methods=['post'], url_path='unshare/(?P<uuid>[^/.]+)')
def unshare(self, request, pk=None, uuid=None):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
if not uuid:
return Response({"error": "User UUID is required"}, status=400)
try:
user = User.objects.get(uuid=uuid, public_profile=True)
except User.DoesNotExist:
@@ -201,34 +319,93 @@ class CollectionViewSet(viewsets.ModelViewSet):
if not collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Collection is not shared with this user"}, status=400)
# Remove user from shared_with
collection.shared_with.remove(user)
# Handle locations owned by the unshared user that are in this collection
# These locations should be removed from the collection since they lose access
locations_to_remove = collection.locations.filter(user=user)
removed_count = locations_to_remove.count()
if locations_to_remove.exists():
# Remove these locations from the collection
collection.locations.remove(*locations_to_remove)
collection.save()
return Response({"success": f"Unshared with {user.username}"})
success_message = f"Unshared with {user.username}"
if removed_count > 0:
success_message += f" and removed {removed_count} location(s) they owned from the collection"
return Response({"success": success_message})
# Action for a shared user to leave a collection
@action(detail=True, methods=['post'], url_path='leave')
def leave(self, request, pk=None):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
if request.user == collection.user:
return Response({"error": "Owner cannot leave their own collection"}, status=400)
if not collection.shared_with.filter(id=request.user.id).exists():
return Response({"error": "You are not a member of this collection"}, status=400)
# Remove the user from shared_with
collection.shared_with.remove(request.user)
# Handle locations owned by the user that are in this collection
locations_to_remove = collection.locations.filter(user=request.user)
removed_count = locations_to_remove.count()
if locations_to_remove.exists():
# Remove these locations from the collection
collection.locations.remove(*locations_to_remove)
collection.save()
success_message = f"You have left the collection: {collection.name}"
if removed_count > 0:
success_message += f" and removed {removed_count} location(s) you owned from the collection"
return Response({"success": success_message})
def get_queryset(self):
if self.action == 'destroy':
return Collection.objects.filter(user_id=self.request.user.id)
return Collection.objects.filter(user=self.request.user.id)
if self.action in ['update', 'partial_update']:
return Collection.objects.filter(
Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
Q(user=self.request.user.id) | Q(shared_with=self.request.user)
).distinct()
# Allow access to collections with pending invites for accept/decline actions
if self.action in ['accept_invite', 'decline_invite']:
if not self.request.user.is_authenticated:
return Collection.objects.none()
return Collection.objects.filter(
Q(user=self.request.user.id) |
Q(shared_with=self.request.user) |
Q(invites__invited_user=self.request.user)
).distinct()
if self.action == 'retrieve':
if not self.request.user.is_authenticated:
return Collection.objects.filter(is_public=True)
return Collection.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
Q(is_public=True) | Q(user=self.request.user.id) | Q(shared_with=self.request.user)
).distinct()
# For list action, include collections owned by the user or shared with the user, that are not archived
return Collection.objects.filter(
(Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False)
(Q(user=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False)
).distinct()
def perform_create(self, serializer):
# This is ok because you cannot share a collection when creating it
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)
def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class()

View File

@@ -3,8 +3,8 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from django.contrib.postgres.search import SearchVector, SearchQuery
from adventures.models import Adventure, Collection
from adventures.serializers import AdventureSerializer, CollectionSerializer
from adventures.models import Location, Collection
from adventures.serializers import LocationSerializer, CollectionSerializer
from worldtravel.models import Country, Region, City, VisitedCity, VisitedRegion
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer, VisitedCitySerializer, VisitedRegionSerializer
from users.models import CustomUser as User
@@ -20,7 +20,7 @@ class GlobalSearchView(viewsets.ViewSet):
# Initialize empty results
results = {
"adventures": [],
"locations": [],
"collections": [],
"users": [],
"countries": [],
@@ -30,15 +30,15 @@ class GlobalSearchView(viewsets.ViewSet):
"visited_cities": []
}
# Adventures: Full-Text Search
adventures = Adventure.objects.annotate(
# Locations: Full-Text Search
locations = Location.objects.annotate(
search=SearchVector('name', 'description', 'location')
).filter(search=SearchQuery(search_term), user_id=request.user)
results["adventures"] = AdventureSerializer(adventures, many=True).data
).filter(search=SearchQuery(search_term), user=request.user)
results["locations"] = LocationSerializer(locations, many=True).data
# Collections: Partial Match Search
collections = Collection.objects.filter(
Q(name__icontains=search_term) & Q(user_id=request.user)
Q(name__icontains=search_term) & Q(user=request.user)
)
results["collections"] = CollectionSerializer(collections, many=True).data
@@ -64,10 +64,10 @@ class GlobalSearchView(viewsets.ViewSet):
results["cities"] = CitySerializer(cities, many=True).data
# Visited Regions and Cities
visited_regions = VisitedRegion.objects.filter(user_id=request.user)
visited_regions = VisitedRegion.objects.filter(user=request.user)
results["visited_regions"] = VisitedRegionSerializer(visited_regions, many=True).data
visited_cities = VisitedCity.objects.filter(user_id=request.user)
visited_cities = VisitedCity.objects.filter(user=request.user)
results["visited_cities"] = VisitedCitySerializer(visited_cities, many=True).data
return Response(results)

View File

@@ -4,27 +4,26 @@ from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from icalendar import Calendar, Event, vText, vCalAddress
from datetime import datetime, timedelta
from adventures.models import Adventure
from adventures.serializers import AdventureSerializer
from adventures.models import Location
from adventures.serializers import LocationSerializer
class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def generate(self, request):
adventures = Adventure.objects.filter(user_id=request.user)
serializer = AdventureSerializer(adventures, many=True)
locations = Location.objects.filter(user=request.user)
serializer = LocationSerializer(locations, many=True)
user = request.user
name = f"{user.first_name} {user.last_name}"
print(serializer.data)
cal = Calendar()
cal.add('prodid', '-//My Adventure Calendar//example.com//')
cal.add('version', '2.0')
for adventure in serializer.data:
if adventure['visits']:
for visit in adventure['visits']:
for location in serializer.data:
if location['visits']:
for visit in location['visits']:
# Skip if start_date is missing
if not visit.get('start_date'):
continue
@@ -42,7 +41,7 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
# Create event
event = Event()
event.add('summary', adventure['name'])
event.add('summary', location['name'])
event.add('dtstart', start_date)
event.add('dtend', end_date)
event.add('dtstamp', datetime.now())
@@ -50,11 +49,11 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
event.add('class', 'PUBLIC')
event.add('created', datetime.now())
event.add('last-modified', datetime.now())
event.add('description', adventure['description'])
if adventure.get('location'):
event.add('location', adventure['location'])
if adventure.get('link'):
event.add('url', adventure['link'])
event.add('description', location['description'])
if location.get('location'):
event.add('location', location['location'])
if location.get('link'):
event.add('url', location['link'])
organizer = vCalAddress(f'MAILTO:{user.email}')
organizer.params['cn'] = vText(name)

View File

@@ -0,0 +1,784 @@
# views.py
import json
import zipfile
import tempfile
import os
from datetime import datetime
from django.http import HttpResponse
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from adventures.models import (
Location, Collection, Transportation, Note, Checklist, ChecklistItem,
ContentImage, ContentAttachment, Category, Lodging, Visit, Trail, Activity
)
from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country
User = get_user_model()
class BackupViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
"""
Simple ViewSet for handling backup and import operations
"""
@action(detail=False, methods=['get'])
def export(self, request):
"""
Export all user data as a ZIP file containing JSON data and files
"""
user = request.user
# Build export data structure
export_data = {
'version': settings.ADVENTURELOG_RELEASE_VERSION,
'export_date': datetime.now().isoformat(),
'user_email': user.email,
'user_username': user.username,
'categories': [],
'collections': [],
'locations': [],
'transportation': [],
'notes': [],
'checklists': [],
'lodging': [],
'visited_cities': [],
'visited_regions': []
}
# Export Visited Cities
for visited_city in user.visitedcity_set.all():
export_data['visited_cities'].append({
'city': visited_city.city.id,
})
# Export Visited Regions
for visited_region in user.visitedregion_set.all():
export_data['visited_regions'].append({
'region': visited_region.region.id,
})
# Export Categories
for category in user.category_set.all():
export_data['categories'].append({
'name': category.name,
'display_name': category.display_name,
'icon': category.icon,
})
# Export Collections
for idx, collection in enumerate(user.collection_set.all()):
export_data['collections'].append({
'export_id': idx, # Add unique identifier for this export
'name': collection.name,
'description': collection.description,
'is_public': collection.is_public,
'start_date': collection.start_date.isoformat() if collection.start_date else None,
'end_date': collection.end_date.isoformat() if collection.end_date else None,
'is_archived': collection.is_archived,
'link': collection.link,
'shared_with_user_ids': [str(uuid) for uuid in collection.shared_with.values_list('uuid', flat=True)]
})
# Create collection name to export_id mapping
collection_name_to_id = {col.name: idx for idx, col in enumerate(user.collection_set.all())}
# Export locations with related data
for idx, location in enumerate(user.location_set.all()):
location_data = {
'export_id': idx, # Add unique identifier for this export
'name': location.name,
'location': location.location,
'tags': location.tags,
'description': location.description,
'rating': location.rating,
'link': location.link,
'is_public': location.is_public,
'longitude': str(location.longitude) if location.longitude else None,
'latitude': str(location.latitude) if location.latitude else None,
'city': location.city_id,
'region': location.region_id,
'country': location.country_id,
'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_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,
'activities': []
}
# Add activities for this visit
for activity in visit.activities.all():
activity_data = {
'name': activity.name,
'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():
image_data = {
'immich_id': image.immich_id,
'is_primary': image.is_primary,
'filename': None,
}
if image.image:
image_data['filename'] = image.image.name.split('/')[-1]
location_data['images'].append(image_data)
# Add attachments
for attachment in location.attachments.all():
attachment_data = {
'name': attachment.name,
'filename': None
}
if attachment.file:
attachment_data['filename'] = attachment.file.name.split('/')[-1]
location_data['attachments'].append(attachment_data)
export_data['locations'].append(location_data)
# Export Transportation
for transport in user.transportation_set.all():
collection_export_id = None
if transport.collection:
collection_export_id = collection_name_to_id.get(transport.collection.name)
export_data['transportation'].append({
'type': transport.type,
'name': transport.name,
'description': transport.description,
'rating': transport.rating,
'link': transport.link,
'date': transport.date.isoformat() if transport.date else None,
'end_date': transport.end_date.isoformat() if transport.end_date else None,
'start_timezone': transport.start_timezone,
'end_timezone': transport.end_timezone,
'flight_number': transport.flight_number,
'from_location': transport.from_location,
'origin_latitude': str(transport.origin_latitude) if transport.origin_latitude else None,
'origin_longitude': str(transport.origin_longitude) if transport.origin_longitude else None,
'destination_latitude': str(transport.destination_latitude) if transport.destination_latitude else None,
'destination_longitude': str(transport.destination_longitude) if transport.destination_longitude else None,
'to_location': transport.to_location,
'is_public': transport.is_public,
'collection_export_id': collection_export_id
})
# Export Notes
for note in user.note_set.all():
collection_export_id = None
if note.collection:
collection_export_id = collection_name_to_id.get(note.collection.name)
export_data['notes'].append({
'name': note.name,
'content': note.content,
'links': note.links,
'date': note.date.isoformat() if note.date else None,
'is_public': note.is_public,
'collection_export_id': collection_export_id
})
# Export Checklists
for checklist in user.checklist_set.all():
collection_export_id = None
if checklist.collection:
collection_export_id = collection_name_to_id.get(checklist.collection.name)
checklist_data = {
'name': checklist.name,
'date': checklist.date.isoformat() if checklist.date else None,
'is_public': checklist.is_public,
'collection_export_id': collection_export_id,
'items': []
}
# Add checklist items
for item in checklist.checklistitem_set.all():
checklist_data['items'].append({
'name': item.name,
'is_checked': item.is_checked
})
export_data['checklists'].append(checklist_data)
# Export Lodging
for lodging in user.lodging_set.all():
collection_export_id = None
if lodging.collection:
collection_export_id = collection_name_to_id.get(lodging.collection.name)
export_data['lodging'].append({
'name': lodging.name,
'type': lodging.type,
'description': lodging.description,
'rating': lodging.rating,
'link': lodging.link,
'check_in': lodging.check_in.isoformat() if lodging.check_in else None,
'check_out': lodging.check_out.isoformat() if lodging.check_out else None,
'timezone': lodging.timezone,
'reservation_number': lodging.reservation_number,
'price': str(lodging.price) if lodging.price else None,
'latitude': str(lodging.latitude) if lodging.latitude else None,
'longitude': str(lodging.longitude) if lodging.longitude else None,
'location': lodging.location,
'is_public': lodging.is_public,
'collection_export_id': collection_export_id
})
# Create ZIP file
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file:
with zipfile.ZipFile(tmp_file.name, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# Add JSON data
zip_file.writestr('data.json', json.dumps(export_data, indent=2))
# Add images, attachments, and GPX files
files_added = set()
for location in user.location_set.all():
# Add images
for image in location.images.all():
if image.image and image.image.name not in files_added:
try:
image_content = default_storage.open(image.image.name).read()
filename = image.image.name.split('/')[-1]
zip_file.writestr(f'images/{filename}', image_content)
files_added.add(image.image.name)
except Exception as e:
print(f"Error adding image {image.image.name}: {e}")
# Add attachments
for attachment in location.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
file_content = default_storage.open(attachment.file.name).read()
filename = attachment.file.name.split('/')[-1]
zip_file.writestr(f'attachments/{filename}', file_content)
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:
response = HttpResponse(zip_file.read(), content_type='application/zip')
filename = f"adventurelog_backup_{user.username}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
# Clean up
os.unlink(tmp_file.name)
return response
@action(
detail=False,
methods=['post'],
parser_classes=[MultiPartParser],
url_path='import', # changes the URL path to /import
url_name='import' # changes the reverse name to 'import'
)
def import_data(self, request):
"""
Import data from a ZIP backup file
"""
if 'file' not in request.FILES:
return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST)
if 'confirm' not in request.data or request.data['confirm'] != 'yes':
return Response({'error': 'Confirmation required to proceed with import'},
status=status.HTTP_400_BAD_REQUEST)
backup_file = request.FILES['file']
user = request.user
# Save file temporarily
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file:
for chunk in backup_file.chunks():
tmp_file.write(chunk)
tmp_file_path = tmp_file.name
try:
with zipfile.ZipFile(tmp_file_path, 'r') as zip_file:
# Validate backup structure
if 'data.json' not in zip_file.namelist():
return Response({'error': 'Invalid backup file - missing data.json'},
status=status.HTTP_400_BAD_REQUEST)
# Load data
backup_data = json.loads(zip_file.read('data.json').decode('utf-8'))
# Import with transaction
with transaction.atomic():
# Clear existing data first
self._clear_user_data(user)
summary = self._import_data(backup_data, zip_file, user)
return Response({
'success': True,
'message': 'Data imported successfully',
'summary': summary
}, status=status.HTTP_200_OK)
except json.JSONDecodeError:
return Response({'error': 'Invalid JSON in backup file'},
status=status.HTTP_400_BAD_REQUEST)
except Exception:
import logging
logging.error("Import failed", exc_info=True)
return Response({'error': 'An internal error occurred during import'},
status=status.HTTP_400_BAD_REQUEST)
finally:
os.unlink(tmp_file_path)
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()
user.transportation_set.all().delete()
user.lodging_set.all().delete()
# Delete location-related data
user.contentimage_set.all().delete()
user.contentattachment_set.all().delete()
# Visits are deleted via cascade when locations are deleted
user.location_set.all().delete()
# Delete collections and categories last
user.collection_set.all().delete()
user.category_set.all().delete()
# Clear visited cities and regions
user.visitedcity_set.all().delete()
user.visitedregion_set.all().delete()
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,
'trails': 0, 'activities': 0, 'gpx_files': 0
}
# Import Visited Cities
for city_data in backup_data.get('visited_cities', []):
try:
city_obj = City.objects.get(id=city_data['city'])
visited_city, created = VisitedCity.objects.get_or_create(user=user, city=city_obj)
if created:
summary['visited_cities'] += 1
except City.DoesNotExist:
# If city does not exist, we can skip or log it
pass
# Import Visited Regions
for region_data in backup_data.get('visited_regions', []):
try:
region_obj = Region.objects.get(id=region_data['region'])
visited_region, created = VisitedRegion.objects.get_or_create(user=user, region=region_obj)
if created:
summary['visited_regions'] += 1
except Region.DoesNotExist:
# If region does not exist, we can skip or log it
pass
# Import Categories
for cat_data in backup_data.get('categories', []):
category = Category.objects.create(
user=user,
name=cat_data['name'],
display_name=cat_data['display_name'],
icon=cat_data.get('icon', '🌍')
)
category_map[cat_data['name']] = category
summary['categories'] += 1
# Import Collections
for col_data in backup_data.get('collections', []):
collection = Collection.objects.create(
user=user,
name=col_data['name'],
description=col_data.get('description', ''),
is_public=col_data.get('is_public', False),
start_date=col_data.get('start_date'),
end_date=col_data.get('end_date'),
is_archived=col_data.get('is_archived', False),
link=col_data.get('link')
)
collection_map[col_data['export_id']] = collection
summary['collections'] += 1
# Handle shared users
for uuid in col_data.get('shared_with_user_ids', []):
try:
shared_user = User.objects.get(uuid=uuid)
if shared_user.public_profile:
collection.shared_with.add(shared_user)
except User.DoesNotExist:
pass
# Import Locations
for adv_data in backup_data.get('locations', []):
city = None
if adv_data.get('city'):
try:
city = City.objects.get(id=adv_data['city'])
except City.DoesNotExist:
city = None
region = None
if adv_data.get('region'):
try:
region = Region.objects.get(id=adv_data['region'])
except Region.DoesNotExist:
region = None
country = None
if adv_data.get('country'):
try:
country = Country.objects.get(id=adv_data['country'])
except Country.DoesNotExist:
country = None
location = Location(
user=user,
name=adv_data['name'],
location=adv_data.get('location'),
tags=adv_data.get('tags', []),
description=adv_data.get('description'),
rating=adv_data.get('rating'),
link=adv_data.get('link'),
is_public=adv_data.get('is_public', False),
longitude=adv_data.get('longitude'),
latitude=adv_data.get('latitude'),
city=city,
region=region,
country=country,
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 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 = 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'],
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')
for img_data in adv_data.get('images', []):
immich_id = img_data.get('immich_id')
if immich_id:
ContentImage.objects.create(
user=user,
immich_id=immich_id,
is_primary=img_data.get('is_primary', False),
content_type=content_type,
object_id=location.id
)
summary['images'] += 1
else:
filename = img_data.get('filename')
if filename:
try:
img_content = zip_file.read(f'images/{filename}')
img_file = ContentFile(img_content, name=filename)
ContentImage.objects.create(
user=user,
image=img_file,
is_primary=img_data.get('is_primary', False),
content_type=content_type,
object_id=location.id
)
summary['images'] += 1
except KeyError:
pass
# Import attachments
for att_data in adv_data.get('attachments', []):
filename = att_data.get('filename')
if filename:
try:
att_content = zip_file.read(f'attachments/{filename}')
att_file = ContentFile(att_content, name=filename)
ContentAttachment.objects.create(
user=user,
file=att_file,
name=att_data.get('name'),
content_type=content_type,
object_id=location.id
)
summary['attachments'] += 1
except KeyError:
pass
summary['locations'] += 1
# Import Transportation
for trans_data in backup_data.get('transportation', []):
collection = None
if trans_data.get('collection_export_id') is not None:
collection = collection_map.get(trans_data['collection_export_id'])
Transportation.objects.create(
user=user,
type=trans_data['type'],
name=trans_data['name'],
description=trans_data.get('description'),
rating=trans_data.get('rating'),
link=trans_data.get('link'),
date=trans_data.get('date'),
end_date=trans_data.get('end_date'),
start_timezone=trans_data.get('start_timezone'),
end_timezone=trans_data.get('end_timezone'),
flight_number=trans_data.get('flight_number'),
from_location=trans_data.get('from_location'),
origin_latitude=trans_data.get('origin_latitude'),
origin_longitude=trans_data.get('origin_longitude'),
destination_latitude=trans_data.get('destination_latitude'),
destination_longitude=trans_data.get('destination_longitude'),
to_location=trans_data.get('to_location'),
is_public=trans_data.get('is_public', False),
collection=collection
)
summary['transportation'] += 1
# Import Notes
for note_data in backup_data.get('notes', []):
collection = None
if note_data.get('collection_export_id') is not None:
collection = collection_map.get(note_data['collection_export_id'])
Note.objects.create(
user=user,
name=note_data['name'],
content=note_data.get('content'),
links=note_data.get('links', []),
date=note_data.get('date'),
is_public=note_data.get('is_public', False),
collection=collection
)
summary['notes'] += 1
# Import Checklists
for check_data in backup_data.get('checklists', []):
collection = None
if check_data.get('collection_export_id') is not None:
collection = collection_map.get(check_data['collection_export_id'])
checklist = Checklist.objects.create(
user=user,
name=check_data['name'],
date=check_data.get('date'),
is_public=check_data.get('is_public', False),
collection=collection
)
# Import checklist items
for item_data in check_data.get('items', []):
ChecklistItem.objects.create(
user=user,
checklist=checklist,
name=item_data['name'],
is_checked=item_data.get('is_checked', False)
)
summary['checklist_items'] += 1
summary['checklists'] += 1
# Import Lodging
for lodg_data in backup_data.get('lodging', []):
collection = None
if lodg_data.get('collection_export_id') is not None:
collection = collection_map.get(lodg_data['collection_export_id'])
Lodging.objects.create(
user=user,
name=lodg_data['name'],
type=lodg_data.get('type', 'other'),
description=lodg_data.get('description'),
rating=lodg_data.get('rating'),
link=lodg_data.get('link'),
check_in=lodg_data.get('check_in'),
check_out=lodg_data.get('check_out'),
timezone=lodg_data.get('timezone'),
reservation_number=lodg_data.get('reservation_number'),
price=lodg_data.get('price'),
latitude=lodg_data.get('latitude'),
longitude=lodg_data.get('longitude'),
location=lodg_data.get('location'),
is_public=lodg_data.get('is_public', False),
collection=collection
)
summary['lodging'] += 1
return summary

View File

@@ -0,0 +1,307 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Q
from django.core.files.base import ContentFile
from django.contrib.contenttypes.models import ContentType
from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentImage
from adventures.serializers import ContentImageSerializer
from integrations.models import ImmichIntegration
from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existing permission class
import requests
from adventures.permissions import ContentImagePermission
class ContentImageViewSet(viewsets.ModelViewSet):
serializer_class = ContentImageSerializer
permission_classes = [ContentImagePermission]
def get_queryset(self):
"""Get all images the user has access to"""
if not self.request.user.is_authenticated:
return ContentImage.objects.none()
# Import here to avoid circular imports
from adventures.models import Location, Transportation, Note, Lodging, Visit
# Build a single query with all conditions
return ContentImage.objects.filter(
# User owns the image directly (if user field exists on ContentImage)
Q(user=self.request.user) |
# Or user has access to the content object
(
# Locations owned by user
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Shared locations
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(collections__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Collections owned by user containing locations
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(collections__user=self.request.user).values_list('id', flat=True))
) |
(
# Transportation owned by user
Q(content_type=ContentType.objects.get_for_model(Transportation)) &
Q(object_id__in=Transportation.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Notes owned by user
Q(content_type=ContentType.objects.get_for_model(Note)) &
Q(object_id__in=Note.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Lodging owned by user
Q(content_type=ContentType.objects.get_for_model(Lodging)) &
Q(object_id__in=Lodging.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Notes shared with user
Q(content_type=ContentType.objects.get_for_model(Note)) &
Q(object_id__in=Note.objects.filter(collection__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Lodging shared with user
Q(content_type=ContentType.objects.get_for_model(Lodging)) &
Q(object_id__in=Lodging.objects.filter(collection__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Transportation shared with user
Q(content_type=ContentType.objects.get_for_model(Transportation)) &
Q(object_id__in=Transportation.objects.filter(collection__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through location's user
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__user=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through shared locations
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__collections__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through collections owned by user
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__collections__user=self.request.user).values_list('id', flat=True))
)
).distinct()
@action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
@action(detail=True, methods=['post'])
def toggle_primary(self, request, *args, **kwargs):
instance = self.get_object()
# Check if the image is already the primary image
if instance.is_primary:
return Response(
{"error": "Image is already the primary image"},
status=status.HTTP_400_BAD_REQUEST
)
# Set other images of the same content object to not primary
ContentImage.objects.filter(
content_type=instance.content_type,
object_id=instance.object_id,
is_primary=True
).update(is_primary=False)
# Set the new image to primary
instance.is_primary = True
instance.save()
return Response({"success": "Image set as primary image"})
def create(self, request, *args, **kwargs):
# Get content type and object ID from request
content_type_name = request.data.get('content_type')
object_id = request.data.get('object_id')
if not content_type_name or not object_id:
return Response({
"error": "content_type and object_id are required"
}, status=status.HTTP_400_BAD_REQUEST)
# Get the content object and validate permissions
content_object = self._get_and_validate_content_object(content_type_name, object_id)
if isinstance(content_object, Response): # Error response
return content_object
content_type = ContentType.objects.get_for_model(content_object.__class__)
# Handle Immich ID for shared users by downloading the image
if (hasattr(content_object, 'user') and
request.user != content_object.user and
'immich_id' in request.data and
request.data.get('immich_id')):
return self._handle_immich_image_creation(request, content_object, content_type, object_id)
# Standard image creation
return self._create_standard_image(request, content_object, content_type, object_id)
def _get_and_validate_content_object(self, content_type_name, object_id):
"""Get and validate the content object exists and user has access"""
# Map content type names to model classes
content_type_map = {
'location': Location,
'transportation': Transportation,
'note': Note,
'lodging': Lodging,
'visit': Visit,
}
if content_type_name not in content_type_map:
return Response({
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
}, status=status.HTTP_400_BAD_REQUEST)
# Get the content object
try:
content_object = content_type_map[content_type_name].objects.get(id=object_id)
except (ValueError, content_type_map[content_type_name].DoesNotExist):
return Response({
"error": f"{content_type_name} not found"
}, status=status.HTTP_404_NOT_FOUND)
# Check permissions using the permission class
permission_checker = IsOwnerOrSharedWithFullAccess()
if not permission_checker.has_object_permission(self.request, self, content_object):
return Response({
"error": "User does not have permission to access this content"
}, status=status.HTTP_403_FORBIDDEN)
return content_object
def _handle_immich_image_creation(self, request, content_object, content_type, object_id):
"""Handle creation of image from Immich for shared users"""
immich_id = request.data.get('immich_id')
# Get the shared user's Immich integration
try:
user_integration = ImmichIntegration.objects.get(user=request.user)
except ImmichIntegration.DoesNotExist:
return Response({
"error": "No Immich integration found for your account. Please set up Immich integration first.",
"code": "immich_integration_not_found"
}, status=status.HTTP_400_BAD_REQUEST)
# Download the image from the shared user's Immich server
try:
immich_response = requests.get(
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
headers={'x-api-key': user_integration.api_key},
timeout=10
)
immich_response.raise_for_status()
# Create a temporary file with the downloaded content
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg')
if not content_type_header.startswith('image/'):
return Response({
"error": "Invalid content type returned from Immich server.",
"code": "invalid_content_type"
}, status=status.HTTP_400_BAD_REQUEST)
# Determine file extension from content type
ext_map = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif'
}
file_ext = ext_map.get(content_type_header, '.jpg')
filename = f"immich_{immich_id}{file_ext}"
# Create a Django ContentFile from the downloaded image
image_file = ContentFile(immich_response.content, name=filename)
# Modify request data to use the downloaded image instead of immich_id
request_data = request.data.copy()
request_data.pop('immich_id', None) # Remove immich_id
request_data['image'] = image_file # Add the image file
request_data['content_type'] = content_type.id
request_data['object_id'] = object_id
# Create the serializer with the modified data
serializer = self.get_serializer(data=request_data)
serializer.is_valid(raise_exception=True)
# Save with the downloaded image
serializer.save(
user=content_object.user if hasattr(content_object, 'user') else request.user,
image=image_file,
content_type=content_type,
object_id=object_id
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except requests.exceptions.RequestException:
return Response({
"error": f"Failed to fetch image from Immich server",
"code": "immich_fetch_failed"
}, status=status.HTTP_502_BAD_GATEWAY)
except Exception:
return Response({
"error": f"Unexpected error processing Immich image",
"code": "immich_processing_error"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _create_standard_image(self, request, content_object, content_type, object_id):
"""Handle standard image creation without deepcopy issues"""
# Get uploaded image file safely
image_file = request.FILES.get('image')
immich_id = request.data.get('immich_id')
if not image_file and not immich_id:
return Response({"error": "No image uploaded"}, status=status.HTTP_400_BAD_REQUEST)
# Build a clean dict for serializer input
request_data = {
'content_type': content_type.id,
'object_id': object_id,
}
# Add immich_id if provided
if immich_id:
request_data['immich_id'] = immich_id
# Optionally add other fields (e.g., caption, alt text) from request.data
for key in ['caption', 'alt_text', 'description']: # update as needed
if key in request.data:
request_data[key] = request.data[key]
# Create and validate serializer
serializer = self.get_serializer(data=request_data)
serializer.is_valid(raise_exception=True)
# Prepare save parameters
save_kwargs = {
'user': getattr(content_object, 'user', request.user),
'content_type': content_type,
'object_id': object_id,
}
# Add image file if provided
if image_file:
save_kwargs['image'] = image_file
# Save with appropriate parameters
serializer.save(**save_kwargs)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def perform_create(self, serializer):
# The content_type and object_id are already set in the create method
# Just ensure the user is set correctly
pass

View File

@@ -7,19 +7,17 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
import requests
from adventures.models import Adventure, Category, Transportation, Lodging
from adventures.models import Location, Category
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer
from adventures.serializers import LocationSerializer
from adventures.utils import pagination
class AdventureViewSet(viewsets.ModelViewSet):
class LocationViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing Adventure objects with support for filtering, sorting,
and sharing functionality.
"""
serializer_class = AdventureSerializer
serializer_class = LocationSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
pagination_class = pagination.StandardResultsSetPagination
@@ -28,20 +26,20 @@ class AdventureViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""
Returns queryset based on user authentication and action type.
Public actions allow unauthenticated access to public adventures.
Public actions allow unauthenticated access to public locations.
"""
user = self.request.user
public_allowed_actions = {'retrieve', 'additional_info'}
if not user.is_authenticated:
if self.action in public_allowed_actions:
return Adventure.objects.retrieve_adventures(
return Location.objects.retrieve_locations(
user, include_public=True
).order_by('-updated_at')
return Adventure.objects.none()
return Location.objects.none()
include_public = self.action in public_allowed_actions
return Adventure.objects.retrieve_adventures(
return Location.objects.retrieve_locations(
user,
include_public=include_public,
include_owned=True,
@@ -67,7 +65,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
# Apply sorting logic
queryset = self._apply_ordering(queryset, order_by, order_direction)
# Filter adventures without collections if requested
# Filter locations without collections if requested
if include_collections == 'false':
queryset = queryset.filter(collections__isnull=True)
@@ -116,7 +114,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
# Use the current user as owner since ManyToMany allows multiple collection owners
user_to_assign = self.request.user
serializer.save(user_id=user_to_assign)
serializer.save(user=user_to_assign)
def perform_update(self, serializer):
"""Update adventure."""
@@ -142,23 +140,33 @@ class AdventureViewSet(viewsets.ModelViewSet):
self.perform_update(serializer)
return Response(serializer.data)
def destroy(self, request, *args, **kwargs):
"""Only allow the owner to delete a location."""
instance = self.get_object()
# Check if the user is the owner
if instance.user != request.user:
raise PermissionDenied("Only the owner can delete this location.")
return super().destroy(request, *args, **kwargs)
# ==================== CUSTOM ACTIONS ====================
@action(detail=False, methods=['get'])
def filtered(self, request):
"""Filter adventures by category types and visit status."""
"""Filter locations by category types and visit status."""
types = request.query_params.get('types', '').split(',')
# Handle 'all' types
if 'all' in types:
types = Category.objects.filter(
user_id=request.user
user=request.user
).values_list('name', flat=True)
else:
# Validate provided types
if not types or not all(
Category.objects.filter(user_id=request.user, name=type_name).exists()
Category.objects.filter(user=request.user, name=type_name).exists()
for type_name in types
):
return Response(
@@ -167,9 +175,9 @@ class AdventureViewSet(viewsets.ModelViewSet):
)
# Build base queryset
queryset = Adventure.objects.filter(
category__in=Category.objects.filter(name__in=types, user_id=request.user),
user_id=request.user.id
queryset = Location.objects.filter(
category__in=Category.objects.filter(name__in=types, user=request.user),
user=request.user.id
)
# Apply visit status filtering
@@ -180,19 +188,19 @@ class AdventureViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=['get'])
def all(self, request):
"""Get all adventures (public and owned) with optional collection filtering."""
"""Get all locations (public and owned) with optional collection filtering."""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
include_collections = request.query_params.get('include_collections', 'false') == 'true'
# Build queryset with collection filtering
base_filter = Q(user_id=request.user.id)
base_filter = Q(user=request.user.id)
if include_collections:
queryset = Adventure.objects.filter(base_filter)
queryset = Location.objects.filter(base_filter)
else:
queryset = Adventure.objects.filter(base_filter, collections__isnull=True)
queryset = Location.objects.filter(base_filter, collections__isnull=True)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
@@ -222,37 +230,50 @@ class AdventureViewSet(viewsets.ModelViewSet):
# ==================== HELPER METHODS ====================
def _validate_collection_permissions(self, collections):
"""Validate user has permission to use all provided collections. Only the owner or shared users can use collections."""
for collection in collections:
if not (collection.user_id == self.request.user or
collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
raise PermissionDenied(
f"You do not have permission to use collection '{collection.name}'."
)
def _validate_collection_update_permissions(self, instance, new_collections):
"""Validate permissions for collection updates (add/remove)."""
# Check permissions for new collections being added
for collection in new_collections:
if (collection.user_id != self.request.user and
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
raise PermissionDenied(
f"You do not have permission to use collection '{collection.name}'."
)
# Check permissions for collections being removed
"""Validate collection permissions for updates, allowing collection owners to unlink locations."""
current_collections = set(instance.collections.all())
new_collections_set = set(new_collections)
# Collections being added
collections_to_add = new_collections_set - current_collections
# Collections being removed
collections_to_remove = current_collections - new_collections_set
# Validate permissions for collections being added
for collection in collections_to_add:
# Standard validation for adding collections
if collection.user != self.request.user:
# Check if user has shared access to the collection
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
raise PermissionDenied(
f"You don't have permission to add location to collection '{collection.name}'"
)
# For collections being removed, allow if:
# 1. User owns the location, OR
# 2. User owns the collection (even if they don't own the location)
for collection in collections_to_remove:
if (collection.user_id != self.request.user and
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
raise PermissionDenied(
f"You cannot remove the adventure from collection '{collection.name}' "
f"as you don't have permission."
)
user_owns_location = instance.user == self.request.user
user_owns_collection = collection.user == self.request.user
if not (user_owns_location or user_owns_collection):
# Check if user has shared access to the collection
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
raise PermissionDenied(
f"You don't have permission to remove this location from one of the collections it's linked to.'"
)
def _validate_collection_permissions(self, collections):
"""Validate permissions for all collections (used in create)."""
for collection in collections:
if collection.user != self.request.user:
# Check if user has shared access to the collection
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
raise PermissionDenied(
f"You don't have permission to add location to collection '{collection.name}'"
)
def _apply_visit_filtering(self, queryset, request):
"""Apply visit status filtering to queryset."""
@@ -284,13 +305,13 @@ class AdventureViewSet(viewsets.ModelViewSet):
return True
# Check ownership
if user.is_authenticated and adventure.user_id == user:
if user.is_authenticated and adventure.user == user:
return True
# Check shared collection access
if user.is_authenticated:
for collection in adventure.collections.all():
if collection.shared_with.filter(uuid=user.uuid).exists():
if collection.shared_with.filter(uuid=user.uuid).exists() or collection.user == user:
return True
return False

View File

@@ -17,7 +17,7 @@ class LodgingViewSet(viewsets.ModelViewSet):
if not request.user.is_authenticated:
return Response(status=status.HTTP_403_FORBIDDEN)
queryset = Lodging.objects.filter(
Q(user_id=request.user.id)
Q(user=request.user.id)
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@@ -25,13 +25,13 @@ class LodgingViewSet(viewsets.ModelViewSet):
def get_queryset(self):
user = self.request.user
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures, user's own adventures and shared adventures
# For individual adventure retrieval, include public locations, user's own locations and shared locations
return Lodging.objects.filter(
Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id)
Q(is_public=True) | Q(user=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')
# For other actions, include user's own adventures and shared adventures
# For other actions, include user's own locations and shared locations
return Lodging.objects.filter(
Q(user_id=user.id) | Q(collection__shared_with=user.id)
Q(user=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')
def partial_update(self, request, *args, **kwargs):
@@ -48,11 +48,11 @@ class LodgingViewSet(viewsets.ModelViewSet):
if new_collection is not None and new_collection != instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
if new_collection.user != user or instance.user != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
if instance.collection is not None and instance.collection.user != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
@@ -73,12 +73,12 @@ class LodgingViewSet(viewsets.ModelViewSet):
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
if collection.user != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
serializer.save(user=collection.user)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)

View File

@@ -15,7 +15,7 @@ class NoteViewSet(viewsets.ModelViewSet):
# return error message if user is not authenticated on the root endpoint
def list(self, request, *args, **kwargs):
# Prevent listing all adventures
# Prevent listing all locations
return Response({"detail": "Listing all notes is not allowed."},
status=status.HTTP_403_FORBIDDEN)
@@ -24,7 +24,7 @@ class NoteViewSet(viewsets.ModelViewSet):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Note.objects.filter(
Q(user_id=request.user.id)
Q(user=request.user.id)
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@@ -39,14 +39,14 @@ class NoteViewSet(viewsets.ModelViewSet):
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
# For individual adventure retrieval, include public locations
return Note.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
Q(is_public=True) | Q(user=self.request.user.id) | Q(collection__shared_with=self.request.user)
).distinct().order_by('-updated_at')
else:
# For other actions, include user's own adventures and shared adventures
# For other actions, include user's own locations and shared locations
return Note.objects.filter(
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
Q(user=self.request.user.id) | Q(collection__shared_with=self.request.user)
).distinct().order_by('-updated_at')
def partial_update(self, request, *args, **kwargs):
@@ -65,11 +65,11 @@ class NoteViewSet(viewsets.ModelViewSet):
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
if new_collection.user != user or instance.user != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
if instance.collection is not None and instance.collection.user != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
@@ -94,11 +94,11 @@ class NoteViewSet(viewsets.ModelViewSet):
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
if new_collection.user != user or instance.user != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
if instance.collection is not None and instance.collection.user != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
@@ -119,12 +119,12 @@ class NoteViewSet(viewsets.ModelViewSet):
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
if collection.user != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
serializer.save(user=collection.user)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)

View File

@@ -5,8 +5,6 @@ from rest_framework.response import Response
from django.conf import settings
import requests
from geopy.distance import geodesic
import time
class RecommendationsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@@ -14,7 +12,7 @@ class RecommendationsViewSet(viewsets.ViewSet):
HEADERS = {'User-Agent': 'AdventureLog Server'}
def parse_google_places(self, places, origin):
adventures = []
locations = []
for place in places:
location = place.get('location', {})
@@ -45,16 +43,16 @@ class RecommendationsViewSet(viewsets.ViewSet):
"distance_km": round(distance_km, 2),
}
adventures.append(adventure)
locations.append(adventure)
# Sort by distance ascending
adventures.sort(key=lambda x: x["distance_km"])
locations.sort(key=lambda x: x["distance_km"])
return adventures
return locations
def parse_overpass_response(self, data, request):
nodes = data.get('elements', [])
adventures = []
locations = []
all = request.query_params.get('all', False)
origin = None
@@ -102,13 +100,13 @@ class RecommendationsViewSet(viewsets.ViewSet):
"powered_by": "osm"
}
adventures.append(adventure)
locations.append(adventure)
# Sort by distance if available
if origin:
adventures.sort(key=lambda x: x.get("distance_km") or float("inf"))
locations.sort(key=lambda x: x.get("distance_km") or float("inf"))
return adventures
return locations
def query_overpass(self, lat, lon, radius, category, request):
@@ -172,8 +170,8 @@ class RecommendationsViewSet(viewsets.ViewSet):
print("Overpass API error:", e)
return Response({"error": "Failed to retrieve data from Overpass API."}, status=500)
adventures = self.parse_overpass_response(data, request)
return Response(adventures)
locations = self.parse_overpass_response(data, request)
return Response(locations)
def query_google_nearby(self, lat, lon, radius, category, request):
"""Query Google Places API (New) for nearby places"""
@@ -216,9 +214,9 @@ class RecommendationsViewSet(viewsets.ViewSet):
places = data.get('places', [])
origin = (float(lat), float(lon))
adventures = self.parse_google_places(places, origin)
locations = self.parse_google_places(places, origin)
return Response(adventures)
return Response(locations)
except requests.exceptions.RequestException as e:
print(f"Google Places API error: {e}")

View File

@@ -3,11 +3,9 @@ from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
from adventures.models import Adventure
from adventures.serializers import AdventureSerializer
import requests
from adventures.models import Location
from adventures.serializers import LocationSerializer
from adventures.geocoding import reverse_geocode
from adventures.geocoding import extractIsoCode
from django.conf import settings
from adventures.geocoding import search_google, search_osm
@@ -47,14 +45,14 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
@action(detail=False, methods=['post'])
def mark_visited_region(self, request):
# searches through all of the users adventures, if the serialized data is_visited, is true, runs reverse geocode on the adventures and if a region is found, marks it as visited. Use the extractIsoCode function to get the region
# searches through all of the users locations, if the serialized data is_visited, is true, runs reverse geocode on the locations and if a region is found, marks it as visited. Use the extractIsoCode function to get the region
new_region_count = 0
new_regions = {}
new_city_count = 0
new_cities = {}
adventures = Adventure.objects.filter(user_id=self.request.user)
serializer = AdventureSerializer(adventures, many=True)
for adventure, serialized_adventure in zip(adventures, serializer.data):
locations = Location.objects.filter(user=self.request.user)
serializer = LocationSerializer(locations, many=True)
for adventure, serialized_adventure in zip(locations, serializer.data):
if serialized_adventure['is_visited'] == True:
lat = adventure.latitude
lon = adventure.longitude
@@ -69,18 +67,18 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
# data already contains region_id and city_id
if 'region_id' in data and data['region_id'] is not None:
region = Region.objects.filter(id=data['region_id']).first()
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
visited_region = VisitedRegion.objects.filter(region=region, user=self.request.user).first()
if not visited_region:
visited_region = VisitedRegion(region=region, user_id=self.request.user)
visited_region = VisitedRegion(region=region, user=self.request.user)
visited_region.save()
new_region_count += 1
new_regions[region.id] = region.name
if 'city_id' in data and data['city_id'] is not None:
city = City.objects.filter(id=data['city_id']).first()
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
visited_city = VisitedCity.objects.filter(city=city, user=self.request.user).first()
if not visited_city:
visited_city = VisitedCity(city=city, user_id=self.request.user)
visited_city = VisitedCity(city=city, user=self.request.user)
visited_city.save()
new_city_count += 1
new_cities[city.id] = city.name

View File

@@ -1,11 +1,12 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import action
from django.shortcuts import get_object_or_404
from adventures.utils.sports_types import SPORT_CATEGORIES
from adventures.utils.get_is_visited import is_location_visited
from django.db.models import Sum, Avg, Max, Count
from worldtravel.models import City, Region, Country, VisitedCity, VisitedRegion
from adventures.models import Adventure, Collection
from users.serializers import CustomUserDetailsSerializer as PublicUserSerializer
from adventures.models import Location, Collection, Activity
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -14,38 +15,168 @@ class StatsViewSet(viewsets.ViewSet):
"""
A simple ViewSet for listing the stats of a user.
"""
def _get_visited_locations_count(self, user):
"""Calculate count of visited locations for a user"""
visited_count = 0
# Get all locations for this user
user_locations = Location.objects.filter(user=user).prefetch_related('visits')
for location in user_locations:
if is_location_visited(location):
visited_count += 1
return visited_count
def _get_activity_stats_by_category(self, user_activities):
"""Calculate detailed stats for each sport category"""
category_stats = {}
for category, sports in SPORT_CATEGORIES.items():
activities = user_activities.filter(sport_type__in=sports)
if activities.exists():
# Calculate aggregated stats
stats = activities.aggregate(
count=Count('id'),
total_distance=Sum('distance'),
total_moving_time=Sum('moving_time'),
total_elevation_gain=Sum('elevation_gain'),
total_elevation_loss=Sum('elevation_loss'),
avg_distance=Avg('distance'),
max_distance=Max('distance'),
avg_elevation_gain=Avg('elevation_gain'),
max_elevation_gain=Max('elevation_gain'),
avg_speed=Avg('average_speed'),
max_speed=Max('max_speed'),
total_calories=Sum('calories')
)
# Convert Duration objects to total seconds for JSON serialization
total_moving_seconds = 0
if stats['total_moving_time']:
total_moving_seconds = int(stats['total_moving_time'].total_seconds())
# Get sport type breakdown within category
sport_breakdown = {}
for sport in sports:
sport_activities = activities.filter(sport_type=sport)
if sport_activities.exists():
sport_stats = sport_activities.aggregate(
count=Count('id'),
total_distance=Sum('distance'),
total_elevation_gain=Sum('elevation_gain')
)
sport_breakdown[sport] = {
'count': sport_stats['count'],
'total_distance': round(sport_stats['total_distance'] or 0, 2),
'total_elevation_gain': round(sport_stats['total_elevation_gain'] or 0, 2)
}
category_stats[category] = {
'count': stats['count'],
'total_distance': round(stats['total_distance'] or 0, 2),
'total_moving_time': total_moving_seconds,
'total_elevation_gain': round(stats['total_elevation_gain'] or 0, 2),
'total_elevation_loss': round(stats['total_elevation_loss'] or 0, 2),
'avg_distance': round(stats['avg_distance'] or 0, 2),
'max_distance': round(stats['max_distance'] or 0, 2),
'avg_elevation_gain': round(stats['avg_elevation_gain'] or 0, 2),
'max_elevation_gain': round(stats['max_elevation_gain'] or 0, 2),
'avg_speed': round(stats['avg_speed'] or 0, 2),
'max_speed': round(stats['max_speed'] or 0, 2),
'total_calories': round(stats['total_calories'] or 0, 2),
'sports': sport_breakdown
}
return category_stats
def _get_overall_activity_stats(self, user_activities):
"""Calculate overall activity statistics"""
if not user_activities.exists():
return {
'total_count': 0,
'total_distance': 0,
'total_moving_time': 0,
'total_elevation_gain': 0,
'total_elevation_loss': 0,
'total_calories': 0
}
stats = user_activities.aggregate(
total_count=Count('id'),
total_distance=Sum('distance'),
total_moving_time=Sum('moving_time'),
total_elevation_gain=Sum('elevation_gain'),
total_elevation_loss=Sum('elevation_loss'),
total_calories=Sum('calories')
)
# Convert Duration to seconds
total_moving_seconds = 0
if stats['total_moving_time']:
total_moving_seconds = int(stats['total_moving_time'].total_seconds())
return {
'total_count': stats['total_count'],
'total_distance': round(stats['total_distance'] or 0, 2),
'total_moving_time': total_moving_seconds,
'total_elevation_gain': round(stats['total_elevation_gain'] or 0, 2),
'total_elevation_loss': round(stats['total_elevation_loss'] or 0, 2),
'total_calories': round(stats['total_calories'] or 0, 2)
}
@action(detail=False, methods=['get'], url_path=r'counts/(?P<username>[\w.@+-]+)')
def counts(self, request, username):
if request.user.username == username:
user = get_object_or_404(User, username=username)
else:
user = get_object_or_404(User, username=username, public_profile=True)
# serializer = PublicUserSerializer(user)
# remove the email address from the response
user.email = None
# get the counts for the user
adventure_count = Adventure.objects.filter(
user_id=user.id).count()
trips_count = Collection.objects.filter(
user_id=user.id).count()
visited_city_count = VisitedCity.objects.filter(
user_id=user.id).count()
location_count = Location.objects.filter(user=user.id).count()
visited_location_count = self._get_visited_locations_count(user)
trips_count = Collection.objects.filter(user=user.id).count()
visited_city_count = VisitedCity.objects.filter(user=user.id).count()
total_cities = City.objects.count()
visited_region_count = VisitedRegion.objects.filter(
user_id=user.id).count()
visited_region_count = VisitedRegion.objects.filter(user=user.id).count()
total_regions = Region.objects.count()
visited_country_count = VisitedRegion.objects.filter(
user_id=user.id).values('region__country').distinct().count()
user=user.id).values('region__country').distinct().count()
total_countries = Country.objects.count()
# get activity data
user_activities = Activity.objects.filter(user=user.id)
# Get enhanced activity statistics
overall_activity_stats = self._get_overall_activity_stats(user_activities)
activity_stats_by_category = self._get_activity_stats_by_category(user_activities)
return Response({
'adventure_count': adventure_count,
# Travel stats
'location_count': location_count,
'visited_location_count': visited_location_count,
'trips_count': trips_count,
'visited_city_count': visited_city_count,
'total_cities': total_cities,
'visited_region_count': visited_region_count,
'total_regions': total_regions,
'visited_country_count': visited_country_count,
'total_countries': total_countries
'total_countries': total_countries,
# Overall activity stats
'activities_overall': overall_activity_stats,
# Detailed activity stats by category
'activities_by_category': activity_stats_by_category,
# Legacy fields (for backward compatibility)
'activity_distance': overall_activity_stats['total_distance'],
'activity_moving_time': overall_activity_stats['total_moving_time'],
'activity_elevation': overall_activity_stats['total_elevation_gain'],
'activity_count': overall_activity_stats['total_count'],
})

View File

@@ -2,7 +2,7 @@ from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from adventures.models import Adventure
from adventures.models import Location
class ActivityTypesView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@@ -10,7 +10,7 @@ class ActivityTypesView(viewsets.ViewSet):
@action(detail=False, methods=['get'])
def types(self, request):
"""
Retrieve a list of distinct activity types for adventures associated with the current user.
Retrieve a list of distinct activity types for locations associated with the current user.
Args:
request (HttpRequest): The HTTP request object.
@@ -18,7 +18,7 @@ class ActivityTypesView(viewsets.ViewSet):
Returns:
Response: A response containing a list of distinct activity types.
"""
types = Adventure.objects.filter(user_id=request.user.id).values_list('activity_types', flat=True).distinct()
types = Location.objects.filter(user=request.user).values_list('tags', flat=True).distinct()
allTypes = []

View File

@@ -0,0 +1,67 @@
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
from rest_framework.exceptions import PermissionDenied
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:
raise PermissionDenied("You must be authenticated to view trails.")
# 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):
location = serializer.validated_data.get('location')
if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location):
raise PermissionDenied("You do not have permission to add a trail to this location.")
# dont allow a user who does not own the location to attach a wanderer trail
if location.user != self.request.user and serializer.validated_data.get('wanderer_id'):
raise PermissionDenied("You cannot attach a wanderer trail to a location you do not own.")
serializer.save(user=location.user)
def perform_update(self, serializer):
instance = serializer.instance
new_location = serializer.validated_data.get('location')
# Prevent changing location after creation
if new_location and new_location != instance.location:
raise PermissionDenied("Cannot change trail location after creation. Create a new trail instead.")
# Check permission for updates to the existing location
if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location):
raise PermissionDenied("You do not have permission to update this trail.")
serializer.save()
def perform_destroy(self, instance):
if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location):
raise PermissionDenied("You do not have permission to delete this trail.")
instance.delete()

View File

@@ -1,12 +1,10 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Q
from adventures.models import Transportation
from adventures.serializers import TransportationSerializer
from rest_framework.exceptions import PermissionDenied
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from rest_framework.permissions import IsAuthenticated
class TransportationViewSet(viewsets.ModelViewSet):
queryset = Transportation.objects.all()
@@ -17,7 +15,7 @@ class TransportationViewSet(viewsets.ModelViewSet):
if not request.user.is_authenticated:
return Response(status=status.HTTP_403_FORBIDDEN)
queryset = Transportation.objects.filter(
Q(user_id=request.user.id)
Q(user=request.user.id)
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@@ -25,13 +23,13 @@ class TransportationViewSet(viewsets.ModelViewSet):
def get_queryset(self):
user = self.request.user
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures, user's own adventures and shared adventures
# For individual adventure retrieval, include public locations, user's own locations and shared locations
return Transportation.objects.filter(
Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id)
Q(is_public=True) | Q(user=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')
# For other actions, include user's own adventures and shared adventures
# For other actions, include user's own locations and shared locations
return Transportation.objects.filter(
Q(user_id=user.id) | Q(collection__shared_with=user.id)
Q(user=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')
def partial_update(self, request, *args, **kwargs):
@@ -48,11 +46,11 @@ class TransportationViewSet(viewsets.ModelViewSet):
if new_collection is not None and new_collection != instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
if new_collection.user != user or instance.user != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
if instance.collection is not None and instance.collection.user != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
@@ -73,12 +71,12 @@ class TransportationViewSet(viewsets.ModelViewSet):
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
if collection.user != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
serializer.save(user=collection.user)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)

View File

@@ -0,0 +1,72 @@
from rest_framework import viewsets
from django.db.models import Q
from adventures.models import Location, Visit
from adventures.serializers import VisitSerializer
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from rest_framework.exceptions import PermissionDenied
from adventures.models import background_geocode_and_assign
class VisitViewSet(viewsets.ModelViewSet):
serializer_class = VisitSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
def get_queryset(self):
"""
Returns visits based on location permissions.
Users can only see visits 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:
raise PermissionDenied("You must be authenticated to view visits.")
# 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 Visit.objects.filter(location_filter).distinct()
def perform_create(self, serializer):
"""
Set the user when creating a visit and check permissions.
"""
location = serializer.validated_data.get('location')
if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location):
raise PermissionDenied("You do not have permission to add a visit to this location.")
serializer.save()
# This will update any visited regions or cities based on if it's now visited
background_geocode_and_assign(str(location.id))
def perform_update(self, serializer):
instance = serializer.instance
new_location = serializer.validated_data.get('location')
# Prevent changing location after creation
if new_location and new_location != instance.location:
raise PermissionDenied("Cannot change visit location after creation. Create a new visit instead.")
# Check permission for updates to the existing location
if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location):
raise PermissionDenied("You do not have permission to update this visit.")
serializer.save()
background_geocode_and_assign(str(instance.location.id))
def perform_destroy(self, instance):
if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location):
raise PermissionDenied("You do not have permission to delete this visit.")
instance.delete()