mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-12-23 22:58:17 -05:00
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:
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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.')
|
||||
|
||||
@@ -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_,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
33
backend/server/adventures/migrations/0057_trail.py
Normal file
33
backend/server/adventures/migrations/0057_trail.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.2 on 2025-08-01 13:31
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0056_collectioninvite'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Trail',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('link', models.URLField(blank=True, max_length=2083, null=True, verbose_name='External Trail Link')),
|
||||
('wanderer_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Wanderer Trail ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trails', to='adventures.location')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Trail',
|
||||
'verbose_name_plural': 'Trails',
|
||||
},
|
||||
),
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -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'},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.2 on 2025-08-12 12:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0061_alter_activity_sport_type_alter_activity_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='activity',
|
||||
name='type',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='sport_type',
|
||||
field=models.CharField(choices=[('General', 'General'), ('Run', 'Run'), ('TrailRun', 'Trail Run'), ('Walk', 'Walk'), ('Hike', 'Hike'), ('VirtualRun', 'Virtual Run'), ('Ride', 'Ride'), ('MountainBikeRide', 'Mountain Bike Ride'), ('GravelRide', 'Gravel Ride'), ('EBikeRide', 'E-Bike Ride'), ('EMountainBikeRide', 'E-Mountain Bike Ride'), ('Velomobile', 'Velomobile'), ('VirtualRide', 'Virtual Ride'), ('Canoeing', 'Canoe'), ('Kayaking', 'Kayak'), ('Kitesurfing', 'Kitesurf'), ('Rowing', 'Rowing'), ('StandUpPaddling', 'Stand Up Paddling'), ('Surfing', 'Surf'), ('Swim', 'Swim'), ('Windsurfing', 'Windsurf'), ('Sailing', 'Sail'), ('IceSkate', 'Ice Skate'), ('AlpineSki', 'Alpine Ski'), ('BackcountrySki', 'Backcountry Ski'), ('NordicSki', 'Nordic Ski'), ('Snowboard', 'Snowboard'), ('Snowshoe', 'Snowshoe'), ('Handcycle', 'Handcycle'), ('InlineSkate', 'Inline Skate'), ('RockClimbing', 'Rock Climb'), ('RollerSki', 'Roller Ski'), ('Golf', 'Golf'), ('Skateboard', 'Skateboard'), ('Soccer', 'Football (Soccer)'), ('Wheelchair', 'Wheelchair'), ('Badminton', 'Badminton'), ('Tennis', 'Tennis'), ('Pickleball', 'Pickleball'), ('Crossfit', 'Crossfit'), ('Elliptical', 'Elliptical'), ('StairStepper', 'Stair Stepper'), ('WeightTraining', 'Weight Training'), ('Yoga', 'Yoga'), ('Workout', 'Workout'), ('HIIT', 'HIIT'), ('Pilates', 'Pilates'), ('TableTennis', 'Table Tennis'), ('Squash', 'Squash'), ('Racquetball', 'Racquetball'), ('VirtualRow', 'Virtual Rowing')], default='General', max_length=100),
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
39
backend/server/adventures/utils/geojson.py
Normal file
39
backend/server/adventures/utils/geojson.py
Normal 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"
|
||||
}
|
||||
25
backend/server/adventures/utils/get_is_visited.py
Normal file
25
backend/server/adventures/utils/get_is_visited.py
Normal 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
|
||||
77
backend/server/adventures/utils/sports_types.py
Normal file
77
backend/server/adventures/utils/sports_types.py
Normal 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']
|
||||
}
|
||||
419
backend/server/adventures/utils/timezones.py
Normal file
419
backend/server/adventures/utils/timezones.py
Normal 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"
|
||||
]
|
||||
@@ -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 *
|
||||
150
backend/server/adventures/views/activity_view.py
Normal file
150
backend/server/adventures/views/activity_view.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
784
backend/server/adventures/views/import_export_view.py
Normal file
784
backend/server/adventures/views/import_export_view.py
Normal 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
|
||||
307
backend/server/adventures/views/location_image_view.py
Normal file
307
backend/server/adventures/views/location_image_view.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
@@ -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 = []
|
||||
|
||||
67
backend/server/adventures/views/trail_view.py
Normal file
67
backend/server/adventures/views/trail_view.py
Normal 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()
|
||||
@@ -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)
|
||||
72
backend/server/adventures/views/visit_view.py
Normal file
72
backend/server/adventures/views/visit_view.py
Normal 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()
|
||||
@@ -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)
|
||||
29
backend/server/integrations/migrations/0003_stravatoken.py
Normal file
29
backend/server/integrations/migrations/0003_stravatoken.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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 = [
|
||||
|
||||
6
backend/server/integrations/utils.py
Normal file
6
backend/server/integrations/utils.py
Normal 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
|
||||
4
backend/server/integrations/views/__init__.py
Normal file
4
backend/server/integrations/views/__init__.py
Normal 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
|
||||
@@ -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)
|
||||
42
backend/server/integrations/views/integration_view.py
Normal file
42
backend/server/integrations/views/integration_view.py
Normal 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
|
||||
)
|
||||
464
backend/server/integrations/views/strava_view.py
Normal file
464
backend/server/integrations/views/strava_view.py
Normal 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)
|
||||
165
backend/server/integrations/views/wanderer_view.py
Normal file
165
backend/server/integrations/views/wanderer_view.py
Normal 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())
|
||||
296
backend/server/integrations/wanderer_services.py
Normal file
296
backend/server/integrations/wanderer_services.py
Normal 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
|
||||
@@ -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', '')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user