diff --git a/.env.example b/.env.example index 8a923ae3..fed0981c 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,11 @@ DISABLE_REGISTRATION=False # EMAIL_HOST_PASSWORD=password # DEFAULT_FROM_EMAIL=user@example.com +# Optional: Use Strava integration +# https://adventurelog.app/docs/configuration/strava_integration.html +# STRAVA_CLIENT_ID=your_strava_client_id +# STRAVA_CLIENT_SECRET=your_strava_client_secret + # Optional: Use Umami for analytics # https://adventurelog.app/docs/configuration/analytics.html # PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js # If you are using the hosted version of Umami diff --git a/.github/workflows/backend-beta.yml b/.github/workflows/backend-beta.yml index a02bbaf1..a9a80979 100644 --- a/.github/workflows/backend-beta.yml +++ b/.github/workflows/backend-beta.yml @@ -1,5 +1,9 @@ name: Upload beta backend image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: push: branches: @@ -15,17 +19,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.ACCESS_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -36,11 +40,20 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: set lower case owner name - run: | - echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} + - name: Build and push beta Docker image with BuildKit cache + uses: docker/build-push-action@v5 + with: + context: ./backend + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:beta + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:beta + cache-from: | + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache + type=local,src=/tmp/.buildx-cache + cache-to: | + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + type=local,dest=/tmp/.buildx-cache,mode=max env: - OWNER: "${{ github.repository_owner }}" - - - name: Build Docker images - run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:beta -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:beta ./backend + DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/backend-latest.yml b/.github/workflows/backend-latest.yml index 5351d9d6..dd3b9ce1 100644 --- a/.github/workflows/backend-latest.yml +++ b/.github/workflows/backend-latest.yml @@ -1,5 +1,9 @@ name: Upload latest backend image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: push: branches: @@ -15,32 +19,41 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.ACCESS_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: set lower case owner name - run: | - echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} - env: - OWNER: '${{ github.repository_owner }}' - - name: Build Docker images - run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:latest -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:latest ./backend + - name: Build and push latest Docker image with BuildKit cache + uses: docker/build-push-action@v5 + with: + context: ./backend + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest + cache-from: | + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache + type=local,src=/tmp/.buildx-cache + cache-to: | + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + type=local,dest=/tmp/.buildx-cache,mode=max + env: + DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml index 696f8b5a..ff1919db 100644 --- a/.github/workflows/backend-release.yml +++ b/.github/workflows/backend-release.yml @@ -1,5 +1,9 @@ name: Upload the tagged release backend image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: release: types: [released] @@ -12,17 +16,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.ACCESS_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -33,11 +37,20 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: set lower case owner name - run: | - echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} + - name: Build and push release Docker image with BuildKit cache + uses: docker/build-push-action@v5 + with: + context: ./backend + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} + cache-from: | + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache + type=local,src=/tmp/.buildx-cache + cache-to: | + type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + type=local,dest=/tmp/.buildx-cache,mode=max env: - OWNER: "${{ github.repository_owner }}" - - - name: Build Docker images - run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:${{ github.event.release.tag_name }} -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:${{ github.event.release.tag_name }} ./backend + DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 0ce9d7c3..a01cd48f 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -5,39 +5,33 @@ permissions: on: pull_request: - paths: - - 'backend/server/**' - - '.github/workflows/backend-test.yml' push: - paths: - - 'backend/server/**' - - '.github/workflows/backend-test.yml' jobs: build: + name: Build and Test Backend runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - name: set up python 3.12 uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - name: install dependencies run: | sudo apt update -q - sudo apt install -y -q \ - python3-gdal + sudo apt install -y -q python3-gdal - name: start database run: | docker compose -f .github/.docker-compose-database.yml up -d - - name: install python libreries + - name: install python libraries working-directory: backend/server - run: | - pip install -r requirements.txt + run: pip install -r requirements.txt - name: run server working-directory: backend/server diff --git a/.github/workflows/cdn-beta.yml b/.github/workflows/cdn-beta.yml index d5c2c852..d229b606 100644 --- a/.github/workflows/cdn-beta.yml +++ b/.github/workflows/cdn-beta.yml @@ -1,5 +1,9 @@ name: Upload beta CDN image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: push: branches: diff --git a/.github/workflows/cdn-latest.yml b/.github/workflows/cdn-latest.yml index 376ede98..65a88dae 100644 --- a/.github/workflows/cdn-latest.yml +++ b/.github/workflows/cdn-latest.yml @@ -1,5 +1,9 @@ name: Upload latest CDN image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: push: branches: diff --git a/.github/workflows/cdn-release.yml b/.github/workflows/cdn-release.yml index 2bba9afb..11cb258a 100644 --- a/.github/workflows/cdn-release.yml +++ b/.github/workflows/cdn-release.yml @@ -1,5 +1,9 @@ name: Upload the tagged release CDN image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: release: types: [released] diff --git a/.github/workflows/frontend-beta.yml b/.github/workflows/frontend-beta.yml index 29597ae0..7e1553ee 100644 --- a/.github/workflows/frontend-beta.yml +++ b/.github/workflows/frontend-beta.yml @@ -1,5 +1,9 @@ name: Upload beta frontend image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: push: branches: diff --git a/.github/workflows/frontend-latest.yml b/.github/workflows/frontend-latest.yml index a74ae2f5..879ed453 100644 --- a/.github/workflows/frontend-latest.yml +++ b/.github/workflows/frontend-latest.yml @@ -1,5 +1,9 @@ name: Upload latest frontend image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: push: branches: @@ -29,18 +33,18 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: set lower case owner name run: | echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} env: - OWNER: '${{ github.repository_owner }}' + OWNER: "${{ github.repository_owner }}" - name: Build Docker images run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:latest -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:latest ./frontend diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index bb7fc6ba..d8de6ef3 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -1,5 +1,9 @@ name: Upload tagged release frontend image to GHCR and Docker Hub +permissions: + contents: read + packages: write + on: release: types: [released] diff --git a/.github/workflows/frontend-test.yml b/.github/workflows/frontend-test.yml index 73d9b24d..8a95cd83 100644 --- a/.github/workflows/frontend-test.yml +++ b/.github/workflows/frontend-test.yml @@ -5,17 +5,13 @@ permissions: on: pull_request: - paths: - - "frontend/**" - - ".github/workflows/frontend-test.yml" push: - paths: - - "frontend/**" - - ".github/workflows/frontend-test.yml" jobs: build: + name: Build and Test Frontend runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/trivy_security_scans.yml b/.github/workflows/trivy_security_scans.yml new file mode 100644 index 00000000..43ecfcbe --- /dev/null +++ b/.github/workflows/trivy_security_scans.yml @@ -0,0 +1,77 @@ +name: Trivy Security Scans + +permissions: + contents: read # Minimal permissions needed for scanning source and images + +on: + push: + branches: + - main + - development + pull_request: + branches: + - main + - development + schedule: + - cron: "0 8 * * 1" # Weekly scan on Mondays at 8 AM UTC + +jobs: + filesystem-scan: + name: Trivy Filesystem Scan (Source Code) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Scan source code (Filesystem) with Trivy + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + format: table + exit-code: 1 + ignore-unfixed: true + severity: CRITICAL,HIGH + + image-scan: + name: Trivy Docker Image Scan (Backend & Frontend) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build backend Docker image + run: docker build -t adventurelog-backend ./backend + + - name: Build frontend Docker image + run: docker build -t adventurelog-frontend ./frontend + + - name: Scan backend Docker image with Trivy + uses: aquasecurity/trivy-action@master + with: + image-ref: adventurelog-backend + format: table + exit-code: 1 + ignore-unfixed: true + severity: CRITICAL,HIGH + + - name: Scan frontend Docker image with Trivy + uses: aquasecurity/trivy-action@master + with: + image-ref: adventurelog-frontend + format: table + exit-code: 1 + ignore-unfixed: true + severity: CRITICAL,HIGH diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b49b3d2..b6013008 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,9 @@ We’re excited to have you contribute to AdventureLog! To ensure that this comm 1. **Open an Issue First**: Discuss any changes or features you plan to implement by opening an issue. This helps to clarify your idea and ensures there’s a shared understanding. 2. **Document Changes**: If your changes impact the user interface, add new environment variables, or introduce new container configurations, make sure to update the documentation accordingly. The documentation is located in the `documentation` folder. -3. **Pull Request**: Submit a pull request with your changes. Make sure to reference the issue you opened in the description. +3. **Pull Request**: Submit a pull request with your changes directed towards the `development` branch. Make sure to reference the issue you opened in the description. After your pull request is submitted, it will be reviewed by the maintainers. +4. **Review Process**: The maintainers will review your pull request. They may suggest changes or improvements. Please be open to feedback and ready to make adjustments as needed. +5. **Merge**: Once your pull request is approved, it will be merged into the `development` branch. This branch is where all new features and changes are integrated before being released to the main branch. ## Code of Conduct diff --git a/README.md b/README.md index 73aa31b8..e1c7b4cf 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ## ⭐ About the Project -Starting from a simple idea of tracking travel locations (called adventures), AdventureLog has grown into a full-fledged travel companion. With AdventureLog, you can log your adventures, keep track of where you've been on the world map, plan your next trip collaboratively, and share your experiences with friends and family. +Starting from a simple idea of tracking travel locations, AdventureLog has grown into a full-fledged travel companion. With AdventureLog, you can log your adventures, keep track of where you've been on the world map, plan your next trip collaboratively, and share your experiences with friends and family. AdventureLog was created to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone. @@ -47,15 +47,17 @@ AdventureLog was created to solve a problem: the lack of a modern, open-source, ### 📷 Screenshots
- Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
-
- Shows specific details about an adventure, including the name, date, location, description, and rating.
+
+ Displays the locations you have visited and the ones you plan to embark on. You can also filter and sort the locations.
+
+ Shows specific details about a location, including the name, date, location, description, and rating.
-
- View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map
+
+ View all of your locations on a map, with the ability to filter by visit status and add new ones by click on the map
+
+ View a 3D representation of your locations and activities on the map, allowing for a more immersive exploration of your travel history.
- Displays a summary of your adventures, including your world travel stats.
+Displays a summary of your locations, including your world travel stats.
Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.
@@ -92,16 +94,17 @@ AdventureLog was created to solve a problem: the lack of a modern, open-source,
### 🎯 Features
- **Track Your Adventures** 🌍: Log your adventures and keep track of where you've been on the world map.
- - Adventures can store a variety of information, including the location, date, and description.
- - Adventures can be sorted into custom categories for easy organization.
- - Adventures can be marked as private or public, allowing you to share your adventures with friends and family.
+ - Locations can store a variety of information, including the location, date, and description.
+ - Locations can be sorted into custom categories for easy organization.
+ - Locations can be marked as private or public, allowing you to share your adventures with friends and family.
- Keep track of the countries and regions you've visited with the world travel book.
+ - Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
- Itineraries can be created for any number of days and can include multiple destinations.
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
- Itineraries can be shared with friends and family for collaborative planning.
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
- - Adventures and itineraries can be shared via a public link or directly with other AdventureLog users.
+ - Locations and itineraries can be shared via a public link or directly with other AdventureLog users.
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
diff --git a/backend/Dockerfile b/backend/Dockerfile
index b3f41b77..953d0563 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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"]
diff --git a/backend/server/.env.example b/backend/server/.env.example
index 2c932085..d1af5396 100644
--- a/backend/server/.env.example
+++ b/backend/server/.env.example
@@ -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
diff --git a/backend/server/adventurelog.txt b/backend/server/adventurelog.txt
index 229c7712..c00b20f0 100644
--- a/backend/server/adventurelog.txt
+++ b/backend/server/adventurelog.txt
@@ -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
diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py
index e23fa158..5967850b 100644
--- a/backend/server/adventures/admin.py
+++ b/backend/server/adventures/admin.py
@@ -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'
diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py
index f10c97a6..86aab82b 100644
--- a/backend/server/adventures/geocoding.py
+++ b/backend/server/adventures/geocoding.py
@@ -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):
diff --git a/backend/server/adventures/management/commands/activity_elevation_fix.py b/backend/server/adventures/management/commands/activity_elevation_fix.py
new file mode 100644
index 00000000..756a1027
--- /dev/null
+++ b/backend/server/adventures/management/commands/activity_elevation_fix.py
@@ -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
\ No newline at end of file
diff --git a/backend/server/adventures/management/commands/image_cleanup.py b/backend/server/adventures/management/commands/image_cleanup.py
new file mode 100644
index 00000000..806d1854
--- /dev/null
+++ b/backend/server/adventures/management/commands/image_cleanup.py
@@ -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.')
+
diff --git a/backend/server/adventures/management/commands/travel-seed.py b/backend/server/adventures/management/commands/travel-seed.py
index 96700c95..eec9e35b 100644
--- a/backend/server/adventures/management/commands/travel-seed.py
+++ b/backend/server/adventures/management/commands/travel-seed.py
@@ -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_,
diff --git a/backend/server/adventures/managers.py b/backend/server/adventures/managers.py
index 4a12194e..525bf3ba 100644
--- a/backend/server/adventures/managers.py
+++ b/backend/server/adventures/managers.py
@@ -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)
diff --git a/backend/server/adventures/migrations/0036_rename_adventure_location_squashed_0050_rename_user_id_lodging_user.py b/backend/server/adventures/migrations/0036_rename_adventure_location_squashed_0050_rename_user_id_lodging_user.py
new file mode 100644
index 00000000..52f989ae
--- /dev/null
+++ b/backend/server/adventures/migrations/0036_rename_adventure_location_squashed_0050_rename_user_id_lodging_user.py
@@ -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',
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0051_rename_activity_types_location_tags_and_more.py b/backend/server/adventures/migrations/0051_rename_activity_types_location_tags_and_more.py
new file mode 100644
index 00000000..03a8b659
--- /dev/null
+++ b/backend/server/adventures/migrations/0051_rename_activity_types_location_tags_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0052_rename_attachment_contentattachment_and_more.py b/backend/server/adventures/migrations/0052_rename_attachment_contentattachment_and_more.py
new file mode 100644
index 00000000..0b0840cd
--- /dev/null
+++ b/backend/server/adventures/migrations/0052_rename_attachment_contentattachment_and_more.py
@@ -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',
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0053_alter_contentattachment_options_and_more.py b/backend/server/adventures/migrations/0053_alter_contentattachment_options_and_more.py
new file mode 100644
index 00000000..22eb7783
--- /dev/null
+++ b/backend/server/adventures/migrations/0053_alter_contentattachment_options_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0054_migrate_location_images_generic_relation.py b/backend/server/adventures/migrations/0054_migrate_location_images_generic_relation.py
new file mode 100644
index 00000000..7277b7f9
--- /dev/null
+++ b/backend/server/adventures/migrations/0054_migrate_location_images_generic_relation.py
@@ -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
+ )
+ ]
\ No newline at end of file
diff --git a/backend/server/adventures/migrations/0055_alter_contentattachment_content_type_and_more.py b/backend/server/adventures/migrations/0055_alter_contentattachment_content_type_and_more.py
new file mode 100644
index 00000000..923a8b02
--- /dev/null
+++ b/backend/server/adventures/migrations/0055_alter_contentattachment_content_type_and_more.py
@@ -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(),
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0056_collectioninvite.py b/backend/server/adventures/migrations/0056_collectioninvite.py
new file mode 100644
index 00000000..3bde4a51
--- /dev/null
+++ b/backend/server/adventures/migrations/0056_collectioninvite.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0057_trail.py b/backend/server/adventures/migrations/0057_trail.py
new file mode 100644
index 00000000..c535029e
--- /dev/null
+++ b/backend/server/adventures/migrations/0057_trail.py
@@ -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',
+ },
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py b/backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py
new file mode 100644
index 00000000..45129251
--- /dev/null
+++ b/backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py
@@ -0,0 +1,55 @@
+# Generated by Django 5.2.2 on 2025-08-03 16:30
+
+import adventures.models
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('adventures', '0057_trail'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='collectioninvite',
+ options={'verbose_name': 'Collection Invite'},
+ ),
+ migrations.CreateModel(
+ name='Activity',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('gpx_file', models.FileField(blank=True, null=True, upload_to=adventures.models.PathAndRename('activities/'), validators=[adventures.models.validate_file_extension])),
+ ('name', models.CharField(max_length=200)),
+ ('type', models.CharField(default='general', max_length=100)),
+ ('sport_type', models.CharField(blank=True, max_length=100, null=True)),
+ ('distance', models.FloatField(blank=True, null=True)),
+ ('moving_time', models.DurationField(blank=True, null=True)),
+ ('elapsed_time', models.DurationField(blank=True, null=True)),
+ ('rest_time', models.DurationField(blank=True, null=True)),
+ ('elevation_gain', models.FloatField(blank=True, null=True)),
+ ('elevation_loss', models.FloatField(blank=True, null=True)),
+ ('elev_high', models.FloatField(blank=True, null=True)),
+ ('elev_low', models.FloatField(blank=True, null=True)),
+ ('start_date', models.DateTimeField(blank=True, null=True)),
+ ('start_date_local', models.DateTimeField(blank=True, null=True)),
+ ('timezone', models.CharField(blank=True, choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Sydney', 'Australia/Sydney'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zurich', 'Europe/Zurich'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis')], max_length=50, null=True)),
+ ('average_speed', models.FloatField(blank=True, null=True)),
+ ('max_speed', models.FloatField(blank=True, null=True)),
+ ('average_cadence', models.FloatField(blank=True, null=True)),
+ ('calories', models.FloatField(blank=True, null=True)),
+ ('start_lat', models.FloatField(blank=True, null=True)),
+ ('start_lng', models.FloatField(blank=True, null=True)),
+ ('end_lat', models.FloatField(blank=True, null=True)),
+ ('end_lng', models.FloatField(blank=True, null=True)),
+ ('external_service_id', models.CharField(blank=True, max_length=100, null=True)),
+ ('trail', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='adventures.trail')),
+ ('user', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('visit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='adventures.visit')),
+ ],
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0059_alter_activity_options.py b/backend/server/adventures/migrations/0059_alter_activity_options.py
new file mode 100644
index 00000000..f247d779
--- /dev/null
+++ b/backend/server/adventures/migrations/0059_alter_activity_options.py
@@ -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'},
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0060_alter_collectioninvite_unique_together.py b/backend/server/adventures/migrations/0060_alter_collectioninvite_unique_together.py
new file mode 100644
index 00000000..23a64ebc
--- /dev/null
+++ b/backend/server/adventures/migrations/0060_alter_collectioninvite_unique_together.py
@@ -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')},
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0061_alter_activity_sport_type_alter_activity_type.py b/backend/server/adventures/migrations/0061_alter_activity_sport_type_alter_activity_type.py
new file mode 100644
index 00000000..81ac5288
--- /dev/null
+++ b/backend/server/adventures/migrations/0061_alter_activity_sport_type_alter_activity_type.py
@@ -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),
+ ),
+ ]
diff --git a/backend/server/adventures/migrations/0062_remove_activity_type_alter_activity_sport_type.py b/backend/server/adventures/migrations/0062_remove_activity_type_alter_activity_sport_type.py
new file mode 100644
index 00000000..65dec939
--- /dev/null
+++ b/backend/server/adventures/migrations/0062_remove_activity_type_alter_activity_sport_type.py
@@ -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),
+ ),
+ ]
diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py
index 40bb680a..63f488d5 100644
--- a/backend/server/adventures/models.py
+++ b/backend/server/adventures/models.py
@@ -1,10 +1,9 @@
from django.core.exceptions import ValidationError
import os
-from typing import Iterable
import uuid
from django.db import models
from django.utils.deconstruct import deconstructible
-from adventures.managers import AdventureManager
+from adventures.managers import LocationManager
import threading
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
@@ -13,46 +12,50 @@ from django_resized import ResizedImageField
from worldtravel.models import City, Country, Region, VisitedCity, VisitedRegion
from django.core.exceptions import ValidationError
from django.utils import timezone
+from adventures.utils.timezones import TIMEZONES
+from adventures.utils.sports_types import SPORT_TYPE_CHOICES
+from adventures.utils.get_is_visited import is_location_visited
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.fields import GenericRelation
-def background_geocode_and_assign(adventure_id: str):
- print(f"[Adventure Geocode Thread] Starting geocode for adventure {adventure_id}")
+def background_geocode_and_assign(location_id: str):
+ print(f"[Location Geocode Thread] Starting geocode for location {location_id}")
try:
- adventure = Adventure.objects.get(id=adventure_id)
- if not (adventure.latitude and adventure.longitude):
+ location = Location.objects.get(id=location_id)
+ if not (location.latitude and location.longitude):
return
from adventures.geocoding import reverse_geocode # or wherever you defined it
- is_visited = adventure.is_visited_status()
- result = reverse_geocode(adventure.latitude, adventure.longitude, adventure.user_id)
+ is_visited = location.is_visited_status()
+ result = reverse_geocode(location.latitude, location.longitude, location.user)
if 'region_id' in result:
region = Region.objects.filter(id=result['region_id']).first()
if region:
- adventure.region = region
+ location.region = region
if is_visited:
- VisitedRegion.objects.get_or_create(user_id=adventure.user_id, region=region)
+ VisitedRegion.objects.get_or_create(user=location.user, region=region)
if 'city_id' in result:
city = City.objects.filter(id=result['city_id']).first()
if city:
- adventure.city = city
+ location.city = city
if is_visited:
- VisitedCity.objects.get_or_create(user_id=adventure.user_id, city=city)
+ VisitedCity.objects.get_or_create(user=location.user, city=city)
if 'country_id' in result:
country = Country.objects.filter(country_code=result['country_id']).first()
if country:
- adventure.country = country
+ location.country = country
# Save updated location info
# Save updated location info, skip geocode threading
- adventure.save(update_fields=["region", "city", "country"], _skip_geocode=True)
-
- # print(f"[Adventure Geocode Thread] Successfully processed {adventure_id}: {adventure.name} - {adventure.latitude}, {adventure.longitude}")
+ location.save(update_fields=["region", "city", "country"], _skip_geocode=True)
except Exception as e:
# Optional: log or print the error
- print(f"[Adventure Geocode Thread] Error processing {adventure_id}: {e}")
+ print(f"[Location Geocode Thread] Error processing {location_id}: {e}")
def validate_file_extension(value):
import os
@@ -62,6 +65,7 @@ def validate_file_extension(value):
if not ext.lower() in valid_extensions:
raise ValidationError('Unsupported file extension.')
+# Legacy support for old adventure types, not used in newer versions since custom categories are now used
ADVENTURE_TYPES = [
('general', 'General 🌍'),
('outdoor', 'Outdoor 🏞️'),
@@ -87,426 +91,6 @@ ADVENTURE_TYPES = [
('other', 'Other')
]
-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"
-]
-
LODGING_TYPES = [
('hotel', 'Hotel'),
('hostel', 'Hostel'),
@@ -533,13 +117,13 @@ TRANSPORTATION_TYPES = [
]
# Assuming you have a default user ID you want to use
-default_user_id = 1 # Replace with an actual user ID
+default_user = 1 # Replace with an actual user ID
User = get_user_model()
class Visit(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
- adventure = models.ForeignKey('Adventure', on_delete=models.CASCADE, related_name='visits')
+ location = models.ForeignKey('Location', on_delete=models.CASCADE, related_name='visits')
start_date = models.DateTimeField(null=True, blank=True)
end_date = models.DateTimeField(null=True, blank=True)
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
@@ -547,54 +131,53 @@ class Visit(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+ # Generic relations for images and attachments
+ images = GenericRelation('ContentImage', related_query_name='visit')
+ attachments = GenericRelation('ContentAttachment', related_query_name='visit')
+
def clean(self):
if self.start_date > self.end_date:
raise ValidationError('The start date must be before or equal to the end date.')
+ def delete(self, *args, **kwargs):
+ # Delete all associated images and attachments
+ for image in self.images.all():
+ image.delete()
+ for attachment in self.attachments.all():
+ attachment.delete()
+ super().delete(*args, **kwargs)
+
def __str__(self):
- return f"{self.adventure.name} - {self.start_date} to {self.end_date}"
+ return f"{self.location.name} - {self.start_date} to {self.end_date}"
-class Adventure(models.Model):
- #id = models.AutoField(primary_key=True)
+class Location(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=True, null=True)
name = models.CharField(max_length=200)
location = models.CharField(max_length=200, blank=True, null=True)
- activity_types = ArrayField(models.CharField(
- max_length=100), blank=True, null=True)
+ tags = ArrayField(models.CharField(max_length=100), blank=True, null=True)
description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083)
is_public = models.BooleanField(default=False)
-
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
-
city = models.ForeignKey(City, on_delete=models.SET_NULL, blank=True, null=True)
region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True)
country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True)
-
- # Changed from ForeignKey to ManyToManyField
- collections = models.ManyToManyField('Collection', blank=True, related_name='adventures')
-
+ collections = models.ManyToManyField('Collection', blank=True, related_name='locations')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
- objects = AdventureManager()
+ # Generic relations for images and attachments
+ images = GenericRelation('ContentImage', related_query_name='location')
+ attachments = GenericRelation('ContentAttachment', related_query_name='location')
+
+ objects = LocationManager()
def is_visited_status(self):
- current_date = timezone.now().date()
- for visit in self.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
+ return is_location_visited(self)
def clean(self, skip_shared_validation=False):
"""
@@ -609,18 +192,18 @@ class Adventure(models.Model):
if self.pk: # Only check if the instance has been saved
for collection in self.collections.all():
if collection.is_public and not self.is_public:
- raise ValidationError(f'Adventures associated with a public collection must be public. Collection: {collection.name} Adventure: {self.name}')
+ raise ValidationError(f'Locations associated with a public collection must be public. Collection: {collection.name} Location: {self.name}')
# Only enforce same-user constraint for non-shared collections
- if self.user_id != collection.user_id:
+ if self.user != collection.user:
# Check if this is a shared collection scenario
- # Allow if the adventure owner has access to the collection through sharing
- if not collection.shared_with.filter(uuid=self.user_id.uuid).exists():
- raise ValidationError(f'Adventures must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user_id.username} Adventure owner: {self.user_id.username}')
+ # Allow if the location owner has access to the collection through sharing
+ if not collection.shared_with.filter(uuid=self.user.uuid).exists():
+ raise ValidationError(f'Locations must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user.username} Location owner: {self.user.username}')
if self.category:
- if self.user_id != self.category.user_id:
- raise ValidationError(f'Adventures must be associated with categories owned by the same user. Category owner: {self.category.user_id.username} Adventure owner: {self.user_id.username}')
+ if self.user != self.category.user:
+ raise ValidationError(f'Locations must be associated with categories owned by the same user. Category owner: {self.category.user.username} Location owner: {self.user.username}')
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, _skip_geocode=False, _skip_shared_validation=False):
if force_insert and force_update:
@@ -628,7 +211,7 @@ class Adventure(models.Model):
if not self.category:
category, _ = Category.objects.get_or_create(
- user_id=self.user_id,
+ user=self.user,
name='general',
defaults={'display_name': 'General', 'icon': '🌍'}
)
@@ -656,14 +239,43 @@ class Adventure(models.Model):
return result
+ def delete(self, *args, **kwargs):
+ # Delete all associated images and attachments (handled by GenericRelation)
+ for image in self.images.all():
+ image.delete()
+ for attachment in self.attachments.all():
+ attachment.delete()
+ super().delete(*args, **kwargs)
+
def __str__(self):
return self.name
+
+class CollectionInvite(models.Model):
+ id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
+ collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name='invites')
+ invited_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='collection_invites')
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f"Invite for {self.invited_user.username} to {self.collection.name}"
+
+ def clean(self):
+ if self.collection.user == self.invited_user:
+ raise ValidationError("You cannot invite yourself to your own collection.")
+ # dont allow if the user is already shared with the collection
+ if self.invited_user in self.collection.shared_with.all():
+ raise ValidationError("This user is already shared with the collection.")
+
+ class Meta:
+ verbose_name = "Collection Invite"
+ unique_together = ('collection', 'invited_user')
class Collection(models.Model):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
name = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True)
is_public = models.BooleanField(default=False)
@@ -675,22 +287,20 @@ class Collection(models.Model):
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
link = models.URLField(blank=True, null=True, max_length=2083)
- # if connected adventures are private and collection is public, raise an error
+ # if connected locations are private and collection is public, raise an error
def clean(self):
if self.is_public and self.pk: # Only check if the instance has a primary key
- # Updated to use the new related_name 'adventures'
- for adventure in self.adventures.all():
- if not adventure.is_public:
- raise ValidationError(f'Public collections cannot be associated with private adventures. Collection: {self.name} Adventure: {adventure.name}')
+ # Updated to use the new related_name 'locations'
+ for location in self.locations.all():
+ if not location.is_public:
+ raise ValidationError(f'Public collections cannot be associated with private locations. Collection: {self.name} Location: {location.name}')
def __str__(self):
return self.name
class Transportation(models.Model):
- #id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
type = models.CharField(max_length=100, choices=TRANSPORTATION_TYPES)
name = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True)
@@ -712,25 +322,34 @@ class Transportation(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+ # Generic relations for images and attachments
+ images = GenericRelation('ContentImage', related_query_name='transportation')
+ attachments = GenericRelation('ContentAttachment', related_query_name='transportation')
+
def clean(self):
- print(self.date)
if self.date and self.end_date and self.date > self.end_date:
raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date))
if self.collection:
if self.collection.is_public and not self.is_public:
raise ValidationError('Transportations associated with a public collection must be public. Collection: ' + self.collection.name + ' Transportation: ' + self.name)
- if self.user_id != self.collection.user_id:
- raise ValidationError('Transportations must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Transportation owner: ' + self.user_id.username)
+ if self.user != self.collection.user:
+ raise ValidationError('Transportations must be associated with collections owned by the same user. Collection owner: ' + self.collection.user.username + ' Transportation owner: ' + self.user.username)
+
+ def delete(self, *args, **kwargs):
+ # Delete all associated images and attachments
+ for image in self.images.all():
+ image.delete()
+ for attachment in self.attachments.all():
+ attachment.delete()
+ super().delete(*args, **kwargs)
def __str__(self):
return self.name
class Note(models.Model):
- #id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
name = models.CharField(max_length=200)
content = models.TextField(blank=True, null=True)
links = ArrayField(models.URLField(), blank=True, null=True)
@@ -740,12 +359,24 @@ class Note(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+ # Generic relations for images and attachments
+ images = GenericRelation('ContentImage', related_query_name='note')
+ attachments = GenericRelation('ContentAttachment', related_query_name='note')
+
def clean(self):
if self.collection:
if self.collection.is_public and not self.is_public:
- raise ValidationError('Notes associated with a public collection must be public. Collection: ' + self.collection.name + ' Transportation: ' + self.name)
- if self.user_id != self.collection.user_id:
- raise ValidationError('Notes must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Transportation owner: ' + self.user_id.username)
+ raise ValidationError('Notes associated with a public collection must be public. Collection: ' + self.collection.name + ' Note: ' + self.name)
+ if self.user != self.collection.user:
+ raise ValidationError('Notes must be associated with collections owned by the same user. Collection owner: ' + self.collection.user.username + ' Note owner: ' + self.user.username)
+
+ def delete(self, *args, **kwargs):
+ # Delete all associated images and attachments
+ for image in self.images.all():
+ image.delete()
+ for attachment in self.attachments.all():
+ attachment.delete()
+ super().delete(*args, **kwargs)
def __str__(self):
return self.name
@@ -753,8 +384,8 @@ class Note(models.Model):
class Checklist(models.Model):
# id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
name = models.CharField(max_length=200)
date = models.DateField(blank=True, null=True)
is_public = models.BooleanField(default=False)
@@ -766,8 +397,8 @@ class Checklist(models.Model):
if self.collection:
if self.collection.is_public and not self.is_public:
raise ValidationError('Checklists associated with a public collection must be public. Collection: ' + self.collection.name + ' Checklist: ' + self.name)
- if self.user_id != self.collection.user_id:
- raise ValidationError('Checklists must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Checklist owner: ' + self.user_id.username)
+ if self.user != self.collection.user:
+ raise ValidationError('Checklists must be associated with collections owned by the same user. Collection owner: ' + self.collection.user.username + ' Checklist owner: ' + self.user.username)
def __str__(self):
return self.name
@@ -775,8 +406,8 @@ class Checklist(models.Model):
class ChecklistItem(models.Model):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
name = models.CharField(max_length=200)
is_checked = models.BooleanField(default=False)
checklist = models.ForeignKey('Checklist', on_delete=models.CASCADE)
@@ -786,8 +417,8 @@ class ChecklistItem(models.Model):
def clean(self):
if self.checklist.is_public and not self.checklist.is_public:
raise ValidationError('Checklist items associated with a public checklist must be public. Checklist: ' + self.checklist.name + ' Checklist item: ' + self.name)
- if self.user_id != self.checklist.user_id:
- raise ValidationError('Checklist items must be associated with checklists owned by the same user. Checklist owner: ' + self.checklist.user_id.username + ' Checklist item owner: ' + self.user_id.username)
+ if self.user != self.checklist.user:
+ raise ValidationError('Checklist items must be associated with checklists owned by the same user. Checklist owner: ' + self.checklist.user.username + ' Checklist item owner: ' + self.user.username)
def __str__(self):
return self.name
@@ -803,9 +434,10 @@ class PathAndRename:
filename = f"{uuid.uuid4()}.{ext}"
return os.path.join(self.path, filename)
-class AdventureImage(models.Model):
+class ContentImage(models.Model):
+ """Generic image model that can be attached to any content type"""
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
image = ResizedImageField(
force_format="WEBP",
quality=75,
@@ -814,11 +446,21 @@ class AdventureImage(models.Model):
null=True,
)
immich_id = models.CharField(max_length=200, null=True, blank=True)
- adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
is_primary = models.BooleanField(default=False)
+
+ # Generic foreign key fields
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='content_images')
+ object_id = models.UUIDField()
+ content_object = GenericForeignKey('content_type', 'object_id')
+
+ class Meta:
+ verbose_name = "Content Image"
+ verbose_name_plural = "Content Images"
+ indexes = [
+ models.Index(fields=["content_type", "object_id"]),
+ ]
def clean(self):
-
# One of image or immich_id must be set, but not both
has_image = bool(self.image and str(self.image).strip())
has_immich_id = bool(self.immich_id and str(self.immich_id).strip())
@@ -827,7 +469,7 @@ class AdventureImage(models.Model):
raise ValidationError("Cannot have both image file and Immich ID. Please provide only one.")
if not has_image and not has_immich_id:
raise ValidationError("Must provide either an image file or an Immich ID.")
-
+
def save(self, *args, **kwargs):
# Clean empty strings to None for proper database storage
if not self.image:
@@ -835,34 +477,58 @@ class AdventureImage(models.Model):
if not self.immich_id or not str(self.immich_id).strip():
self.immich_id = None
- self.full_clean() # This calls clean() method
+ self.full_clean()
super().save(*args, **kwargs)
- def __str__(self):
- return self.image.url if self.image else f"Immich ID: {self.immich_id or 'No image'}"
-
-class Attachment(models.Model):
- id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
- user_id = models.ForeignKey(
- User, on_delete=models.CASCADE, default=default_user_id)
- file = models.FileField(upload_to=PathAndRename('attachments/'),validators=[validate_file_extension])
- adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE)
- name = models.CharField(max_length=200, null=True, blank=True)
+ def delete(self, *args, **kwargs):
+ # Remove file from disk when deleting image
+ if self.image and os.path.isfile(self.image.path):
+ os.remove(self.image.path)
+ super().delete(*args, **kwargs)
def __str__(self):
- return self.file.url
+ content_name = getattr(self.content_object, 'name', 'Unknown')
+ return f"Image for {self.content_type.model}: {content_name}"
+
+class ContentAttachment(models.Model):
+ """Generic attachment model that can be attached to any content type"""
+ id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
+ user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
+ file = models.FileField(upload_to=PathAndRename('attachments/'), validators=[validate_file_extension])
+ name = models.CharField(max_length=200, null=True, blank=True)
+
+ # Generic foreign key fields
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='content_attachments')
+ object_id = models.UUIDField()
+ content_object = GenericForeignKey('content_type', 'object_id')
+
+ class Meta:
+ verbose_name = "Content Attachment"
+ verbose_name_plural = "Content Attachments"
+ indexes = [
+ models.Index(fields=["content_type", "object_id"]),
+ ]
+
+ def delete(self, *args, **kwargs):
+ if self.file and os.path.isfile(self.file.path):
+ os.remove(self.file.path)
+ super().delete(*args, **kwargs)
+
+ def __str__(self):
+ content_name = getattr(self.content_object, 'name', 'Unknown')
+ return f"Attachment for {self.content_type.model}: {content_name}"
class Category(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
name = models.CharField(max_length=200)
display_name = models.CharField(max_length=200)
icon = models.CharField(max_length=200, default='🌍')
class Meta:
verbose_name_plural = 'Categories'
- unique_together = ['name', 'user_id']
+ unique_together = ['name', 'user']
def clean(self) -> None:
self.name = self.name.lower().strip()
@@ -875,8 +541,7 @@ class Category(models.Model):
class Lodging(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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)
name = models.CharField(max_length=200)
type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other')
description = models.TextField(blank=True, null=True)
@@ -895,15 +560,117 @@ class Lodging(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+ # Generic relations for images and attachments
+ images = GenericRelation('ContentImage', related_query_name='lodging')
+ attachments = GenericRelation('ContentAttachment', related_query_name='lodging')
+
def clean(self):
if self.check_in and self.check_out and self.check_in > self.check_out:
raise ValidationError('The start date must be before the end date. Start date: ' + str(self.check_in) + ' End date: ' + str(self.check_out))
if self.collection:
if self.collection.is_public and not self.is_public:
- raise ValidationError('Lodging associated with a public collection must be public. Collection: ' + self.collection.name + ' Loging: ' + self.name)
- if self.user_id != self.collection.user_id:
- raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Lodging owner: ' + self.user_id.username)
+ raise ValidationError('Lodging associated with a public collection must be public. Collection: ' + self.collection.name + ' Lodging: ' + self.name)
+ if self.user != self.collection.user:
+ raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user.username + ' Lodging owner: ' + self.user.username)
+
+ def delete(self, *args, **kwargs):
+ # Delete all associated images and attachments
+ for image in self.images.all():
+ image.delete()
+ for attachment in self.attachments.all():
+ attachment.delete()
+ super().delete(*args, **kwargs)
def __str__(self):
- return self.name
\ No newline at end of file
+ return self.name
+
+class Trail(models.Model):
+ """
+ Represents a trail associated with a user.
+ Supports referencing either a Wanderer trail ID or an external link (e.g., AllTrails).
+ """
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name='trails')
+ name = models.CharField(max_length=200)
+
+ # Either an external link (e.g., AllTrails, Trailforks) or a Wanderer ID
+ link = models.URLField("External Trail Link", max_length=2083, blank=True, null=True)
+ wanderer_id = models.CharField("Wanderer Trail ID", max_length=100, blank=True, null=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ verbose_name = "Trail"
+ verbose_name_plural = "Trails"
+
+ def clean(self):
+ has_link = bool(self.link and str(self.link).strip())
+ has_wanderer_id = bool(self.wanderer_id and str(self.wanderer_id).strip())
+
+ if has_link and has_wanderer_id:
+ raise ValidationError("Cannot have both a link and a Wanderer ID. Provide only one.")
+ if not has_link and not has_wanderer_id:
+ raise ValidationError("You must provide either a link or a Wanderer ID.")
+
+ def save(self, *args, **kwargs):
+ self.full_clean() # Ensure clean() is called on save
+ super().save(*args, **kwargs)
+
+ def __str__(self):
+ return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
+
+class Activity(models.Model):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
+ user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
+ visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name='activities')
+ trail = models.ForeignKey(Trail, on_delete=models.CASCADE, related_name='activities', blank=True, null=True)
+
+ # GPX File
+ gpx_file = models.FileField(upload_to=PathAndRename('activities/'), validators=[validate_file_extension], blank=True, null=True)
+
+ # Descriptive
+ name = models.CharField(max_length=200)
+ sport_type = models.CharField(max_length=100, choices=SPORT_TYPE_CHOICES, default='General') # Optional detailed type
+
+ # Time & Distance
+ distance = models.FloatField(blank=True, null=True) # in meters
+ moving_time = models.DurationField(blank=True, null=True)
+ elapsed_time = models.DurationField(blank=True, null=True)
+ rest_time = models.DurationField(blank=True, null=True)
+
+ # Elevation
+ elevation_gain = models.FloatField(blank=True, null=True) # in meters
+ elevation_loss = models.FloatField(blank=True, null=True) # estimated
+ elev_high = models.FloatField(blank=True, null=True)
+ elev_low = models.FloatField(blank=True, null=True)
+
+ # Timing
+ start_date = models.DateTimeField(blank=True, null=True)
+ start_date_local = models.DateTimeField(blank=True, null=True)
+ timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], blank=True, null=True)
+
+ # Speed
+ average_speed = models.FloatField(blank=True, null=True) # in m/s
+ max_speed = models.FloatField(blank=True, null=True) # in m/s
+
+ # Optional metrics
+ average_cadence = models.FloatField(blank=True, null=True)
+ calories = models.FloatField(blank=True, null=True)
+
+ # Coordinates
+ start_lat = models.FloatField(blank=True, null=True)
+ start_lng = models.FloatField(blank=True, null=True)
+ end_lat = models.FloatField(blank=True, null=True)
+ end_lng = models.FloatField(blank=True, null=True)
+
+ # Optional links
+ external_service_id = models.CharField(max_length=100, blank=True, null=True) # E.g., Strava ID
+
+ def __str__(self):
+ return f"{self.name} ({self.sport_type})"
+
+ class Meta:
+ verbose_name = "Activity"
+ verbose_name_plural = "Activities"
\ No newline at end of file
diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py
index ce38f747..991ce862 100644
--- a/backend/server/adventures/permissions.py
+++ b/backend/server/adventures/permissions.py
@@ -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)
\ No newline at end of file
diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py
index 622e2eb3..a0bf6e0b 100644
--- a/backend/server/adventures/serializers.py
+++ b/backend/server/adventures/serializers.py
@@ -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
\ No newline at end of file
+ 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']
\ No newline at end of file
diff --git a/backend/server/adventures/signals.py b/backend/server/adventures/signals.py
index b8501c8c..6e62b882 100644
--- a/backend/server/adventures/signals.py
+++ b/backend/server/adventures/signals.py
@@ -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()
diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py
index d1bf6cbc..fdd680d5 100644
--- a/backend/server/adventures/urls.py
+++ b/backend/server/adventures/urls.py
@@ -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
diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py
index b9a63f00..1c2f2ba2 100644
--- a/backend/server/adventures/utils/file_permissions.py
+++ b/backend/server/adventures/utils/file_permissions.py
@@ -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
\ No newline at end of file
diff --git a/backend/server/adventures/utils/geojson.py b/backend/server/adventures/utils/geojson.py
new file mode 100644
index 00000000..2e8d04b8
--- /dev/null
+++ b/backend/server/adventures/utils/geojson.py
@@ -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"
+ }
\ No newline at end of file
diff --git a/backend/server/adventures/utils/get_is_visited.py b/backend/server/adventures/utils/get_is_visited.py
new file mode 100644
index 00000000..e069f292
--- /dev/null
+++ b/backend/server/adventures/utils/get_is_visited.py
@@ -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
\ No newline at end of file
diff --git a/backend/server/adventures/utils/sports_types.py b/backend/server/adventures/utils/sports_types.py
new file mode 100644
index 00000000..3d6cdb99
--- /dev/null
+++ b/backend/server/adventures/utils/sports_types.py
@@ -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']
+ }
\ No newline at end of file
diff --git a/backend/server/adventures/utils/timezones.py b/backend/server/adventures/utils/timezones.py
new file mode 100644
index 00000000..58575d3e
--- /dev/null
+++ b/backend/server/adventures/utils/timezones.py
@@ -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"
+]
diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py
index c9aedb0b..c13d73d8 100644
--- a/backend/server/adventures/views/__init__.py
+++ b/backend/server/adventures/views/__init__.py
@@ -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 *
\ No newline at end of file
+from .recommendations_view import *
+from .import_export_view import *
+from .trail_view import *
+from .activity_view import *
+from .visit_view import *
\ No newline at end of file
diff --git a/backend/server/adventures/views/activity_view.py b/backend/server/adventures/views/activity_view.py
new file mode 100644
index 00000000..e67fcd3f
--- /dev/null
+++ b/backend/server/adventures/views/activity_view.py
@@ -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
\ No newline at end of file
diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py
deleted file mode 100644
index ab7f8e10..00000000
--- a/backend/server/adventures/views/adventure_image_view.py
+++ /dev/null
@@ -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