mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-19 12:59:36 +02:00
feat: add unique constraint for immich_id per user in AdventureImage model and enhance Immich integration image retrieval
This commit is contained in:
parent
06787bccf6
commit
0838a41156
4 changed files with 133 additions and 45 deletions
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,7 +11,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django_resized import ResizedImageField
|
from django_resized import ResizedImageField
|
||||||
from worldtravel.models import City, Country, Region, VisitedCity, VisitedRegion
|
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
|
from django.utils import timezone
|
||||||
|
|
||||||
def background_geocode_and_assign(adventure_id: str):
|
def background_geocode_and_assign(adventure_id: str):
|
||||||
|
@ -799,19 +799,25 @@ class AdventureImage(models.Model):
|
||||||
is_primary = models.BooleanField(default=False)
|
is_primary = models.BooleanField(default=False)
|
||||||
|
|
||||||
def clean(self):
|
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_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())
|
||||||
|
|
||||||
# Exactly one must be provided
|
|
||||||
if has_image and has_immich_id:
|
if has_image and has_immich_id:
|
||||||
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.")
|
||||||
|
|
||||||
|
# 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):
|
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:
|
||||||
|
|
|
@ -8,6 +8,8 @@ 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 AdventureImage
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
class IntegrationView(viewsets.ViewSet):
|
class IntegrationView(viewsets.ViewSet):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
@ -41,6 +43,15 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
- None if the integration exists.
|
- None if the integration exists.
|
||||||
- A Response with an error message if the integration is missing.
|
- 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)
|
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||||
if not user_integrations.exists():
|
if not user_integrations.exists():
|
||||||
return Response(
|
return Response(
|
||||||
|
@ -120,43 +131,6 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)')
|
|
||||||
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'])
|
@action(detail=False, methods=['get'])
|
||||||
def albums(self, request):
|
def albums(self, request):
|
||||||
|
@ -244,6 +218,78 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)', 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):
|
class ImmichIntegrationViewSet(viewsets.ModelViewSet):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
serializer_class = ImmichIntegrationSerializer
|
serializer_class = ImmichIntegrationSerializer
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue