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,60 +1,73 @@
# Use the official Python slim image as the base image
FROM python:3.13-slim
# Stage 1: Build stage with dependencies
FROM python:3.13-slim AS builder
# Metadata labels for the AdventureLog image
# Metadata labels
LABEL maintainer="Sean Morley" \
version="v0.10.0" \
version="0.10.0" \
description="AdventureLog — the ultimate self-hosted travel companion." \
org.opencontainers.image.title="AdventureLog" \
org.opencontainers.image.description="AdventureLog is a self-hosted travel companion that helps you plan, track, and share your adventures." \
org.opencontainers.image.version="v0.10.0" \
org.opencontainers.image.description="AdventureLog helps you plan, track, and share your adventures." \
org.opencontainers.image.version="0.10.0" \
org.opencontainers.image.authors="Sean Morley" \
org.opencontainers.image.url="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/banner.png" \
org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \
org.opencontainers.image.vendor="Sean Morley" \
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
org.opencontainers.image.licenses="GPL-3.0"
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set the working directory
WORKDIR /code
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies (Nginx included)
RUN apt-get update \
&& apt-get install -y git postgresql-client gdal-bin libgdal-dev nginx supervisor \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install system dependencies needed for build
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
postgresql-client \
gdal-bin \
libgdal-dev \
nginx \
memcached \
supervisor \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY ./server/requirements.txt /code/
RUN pip install --upgrade pip \
&& pip install -r requirements.txt
&& pip install --no-cache-dir -r requirements.txt
# Create necessary directories
RUN mkdir -p /code/static /code/media
# RUN mkdir -p /code/staticfiles /code/media
# Stage 2: Final image with runtime dependencies
FROM python:3.13-slim
WORKDIR /code
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DEBIAN_FRONTEND=noninteractive
# Copy the Django project code into the Docker image
# Install runtime dependencies (including GDAL)
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
gdal-bin \
libgdal-dev \
nginx \
memcached \
supervisor \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy Python packages from builder
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy project code and configs
COPY ./server /code/
# Copy Nginx configuration
COPY ./nginx.conf /etc/nginx/nginx.conf
# Copy Supervisor configuration
COPY ./supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY ./entrypoint.sh /code/entrypoint.sh
RUN chmod +x /code/entrypoint.sh \
&& mkdir -p /code/static /code/media
# Collect static files
RUN python3 manage.py collectstatic --noinput --verbosity 2
# Set the entrypoint script
COPY ./entrypoint.sh /code/entrypoint.sh
RUN chmod +x /code/entrypoint.sh
# Expose ports for NGINX and Gunicorn
# Expose ports
EXPOSE 80 8000
# Command to start Supervisor (which starts Nginx and Gunicorn)
# Start Supervisor
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -13,6 +13,9 @@ FRONTEND_URL='http://localhost:3000'
EMAIL_BACKEND='console'
# STRAVA_CLIENT_ID=''
# STRAVA_CLIENT_SECRET=''
# EMAIL_BACKEND='email'
# EMAIL_HOST='smtp.gmail.com'
# EMAIL_USE_TLS=False

View File

@@ -4,4 +4,4 @@
██╔══██║██║ ██║╚██╗ ██╔╝██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██╗██╔══╝ ██║ ██║ ██║██║ ██║
██║ ██║██████╔╝ ╚████╔╝ ███████╗██║ ╚████║ ██║ ╚██████╔╝██║ ██║███████╗███████╗╚██████╔╝╚██████╔╝
╚═╝ ╚═╝╚═════╝ ╚═══╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═════╝
The world is full of wonderful things you haven't seen yet. Don't ever give up on the chance of seeing them.” - J.K. Rowling
Go confidently in the direction of your dreams! Live the life you've imagined.” ― Henry David Thoreau

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
from django.contrib import admin
from allauth.account.decorators import secure_admin_login
from .models import ImmichIntegration
from .models import ImmichIntegration, StravaToken, WandererIntegration
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
admin.site.register(ImmichIntegration)
admin.site.register(ImmichIntegration)
admin.site.register(StravaToken)
admin.site.register(WandererIntegration)

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.2 on 2025-08-01 00:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0002_immichintegration_copy_locally'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StravaToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_token', models.CharField(max_length=255)),
('refresh_token', models.CharField(max_length=255)),
('expires_at', models.BigIntegerField()),
('athlete_id', models.BigIntegerField(blank=True, null=True)),
('scope', models.CharField(blank=True, max_length=255, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='strava_tokens', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.2.2 on 2025-08-04 16:40
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0003_stravatoken'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WandererIntegration',
fields=[
('server_url', models.CharField(max_length=255)),
('username', models.CharField(max_length=255)),
('token', models.CharField(blank=True, max_length=255, null=True)),
('token_expiry', models.DateTimeField(blank=True, null=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wanderer_integrations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Wanderer Integration',
'verbose_name_plural': 'Wanderer Integrations',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2025-08-04 16:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0004_wandererintegration'),
]
operations = [
migrations.AlterField(
model_name='wandererintegration',
name='token',
field=models.CharField(blank=True, max_length=1000, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2025-08-04 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0005_alter_wandererintegration_token'),
]
operations = [
migrations.AlterField(
model_name='wandererintegration',
name='token',
field=models.CharField(blank=True, null=True),
),
]

View File

@@ -13,4 +13,30 @@ class ImmichIntegration(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
def __str__(self):
return self.user.username + ' - ' + self.server_url
return self.user.username + ' - ' + self.server_url
class StravaToken(models.Model):
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name='strava_tokens')
access_token = models.CharField(max_length=255)
refresh_token = models.CharField(max_length=255)
expires_at = models.BigIntegerField() # Unix timestamp
athlete_id = models.BigIntegerField(null=True, blank=True)
scope = models.CharField(max_length=255, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class WandererIntegration(models.Model):
server_url = models.CharField(max_length=255)
username = models.CharField(max_length=255)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name='wanderer_integrations')
token = models.CharField(null=True, blank=True)
token_expiry = models.DateTimeField(null=True, blank=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
def __str__(self):
return self.user.username + ' - ' + self.server_url
class Meta:
verbose_name = "Wanderer Integration"
verbose_name_plural = "Wanderer Integrations"

View File

@@ -1,12 +1,15 @@
from integrations.views import *
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet
from integrations.views import IntegrationView, StravaIntegrationView, WandererIntegrationViewSet
# Create the router and register the ViewSet
router = DefaultRouter()
router.register(r'immich', ImmichIntegrationView, basename='immich')
router.register(r'', IntegrationView, basename='integrations')
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
router.register(r'strava', StravaIntegrationView, basename='strava')
router.register(r'wanderer', WandererIntegrationViewSet, basename='wanderer')
# Include the router URLs
urlpatterns = [

View File

@@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class StandardResultsSetPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 1000

View File

@@ -0,0 +1,4 @@
from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet
from .integration_view import IntegrationView
from .strava_view import StravaIntegrationView
from .wanderer_view import WandererIntegrationViewSet

View File

@@ -1,42 +1,19 @@
import os
from rest_framework.response import Response
from rest_framework import viewsets, status
from .serializers import ImmichIntegrationSerializer
from .models import ImmichIntegration
from integrations.serializers import ImmichIntegrationSerializer
from integrations.models import ImmichIntegration
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
import requests
from rest_framework.pagination import PageNumberPagination
from django.conf import settings
from adventures.models import AdventureImage
from adventures.models import ContentImage
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from integrations.utils import StandardResultsSetPagination
import logging
logger = logging.getLogger(__name__)
class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""
RESTful GET method for listing all integrations.
"""
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
return Response(
{
'immich': immich_integrations.exists(),
'google_maps': google_map_integration
},
status=status.HTTP_200_OK
)
class StandardResultsSetPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 1000
class ImmichIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination
@@ -253,11 +230,11 @@ class ImmichIntegrationView(viewsets.ViewSet):
"""
GET an Immich image using the integration and asset ID.
Access levels (in order of priority):
1. Public adventures: accessible by anyone
2. Private adventures in public collections: accessible by anyone
3. Private adventures in private collections shared with user: accessible by shared users
4. Private adventures: accessible only to the owner
5. No AdventureImage: owner can still view via integration
1. Public locations: accessible by anyone
2. Private locations in public collections: accessible by anyone
3. Private locations in private collections shared with user: accessible by shared users, and the collection owner
4. Private locations: accessible only to the owner
5. No ContentImage: owner can still view via integration
"""
if not imageid or not integration_id:
return Response({
@@ -268,55 +245,101 @@ class ImmichIntegrationView(viewsets.ViewSet):
# Lookup integration and user
integration = get_object_or_404(ImmichIntegration, id=integration_id)
owner_id = integration.user_id
owner_id = integration.user
# Try to find the image entry with collections and sharing information
image_entry = (
AdventureImage.objects
.filter(immich_id=imageid, user_id=owner_id)
.select_related('adventure')
.prefetch_related('adventure__collections', 'adventure__collections__shared_with')
.order_by('-adventure__is_public') # Public adventures first
.first()
# Get all images for this immich_id and user
image_entries = list(
ContentImage.objects
.filter(immich_id=imageid, user=owner_id)
.select_related('content_type')
)
# Sort by access level priority and find the best match
def get_access_priority(image_entry):
"""Return priority score for access control (lower = higher priority)"""
content_obj = image_entry.content_object
# Only handle Location objects for now (can be extended for other types)
if not hasattr(content_obj, 'is_public'):
return 999 # Low priority for non-location objects
# For Location objects, check access levels
if content_obj.is_public:
return 0 # Highest priority - public location
# Check if location is in any public collection
if hasattr(content_obj, 'collections'):
collections = content_obj.collections.all()
if any(collection.is_public for collection in collections):
return 1 # Second priority - private location in public collection
# Check for shared collections (if user is authenticated)
if (request.user.is_authenticated and
any(collection.shared_with.filter(id=request.user.id).exists()
for collection in collections)):
return 2 # Third priority - shared collection access
return 3 # Lowest priority - private location, owner access only
# Sort image entries by access priority
image_entries.sort(key=get_access_priority)
image_entry = image_entries[0] if image_entries else None
# Access control
if image_entry:
adventure = image_entry.adventure
collections = adventure.collections.all()
content_obj = image_entry.content_object
# Determine access level
is_authorized = False
# Level 1: Public adventure (highest priority)
if adventure.is_public:
is_authorized = True
# Only apply access control to Location objects
if hasattr(content_obj, 'is_public'):
location = content_obj
# Level 2: Private adventure in any public collection
elif any(collection.is_public for collection in collections):
is_authorized = True
# Determine access level
is_authorized = False
# Level 1: Public location (highest priority)
if location.is_public:
is_authorized = True
# Level 2: Private location in any public collection
elif hasattr(location, 'collections'):
collections = location.collections.all()
if any(collection.is_public for collection in collections):
is_authorized = True
# Level 3: Owner access
elif request.user.is_authenticated and request.user == owner_id:
is_authorized = True
# Level 4: Shared collection access or collection owner access
elif (request.user.is_authenticated and
(any(collection.shared_with.filter(id=request.user.id).exists()
for collection in collections) or
any(collection.user == request.user for collection in collections))):
is_authorized = True
else:
# Location without collections - owner access only
if request.user.is_authenticated and request.user == owner_id:
is_authorized = True
# Level 3: Owner access
elif request.user.is_authenticated and request.user.id == owner_id:
is_authorized = True
# Level 4: Shared collection access - check if user has access to any collection
elif (request.user.is_authenticated and
any(collection.shared_with.filter(id=request.user.id).exists()
for collection in collections)):
is_authorized = True
if not is_authorized:
return Response({
'message': 'This image belongs to a private adventure and you are not authorized.',
'error': True,
'code': 'immich.permission_denied'
}, status=status.HTTP_403_FORBIDDEN)
if not is_authorized:
return Response({
'message': 'This image belongs to a private location and you are not authorized.',
'error': True,
'code': 'immich.permission_denied'
}, status=status.HTTP_403_FORBIDDEN)
else:
# For non-Location objects, allow only owner access for now
if not request.user.is_authenticated or request.user != owner_id:
return Response({
'message': 'This image is not publicly accessible and you are not the owner.',
'error': True,
'code': 'immich.permission_denied'
}, status=status.HTTP_403_FORBIDDEN)
else:
# No AdventureImage exists; allow only the integration owner
if not request.user.is_authenticated or request.user.id != owner_id:
# No ContentImage exists; allow only the integration owner
if not request.user.is_authenticated or request.user != owner_id:
return Response({
'message': 'Image is not linked to any adventure and you are not the owner.',
'message': 'Image is not linked to any location and you are not the owner.',
'error': True,
'code': 'immich.not_found'
}, status=status.HTTP_404_NOT_FOUND)

View File

@@ -0,0 +1,42 @@
import os
from rest_framework.response import Response
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from integrations.models import ImmichIntegration, StravaToken, WandererIntegration
from django.conf import settings
class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""
RESTful GET method for listing all integrations.
"""
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
strava_integration_user = StravaToken.objects.filter(user=request.user).exists()
wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists()
is_wanderer_expired = False
if wanderer_integration:
token_expiry = WandererIntegration.objects.filter(user=request.user).first().token_expiry
if token_expiry and token_expiry < timezone.now():
is_wanderer_expired = True
return Response(
{
'immich': immich_integrations.exists(),
'google_maps': google_map_integration,
'strava': {
'global': strava_integration_global,
'user': strava_integration_user
},
'wanderer': {
'exists': wanderer_integration,
'expired': is_wanderer_expired
}
},
status=status.HTTP_200_OK
)

View File

@@ -0,0 +1,464 @@
from rest_framework.response import Response
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
import requests
import logging
import time
import re
from datetime import datetime, timedelta
from django.shortcuts import redirect
from django.conf import settings
from integrations.models import StravaToken
from adventures.utils.timezones import TIMEZONES
logger = logging.getLogger(__name__)
class StravaIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def extract_timezone_from_strava(self, strava_timezone):
"""
Extract IANA timezone from Strava's GMT offset format
Input: "(GMT-05:00) America/New_York" or "(GMT+01:00) Europe/Zurich"
Output: "America/New_York" if it exists in TIMEZONES, otherwise None
"""
if not strava_timezone:
return None
# Use regex to extract the IANA timezone identifier
# Pattern matches: (GMT±XX:XX) Timezone/Name
match = re.search(r'\(GMT[+-]\d{2}:\d{2}\)\s*(.+)', strava_timezone)
if match:
timezone_name = match.group(1).strip()
# Check if this timezone exists in our TIMEZONES list
if timezone_name in TIMEZONES:
return timezone_name
# If no match or timezone not in our list, try to find a close match
# This handles cases where Strava might use slightly different names
if match:
timezone_name = match.group(1).strip()
# Try some common variations
variations = [
timezone_name,
timezone_name.replace('_', '/'),
timezone_name.replace('/', '_'),
]
for variation in variations:
if variation in TIMEZONES:
return variation
return None
@action(detail=False, methods=['get'], url_path='authorize')
def authorize(self, request):
"""
Redirects the user to Strava's OAuth authorization page.
"""
client_id = settings.STRAVA_CLIENT_ID
redirect_uri = f"{settings.PUBLIC_URL}/api/integrations/strava/callback/"
scope = 'activity:read_all'
auth_url = (
f'https://www.strava.com/oauth/authorize?client_id={client_id}'
f'&response_type=code'
f'&redirect_uri={redirect_uri}'
f'&approval_prompt=auto'
f'&scope={scope}'
)
return Response({'auth_url': auth_url}, status=status.HTTP_200_OK)
@action(detail=False, methods=['get'], url_path='callback')
def callback(self, request):
"""
Handles the OAuth callback from Strava and exchanges the code for an access token.
Saves or updates the StravaToken model instance for the authenticated user.
"""
code = request.query_params.get('code')
if not code:
return Response(
{
'message': 'Missing authorization code from Strava.',
'error': True,
'code': 'strava.missing_code'
},
status=status.HTTP_400_BAD_REQUEST
)
token_url = 'https://www.strava.com/oauth/token'
payload = {
'client_id': int(settings.STRAVA_CLIENT_ID),
'client_secret': settings.STRAVA_CLIENT_SECRET,
'code': code,
'grant_type': 'authorization_code'
}
try:
response = requests.post(token_url, data=payload)
response_data = response.json()
if response.status_code != 200:
logger.warning("Strava token exchange failed: %s", response_data)
return Response(
{
'message': 'Failed to exchange code for access token.',
'error': True,
'code': 'strava.exchange_failed',
'details': response_data.get('message', 'Unknown error')
},
status=status.HTTP_400_BAD_REQUEST
)
logger.info("Strava token exchange successful for user %s", request.user.username)
# Save or update tokens in DB
strava_token, created = StravaToken.objects.update_or_create(
user=request.user,
defaults={
'access_token': response_data.get('access_token'),
'refresh_token': response_data.get('refresh_token'),
'expires_at': response_data.get('expires_at'),
'athlete_id': response_data.get('athlete', {}).get('id'),
'scope': response_data.get('scope'),
}
)
# redirect to frontend url / settings
frontend_url = settings.FRONTEND_URL
if not frontend_url.endswith('/'):
frontend_url += '/'
return redirect(f"{frontend_url}settings?tab=integrations")
except requests.RequestException as e:
logger.error("Error during Strava OAuth token exchange: %s", str(e))
return Response(
{
'message': 'Failed to connect to Strava.',
'error': True,
'code': 'strava.connection_failed'
},
status=status.HTTP_502_BAD_GATEWAY
)
@action(detail=False, methods=['post'], url_path='disable')
def disable(self, request):
"""
Disables the Strava integration for the authenticated user by deleting their StravaToken.
"""
strava_token = StravaToken.objects.filter(user=request.user).first()
if not strava_token:
return Response(
{
'message': 'Strava integration is not enabled for this user.',
'error': True,
'code': 'strava.not_enabled'
},
status=status.HTTP_404_NOT_FOUND
)
strava_token.delete()
return Response(
{'message': 'Strava integration disabled successfully.'},
status=status.HTTP_204_NO_CONTENT
)
def refresh_strava_token_if_needed(self, user):
strava_token = StravaToken.objects.filter(user=user).first()
if not strava_token:
return None, Response({
'message': 'You need to authorize Strava first.',
'error': True,
'code': 'strava.not_authorized'
}, status=status.HTTP_403_FORBIDDEN)
now = int(time.time())
# If token expires in less than 5 minutes, refresh it
if strava_token.expires_at - now < 300:
logger.info(f"Refreshing Strava token for user {user.username}")
refresh_url = 'https://www.strava.com/oauth/token'
payload = {
'client_id': int(settings.STRAVA_CLIENT_ID),
'client_secret': settings.STRAVA_CLIENT_SECRET,
'grant_type': 'refresh_token',
'refresh_token': strava_token.refresh_token,
}
try:
response = requests.post(refresh_url, data=payload)
data = response.json()
if response.status_code == 200:
# Update token info
strava_token.access_token = data['access_token']
strava_token.refresh_token = data['refresh_token']
strava_token.expires_at = data['expires_at']
strava_token.save()
return strava_token, None
else:
logger.error(f"Failed to refresh Strava token: {data}")
return None, Response({
'message': 'Failed to refresh Strava token.',
'error': True,
'code': 'strava.refresh_failed',
'details': data.get('message', 'Unknown error')
}, status=status.HTTP_400_BAD_REQUEST)
except requests.RequestException as e:
logger.error(f"Error refreshing Strava token: {str(e)}")
return None, Response({
'message': 'Failed to connect to Strava for token refresh.',
'error': True,
'code': 'strava.connection_failed'
}, status=status.HTTP_502_BAD_GATEWAY)
return strava_token, None
def extract_essential_activity_info(self, activity):
"""
Extract essential fields from a single activity dict with enhanced metrics
"""
# Calculate additional elevation metrics
elev_high = activity.get("elev_high")
elev_low = activity.get("elev_low")
total_elevation_gain = activity.get("total_elevation_gain", 0)
# Calculate total elevation loss (approximate)
total_elevation_range = None
estimated_elevation_loss = None
if elev_high is not None and elev_low is not None:
total_elevation_range = elev_high - elev_low
estimated_elevation_loss = max(0, total_elevation_range - total_elevation_gain)
# Calculate pace metrics
moving_time = activity.get("moving_time")
distance = activity.get("distance")
pace_per_km = None
pace_per_mile = None
if moving_time and distance and distance > 0:
pace_per_km = moving_time / (distance / 1000)
pace_per_mile = moving_time / (distance / 1609.34)
# Calculate efficiency metrics
grade_adjusted_speed = None
if activity.get("splits_metric") and len(activity.get("splits_metric", [])) > 0:
splits = activity.get("splits_metric", [])
grade_speeds = [split.get("average_grade_adjusted_speed") for split in splits if split.get("average_grade_adjusted_speed")]
if grade_speeds:
grade_adjusted_speed = sum(grade_speeds) / len(grade_speeds)
# Calculate time metrics
elapsed_time = activity.get("elapsed_time")
moving_time = activity.get("moving_time")
rest_time = None
if elapsed_time and moving_time:
rest_time = elapsed_time - moving_time
# Extract and normalize timezone
strava_timezone = activity.get("timezone")
normalized_timezone = self.extract_timezone_from_strava(strava_timezone)
return {
# Basic activity info
"id": activity.get("id"),
"name": activity.get("name"),
"type": activity.get("type"),
"sport_type": activity.get("sport_type"),
# Distance and time
"distance": activity.get("distance"), # meters
"distance_km": round(activity.get("distance", 0) / 1000, 2) if activity.get("distance") else None,
"distance_miles": round(activity.get("distance", 0) / 1609.34, 2) if activity.get("distance") else None,
"moving_time": activity.get("moving_time"), # seconds
"elapsed_time": activity.get("elapsed_time"), # seconds
"rest_time": rest_time, # seconds of non-moving time
# Enhanced elevation metrics
"total_elevation_gain": activity.get("total_elevation_gain"), # meters
"estimated_elevation_loss": estimated_elevation_loss, # meters (estimated)
"elev_high": activity.get("elev_high"), # highest point in meters
"elev_low": activity.get("elev_low"), # lowest point in meters
"total_elevation_range": total_elevation_range, # difference between high and low
# Date and location
"start_date": activity.get("start_date"),
"start_date_local": activity.get("start_date_local"),
"timezone": normalized_timezone, # Normalized IANA timezone
"timezone_raw": strava_timezone, # Original Strava format for reference
# Speed and pace metrics
"average_speed": activity.get("average_speed"), # m/s
"average_speed_kmh": round(activity.get("average_speed", 0) * 3.6, 2) if activity.get("average_speed") else None,
"average_speed_mph": round(activity.get("average_speed", 0) * 2.237, 2) if activity.get("average_speed") else None,
"max_speed": activity.get("max_speed"), # m/s
"max_speed_kmh": round(activity.get("max_speed", 0) * 3.6, 2) if activity.get("max_speed") else None,
"max_speed_mph": round(activity.get("max_speed", 0) * 2.237, 2) if activity.get("max_speed") else None,
"pace_per_km_seconds": pace_per_km, # seconds per km
"pace_per_mile_seconds": pace_per_mile, # seconds per mile
"grade_adjusted_average_speed": grade_adjusted_speed, # m/s accounting for elevation
# Performance metrics
"average_cadence": activity.get("average_cadence"),
"average_watts": activity.get("average_watts"),
"max_watts": activity.get("max_watts"),
"kilojoules": activity.get("kilojoules"),
"calories": activity.get("calories"),
# Achievement metrics
"achievement_count": activity.get("achievement_count"),
"kudos_count": activity.get("kudos_count"),
"comment_count": activity.get("comment_count"),
"pr_count": activity.get("pr_count"), # personal records achieved
# Equipment and technical
"gear_id": activity.get("gear_id"),
"device_name": activity.get("device_name"),
"trainer": activity.get("trainer"), # indoor trainer activity
"manual": activity.get("manual"), # manually entered
# GPS coordinates
"start_latlng": activity.get("start_latlng"),
"end_latlng": activity.get("end_latlng"),
# Export links
'export_original': f'https://www.strava.com/activities/{activity.get("id")}/export_original',
'export_gpx': f'https://www.strava.com/activities/{activity.get("id")}/export_gpx',
# Additional useful fields
"visibility": activity.get("visibility"),
"photo_count": activity.get("photo_count"),
"has_heartrate": activity.get("has_heartrate"),
"flagged": activity.get("flagged"),
"commute": activity.get("commute"),
}
@staticmethod
def format_pace_readable(pace_seconds):
"""
Helper function to convert pace in seconds to readable format (MM:SS)
"""
if pace_seconds is None:
return None
minutes = int(pace_seconds // 60)
seconds = int(pace_seconds % 60)
return f"{minutes}:{seconds:02d}"
@staticmethod
def format_time_readable(time_seconds):
"""
Helper function to convert time in seconds to readable format (HH:MM:SS)
"""
if time_seconds is None:
return None
hours = int(time_seconds // 3600)
minutes = int((time_seconds % 3600) // 60)
seconds = int(time_seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes}:{seconds:02d}"
@action(detail=False, methods=['get'], url_path='activities')
def activities(self, request):
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
if error_response:
return error_response
# Get date parameters from query string
start_date = request.query_params.get('start_date')
end_date = request.query_params.get('end_date')
per_page = request.query_params.get('per_page', 30) # Default to 30 activities
page = request.query_params.get('page', 1)
# Build query parameters for Strava API
params = {
'per_page': min(int(per_page), 200), # Strava max is 200
'page': int(page)
}
if start_date:
try:
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
params['after'] = int(start_dt.timestamp())
except ValueError:
return Response({
'message': 'Invalid start_date format. Use ISO format (e.g., 2024-01-01T00:00:00Z)',
'error': True,
'code': 'strava.invalid_start_date'
}, status=status.HTTP_400_BAD_REQUEST)
if end_date:
try:
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
params['before'] = int(end_dt.timestamp())
except ValueError:
return Response({
'message': 'Invalid end_date format. Use ISO format (e.g., 2024-12-31T23:59:59Z)',
'error': True,
'code': 'strava.invalid_end_date'
}, status=status.HTTP_400_BAD_REQUEST)
headers = {'Authorization': f'Bearer {strava_token.access_token}'}
try:
response = requests.get('https://www.strava.com/api/v3/athlete/activities',
headers=headers, params=params)
if response.status_code != 200:
return Response({
'message': 'Failed to fetch activities from Strava.',
'error': True,
'code': 'strava.fetch_failed',
'details': response.json().get('message', 'Unknown error')
}, status=status.HTTP_400_BAD_REQUEST)
activities = response.json()
essential_activities = [self.extract_essential_activity_info(act) for act in activities]
return Response({
'activities': essential_activities,
'count': len(essential_activities),
'page': int(page),
'per_page': int(per_page)
}, status=status.HTTP_200_OK)
except requests.RequestException as e:
logger.error(f"Error fetching Strava activities: {str(e)}")
return Response({
'message': 'Failed to connect to Strava.',
'error': True,
'code': 'strava.connection_failed'
}, status=status.HTTP_502_BAD_GATEWAY)
@action(detail=False, methods=['get'], url_path='activities/(?P<activity_id>[^/.]+)')
def activity(self, request, activity_id=None):
if not activity_id:
return Response({
'message': 'Activity ID is required.',
'error': True,
'code': 'strava.activity_id_required'
}, status=status.HTTP_400_BAD_REQUEST)
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
if error_response:
return error_response
headers = {'Authorization': f'Bearer {strava_token.access_token}'}
try:
response = requests.get(f'https://www.strava.com/api/v3/activities/{activity_id}', headers=headers)
if response.status_code != 200:
return Response({
'message': 'Failed to fetch activity from Strava.',
'error': True,
'code': 'strava.fetch_failed',
'details': response.json().get('message', 'Unknown error')
}, status=status.HTTP_400_BAD_REQUEST)
activity = response.json()
essential_activity = self.extract_essential_activity_info(activity)
return Response(essential_activity, status=status.HTTP_200_OK)
except requests.RequestException as e:
logger.error(f"Error fetching Strava activity: {str(e)}")
return Response({
'message': 'Failed to connect to Strava.',
'error': True,
'code': 'strava.connection_failed'
}, status=status.HTTP_502_BAD_GATEWAY)

View File

@@ -0,0 +1,165 @@
# views.py
import requests
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 rest_framework.exceptions import ValidationError, NotFound
from integrations.models import WandererIntegration
from integrations.wanderer_services import get_valid_session, login_to_wanderer, IntegrationError
from django.utils import timezone
class WandererIntegrationViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def _get_obj(self):
try:
return WandererIntegration.objects.filter(user=self.request.user).first()
except WandererIntegration.DoesNotExist:
raise NotFound("Wanderer integration not found.")
# def list(self, request):
# try:
# inst = self._get_obj()
# except NotFound:
# return Response([], status=status.HTTP_200_OK)
# return Response({
# "id": inst.id,
# "server_url": inst.server_url,
# "username": inst.username,
# "is_connected": bool(inst.token and inst.token_expiry and inst.token_expiry > timezone.now()),
# "token_expiry": inst.token_expiry,
# })
def create(self, request):
if WandererIntegration.objects.filter(user=request.user).exists():
raise ValidationError("Wanderer integration already exists. Use UPDATE instead.")
if not request.user.is_authenticated:
raise ValidationError("You must be authenticated to create a Wanderer integration.")
server_url = request.data.get("server_url")
username = request.data.get("username")
password = request.data.get("password")
if not server_url or not username or not password:
raise ValidationError(
"Must provide server_url, username + password in request data."
)
inst = WandererIntegration(
user=request.user,
server_url=server_url.rstrip("/"),
username=username,
)
try:
token, expiry = login_to_wanderer(inst, password)
except IntegrationError:
raise ValidationError({"error": "Failed to authenticate with Wanderer server."})
inst.token = token
inst.token_expiry = expiry
inst.save()
return Response(
{"message": "Wanderer integration created and authenticated successfully."},
status=status.HTTP_201_CREATED,
)
def update(self, request, pk=None):
inst = self._get_obj()
if not inst:
raise NotFound("Wanderer integration not found.")
if not self.request.user.is_authenticated:
raise ValidationError("You must be authenticated to update the integration.")
changed = False
for field in ("server_url", "username"):
if field in request.data and getattr(inst, field) != request.data[field]:
setattr(inst, field, request.data[field].rstrip("/") if field=="server_url" else request.data[field])
changed = True
password = request.data.get("password")
if not changed and not password:
return Response(
{"detail": "Nothing updated: send at least one of server_url, username, or password."},
status=status.HTTP_400_BAD_REQUEST,
)
# If password provided: re-auth / token renewal
if password:
try:
token, expiry = login_to_wanderer(inst, password)
except IntegrationError:
raise ValidationError({"error": "Failed to update integration. Please check your credentials and try again."})
inst.token = token
inst.token_expiry = expiry
inst.save()
return Response({"message": "Integration updated successfully."})
@action(detail=False, methods=["post"])
def disable(self, request):
inst = self._get_obj()
if not inst:
raise NotFound("Wanderer integration not found.")
if not self.request.user.is_authenticated:
raise ValidationError("You must be authenticated to disable the integration.")
inst.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"])
def refresh(self, request):
inst = self._get_obj()
if not self.request.user.is_authenticated:
raise ValidationError("You must be authenticated to refresh the integration.")
password = request.data.get("password")
try:
session = get_valid_session(inst, password_for_reauth=password)
except IntegrationError:
raise ValidationError({"detail": "An error occurred while refreshing the integration."})
return Response({
"token": inst.token,
"token_expiry": inst.token_expiry,
"is_connected": True,
})
@action(detail=False, methods=["get"], url_path='trails')
def trails(self, request):
inst = self._get_obj()
if not self.request.user.is_authenticated:
raise ValidationError("You must be authenticated to access trails.")
# Check if we need to prompt for password
password = request.query_params.get("password") # Allow password via query param if needed
try:
session = get_valid_session(inst, password_for_reauth=password)
except IntegrationError as e:
# If session expired and no password provided, give a helpful error
if "password is required" in str(e).lower():
raise ValidationError({
"detail": "Session expired or not authenticated. Please provide your password to re-authenticate.",
"requires_password": True
})
raise ValidationError({"detail": "An error occurred while refreshing the integration."})
# Pass along all query parameters except password
params = {k: v for k, v in request.query_params.items() if k != "password"}
url = f"{inst.server_url.rstrip('/')}/api/v1/trail"
try:
response = session.get(url, params=params, timeout=10)
response.raise_for_status()
except requests.RequestException:
raise ValidationError({"detail": f"Error fetching trails"})
return Response(response.json())

View File

@@ -0,0 +1,296 @@
# wanderer_services.py
import requests
from datetime import datetime
from datetime import timezone as dt_timezone
from django.utils import timezone as django_timezone
from django.core.cache import cache
from django.conf import settings
import logging
import hashlib
import json
from .models import WandererIntegration
logger = logging.getLogger(__name__)
class IntegrationError(Exception):
pass
# Use both possible cookie names
COOKIE_NAMES = ("pb_auth", "pb-auth")
LOGIN_PATH = "/api/v1/auth/login"
# Cache settings
TRAIL_CACHE_TIMEOUT = getattr(settings, 'WANDERER_TRAIL_CACHE_TIMEOUT', 60 * 15) # 15 minutes default
TRAIL_CACHE_PREFIX = 'wanderer_trail'
def _get_cache_key(integration_id: int, trail_id: str) -> str:
"""Generate a consistent cache key for trail data."""
return f"{TRAIL_CACHE_PREFIX}:{integration_id}:{trail_id}"
def _get_etag_cache_key(integration_id: int, trail_id: str) -> str:
"""Generate cache key for ETags."""
return f"{TRAIL_CACHE_PREFIX}_etag:{integration_id}:{trail_id}"
def login_to_wanderer(integration: WandererIntegration, password: str):
"""
Authenticate with Wanderer and return the auth cookie and expiry.
"""
url = integration.server_url.rstrip("/") + LOGIN_PATH
try:
resp = requests.post(url, json={
"username": integration.username,
"password": password
}, timeout=10)
resp.raise_for_status()
except requests.RequestException as exc:
logger.error("Error connecting to Wanderer login: %s", exc)
raise IntegrationError("Could not connect to Wanderer server.")
# Log only summary (not full token body)
logger.debug("Wanderer login status: %s, headers: %s", resp.status_code, resp.headers.get("Set-Cookie"))
# Extract auth cookie and expiry
token = None
expiry = None
for cookie in resp.cookies:
if cookie.name in COOKIE_NAMES:
token = cookie.value
if cookie.expires:
expiry = datetime.fromtimestamp(cookie.expires, tz=dt_timezone.utc)
else:
# If no expiry set, assume 24 hours from now
expiry = django_timezone.now() + django_timezone.timedelta(hours=24)
break
if not token:
logger.error("Wanderer login succeeded but no auth cookie in response.")
raise IntegrationError("Authentication succeeded, but token cookie not found.")
logger.info(f"Successfully authenticated with Wanderer. Token expires: {expiry}")
return token, expiry
def get_valid_session(integration: WandererIntegration, password_for_reauth: str = None):
"""
Get a requests session with valid authentication.
Will reuse existing token if valid, or re-authenticate if needed.
"""
now = django_timezone.now()
session = requests.Session()
if not integration:
raise IntegrationError("No Wanderer integration found.")
# Check if we have a valid token
if integration.token and integration.token_expiry and integration.token_expiry > now:
logger.debug("Using existing valid token")
session.cookies.set(COOKIE_NAMES[0], integration.token)
return session
# Token expired or missing - need to re-authenticate
if password_for_reauth is None:
raise IntegrationError("Session expired; password is required to reconnect.")
logger.info("Token expired, re-authenticating with Wanderer")
token, expiry = login_to_wanderer(integration, password_for_reauth)
# Update the integration with new token
integration.token = token
integration.token_expiry = expiry
integration.save(update_fields=["token", "token_expiry"])
# Set the cookie in the session
session.cookies.set(COOKIE_NAMES[0], token)
return session
def make_wanderer_request(integration: WandererIntegration, endpoint: str, method: str = "GET", password_for_reauth: str = None, **kwargs):
"""
Helper function to make authenticated requests to Wanderer API.
Args:
integration: WandererIntegration instance
endpoint: API endpoint (e.g., '/api/v1/list')
method: HTTP method (GET, POST, etc.)
password_for_reauth: Password to use if re-authentication is needed
**kwargs: Additional arguments to pass to requests method
Returns:
requests.Response object
"""
session = get_valid_session(integration, password_for_reauth)
url = f"{integration.server_url.rstrip('/')}{endpoint}"
try:
response = getattr(session, method.lower())(url, timeout=10, **kwargs)
response.raise_for_status()
return response
except requests.RequestException as exc:
logger.error(f"Error making {method} request to {url}: {exc}")
raise IntegrationError(f"Error communicating with Wanderer: {exc}")
def fetch_trail_by_id(integration: WandererIntegration, trail_id: str, password_for_reauth: str = None, use_cache: bool = True):
"""
Fetch a specific trail by its ID from the Wanderer API with intelligent caching.
Args:
integration: WandererIntegration instance
trail_id: ID of the trail to fetch
password_for_reauth: Password to use if re-authentication is needed
use_cache: Whether to use caching (default: True)
Returns:
dict: Trail data from the API
"""
cache_key = _get_cache_key(integration.id, trail_id)
etag_cache_key = _get_etag_cache_key(integration.id, trail_id)
# Try to get from cache first
if use_cache:
cached_data = cache.get(cache_key)
if cached_data:
logger.debug(f"Trail {trail_id} found in cache")
return cached_data
# Prepare headers for conditional requests
headers = {}
if use_cache:
cached_etag = cache.get(etag_cache_key)
if cached_etag:
headers['If-None-Match'] = cached_etag
try:
response = make_wanderer_request(
integration,
f"/api/v1/trail/{trail_id}",
password_for_reauth=password_for_reauth,
headers=headers
)
# Handle 304 Not Modified
if response.status_code == 304:
logger.debug(f"Trail {trail_id} not modified, using cached version")
cached_data = cache.get(cache_key)
if cached_data:
return cached_data
trail_data = response.json()
# Cache the result
if use_cache:
cache.set(cache_key, trail_data, TRAIL_CACHE_TIMEOUT)
# Cache ETag if present
etag = response.headers.get('ETag')
if etag:
cache.set(etag_cache_key, etag, TRAIL_CACHE_TIMEOUT)
logger.debug(f"Trail {trail_id} cached for {TRAIL_CACHE_TIMEOUT} seconds")
return trail_data
except requests.RequestException as exc:
# If we have cached data and the request fails, return cached data as fallback
if use_cache:
cached_data = cache.get(cache_key)
if cached_data:
logger.debug(f"API request failed, returning cached trail {trail_id}: {exc}")
return cached_data
raise
def fetch_multiple_trails_by_id(integration: WandererIntegration, trail_ids: list, password_for_reauth: str = None, use_cache: bool = True):
"""
Fetch multiple trails efficiently with batch caching.
Args:
integration: WandererIntegration instance
trail_ids: List of trail IDs to fetch
password_for_reauth: Password to use if re-authentication is needed
use_cache: Whether to use caching (default: True)
Returns:
dict: Dictionary mapping trail_id to trail data
"""
results = {}
uncached_ids = []
if use_cache:
# Get cache keys for all trails
cache_keys = {trail_id: _get_cache_key(integration.id, trail_id) for trail_id in trail_ids}
# Batch get from cache
cached_trails = cache.get_many(cache_keys.values())
key_to_id = {v: k for k, v in cache_keys.items()}
# Separate cached and uncached
for cache_key, trail_data in cached_trails.items():
trail_id = key_to_id[cache_key]
results[trail_id] = trail_data
uncached_ids = [tid for tid in trail_ids if tid not in results]
logger.debug(f"Found {len(results)} trails in cache, need to fetch {len(uncached_ids)}")
else:
uncached_ids = trail_ids
# Fetch uncached trails
for trail_id in uncached_ids:
try:
trail_data = fetch_trail_by_id(integration, trail_id, password_for_reauth, use_cache)
results[trail_id] = trail_data
except IntegrationError as e:
logger.error(f"Failed to fetch trail {trail_id}: {e}")
# Continue with other trails
continue
return results
def invalidate_trail_cache(integration_id: int, trail_id: str = None):
"""
Invalidate cached trail data.
Args:
integration_id: Integration ID
trail_id: Specific trail ID to invalidate, or None to clear all trails for this integration
"""
if trail_id:
# Invalidate specific trail
cache_key = _get_cache_key(integration_id, trail_id)
etag_cache_key = _get_etag_cache_key(integration_id, trail_id)
cache.delete_many([cache_key, etag_cache_key])
logger.info(f"Invalidated cache for trail {trail_id}")
else:
# This would require a more complex implementation to find all keys
# For now, we'll just log it - you might want to use cache versioning instead
logger.debug("Cache invalidation for all trails not implemented - consider using cache versioning")
def warm_trail_cache(integration: WandererIntegration, trail_ids: list, password_for_reauth: str = None):
"""
Pre-warm the cache with trail data.
Args:
integration: WandererIntegration instance
trail_ids: List of trail IDs to pre-load
password_for_reauth: Password to use if re-authentication is needed
"""
logger.info(f"Warming cache for {len(trail_ids)} trails")
fetch_multiple_trails_by_id(integration, trail_ids, password_for_reauth, use_cache=True)
# Decorator for additional caching layers
def cached_trail_method(timeout=TRAIL_CACHE_TIMEOUT):
"""
Decorator to add method-level caching to any function that takes integration and trail_id.
"""
def decorator(func):
def wrapper(integration, trail_id, *args, **kwargs):
# Create cache key based on function name and arguments
cache_key = f"{func.__name__}:{integration.id}:{trail_id}:{hashlib.md5(str(args).encode()).hexdigest()}"
cached_result = cache.get(cache_key)
if cached_result is not None:
return cached_result
result = func(integration, trail_id, *args, **kwargs)
cache.set(cache_key, result, timeout)
return result
return wrapper
return decorator

View File

@@ -88,7 +88,9 @@ ACCOUNT_EMAIL_VERIFICATION = 'none'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
'LOCATION': '127.0.0.1:11211',
'TIMEOUT': 60 * 60 * 24, # Optional: 1 day cache
}
}
@@ -295,6 +297,8 @@ CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGIN
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CORS_ALLOW_CREDENTIALS = True
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
LOGGING = {
@@ -322,9 +326,17 @@ LOGGING = {
},
}
PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
# Major release version of AdventureLog, not including the patch version date.
ADVENTURELOG_RELEASE_VERSION = 'v0.10.0'
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.6'
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')

View File

@@ -6,5 +6,5 @@ def get_user_uuid(user):
class CustomModelSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user_id'] = get_user_uuid(instance.user_id)
representation['user'] = get_user_uuid(instance.user)
return representation

View File

@@ -1,4 +1,4 @@
Django==5.2.1
Django==5.2.2
djangorestframework>=3.15.2
django-allauth==0.63.3
drf-yasg==1.21.4
@@ -6,7 +6,7 @@ django-cors-headers==4.4.0
coreapi==2.3.3
python-dotenv==1.1.0
psycopg2-binary==2.9.10
pillow==11.2.1
pillow==11.3.0
whitenoise==6.9.0
django-resized==1.0.3
django-geojson==4.2.0
@@ -23,4 +23,7 @@ tqdm==4.67.1
overpy==0.7
publicsuffix2==2.20191221
geopy==2.4.1
psutil==6.1.1
psutil==6.1.1
geojson==3.2.0
gpxpy==1.6.2
pymemcache==4.0.0

View File

@@ -2,111 +2,199 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="AdventureLog Server" />
<meta name="description" content="AdventureLog API Server" />
<meta name="author" content="Sean Morley" />
<title>AdventureLog API Server</title>
<!-- Latest compiled and minified CSS -->
<!-- Bootstrap 5 CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<!-- Bootstrap Icons -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css"
/>
<!-- Optional theme -->
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css"
/>
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
<style>
body {
background-color: #f9f9fb;
color: #222;
font-family: "Segoe UI", sans-serif;
}
.navbar {
background-color: #2c3e50;
}
.navbar-brand,
.nav-link {
color: #ecf0f1 !important;
}
.hero {
padding: 4rem 1rem;
background: linear-gradient(135deg, #2980b9, #6dd5fa);
color: white;
text-align: center;
border-radius: 0 0 1rem 1rem;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.25rem;
margin-bottom: 2rem;
}
.api-response {
margin-top: 1rem;
font-family: monospace;
background-color: #eef2f7;
padding: 1rem;
border-radius: 0.5rem;
}
footer {
text-align: center;
padding: 2rem 0;
font-size: 0.9rem;
color: #888;
}
</style>
</head>
<body role="document">
<div class="navbar navbar-inverse" role="navigation">
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg">
<div class="container">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target=".navbar-collapse"
>
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">AdventureLog API Server</a>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<li class="active"><a href="/">Server Home</a></li>
<li>
<a target="_blank" href="http://adventurelog.app"
>Documentation</a
>
</li>
<li>
<a class="navbar-brand" href="/">AdventureLog API</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item">
<a
class="nav-link"
href="http://adventurelog.app"
target="_blank"
href="https://github.com/seanmorley15/AdventureLog"
>Source Code</a
>
Documentation
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
>
Source Code
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/docs">API Docs</a>
</li>
<li><a href="/docs">API Docs</a></li>
</ul>
</div>
<!--/.nav-collapse -->
</div>
</nav>
<!-- Hero Section -->
<div class="hero">
<div class="container">
<h1><i class="bi bi-map"></i> AdventureLog API</h1>
<p>
The backend powering your travels — flexible, powerful, and open
source.
</p>
<a href="/docs" class="btn btn-light btn-lg shadow-sm"
><i class="bi bi-book"></i> Explore API Docs</a
>
</div>
</div>
<div class="container theme-showcase" role="main">
{% block content %}{% endblock %}
<!-- Main Content -->
<div class="container my-5">
{% block content %}
<div class="text-center">
<h2>Try a Sample Request</h2>
<p>Use the form below to test an API POST request.</p>
<form
class="ajax-post d-flex flex-column align-items-center"
action="/api/test"
method="post"
style="max-width: 500px; margin: auto"
>
<input
type="text"
name="example"
placeholder="Enter example data"
class="form-control mb-3"
required
/>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send"></i> Send Request
</button>
</form>
<div class="api-response"></div>
</div>
{% endblock %}
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<script type="text/javascript">
var error_response = function (data) {
<footer class="text-center text-muted py-4">
Open source with ❤️ by
<a href="https://seanmorley.com" target="_blank">Sean Morley</a> • View on
<a href="https://github.com/seanmorley15/AdventureLog" target="_blank"
>GitHub</a
>
<a href="https://adventurelog.app" target="_blank">adventurelog.app</a>
</footer>
<!-- Bootstrap JS -->
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<!-- jQuery (optional, used here for legacy script) -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK"
crossorigin="anonymous"
></script>
<script>
const error_response = (data) => {
$(".api-response").html(
"API Response: " +
data.status +
" " +
data.statusText +
"<br/>Content: " +
data.responseText
`<strong>API Response:</strong> ${data.status} ${data.statusText}<br/><strong>Content:</strong> ${data.responseText}`
);
};
var susccess_response = function (data) {
const susccess_response = (data) => {
$(".api-response").html(
"API Response: OK<br/>Content: " + JSON.stringify(data)
`<strong>API Response:</strong> OK<br/><strong>Content:</strong> ${JSON.stringify(
data,
null,
2
)}`
);
};
$().ready(function () {
$(document).ready(() => {
$("form.ajax-post button[type=submit]").click(function () {
var form = $("form.ajax-post");
const form = $("form.ajax-post");
$.post(form.attr("action"), form.serialize())
.fail(function (data) {
error_response(data);
})
.done(function (data) {
susccess_response(data);
});
.fail(error_response)
.done(susccess_response);
return false;
});
});
</script>
{% block script %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2025-08-05 15:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0004_customuser_disable_password'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='measurement_system',
field=models.CharField(choices=[('metric', 'Metric'), ('imperial', 'Imperial')], default='metric', max_length=10),
),
]

View File

@@ -9,6 +9,7 @@ class CustomUser(AbstractUser):
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
public_profile = models.BooleanField(default=False)
disable_password = models.BooleanField(default=False)
measurement_system = models.CharField(max_length=10, choices=[('metric', 'Metric'), ('imperial', 'Imperial')], default='metric')
def __str__(self):
return self.username

View File

@@ -50,7 +50,7 @@ class UserDetailsSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
extra_fields = ['profile_pic', 'uuid', 'public_profile']
extra_fields = ['profile_pic', 'uuid', 'public_profile', 'measurement_system']
if hasattr(UserModel, 'USERNAME_FIELD'):
extra_fields.append(UserModel.USERNAME_FIELD)
@@ -66,6 +66,8 @@ class UserDetailsSerializer(serializers.ModelSerializer):
extra_fields.append('is_staff')
if hasattr(UserModel, 'disable_password'):
extra_fields.append('disable_password')
if hasattr(UserModel, 'measurement_system'):
extra_fields.append('measurement_system')
fields = ['pk', *extra_fields]
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk', 'disable_password')
@@ -96,7 +98,7 @@ class CustomUserDetailsSerializer(UserDetailsSerializer):
class Meta(UserDetailsSerializer.Meta):
model = CustomUser
fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password', 'disable_password']
fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password', 'disable_password', 'measurement_system']
read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password', 'disable_password')
@staticmethod

View File

@@ -11,8 +11,8 @@ from django.shortcuts import get_object_or_404
from django.contrib.auth import get_user_model
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
from allauth.socialaccount.models import SocialApp
from adventures.serializers import AdventureSerializer, CollectionSerializer
from adventures.models import Adventure, Collection
from adventures.serializers import LocationSerializer, CollectionSerializer
from adventures.models import Location, Collection
from allauth.socialaccount.models import SocialAccount
User = get_user_model()
@@ -99,9 +99,9 @@ class PublicUserDetailView(APIView):
user.email = None
# Get the users adventures and collections to include in the response
adventures = Adventure.objects.filter(user_id=user, is_public=True)
collections = Collection.objects.filter(user_id=user, is_public=True)
adventure_serializer = AdventureSerializer(adventures, many=True)
adventures = Location.objects.filter(user=user, is_public=True)
collections = Collection.objects.filter(user=user, is_public=True)
adventure_serializer = LocationSerializer(adventures, many=True)
collection_serializer = CollectionSerializer(collections, many=True)
return Response({

View File

@@ -1,12 +1,12 @@
from django.core.management.base import BaseCommand
from adventures.models import Adventure
from adventures.models import Location
import time
class Command(BaseCommand):
help = 'Bulk geocode all adventures by triggering save on each one'
def handle(self, *args, **options):
adventures = Adventure.objects.all()
adventures = Location.objects.all()
total = adventures.count()
self.stdout.write(self.style.SUCCESS(f'Starting bulk geocoding of {total} adventures'))

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.1 on 2025-06-19 20:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0016_remove_city_insert_id_remove_country_insert_id_and_more'),
]
operations = [
migrations.RenameField(
model_name='visitedregion',
old_name='user_id',
new_name='user',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.1 on 2025-06-19 20:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0017_rename_user_id_visitedregion_user'),
]
operations = [
migrations.RenameField(
model_name='visitedcity',
old_name='user_id',
new_name='user',
),
]

View File

@@ -6,7 +6,7 @@ from django.contrib.gis.db import models as gis_models
User = get_user_model()
default_user_id = 1 # Replace with an actual user ID
default_user = 1 # Replace with an actual user ID
class Country(models.Model):
@@ -50,29 +50,29 @@ class City(models.Model):
class VisitedRegion(models.Model):
id = models.AutoField(primary_key=True)
user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id)
user = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user)
region = models.ForeignKey(Region, on_delete=models.CASCADE)
def __str__(self):
return f'{self.region.name} ({self.region.country.country_code}) visited by: {self.user_id.username}'
return f'{self.region.name} ({self.region.country.country_code}) visited by: {self.user.username}'
def save(self, *args, **kwargs):
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
if VisitedRegion.objects.filter(user=self.user, region=self.region).exists():
raise ValidationError("Region already visited by user.")
super().save(*args, **kwargs)
class VisitedCity(models.Model):
id = models.AutoField(primary_key=True)
user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id)
user = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user)
city = models.ForeignKey(City, on_delete=models.CASCADE)
def __str__(self):
return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}'
return f'{self.city.name} ({self.city.region.name}) visited by: {self.user.username}'
def save(self, *args, **kwargs):
if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists():
if VisitedCity.objects.filter(user=self.user, city=self.city).exists():
raise ValidationError("City already visited by user.")
super().save(*args, **kwargs)

View File

@@ -25,7 +25,7 @@ class CountrySerializer(serializers.ModelSerializer):
user = getattr(request, 'user', None)
if user and user.is_authenticated:
return VisitedRegion.objects.filter(region__country=obj, user_id=user).count()
return VisitedRegion.objects.filter(region__country=obj, user=user).count()
return 0
@@ -62,8 +62,8 @@ class VisitedRegionSerializer(CustomModelSerializer):
class Meta:
model = VisitedRegion
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
fields = ['id', 'user', 'region', 'longitude', 'latitude', 'name']
read_only_fields = ['user', 'id', 'longitude', 'latitude', 'name']
class VisitedCitySerializer(CustomModelSerializer):
longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True)
@@ -72,5 +72,5 @@ class VisitedCitySerializer(CustomModelSerializer):
class Meta:
model = VisitedCity
fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
fields = ['id', 'user', 'city', 'longitude', 'latitude', 'name']
read_only_fields = ['user', 'id', 'longitude', 'latitude', 'name']

View File

@@ -13,7 +13,7 @@ from django.contrib.gis.geos import Point
from django.conf import settings
from rest_framework.decorators import action
from django.contrib.staticfiles import finders
from adventures.models import Adventure
from adventures.models import Location
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@@ -28,7 +28,7 @@ def regions_by_country(request, country_code):
@permission_classes([IsAuthenticated])
def visits_by_country(request, country_code):
country = get_object_or_404(Country, country_code=country_code)
visits = VisitedRegion.objects.filter(region__country=country, user_id=request.user.id)
visits = VisitedRegion.objects.filter(region__country=country, user=request.user.id)
serializer = VisitedRegionSerializer(visits, many=True)
return Response(serializer.data)
@@ -45,7 +45,7 @@ def cities_by_region(request, region_id):
@permission_classes([IsAuthenticated])
def visits_by_region(request, region_id):
region = get_object_or_404(Region, id=region_id)
visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id)
visits = VisitedCity.objects.filter(city__region=region, user=request.user.id)
serializer = VisitedCitySerializer(visits, many=True)
return Response(serializer.data)
@@ -71,7 +71,7 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
# make a post action that will get all of the users adventures and check if the point is in any of the regions if so make a visited region object for that user if it does not already exist
@action(detail=False, methods=['post'])
def region_check_all_adventures(self, request):
adventures = Adventure.objects.filter(user_id=request.user.id, type='visited')
adventures = Location.objects.filter(user=request.user.id, type='visited')
count = 0
for adventure in adventures:
if adventure.latitude is not None and adventure.longitude is not None:
@@ -79,8 +79,8 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
point = Point(float(adventure.longitude), float(adventure.latitude), srid=4326)
region = Region.objects.filter(geometry__contains=point).first()
if region:
if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists():
VisitedRegion.objects.create(user_id=request.user, region=region)
if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists():
VisitedRegion.objects.create(user=request.user, region=region)
count += 1
except Exception as e:
print(f"Error processing adventure {adventure.id}: {e}")
@@ -97,14 +97,14 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
return VisitedRegion.objects.filter(user_id=self.request.user.id)
return VisitedRegion.objects.filter(user=self.request.user.id)
def perform_create(self, serializer):
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)
def create(self, request, *args, **kwargs):
request.data['user_id'] = request.user
if VisitedRegion.objects.filter(user_id=request.user.id, region=request.data['region']).exists():
request.data['user'] = request.user
if VisitedRegion.objects.filter(user=request.user.id, region=request.data['region']).exists():
return Response({"error": "Region already visited by user."}, status=400)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -115,7 +115,7 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
def destroy(self, request, **kwargs):
# delete by region id
region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region)
visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region)
if visited_region.exists():
visited_region.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -127,27 +127,27 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
return VisitedCity.objects.filter(user_id=self.request.user.id)
return VisitedCity.objects.filter(user=self.request.user.id)
def perform_create(self, serializer):
serializer.save(user_id=self.request.user)
serializer.save(user=self.request.user)
def create(self, request, *args, **kwargs):
request.data['user_id'] = request.user
request.data['user'] = request.user
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# if the region is not visited, visit it
region = serializer.validated_data['city'].region
if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists():
VisitedRegion.objects.create(user_id=request.user, region=region)
if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists():
VisitedRegion.objects.create(user=request.user, region=region)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by city id
city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city)
visited_city = VisitedCity.objects.filter(user=request.user.id, city=city)
if visited_city.exists():
visited_city.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -12,5 +12,13 @@ command=/code/entrypoint.sh
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[program:memcached]
command=memcached -u nobody -m 64 -p 11211
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0