From 0838a411569aa6b5a91bcdba79701cd0d85c8292 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sun, 1 Jun 2025 22:50:26 -0400 Subject: [PATCH] feat: add unique constraint for immich_id per user in AdventureImage model and enhance Immich integration image retrieval --- ...dventureimage_unique_immich_id_per_user.py | 19 +++ ...dventureimage_unique_immich_id_per_user.py | 17 +++ backend/server/adventures/models.py | 20 ++- backend/server/integrations/views.py | 122 ++++++++++++------ 4 files changed, 133 insertions(+), 45 deletions(-) create mode 100644 backend/server/adventures/migrations/0033_adventureimage_unique_immich_id_per_user.py create mode 100644 backend/server/adventures/migrations/0034_remove_adventureimage_unique_immich_id_per_user.py diff --git a/backend/server/adventures/migrations/0033_adventureimage_unique_immich_id_per_user.py b/backend/server/adventures/migrations/0033_adventureimage_unique_immich_id_per_user.py new file mode 100644 index 0000000..d3b1bb5 --- /dev/null +++ b/backend/server/adventures/migrations/0033_adventureimage_unique_immich_id_per_user.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.1 on 2025-06-02 02:31 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0032_remove_adventureimage_image_xor_immich_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name='adventureimage', + constraint=models.UniqueConstraint(fields=('immich_id', 'user_id'), name='unique_immich_id_per_user'), + ), + ] diff --git a/backend/server/adventures/migrations/0034_remove_adventureimage_unique_immich_id_per_user.py b/backend/server/adventures/migrations/0034_remove_adventureimage_unique_immich_id_per_user.py new file mode 100644 index 0000000..52b3e52 --- /dev/null +++ b/backend/server/adventures/migrations/0034_remove_adventureimage_unique_immich_id_per_user.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.1 on 2025-06-02 02:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0033_adventureimage_unique_immich_id_per_user'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='adventureimage', + name='unique_immich_id_per_user', + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 7e2edd6..72d1294 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -11,7 +11,7 @@ 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 adventures.geocoding import reverse_geocode +from django.core.exceptions import ValidationError from django.utils import timezone def background_geocode_and_assign(adventure_id: str): @@ -799,19 +799,25 @@ class AdventureImage(models.Model): is_primary = models.BooleanField(default=False) def clean(self): - from django.core.exceptions import ValidationError - - # Normalize empty values to None + + # 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()) - - # Exactly one must be provided + 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.") + # Enforce: immich_id may only be used by a single user + if has_immich_id: + # Check if this immich_id is already used by a *different* user + from adventures.models import AdventureImage + conflict = AdventureImage.objects.filter(immich_id=self.immich_id).exclude(user_id=self.user_id) + + if conflict.exists(): + raise ValidationError("This Immich ID is already used by another user.") + def save(self, *args, **kwargs): # Clean empty strings to None for proper database storage if not self.image: diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views.py index 68ced57..ef89aa7 100644 --- a/backend/server/integrations/views.py +++ b/backend/server/integrations/views.py @@ -8,6 +8,8 @@ from rest_framework.permissions import IsAuthenticated import requests from rest_framework.pagination import PageNumberPagination from django.conf import settings +from adventures.models import AdventureImage +from django.http import HttpResponse class IntegrationView(viewsets.ViewSet): permission_classes = [IsAuthenticated] @@ -41,6 +43,15 @@ class ImmichIntegrationView(viewsets.ViewSet): - None if the integration exists. - A Response with an error message if the integration is missing. """ + if not request.user.is_authenticated: + return Response( + { + 'message': 'You need to be authenticated to use this feature.', + 'error': True, + 'code': 'immich.authentication_required' + }, + status=status.HTTP_403_FORBIDDEN + ) user_integrations = ImmichIntegration.objects.filter(user=request.user) if not user_integrations.exists(): return Response( @@ -120,43 +131,6 @@ class ImmichIntegrationView(viewsets.ViewSet): status=status.HTTP_404_NOT_FOUND ) - @action(detail=False, methods=['get'], url_path='get/(?P[^/.]+)') - def get(self, request, imageid=None): - """ - RESTful GET method for retrieving a specific Immich image by ID. - """ - # Check for integration before proceeding - integration = self.check_integration(request) - if isinstance(integration, Response): - return integration - - if not imageid: - return Response( - { - 'message': 'Image ID is required.', - 'error': True, - 'code': 'immich.imageid_required' - }, - status=status.HTTP_400_BAD_REQUEST - ) - - # check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code - try: - immich_fetch = requests.get(f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', headers={ - 'x-api-key': integration.api_key - }) - # should return the image file - from django.http import HttpResponse - return HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK) - except requests.exceptions.ConnectionError: - return Response( - { - 'message': 'The Immich server is currently down or unreachable.', - 'error': True, - 'code': 'immich.server_down' - }, - status=status.HTTP_503_SERVICE_UNAVAILABLE - ) @action(detail=False, methods=['get']) def albums(self, request): @@ -188,7 +162,7 @@ class ImmichIntegrationView(viewsets.ViewSet): res, status=status.HTTP_200_OK ) - + @action(detail=False, methods=['get'], url_path='albums/(?P[^/.]+)') def album(self, request, albumid=None): """ @@ -244,6 +218,78 @@ class ImmichIntegrationView(viewsets.ViewSet): status=status.HTTP_404_NOT_FOUND ) + @action(detail=False, methods=['get'], url_path='get/(?P[^/.]+)', permission_classes=[]) + def get(self, request, imageid=None): + """ + RESTful GET method for retrieving a specific Immich image by ID. + Allows access to images for public adventures even if the user doesn't have Immich integration. + """ + if not imageid: + return Response( + { + 'message': 'Image ID is required.', + 'error': True, + 'code': 'immich.imageid_required' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if the image ID is associated with a public adventure + + public_image = AdventureImage.objects.filter( + immich_id=imageid, + adventure__is_public=True + ).first() + + # If it's a public adventure image, use any available integration + if public_image: + integration = ImmichIntegration.objects.filter( + user_id=public_image.adventure.user_id + ).first() + if not integration: + return Response( + { + 'message': 'No Immich integration available for public access.', + 'error': True, + 'code': 'immich.no_integration' + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + else: + # Not a public image, check user's integration + integration = self.check_integration(request) + if isinstance(integration, Response): + return integration + + # Proceed with fetching the image + try: + immich_fetch = requests.get( + f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', + headers={'x-api-key': integration.api_key}, + timeout=5 # Add timeout to prevent hanging + ) + response = HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK) + response['Cache-Control'] = 'public, max-age=86400, stale-while-revalidate=3600' + return response + except requests.exceptions.ConnectionError: + return Response( + { + 'message': 'The Immich server is currently down or unreachable.', + 'error': True, + 'code': 'immich.server_down' + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + except requests.exceptions.Timeout: + return Response( + { + 'message': 'The Immich server request timed out.', + 'error': True, + 'code': 'immich.server_timeout' + }, + status=status.HTTP_504_GATEWAY_TIMEOUT + ) + class ImmichIntegrationViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = ImmichIntegrationSerializer