1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-23 23:09:37 +02:00
AdventureLog/backend/server/adventures/models.py

572 lines
No EOL
27 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 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):
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
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 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