Files
AdventureLog/backend/server/adventures/models.py
Sean Morley a3f0eda63f Activities, Trails, Wanderer + Strava Integration, UI Refresh, Devops Improvments, and more (#785)
* Implement code changes to enhance functionality and improve performance

* Update nl.json

Fix Dutch translations.

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

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

* fix(dependencies): update Django version to 5.2.2

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

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

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

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

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

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

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

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

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

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

* added-fix-image-deletion

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

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

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

---------

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

* Rename Adventures to Locations (#696)

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

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

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

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

* refactor: rename adventures to locations across the application

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

* Refactor code structure for improved readability and maintainability

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

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

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

* docs: Clarify collection examples in usage documentation

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

* Refactor adventure references to locations across the backend and frontend

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

* Update banner image

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

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

* Import and Export Functionality (#698)

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

* Fixed frontend returning corrupt binary data

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

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

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

* Refactor response handling to use arrayBuffer instead of bytes

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

* Update backup export versioning and improve data restore warning message

* Enhance image navigation and localization support in modal components

* Refactor location handling in Immich integration components for consistency

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

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

---------

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

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

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

* chore(requirements): update pillow version to 11.3.0

* Add PT-BR translations (#739)

* Fixed frontend returning corrupt binary data

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

* Add PT-BR translations

Add translation for Brazilian Portuguese to the project;

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

---------

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

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

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

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

* feat: Enhance collection sharing and location management features

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

* feat: enhance transportation card and modal with image handling

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

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

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

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

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

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

* Collection invite system

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

* feat: add Strava integration with OAuth flow and activity management

- Implemented IntegrationView for listing integrations including Immich, Google Maps, and Strava.
- Created StravaIntegrationView for handling OAuth authorization and token exchange.
- Added functionality to refresh Strava access tokens when needed.
- Implemented endpoints to fetch user activities from Strava and extract essential information.
- Added Strava logo asset and integrated it into the frontend settings page.
- Updated settings page to display Strava integration status.
- Enhanced location management to include trails with create, edit, and delete functionalities.
- Updated types and localization files to support new features.

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

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

* Add StravaActivity and Activity types to types.ts

- Introduced StravaActivity type to represent detailed activity data from Strava.
- Added Activity type to encapsulate user activities, including optional trail and GPX file information.
- Updated Location type to include an array of activities associated with each visit.

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

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

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

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

* feat: add Wanderer integration with authentication and management features

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

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

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

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

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

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

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

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

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

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

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

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

* feat: sync visits when updating adventures in collection page

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

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

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

* Add support for Japanese and Arabic languages in localization

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

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

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

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

* fix: update appVersion to reflect the latest development version

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

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

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

* fix: remove debug print statement from IsOwnerOrSharedWithFullAccess permission class

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

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

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

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

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

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

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

* feat: Add attachments support to Transportation and Lodging types

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

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

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

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

* feat: Update inspirational quote in adventure log

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

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

* Translate login button text to support internationalization

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

* chore: Upgrade GitHub Actions and remove unused timezone import

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

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

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

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

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

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

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

* chore: improve Chinese translation (#796)

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

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

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

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

* Update google_maps_integration.md (#743)

* Update google_maps_integration.md

Explain APIs needed for AdventureLogs versions.

Fixes #731 and #727

* Fix a typo google_maps_integration.md

---------

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

* fix: update appVersion to reflect the main branch version

* fix: update image source for satellite map in documentation

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

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

* Add localization updates for multiple languages

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

* fix: enhance image upload handling to support immich_id

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

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

---------

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>
Co-authored-by: Ycer0n <37674033+Ycer0n@users.noreply.github.com>
Co-authored-by: taninme <5262715+taninme@users.noreply.github.com>
Co-authored-by: ferdousahmed <taninme@gmail.com>
Co-authored-by: Christian Zäske <blitzdose@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lucas Zampieri <lcasmz54@gmail.com>
Co-authored-by: pplulee <pplulee@live.cn>
Co-authored-by: Cathelijne Hornstra <github@hornstra.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-19 08:50:45 -04:00

676 lines
32 KiB
Python

from django.core.exceptions import ValidationError
import os
import uuid
from django.db import models
from django.utils.deconstruct import deconstructible
from adventures.managers import LocationManager
import threading
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
from django.forms import ValidationError
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(location_id: str):
print(f"[Location Geocode Thread] Starting geocode for location {location_id}")
try:
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 = 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:
location.region = region
if is_visited:
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:
location.city = city
if is_visited:
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:
location.country = country
# Save updated location info
# Save updated location info, skip geocode threading
location.save(update_fields=["region", "city", "country"], _skip_geocode=True)
except Exception as e:
# Optional: log or print the error
print(f"[Location Geocode Thread] Error processing {location_id}: {e}")
def validate_file_extension(value):
import os
from django.core.exceptions import ValidationError
ext = os.path.splitext(value.name)[1] # [0] returns path+filename
valid_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.wma', '.aac', '.opus', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.lz4', '.lzma', '.lzo', '.z', '.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tar.lz4', '.tar.lzma', '.tar.lzo', '.tar.z', '.gpx', '.md']
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 🏞️'),
('lodging', 'Lodging 🛌'),
('dining', 'Dining 🍽️'),
('activity', 'Activity 🏄'),
('attraction', 'Attraction 🎢'),
('shopping', 'Shopping 🛍️'),
('nightlife', 'Nightlife 🌃'),
('event', 'Event 🎉'),
('transportation', 'Transportation 🚗'),
('culture', 'Culture 🎭'),
('water_sports', 'Water Sports 🚤'),
('hiking', 'Hiking 🥾'),
('wildlife', 'Wildlife 🦒'),
('historical_sites', 'Historical Sites 🏛️'),
('music_concerts', 'Music & Concerts 🎶'),
('fitness', 'Fitness 🏋️'),
('art_museums', 'Art & Museums 🎨'),
('festivals', 'Festivals 🎪'),
('spiritual_journeys', 'Spiritual Journeys 🧘‍♀️'),
('volunteer_work', 'Volunteer Work 🤝'),
('other', 'Other')
]
LODGING_TYPES = [
('hotel', 'Hotel'),
('hostel', 'Hostel'),
('resort', 'Resort'),
('bnb', 'Bed & Breakfast'),
('campground', 'Campground'),
('cabin', 'Cabin'),
('apartment', 'Apartment'),
('house', 'House'),
('villa', 'Villa'),
('motel', 'Motel'),
('other', 'Other')
]
TRANSPORTATION_TYPES = [
('car', 'Car'),
('plane', 'Plane'),
('train', 'Train'),
('bus', 'Bus'),
('boat', 'Boat'),
('bike', 'Bike'),
('walking', 'Walking'),
('other', 'Other')
]
# Assuming you have a default user ID you want to use
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)
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)
notes = models.TextField(blank=True, null=True)
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.location.name} - {self.start_date} to {self.end_date}"
class Location(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
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)
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)
collections = models.ManyToManyField('Collection', blank=True, related_name='locations')
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='location')
attachments = GenericRelation('ContentAttachment', related_query_name='location')
objects = LocationManager()
def is_visited_status(self):
return is_location_visited(self)
def clean(self, skip_shared_validation=False):
"""
Validate model constraints.
skip_shared_validation: Skip validation when called by shared users
"""
# Skip validation if this is a shared user update
if skip_shared_validation:
return
# Check collections after the instance is saved (in save method or separate validation)
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'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 != collection.user:
# Check if this is a shared collection scenario
# 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 != 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:
raise ValueError("Cannot force both insert and updating in model saving.")
if not self.category:
category, _ = Category.objects.get_or_create(
user=self.user,
name='general',
defaults={'display_name': 'General', 'icon': '🌍'}
)
self.category = category
result = super().save(force_insert, force_update, using, update_fields)
# Validate collections after saving (since M2M relationships require saved instance)
if self.pk:
try:
self.clean(skip_shared_validation=_skip_shared_validation)
except ValidationError as e:
# If validation fails, you might want to handle this differently
# For now, we'll re-raise the error
raise e
# ⛔ Skip threading if called from geocode background thread
if _skip_geocode:
return result
if self.latitude and self.longitude:
thread = threading.Thread(target=background_geocode_and_assign, args=(str(self.id),))
thread.daemon = True # Allows the thread to exit when the main program ends
thread.start()
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 = 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)
created_at = models.DateTimeField(auto_now_add=True)
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
is_archived = models.BooleanField(default=False)
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
link = models.URLField(blank=True, null=True, max_length=2083)
# 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 '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.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
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)
rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True)
date = models.DateTimeField(blank=True, null=True)
end_date = models.DateTimeField(blank=True, null=True)
start_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
end_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
flight_number = models.CharField(max_length=100, blank=True, null=True)
from_location = models.CharField(max_length=200, blank=True, null=True)
origin_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
origin_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
destination_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
destination_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
to_location = models.CharField(max_length=200, blank=True, null=True)
is_public = models.BooleanField(default=False)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
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):
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 != 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.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
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)
date = models.DateField(blank=True, null=True)
is_public = models.BooleanField(default=False)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
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 + ' 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
class Checklist(models.Model):
# id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
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)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
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 != 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
class ChecklistItem(models.Model):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
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)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
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 != 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
@deconstructible
class PathAndRename:
def __init__(self, path):
self.path = path
def __call__(self, instance, filename):
ext = filename.split('.')[-1]
# Generate a new UUID for the filename
filename = f"{uuid.uuid4()}.{ext}"
return os.path.join(self.path, filename)
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 = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
image = ResizedImageField(
force_format="WEBP",
quality=75,
upload_to=PathAndRename('images/'),
blank=True,
null=True,
)
immich_id = models.CharField(max_length=200, null=True, blank=True)
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())
if has_image and has_immich_id:
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:
self.image = None
if not self.immich_id or not str(self.immich_id).strip():
self.immich_id = None
self.full_clean()
super().save(*args, **kwargs)
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):
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 = 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']
def clean(self) -> None:
self.name = self.name.lower().strip()
return super().clean()
def __str__(self):
return self.name + ' - ' + self.display_name + ' - ' + self.icon
class Lodging(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
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)
rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083)
check_in = models.DateTimeField(blank=True, null=True)
check_out = models.DateTimeField(blank=True, null=True)
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
reservation_number = models.CharField(max_length=100, blank=True, null=True)
price = models.DecimalField(max_digits=9, decimal_places=2, blank=True, null=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
location = models.CharField(max_length=200, blank=True, null=True)
is_public = models.BooleanField(default=False)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
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 + ' 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
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"