1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 07:19:36 +02:00

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

This commit is contained in:
Sean Morley 2025-07-10 12:12:03 -04:00
parent 1b841f45a0
commit 7f80dad94b
13 changed files with 371 additions and 86 deletions

View file

@ -1,7 +1,7 @@
import os import os
from django.contrib import admin from django.contrib import admin
from django.utils.html import mark_safe from django.utils.html import mark_safe
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, LocationImage, Visit, Category, Attachment, Lodging from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login from allauth.account.decorators import secure_admin_login
@ -96,7 +96,7 @@ class CustomUserAdmin(UserAdmin):
else: else:
return return
class LocationImageAdmin(admin.ModelAdmin): class ContentImageImageAdmin(admin.ModelAdmin):
list_display = ('user', 'image_display') list_display = ('user', 'image_display')
def image_display(self, obj): def image_display(self, obj):
@ -147,11 +147,11 @@ admin.site.register(Transportation)
admin.site.register(Note) admin.site.register(Note)
admin.site.register(Checklist) admin.site.register(Checklist)
admin.site.register(ChecklistItem) admin.site.register(ChecklistItem)
admin.site.register(LocationImage, LocationImageAdmin) admin.site.register(ContentImage, ContentImageImageAdmin)
admin.site.register(Category, CategoryAdmin) admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin) admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity) admin.site.register(VisitedCity)
admin.site.register(Attachment) admin.site.register(ContentAttachment)
admin.site.register(Lodging) admin.site.register(Lodging)
admin.site.site_header = 'AdventureLog Admin' admin.site.site_header = 'AdventureLog Admin'

View file

@ -1,7 +1,7 @@
import os import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from adventures.models import LocationImage, Attachment from adventures.models import ContentImage, ContentAttachment
from users.models import CustomUser from users.models import CustomUser
@ -21,13 +21,13 @@ class Command(BaseCommand):
# Get all image and attachment file paths from database # Get all image and attachment file paths from database
used_files = set() used_files = set()
# Get LocationImage file paths # Get ContentImage file paths
for img in LocationImage.objects.all(): for img in ContentImage.objects.all():
if img.image and img.image.name: if img.image and img.image.name:
used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name)) used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name))
# Get Attachment file paths # Get Attachment file paths
for attachment in Attachment.objects.all(): for attachment in ContentAttachment.objects.all():
if attachment.file and attachment.file.name: if attachment.file and attachment.file.name:
used_files.add(os.path.join(settings.MEDIA_ROOT, attachment.file.name)) used_files.add(os.path.join(settings.MEDIA_ROOT, attachment.file.name))

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.1 on 2025-07-10 14:40
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0051_rename_activity_types_location_tags_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel(
old_name='Attachment',
new_name='ContentAttachment',
),
migrations.RenameModel(
old_name='LocationImage',
new_name='ContentImage',
),
]

View file

@ -0,0 +1,53 @@
# Generated by Django 5.2.1 on 2025-07-10 15:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0052_rename_attachment_contentattachment_and_more'),
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='contentattachment',
options={'verbose_name': 'Content Attachment', 'verbose_name_plural': 'Content Attachments'},
),
migrations.AlterModelOptions(
name='contentimage',
options={'verbose_name': 'Content Image', 'verbose_name_plural': 'Content Images'},
),
migrations.AddField(
model_name='contentattachment',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='contentattachment',
name='object_id',
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name='contentimage',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='contentimage',
name='object_id',
field=models.UUIDField(blank=True, null=True),
),
migrations.AddIndex(
model_name='contentattachment',
index=models.Index(fields=['content_type', 'object_id'], name='adventures__content_e42b72_idx'),
),
migrations.AddIndex(
model_name='contentimage',
index=models.Index(fields=['content_type', 'object_id'], name='adventures__content_aa4984_idx'),
),
]

View file

@ -0,0 +1,73 @@
# Custom migrations to migrate LocationImage and Attachment models to generic ContentImage and ContentAttachment models
from django.db import migrations, models
from django.utils import timezone
def migrate_images_and_attachments_forward(apps, schema_editor):
"""
Migrate existing LocationImage and Attachment records to the new generic ContentImage and ContentAttachment models
"""
# Get the models
ContentImage = apps.get_model('adventures', 'ContentImage')
ContentAttachment = apps.get_model('adventures', 'ContentAttachment')
# Get the ContentType for Location
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
location_ct = ContentType.objects.get(app_label='adventures', model='location')
except ContentType.DoesNotExist:
return
# Update existing ContentImages (which were previously LocationImages)
ContentImage.objects.filter(content_type__isnull=True).update(
content_type=location_ct
)
# Set object_id from location_id for ContentImages
for content_image in ContentImage.objects.filter(object_id__isnull=True):
if hasattr(content_image, 'location_id') and content_image.location_id:
content_image.object_id = content_image.location_id
content_image.save()
# Update existing ContentAttachments (which were previously Attachments)
ContentAttachment.objects.filter(content_type__isnull=True).update(
content_type=location_ct
)
# Set object_id from location_id for ContentAttachments
for content_attachment in ContentAttachment.objects.filter(object_id__isnull=True):
if hasattr(content_attachment, 'location_id') and content_attachment.location_id:
content_attachment.object_id = content_attachment.location_id
content_attachment.save()
def migrate_images_and_attachments_reverse(apps, schema_editor):
"""
Reverse migration to restore location_id fields from object_id
"""
ContentImage = apps.get_model('adventures', 'ContentImage')
ContentAttachment = apps.get_model('adventures', 'ContentAttachment')
# Restore location_id from object_id for ContentImages
for content_image in ContentImage.objects.all():
if content_image.object_id and hasattr(content_image, 'location_id'):
content_image.location_id = content_image.object_id
content_image.save()
# Restore location_id from object_id for ContentAttachments
for content_attachment in ContentAttachment.objects.all():
if content_attachment.object_id and hasattr(content_attachment, 'location_id'):
content_attachment.location_id = content_attachment.object_id
content_attachment.save()
class Migration(migrations.Migration):
dependencies = [
('adventures', '0053_alter_contentattachment_options_and_more'),
]
operations = [
migrations.RunPython(
migrate_images_and_attachments_forward,
migrate_images_and_attachments_reverse,
elidable=True
)
]

View file

@ -0,0 +1,43 @@
# Generated by Django 5.2.1 on 2025-07-10 15:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0054_migrate_location_images_generic_relation'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.RemoveField(
model_name='contentattachment',
name='location',
),
migrations.RemoveField(
model_name='contentimage',
name='location',
),
migrations.AlterField(
model_name='contentattachment',
name='content_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_attachments', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='contentattachment',
name='object_id',
field=models.UUIDField(),
),
migrations.AlterField(
model_name='contentimage',
name='content_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='contentimage',
name='object_id',
field=models.UUIDField(),
),
]

View file

@ -14,6 +14,10 @@ from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from adventures.utils.timezones import TIMEZONES 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): def background_geocode_and_assign(location_id: str):
print(f"[Location Geocode Thread] Starting geocode for location {location_id}") print(f"[Location Geocode Thread] Starting geocode for location {location_id}")
try: try:
@ -126,42 +130,49 @@ class Visit(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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): def clean(self):
if self.start_date > self.end_date: if self.start_date > self.end_date:
raise ValidationError('The start date must be before or equal to the 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): def __str__(self):
return f"{self.location.name} - {self.start_date} to {self.end_date}" return f"{self.location.name} - {self.start_date} to {self.end_date}"
class Location(models.Model): class Location(models.Model):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user = models.ForeignKey( user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
User, on_delete=models.CASCADE, default=default_user)
category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=True, null=True) category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=True, null=True)
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
location = models.CharField(max_length=200, blank=True, null=True) location = models.CharField(max_length=200, blank=True, null=True)
tags = ArrayField(models.CharField( tags = ArrayField(models.CharField(max_length=100), blank=True, null=True)
max_length=100), blank=True, null=True)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True) rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083) link = models.URLField(blank=True, null=True, max_length=2083)
is_public = models.BooleanField(default=False) is_public = models.BooleanField(default=False)
longitude = 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)
latitude = 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) 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) 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) 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='locations') collections = models.ManyToManyField('Collection', blank=True, related_name='locations')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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() objects = LocationManager()
def is_visited_status(self): def is_visited_status(self):
@ -236,10 +247,9 @@ class Location(models.Model):
return result return result
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
# Delete all associated AdventureImages first to trigger their filesystem cleanup # Delete all associated images and attachments (handled by GenericRelation)
for image in self.images.all(): for image in self.images.all():
image.delete() image.delete()
# Delete all associated Attachment files first to trigger their filesystem cleanup
for attachment in self.attachments.all(): for attachment in self.attachments.all():
attachment.delete() attachment.delete()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
@ -275,10 +285,8 @@ class Collection(models.Model):
return self.name return self.name
class Transportation(models.Model): class Transportation(models.Model):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user = models.ForeignKey( user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
User, on_delete=models.CASCADE, default=default_user)
type = models.CharField(max_length=100, choices=TRANSPORTATION_TYPES) type = models.CharField(max_length=100, choices=TRANSPORTATION_TYPES)
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
@ -300,8 +308,11 @@ class Transportation(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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): def clean(self):
print(self.date)
if self.date and self.end_date and self.date > self.end_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)) raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date))
@ -311,14 +322,20 @@ class Transportation(models.Model):
if self.user != self.collection.user: 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) 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): def __str__(self):
return self.name return self.name
class Note(models.Model): class Note(models.Model):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user = models.ForeignKey( user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
User, on_delete=models.CASCADE, default=default_user)
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
content = models.TextField(blank=True, null=True) content = models.TextField(blank=True, null=True)
links = ArrayField(models.URLField(), blank=True, null=True) links = ArrayField(models.URLField(), blank=True, null=True)
@ -328,12 +345,24 @@ class Note(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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): def clean(self):
if self.collection: if self.collection:
if self.collection.is_public and not self.is_public: 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) raise ValidationError('Notes associated with a public collection must be public. Collection: ' + self.collection.name + ' Note: ' + self.name)
if self.user != self.collection.user: 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 + ' Transportation owner: ' + self.user.username) 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): def __str__(self):
return self.name return self.name
@ -391,7 +420,8 @@ class PathAndRename:
filename = f"{uuid.uuid4()}.{ext}" filename = f"{uuid.uuid4()}.{ext}"
return os.path.join(self.path, filename) return os.path.join(self.path, filename)
class LocationImage(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) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user) user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
image = ResizedImageField( image = ResizedImageField(
@ -402,11 +432,21 @@ class LocationImage(models.Model):
null=True, null=True,
) )
immich_id = models.CharField(max_length=200, null=True, blank=True) immich_id = models.CharField(max_length=200, null=True, blank=True)
location = models.ForeignKey(Location, related_name='images', on_delete=models.CASCADE)
is_primary = models.BooleanField(default=False) 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): def clean(self):
# One of image or immich_id must be set, but not both # One of image or immich_id must be set, but not both
has_image = bool(self.image and str(self.image).strip()) has_image = bool(self.image and str(self.image).strip())
has_immich_id = bool(self.immich_id and str(self.immich_id).strip()) has_immich_id = bool(self.immich_id and str(self.immich_id).strip())
@ -415,7 +455,7 @@ class LocationImage(models.Model):
raise ValidationError("Cannot have both image file and Immich ID. Please provide only one.") raise ValidationError("Cannot have both image file and Immich ID. Please provide only one.")
if not has_image and not has_immich_id: if not has_image and not has_immich_id:
raise ValidationError("Must provide either an image file or an Immich ID.") raise ValidationError("Must provide either an image file or an Immich ID.")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Clean empty strings to None for proper database storage # Clean empty strings to None for proper database storage
if not self.image: if not self.image:
@ -423,25 +463,37 @@ class LocationImage(models.Model):
if not self.immich_id or not str(self.immich_id).strip(): if not self.immich_id or not str(self.immich_id).strip():
self.immich_id = None self.immich_id = None
self.full_clean() # This calls clean() method self.full_clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
# Remove file from disk when deleting AdventureImage # Remove file from disk when deleting image
if self.image and os.path.isfile(self.image.path): if self.image and os.path.isfile(self.image.path):
os.remove(self.image.path) os.remove(self.image.path)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
def __str__(self): def __str__(self):
return self.image.url if self.image else f"Immich ID: {self.immich_id or 'No image'}" content_name = getattr(self.content_object, 'name', 'Unknown')
return f"Image for {self.content_type.model}: {content_name}"
class Attachment(models.Model):
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) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user = models.ForeignKey( user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
User, on_delete=models.CASCADE, default=default_user) file = models.FileField(upload_to=PathAndRename('attachments/'), validators=[validate_file_extension])
file = models.FileField(upload_to=PathAndRename('attachments/'),validators=[validate_file_extension])
location = models.ForeignKey(Location, related_name='attachments', on_delete=models.CASCADE)
name = models.CharField(max_length=200, null=True, blank=True) 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): def delete(self, *args, **kwargs):
if self.file and os.path.isfile(self.file.path): if self.file and os.path.isfile(self.file.path):
@ -449,7 +501,8 @@ class Attachment(models.Model):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
def __str__(self): def __str__(self):
return self.file.url content_name = getattr(self.content_object, 'name', 'Unknown')
return f"Attachment for {self.content_type.model}: {content_name}"
class Category(models.Model): class Category(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
@ -474,8 +527,7 @@ class Category(models.Model):
class Lodging(models.Model): class Lodging(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user = models.ForeignKey( user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
User, on_delete=models.CASCADE, default=default_user)
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other') type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other')
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
@ -494,6 +546,10 @@ class Lodging(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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): def clean(self):
if self.check_in and self.check_out and self.check_in > self.check_out: 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)) raise ValidationError('The start date must be before the end date. Start date: ' + str(self.check_in) + ' End date: ' + str(self.check_out))
@ -504,5 +560,13 @@ class Lodging(models.Model):
if self.user != self.collection.user: 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) 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): def __str__(self):
return self.name return self.name

View file

@ -1,6 +1,6 @@
from django.utils import timezone from django.utils import timezone
import os import os
from .models import Location, LocationImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Lodging from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging
from rest_framework import serializers from rest_framework import serializers
from main.utils import CustomModelSerializer from main.utils import CustomModelSerializer
from users.serializers import CustomUserDetailsSerializer from users.serializers import CustomUserDetailsSerializer
@ -9,9 +9,9 @@ from geopy.distance import geodesic
from integrations.models import ImmichIntegration from integrations.models import ImmichIntegration
class LocationImageSerializer(CustomModelSerializer): class ContentImageSerializer(CustomModelSerializer):
class Meta: class Meta:
model = LocationImage model = ContentImage
fields = ['id', 'image', 'location', 'is_primary', 'user', 'immich_id'] fields = ['id', 'image', 'location', 'is_primary', 'user', 'immich_id']
read_only_fields = ['id', 'user'] read_only_fields = ['id', 'user']
@ -41,7 +41,7 @@ class LocationImageSerializer(CustomModelSerializer):
class AttachmentSerializer(CustomModelSerializer): class AttachmentSerializer(CustomModelSerializer):
extension = serializers.SerializerMethodField() extension = serializers.SerializerMethodField()
class Meta: class Meta:
model = Attachment model = ContentAttachment
fields = ['id', 'file', 'location', 'extension', 'name', 'user'] fields = ['id', 'file', 'location', 'extension', 'name', 'user']
read_only_fields = ['id', 'user'] read_only_fields = ['id', 'user']
@ -122,7 +122,7 @@ class LocationSerializer(CustomModelSerializer):
return representation return representation
def get_images(self, obj): def get_images(self, obj):
serializer = LocationImageSerializer(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 # Filter out None values from the serialized data
return [image for image in serializer.data if image is not None] return [image for image in serializer.data if image is not None]

View file

@ -1,4 +1,4 @@
from adventures.models import LocationImage, Attachment from adventures.models import ContentImage, ContentAttachment
protected_paths = ['images/', 'attachments/'] protected_paths = ['images/', 'attachments/']
@ -9,40 +9,69 @@ def checkFilePermission(fileId, user, mediaType):
try: try:
# Construct the full relative path to match the database field # Construct the full relative path to match the database field
image_path = f"images/{fileId}" image_path = f"images/{fileId}"
# Fetch the AdventureImage object # Fetch the ContentImage object
location = LocationImage.objects.get(image=image_path).location content_image = ContentImage.objects.get(image=image_path)
if location.is_public:
# Get the content object (could be Location, Transportation, Note, etc.)
content_object = content_image.content_object
# Check if content object is public
if hasattr(content_object, 'is_public') and content_object.is_public:
return True return True
elif location.user == user:
# Check if user owns the content object
if hasattr(content_object, 'user') and content_object.user == user:
return True return True
elif location.collections.exists():
# Check if the user is in any collection's shared_with list # Check collection-based permissions
for collection in location.collections.all(): 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.shared_with.filter(id=user.id).exists(): if collection.shared_with.filter(id=user.id).exists():
return True return True
return False return False
elif hasattr(content_object, 'collection') and content_object.collection:
# For objects with single collection (like Transportation, Note, etc.)
if content_object.collection.shared_with.filter(id=user.id).exists():
return True
return False
else: else:
return False return False
except LocationImage.DoesNotExist:
except ContentImage.DoesNotExist:
return False return False
elif mediaType == 'attachments/': elif mediaType == 'attachments/':
try: try:
# Construct the full relative path to match the database field # Construct the full relative path to match the database field
attachment_path = f"attachments/{fileId}" attachment_path = f"attachments/{fileId}"
# Fetch the Attachment object # Fetch the ContentAttachment object
attachment = Attachment.objects.get(file=attachment_path) content_attachment = ContentAttachment.objects.get(file=attachment_path)
location = attachment.location
if location.is_public: # 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 return True
elif location.user == user:
# Check if user owns the content object
if hasattr(content_object, 'user') and content_object.user == user:
return True return True
elif location.collections.exists():
# Check if the user is in any collection's shared_with list # Check collection-based permissions
for collection in location.collections.all(): 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.shared_with.filter(id=user.id).exists(): if collection.shared_with.filter(id=user.id).exists():
return True return True
return False return False
elif hasattr(content_object, 'collection') and content_object.collection:
# For objects with single collection (like Transportation, Note, etc.)
if content_object.collection.shared_with.filter(id=user.id).exists():
return True
return False
else: else:
return False return False
except Attachment.DoesNotExist:
except ContentAttachment.DoesNotExist:
return False return False

View file

@ -2,7 +2,7 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from adventures.models import Location, Attachment from adventures.models import Location, ContentAttachment
from adventures.serializers import AttachmentSerializer from adventures.serializers import AttachmentSerializer
class AttachmentViewSet(viewsets.ModelViewSet): class AttachmentViewSet(viewsets.ModelViewSet):
@ -10,7 +10,7 @@ class AttachmentViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get_queryset(self): def get_queryset(self):
return Attachment.objects.filter(user=self.request.user) return ContentAttachment.objects.filter(user=self.request.user)
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:

View file

@ -18,7 +18,7 @@ from django.conf import settings
from adventures.models import ( from adventures.models import (
Location, Collection, Transportation, Note, Checklist, ChecklistItem, Location, Collection, Transportation, Note, Checklist, ChecklistItem,
LocationImage, Attachment, Category, Lodging, Visit ContentImage, ContentAttachment, Category, Lodging, Visit
) )
from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country
@ -347,7 +347,7 @@ class BackupViewSet(viewsets.ViewSet):
user.lodging_set.all().delete() user.lodging_set.all().delete()
# Delete location-related data # Delete location-related data
user.locationimage_set.all().delete() user.contentimage_set.all().delete()
user.attachment_set.all().delete() user.attachment_set.all().delete()
# Visits are deleted via cascade when locations are deleted # Visits are deleted via cascade when locations are deleted
user.location_set.all().delete() user.location_set.all().delete()
@ -487,7 +487,7 @@ class BackupViewSet(viewsets.ViewSet):
for img_data in adv_data.get('images', []): for img_data in adv_data.get('images', []):
immich_id = img_data.get('immich_id') immich_id = img_data.get('immich_id')
if immich_id: if immich_id:
LocationImage.objects.create( ContentImage.objects.create(
user=user, user=user,
location=location, location=location,
immich_id=immich_id, immich_id=immich_id,
@ -500,7 +500,7 @@ class BackupViewSet(viewsets.ViewSet):
try: try:
img_content = zip_file.read(f'images/{filename}') img_content = zip_file.read(f'images/{filename}')
img_file = ContentFile(img_content, name=filename) img_file = ContentFile(img_content, name=filename)
LocationImage.objects.create( ContentImage.objects.create(
user=user, user=user,
location=location, location=location,
image=img_file, image=img_file,
@ -517,7 +517,7 @@ class BackupViewSet(viewsets.ViewSet):
try: try:
att_content = zip_file.read(f'attachments/{filename}') att_content = zip_file.read(f'attachments/{filename}')
att_file = ContentFile(att_content, name=filename) att_file = ContentFile(att_content, name=filename)
Attachment.objects.create( ContentAttachment.objects.create(
user=user, user=user,
location=location, location=location,
file=att_file, file=att_file,

View file

@ -4,14 +4,14 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Q from django.db.models import Q
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from adventures.models import Location, LocationImage from adventures.models import Location, ContentImage
from adventures.serializers import LocationImageSerializer from adventures.serializers import ContentImageSerializer
from integrations.models import ImmichIntegration from integrations.models import ImmichIntegration
import uuid import uuid
import requests import requests
class AdventureImageViewSet(viewsets.ModelViewSet): class AdventureImageViewSet(viewsets.ModelViewSet):
serializer_class = LocationImageSerializer serializer_class = ContentImageSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
@ -34,7 +34,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
# Set the current primary image to false # Set the current primary image to false
LocationImage.objects.filter(location=location, is_primary=True).update(is_primary=False) ContentImage.objects.filter(location=location, is_primary=True).update(is_primary=False)
# Set the new image to true # Set the new image to true
instance.is_primary = True instance.is_primary = True
@ -190,7 +190,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response({"error": "Invalid location ID"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Invalid location ID"}, status=status.HTTP_400_BAD_REQUEST)
# Updated queryset to include images from locations the user owns OR has shared access to # Updated queryset to include images from locations the user owns OR has shared access to
queryset = LocationImage.objects.filter( queryset = ContentImage.objects.filter(
Q(location__id=location_uuid) & ( Q(location__id=location_uuid) & (
Q(location__user=request.user) | # User owns the location Q(location__user=request.user) | # User owns the location
Q(location__collections__shared_with=request.user) # User has shared access via collection Q(location__collections__shared_with=request.user) # User has shared access via collection
@ -202,7 +202,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
# Updated to include images from locations the user owns OR has shared access to # Updated to include images from locations the user owns OR has shared access to
return LocationImage.objects.filter( return ContentImage.objects.filter(
Q(location__user=self.request.user) | # User owns the location Q(location__user=self.request.user) | # User owns the location
Q(location__collections__shared_with=self.request.user) # User has shared access via collection Q(location__collections__shared_with=self.request.user) # User has shared access via collection
).distinct() ).distinct()

View file

@ -8,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated
import requests import requests
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from django.conf import settings from django.conf import settings
from adventures.models import LocationImage from adventures.models import ContentImage
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
import logging import logging
@ -257,7 +257,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
2. Private locations in public collections: accessible by anyone 2. Private locations in public collections: accessible by anyone
3. Private locations in private collections shared with user: accessible by shared users 3. Private locations in private collections shared with user: accessible by shared users
4. Private locations: accessible only to the owner 4. Private locations: accessible only to the owner
5. No LocationImage: owner can still view via integration 5. No ContentImage: owner can still view via integration
""" """
if not imageid or not integration_id: if not imageid or not integration_id:
return Response({ return Response({
@ -272,7 +272,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
# Try to find the image entry with collections and sharing information # Try to find the image entry with collections and sharing information
image_entry = ( image_entry = (
LocationImage.objects ContentImage.objects
.filter(immich_id=imageid, user=owner_id) .filter(immich_id=imageid, user=owner_id)
.select_related('location') .select_related('location')
.prefetch_related('location__collections', 'location__collections__shared_with') .prefetch_related('location__collections', 'location__collections__shared_with')
@ -313,7 +313,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
'code': 'immich.permission_denied' 'code': 'immich.permission_denied'
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
else: else:
# No LocationImage exists; allow only the integration owner # No ContentImage exists; allow only the integration owner
if not request.user.is_authenticated or request.user != owner_id: if not request.user.is_authenticated or request.user != owner_id:
return Response({ return Response({
'message': 'Image is not linked to any location and you are not the owner.', 'message': 'Image is not linked to any location and you are not the owner.',