diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index bbc2a74..43ea40a 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,7 +1,7 @@ import os from django.contrib import admin 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 allauth.account.decorators import secure_admin_login @@ -96,7 +96,7 @@ class CustomUserAdmin(UserAdmin): else: return -class LocationImageAdmin(admin.ModelAdmin): +class ContentImageImageAdmin(admin.ModelAdmin): list_display = ('user', 'image_display') def image_display(self, obj): @@ -147,11 +147,11 @@ admin.site.register(Transportation) admin.site.register(Note) admin.site.register(Checklist) admin.site.register(ChecklistItem) -admin.site.register(LocationImage, LocationImageAdmin) +admin.site.register(ContentImage, ContentImageImageAdmin) admin.site.register(Category, CategoryAdmin) admin.site.register(City, CityAdmin) admin.site.register(VisitedCity) -admin.site.register(Attachment) +admin.site.register(ContentAttachment) admin.site.register(Lodging) admin.site.site_header = 'AdventureLog Admin' diff --git a/backend/server/adventures/management/commands/image_cleanup.py b/backend/server/adventures/management/commands/image_cleanup.py index c6c7f26..806d185 100644 --- a/backend/server/adventures/management/commands/image_cleanup.py +++ b/backend/server/adventures/management/commands/image_cleanup.py @@ -1,7 +1,7 @@ import os from django.core.management.base import BaseCommand from django.conf import settings -from adventures.models import LocationImage, Attachment +from adventures.models import ContentImage, ContentAttachment from users.models import CustomUser @@ -21,13 +21,13 @@ class Command(BaseCommand): # Get all image and attachment file paths from database used_files = set() - # Get LocationImage file paths - for img in LocationImage.objects.all(): + # Get ContentImage file paths + for img in ContentImage.objects.all(): if img.image and img.image.name: used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name)) # Get Attachment file paths - for attachment in Attachment.objects.all(): + for attachment in ContentAttachment.objects.all(): if attachment.file and attachment.file.name: used_files.add(os.path.join(settings.MEDIA_ROOT, attachment.file.name)) diff --git a/backend/server/adventures/migrations/0052_rename_attachment_contentattachment_and_more.py b/backend/server/adventures/migrations/0052_rename_attachment_contentattachment_and_more.py new file mode 100644 index 0000000..0b0840c --- /dev/null +++ b/backend/server/adventures/migrations/0052_rename_attachment_contentattachment_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.1 on 2025-07-10 14:40 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0051_rename_activity_types_location_tags_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RenameModel( + old_name='Attachment', + new_name='ContentAttachment', + ), + migrations.RenameModel( + old_name='LocationImage', + new_name='ContentImage', + ), + ] diff --git a/backend/server/adventures/migrations/0053_alter_contentattachment_options_and_more.py b/backend/server/adventures/migrations/0053_alter_contentattachment_options_and_more.py new file mode 100644 index 0000000..22eb778 --- /dev/null +++ b/backend/server/adventures/migrations/0053_alter_contentattachment_options_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.1 on 2025-07-10 15:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0052_rename_attachment_contentattachment_and_more'), + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='contentattachment', + options={'verbose_name': 'Content Attachment', 'verbose_name_plural': 'Content Attachments'}, + ), + migrations.AlterModelOptions( + name='contentimage', + options={'verbose_name': 'Content Image', 'verbose_name_plural': 'Content Images'}, + ), + migrations.AddField( + model_name='contentattachment', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='contentattachment', + name='object_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='contentimage', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='contentimage', + name='object_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='contentattachment', + index=models.Index(fields=['content_type', 'object_id'], name='adventures__content_e42b72_idx'), + ), + migrations.AddIndex( + model_name='contentimage', + index=models.Index(fields=['content_type', 'object_id'], name='adventures__content_aa4984_idx'), + ), + ] diff --git a/backend/server/adventures/migrations/0054_migrate_location_images_generic_relation.py b/backend/server/adventures/migrations/0054_migrate_location_images_generic_relation.py new file mode 100644 index 0000000..7277b7f --- /dev/null +++ b/backend/server/adventures/migrations/0054_migrate_location_images_generic_relation.py @@ -0,0 +1,73 @@ +# Custom migrations to migrate LocationImage and Attachment models to generic ContentImage and ContentAttachment models +from django.db import migrations, models +from django.utils import timezone + +def migrate_images_and_attachments_forward(apps, schema_editor): + """ + Migrate existing LocationImage and Attachment records to the new generic ContentImage and ContentAttachment models + """ + # Get the models + ContentImage = apps.get_model('adventures', 'ContentImage') + ContentAttachment = apps.get_model('adventures', 'ContentAttachment') + + # Get the ContentType for Location + ContentType = apps.get_model('contenttypes', 'ContentType') + try: + location_ct = ContentType.objects.get(app_label='adventures', model='location') + except ContentType.DoesNotExist: + return + + # Update existing ContentImages (which were previously LocationImages) + ContentImage.objects.filter(content_type__isnull=True).update( + content_type=location_ct + ) + + # Set object_id from location_id for ContentImages + for content_image in ContentImage.objects.filter(object_id__isnull=True): + if hasattr(content_image, 'location_id') and content_image.location_id: + content_image.object_id = content_image.location_id + content_image.save() + + # Update existing ContentAttachments (which were previously Attachments) + ContentAttachment.objects.filter(content_type__isnull=True).update( + content_type=location_ct + ) + + # Set object_id from location_id for ContentAttachments + for content_attachment in ContentAttachment.objects.filter(object_id__isnull=True): + if hasattr(content_attachment, 'location_id') and content_attachment.location_id: + content_attachment.object_id = content_attachment.location_id + content_attachment.save() + +def migrate_images_and_attachments_reverse(apps, schema_editor): + """ + Reverse migration to restore location_id fields from object_id + """ + ContentImage = apps.get_model('adventures', 'ContentImage') + ContentAttachment = apps.get_model('adventures', 'ContentAttachment') + + # Restore location_id from object_id for ContentImages + for content_image in ContentImage.objects.all(): + if content_image.object_id and hasattr(content_image, 'location_id'): + content_image.location_id = content_image.object_id + content_image.save() + + # Restore location_id from object_id for ContentAttachments + for content_attachment in ContentAttachment.objects.all(): + if content_attachment.object_id and hasattr(content_attachment, 'location_id'): + content_attachment.location_id = content_attachment.object_id + content_attachment.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0053_alter_contentattachment_options_and_more'), + ] + + operations = [ + migrations.RunPython( + migrate_images_and_attachments_forward, + migrate_images_and_attachments_reverse, + elidable=True + ) + ] \ No newline at end of file diff --git a/backend/server/adventures/migrations/0055_alter_contentattachment_content_type_and_more.py b/backend/server/adventures/migrations/0055_alter_contentattachment_content_type_and_more.py new file mode 100644 index 0000000..923a8b0 --- /dev/null +++ b/backend/server/adventures/migrations/0055_alter_contentattachment_content_type_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.1 on 2025-07-10 15:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0054_migrate_location_images_generic_relation'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='contentattachment', + name='location', + ), + migrations.RemoveField( + model_name='contentimage', + name='location', + ), + migrations.AlterField( + model_name='contentattachment', + name='content_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_attachments', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='contentattachment', + name='object_id', + field=models.UUIDField(), + ), + migrations.AlterField( + model_name='contentimage', + name='content_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='contentimage', + name='object_id', + field=models.UUIDField(), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 0227cf3..3eaf0d2 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -14,6 +14,10 @@ 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: @@ -126,42 +130,49 @@ class Visit(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Generic relations for images and attachments + images = GenericRelation('ContentImage', related_query_name='visit') + attachments = GenericRelation('ContentAttachment', related_query_name='visit') + def clean(self): if self.start_date > self.end_date: raise ValidationError('The start date must be before or equal to the end date.') + def delete(self, *args, **kwargs): + # Delete all associated images and attachments + for image in self.images.all(): + image.delete() + for attachment in self.attachments.all(): + attachment.delete() + super().delete(*args, **kwargs) + def __str__(self): return f"{self.location.name} - {self.start_date} to {self.end_date}" class Location(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) - + 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) + tags = ArrayField(models.CharField(max_length=100), blank=True, null=True) description = models.TextField(blank=True, null=True) rating = models.FloatField(blank=True, null=True) link = models.URLField(blank=True, null=True, max_length=2083) is_public = models.BooleanField(default=False) - longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - city = models.ForeignKey(City, on_delete=models.SET_NULL, blank=True, null=True) region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True) country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True) - - # Changed from ForeignKey to ManyToManyField collections = models.ManyToManyField('Collection', blank=True, related_name='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): @@ -236,10 +247,9 @@ class Location(models.Model): return result 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(): image.delete() - # Delete all associated Attachment files first to trigger their filesystem cleanup for attachment in self.attachments.all(): attachment.delete() super().delete(*args, **kwargs) @@ -275,10 +285,8 @@ class Collection(models.Model): return self.name class Transportation(models.Model): - #id = models.AutoField(primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) - user = models.ForeignKey( - User, on_delete=models.CASCADE, default=default_user) + 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) @@ -300,8 +308,11 @@ class Transportation(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Generic relations for images and attachments + images = GenericRelation('ContentImage', related_query_name='transportation') + attachments = GenericRelation('ContentAttachment', related_query_name='transportation') + def clean(self): - print(self.date) if self.date and self.end_date and self.date > self.end_date: raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date)) @@ -311,14 +322,20 @@ class Transportation(models.Model): if self.user != self.collection.user: raise ValidationError('Transportations must be associated with collections owned by the same user. Collection owner: ' + self.collection.user.username + ' Transportation owner: ' + self.user.username) + def delete(self, *args, **kwargs): + # Delete all associated images and attachments + for image in self.images.all(): + image.delete() + for attachment in self.attachments.all(): + attachment.delete() + super().delete(*args, **kwargs) + def __str__(self): return self.name class Note(models.Model): - #id = models.AutoField(primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) - user = models.ForeignKey( - User, on_delete=models.CASCADE, default=default_user) + 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) @@ -328,12 +345,24 @@ class Note(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Generic relations for images and attachments + images = GenericRelation('ContentImage', related_query_name='note') + attachments = GenericRelation('ContentAttachment', related_query_name='note') + def clean(self): if self.collection: if self.collection.is_public and not self.is_public: - raise ValidationError('Notes associated with a public collection must be public. Collection: ' + self.collection.name + ' Transportation: ' + self.name) + 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 + ' 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): return self.name @@ -391,7 +420,8 @@ class PathAndRename: filename = f"{uuid.uuid4()}.{ext}" 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) user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user) image = ResizedImageField( @@ -402,11 +432,21 @@ class LocationImage(models.Model): null=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) + + # 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()) @@ -415,7 +455,7 @@ class LocationImage(models.Model): raise ValidationError("Cannot have both image file and Immich ID. Please provide only one.") if not has_image and not has_immich_id: raise ValidationError("Must provide either an image file or an Immich ID.") - + def save(self, *args, **kwargs): # Clean empty strings to None for proper database storage if not self.image: @@ -423,25 +463,37 @@ class LocationImage(models.Model): if not self.immich_id or not str(self.immich_id).strip(): self.immich_id = None - self.full_clean() # This calls clean() method + self.full_clean() super().save(*args, **kwargs) def 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): os.remove(self.image.path) super().delete(*args, **kwargs) def __str__(self): - return self.image.url if self.image else f"Immich ID: {self.immich_id or 'No image'}" - -class Attachment(models.Model): + 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]) - location = models.ForeignKey(Location, related_name='attachments', on_delete=models.CASCADE) + 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): @@ -449,7 +501,8 @@ class Attachment(models.Model): super().delete(*args, **kwargs) 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): 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): 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) name = models.CharField(max_length=200) type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other') description = models.TextField(blank=True, null=True) @@ -494,6 +546,10 @@ class Lodging(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Generic relations for images and attachments + images = GenericRelation('ContentImage', related_query_name='lodging') + attachments = GenericRelation('ContentAttachment', related_query_name='lodging') + def clean(self): if self.check_in and self.check_out and self.check_in > self.check_out: raise ValidationError('The start date must be before the end date. Start date: ' + str(self.check_in) + ' End date: ' + str(self.check_out)) @@ -504,5 +560,13 @@ class Lodging(models.Model): if self.user != self.collection.user: raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user.username + ' Lodging owner: ' + self.user.username) + def delete(self, *args, **kwargs): + # Delete all associated images and attachments + for image in self.images.all(): + image.delete() + for attachment in self.attachments.all(): + attachment.delete() + super().delete(*args, **kwargs) + def __str__(self): return self.name \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 7a22d38..bd560e9 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,6 +1,6 @@ from django.utils import timezone 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 main.utils import CustomModelSerializer from users.serializers import CustomUserDetailsSerializer @@ -9,9 +9,9 @@ from geopy.distance import geodesic from integrations.models import ImmichIntegration -class LocationImageSerializer(CustomModelSerializer): +class ContentImageSerializer(CustomModelSerializer): class Meta: - model = LocationImage + model = ContentImage fields = ['id', 'image', 'location', 'is_primary', 'user', 'immich_id'] read_only_fields = ['id', 'user'] @@ -41,7 +41,7 @@ class LocationImageSerializer(CustomModelSerializer): class AttachmentSerializer(CustomModelSerializer): extension = serializers.SerializerMethodField() class Meta: - model = Attachment + model = ContentAttachment fields = ['id', 'file', 'location', 'extension', 'name', 'user'] read_only_fields = ['id', 'user'] @@ -122,7 +122,7 @@ class LocationSerializer(CustomModelSerializer): return representation 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 return [image for image in serializer.data if image is not None] diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py index 494d7ea..5d6d470 100644 --- a/backend/server/adventures/utils/file_permissions.py +++ b/backend/server/adventures/utils/file_permissions.py @@ -1,4 +1,4 @@ -from adventures.models import LocationImage, Attachment +from adventures.models import ContentImage, ContentAttachment protected_paths = ['images/', 'attachments/'] @@ -9,40 +9,69 @@ def checkFilePermission(fileId, user, mediaType): try: # Construct the full relative path to match the database field image_path = f"images/{fileId}" - # Fetch the AdventureImage object - location = LocationImage.objects.get(image=image_path).location - if location.is_public: + # Fetch the ContentImage object + content_image = ContentImage.objects.get(image=image_path) + + # Get the content object (could be Location, Transportation, Note, etc.) + content_object = content_image.content_object + + # Check if content object is public + if hasattr(content_object, 'is_public') and content_object.is_public: 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 - elif location.collections.exists(): - # Check if the user is in any collection's shared_with list - for collection in location.collections.all(): + + # Check collection-based permissions + if hasattr(content_object, 'collections') and content_object.collections.exists(): + # For objects with multiple collections (like Location) + for collection in content_object.collections.all(): if collection.shared_with.filter(id=user.id).exists(): return True return False + elif hasattr(content_object, 'collection') and content_object.collection: + # For objects with single collection (like Transportation, Note, etc.) + if content_object.collection.shared_with.filter(id=user.id).exists(): + return True + return False else: return False - except LocationImage.DoesNotExist: + + except ContentImage.DoesNotExist: return False elif mediaType == 'attachments/': try: # Construct the full relative path to match the database field attachment_path = f"attachments/{fileId}" - # Fetch the Attachment object - attachment = Attachment.objects.get(file=attachment_path) - location = attachment.location - if location.is_public: + # Fetch the ContentAttachment object + content_attachment = ContentAttachment.objects.get(file=attachment_path) + + # Get the content object (could be Location, Transportation, Note, etc.) + content_object = content_attachment.content_object + + # Check if content object is public + if hasattr(content_object, 'is_public') and content_object.is_public: return True - elif location.user == user: + + # Check if user owns the content object + if hasattr(content_object, 'user') and content_object.user == user: return True - elif location.collections.exists(): - # Check if the user is in any collection's shared_with list - for collection in location.collections.all(): + + # Check collection-based permissions + if hasattr(content_object, 'collections') and content_object.collections.exists(): + # For objects with multiple collections (like Location) + for collection in content_object.collections.all(): if collection.shared_with.filter(id=user.id).exists(): return True return False + elif hasattr(content_object, 'collection') and content_object.collection: + # For objects with single collection (like Transportation, Note, etc.) + if content_object.collection.shared_with.filter(id=user.id).exists(): + return True + return False else: return False - except Attachment.DoesNotExist: + + except ContentAttachment.DoesNotExist: return False \ No newline at end of file diff --git a/backend/server/adventures/views/attachment_view.py b/backend/server/adventures/views/attachment_view.py index a6c0642..9f75211 100644 --- a/backend/server/adventures/views/attachment_view.py +++ b/backend/server/adventures/views/attachment_view.py @@ -2,7 +2,7 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from adventures.models import Location, Attachment +from adventures.models import Location, ContentAttachment from adventures.serializers import AttachmentSerializer class AttachmentViewSet(viewsets.ModelViewSet): @@ -10,7 +10,7 @@ class AttachmentViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] 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): if not request.user.is_authenticated: diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 2e88480..6a59b43 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -18,7 +18,7 @@ from django.conf import settings from adventures.models import ( 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 @@ -347,7 +347,7 @@ class BackupViewSet(viewsets.ViewSet): user.lodging_set.all().delete() # Delete location-related data - user.locationimage_set.all().delete() + user.contentimage_set.all().delete() user.attachment_set.all().delete() # Visits are deleted via cascade when locations are deleted user.location_set.all().delete() @@ -487,7 +487,7 @@ class BackupViewSet(viewsets.ViewSet): for img_data in adv_data.get('images', []): immich_id = img_data.get('immich_id') if immich_id: - LocationImage.objects.create( + ContentImage.objects.create( user=user, location=location, immich_id=immich_id, @@ -500,7 +500,7 @@ class BackupViewSet(viewsets.ViewSet): try: img_content = zip_file.read(f'images/{filename}') img_file = ContentFile(img_content, name=filename) - LocationImage.objects.create( + ContentImage.objects.create( user=user, location=location, image=img_file, @@ -517,7 +517,7 @@ class BackupViewSet(viewsets.ViewSet): try: att_content = zip_file.read(f'attachments/{filename}') att_file = ContentFile(att_content, name=filename) - Attachment.objects.create( + ContentAttachment.objects.create( user=user, location=location, file=att_file, diff --git a/backend/server/adventures/views/location_image_view.py b/backend/server/adventures/views/location_image_view.py index 063c7a8..5e7151b 100644 --- a/backend/server/adventures/views/location_image_view.py +++ b/backend/server/adventures/views/location_image_view.py @@ -4,14 +4,14 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.db.models import Q from django.core.files.base import ContentFile -from adventures.models import Location, LocationImage -from adventures.serializers import LocationImageSerializer +from adventures.models import Location, ContentImage +from adventures.serializers import ContentImageSerializer from integrations.models import ImmichIntegration import uuid import requests class AdventureImageViewSet(viewsets.ModelViewSet): - serializer_class = LocationImageSerializer + serializer_class = ContentImageSerializer permission_classes = [IsAuthenticated] @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) # 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 instance.is_primary = True @@ -190,7 +190,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet): 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 - queryset = LocationImage.objects.filter( + queryset = ContentImage.objects.filter( Q(location__id=location_uuid) & ( Q(location__user=request.user) | # User owns the location 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): # 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__collections__shared_with=self.request.user) # User has shared access via collection ).distinct() diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views.py index 36739b8..e6d35bc 100644 --- a/backend/server/integrations/views.py +++ b/backend/server/integrations/views.py @@ -8,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated import requests from rest_framework.pagination import PageNumberPagination from django.conf import settings -from adventures.models import LocationImage +from adventures.models import ContentImage from django.http import HttpResponse from django.shortcuts import get_object_or_404 import logging @@ -257,7 +257,7 @@ class ImmichIntegrationView(viewsets.ViewSet): 2. Private locations in public collections: accessible by anyone 3. Private locations in private collections shared with user: accessible by shared users 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: return Response({ @@ -272,7 +272,7 @@ class ImmichIntegrationView(viewsets.ViewSet): # Try to find the image entry with collections and sharing information image_entry = ( - LocationImage.objects + ContentImage.objects .filter(immich_id=imageid, user=owner_id) .select_related('location') .prefetch_related('location__collections', 'location__collections__shared_with') @@ -313,7 +313,7 @@ class ImmichIntegrationView(viewsets.ViewSet): 'code': 'immich.permission_denied' }, status=status.HTTP_403_FORBIDDEN) 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: return Response({ 'message': 'Image is not linked to any location and you are not the owner.',