2025-01-17 16:50:01 -05:00
|
|
|
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 django.db.models import Q
|
2025-06-03 17:59:29 -04:00
|
|
|
from django.core.files.base import ContentFile
|
2025-07-12 09:20:23 -04:00
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentImage
|
2025-07-10 12:12:03 -04:00
|
|
|
from adventures.serializers import ContentImageSerializer
|
2025-06-03 17:59:29 -04:00
|
|
|
from integrations.models import ImmichIntegration
|
2025-01-17 16:50:01 -05:00
|
|
|
import uuid
|
2025-06-03 17:59:29 -04:00
|
|
|
import requests
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
class ContentImageViewSet(viewsets.ModelViewSet):
|
2025-07-10 12:12:03 -04:00
|
|
|
serializer_class = ContentImageSerializer
|
2025-01-17 16:50:01 -05:00
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
|
|
|
|
@action(detail=True, methods=['post'])
|
|
|
|
def image_delete(self, request, *args, **kwargs):
|
|
|
|
return self.destroy(request, *args, **kwargs)
|
|
|
|
|
|
|
|
@action(detail=True, methods=['post'])
|
|
|
|
def toggle_primary(self, request, *args, **kwargs):
|
|
|
|
if not request.user.is_authenticated:
|
|
|
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
|
|
|
|
instance = self.get_object()
|
2025-07-12 09:20:23 -04:00
|
|
|
content_object = instance.content_object
|
|
|
|
|
|
|
|
# Check ownership based on content type
|
|
|
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
|
|
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
|
|
|
# Check if the image is already the primary image
|
|
|
|
if instance.is_primary:
|
|
|
|
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
# Set other images of the same content object to not primary
|
|
|
|
ContentImage.objects.filter(
|
|
|
|
content_type=instance.content_type,
|
|
|
|
object_id=instance.object_id,
|
|
|
|
is_primary=True
|
|
|
|
).update(is_primary=False)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
# Set the new image to primary
|
2025-01-17 16:50:01 -05:00
|
|
|
instance.is_primary = True
|
|
|
|
instance.save()
|
|
|
|
return Response({"success": "Image set as primary image"})
|
|
|
|
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
|
|
if not request.user.is_authenticated:
|
|
|
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
2025-07-12 09:20:23 -04:00
|
|
|
|
|
|
|
# Get content type and object ID from request
|
|
|
|
content_type_name = request.data.get('content_type')
|
|
|
|
object_id = request.data.get('object_id')
|
|
|
|
|
|
|
|
if not content_type_name or not object_id:
|
|
|
|
return Response({
|
|
|
|
"error": "content_type and object_id are required"
|
|
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
|
|
# Map content type names to model classes
|
|
|
|
content_type_map = {
|
|
|
|
'location': Location,
|
|
|
|
'transportation': Transportation,
|
|
|
|
'note': Note,
|
|
|
|
'lodging': Lodging,
|
|
|
|
'visit': Visit,
|
|
|
|
}
|
|
|
|
|
|
|
|
if content_type_name not in content_type_map:
|
|
|
|
return Response({
|
|
|
|
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
|
|
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
|
|
# Get the content type and object
|
2025-01-17 16:50:01 -05:00
|
|
|
try:
|
2025-07-12 09:20:23 -04:00
|
|
|
content_type = ContentType.objects.get_for_model(content_type_map[content_type_name])
|
|
|
|
content_object = content_type_map[content_type_name].objects.get(id=object_id)
|
|
|
|
except (ValueError, content_type_map[content_type_name].DoesNotExist):
|
|
|
|
return Response({
|
|
|
|
"error": f"{content_type_name} not found"
|
|
|
|
}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
|
|
|
|
# Check permissions based on content type
|
|
|
|
if hasattr(content_object, 'user'):
|
|
|
|
if content_object.user != request.user:
|
|
|
|
# For Location, check if user has shared access
|
|
|
|
if content_type_name == 'location':
|
|
|
|
if content_object.collections.exists():
|
|
|
|
user_has_access = False
|
|
|
|
for collection in content_object.collections.all():
|
|
|
|
if collection.shared_with.filter(id=request.user.id).exists() or collection.user == request.user:
|
|
|
|
user_has_access = True
|
|
|
|
break
|
|
|
|
|
|
|
|
if not user_has_access:
|
|
|
|
return Response({
|
|
|
|
"error": "User does not have permission to access this content"
|
|
|
|
}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
else:
|
|
|
|
return Response({
|
|
|
|
"error": "User does not own this content"
|
|
|
|
}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
else:
|
|
|
|
return Response({
|
|
|
|
"error": "User does not own this content"
|
|
|
|
}, status=status.HTTP_403_FORBIDDEN)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-06-03 17:59:29 -04:00
|
|
|
# Handle Immich ID for shared users by downloading the image
|
2025-07-12 09:20:23 -04:00
|
|
|
if (hasattr(content_object, 'user') and
|
|
|
|
request.user != content_object.user and
|
2025-06-03 17:59:29 -04:00
|
|
|
'immich_id' in request.data and
|
|
|
|
request.data.get('immich_id')):
|
|
|
|
|
|
|
|
immich_id = request.data.get('immich_id')
|
|
|
|
|
|
|
|
# Get the shared user's Immich integration
|
|
|
|
try:
|
Rename Adventures to Locations (#696)
* Refactor user_id to user in adventures and related models, views, and components
- Updated all instances of user_id to user in the adventures app, including models, serializers, views, and frontend components.
- Adjusted queries and filters to reflect the new user field naming convention.
- Ensured consistency across the codebase for user identification in adventures, collections, notes, and transportation entities.
- Modified frontend components to align with the updated data structure, ensuring proper access control and rendering based on user ownership.
* Refactor adventure-related views and components to use "Location" terminology
- Updated GlobalSearchView to replace AdventureSerializer with LocationSerializer.
- Modified IcsCalendarGeneratorViewSet to use LocationSerializer instead of AdventureSerializer.
- Created new LocationImageViewSet for managing location images, including primary image toggling and image deletion.
- Introduced LocationViewSet for managing locations with enhanced filtering, sorting, and sharing capabilities.
- Updated ReverseGeocodeViewSet to utilize LocationSerializer.
- Added ActivityTypesView to retrieve distinct activity types from locations.
- Refactored user views to replace AdventureSerializer with LocationSerializer.
- Updated frontend components to reflect changes from "adventure" to "location", including AdventureCard, AdventureLink, AdventureModal, and others.
- Adjusted API endpoints in frontend routes to align with new location-based structure.
- Ensured all references to adventures are replaced with locations across the codebase.
* refactor: rename adventures to locations across the application
- Updated localization files to replace adventure-related terms with location-related terms.
- Refactored TypeScript types and variables from Adventure to Location in various routes and components.
- Adjusted UI elements and labels to reflect the change from adventures to locations.
- Ensured all references to adventures in the codebase are consistent with the new location terminology.
* Refactor code structure for improved readability and maintainability
* feat: Implement location details page with server-side loading and deletion functionality
- Added +page.server.ts to handle server-side loading of additional location info.
- Created +page.svelte for displaying location details, including images, visits, and maps.
- Integrated GPX file handling and rendering on the map.
- Updated map route to link to locations instead of adventures.
- Refactored profile and search routes to use LocationCard instead of AdventureCard.
* docs: Update terminology from "Adventure" to "Location" and enhance project overview
* docs: Clarify collection examples in usage documentation
* feat: Enable credentials for GPX file fetch and add CORS_ALLOW_CREDENTIALS setting
* Refactor adventure references to locations across the backend and frontend
- Updated CategoryViewSet to reflect location context instead of adventures.
- Modified ChecklistViewSet to include locations in retrieval logic.
- Changed GlobalSearchView to search for locations instead of adventures.
- Adjusted IcsCalendarGeneratorViewSet to handle locations instead of adventures.
- Refactored LocationImageViewSet to remove unused import.
- Updated LocationViewSet to clarify public access for locations.
- Changed LodgingViewSet to reference locations instead of adventures.
- Modified NoteViewSet to prevent listing all locations.
- Updated RecommendationsViewSet to handle locations in parsing and response.
- Adjusted ReverseGeocodeViewSet to search through user locations.
- Updated StatsViewSet to count locations instead of adventures.
- Changed TagsView to reflect activity types for locations.
- Updated TransportationViewSet to reference locations instead of adventures.
- Added new translations for search results related to locations in multiple languages.
- Updated dashboard and profile pages to reflect location counts instead of adventure counts.
- Adjusted search routes to handle locations instead of adventures.
* Update banner image
* style: Update stats component background and border for improved visibility
* refactor: Rename AdventureCard and AdventureModal to LocationCard and LocationModal for consistency
2025-06-25 11:49:34 -04:00
|
|
|
user_integration = ImmichIntegration.objects.get(user=request.user)
|
2025-06-03 17:59:29 -04:00
|
|
|
except ImmichIntegration.DoesNotExist:
|
|
|
|
return Response({
|
|
|
|
"error": "No Immich integration found for your account. Please set up Immich integration first.",
|
|
|
|
"code": "immich_integration_not_found"
|
|
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
|
|
# Download the image from the shared user's Immich server
|
|
|
|
try:
|
|
|
|
immich_response = requests.get(
|
|
|
|
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
|
|
|
|
headers={'x-api-key': user_integration.api_key},
|
|
|
|
timeout=10
|
|
|
|
)
|
|
|
|
immich_response.raise_for_status()
|
|
|
|
|
|
|
|
# Create a temporary file with the downloaded content
|
2025-07-12 09:20:23 -04:00
|
|
|
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg')
|
|
|
|
if not content_type_header.startswith('image/'):
|
2025-06-03 17:59:29 -04:00
|
|
|
return Response({
|
|
|
|
"error": "Invalid content type returned from Immich server.",
|
|
|
|
"code": "invalid_content_type"
|
|
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
|
|
# Determine file extension from content type
|
|
|
|
ext_map = {
|
|
|
|
'image/jpeg': '.jpg',
|
|
|
|
'image/png': '.png',
|
|
|
|
'image/webp': '.webp',
|
|
|
|
'image/gif': '.gif'
|
|
|
|
}
|
2025-07-12 09:20:23 -04:00
|
|
|
file_ext = ext_map.get(content_type_header, '.jpg')
|
2025-06-03 17:59:29 -04:00
|
|
|
filename = f"immich_{immich_id}{file_ext}"
|
|
|
|
|
|
|
|
# Create a Django ContentFile from the downloaded image
|
|
|
|
image_file = ContentFile(immich_response.content, name=filename)
|
|
|
|
|
|
|
|
# Modify request data to use the downloaded image instead of immich_id
|
|
|
|
request_data = request.data.copy()
|
|
|
|
request_data.pop('immich_id', None) # Remove immich_id
|
2025-06-05 14:53:08 -04:00
|
|
|
request_data['image'] = image_file # Add the image file
|
2025-07-12 09:20:23 -04:00
|
|
|
request_data['content_type'] = content_type.id
|
|
|
|
request_data['object_id'] = object_id
|
2025-06-03 17:59:29 -04:00
|
|
|
|
|
|
|
# Create the serializer with the modified data
|
|
|
|
serializer = self.get_serializer(data=request_data)
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
|
|
# Save with the downloaded image
|
2025-07-12 09:20:23 -04:00
|
|
|
serializer.save(
|
|
|
|
user=content_object.user if hasattr(content_object, 'user') else request.user,
|
|
|
|
image=image_file,
|
|
|
|
content_type=content_type,
|
|
|
|
object_id=object_id
|
|
|
|
)
|
2025-06-03 17:59:29 -04:00
|
|
|
|
|
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
|
|
|
2025-06-03 18:11:29 -04:00
|
|
|
except requests.exceptions.RequestException:
|
2025-06-03 17:59:29 -04:00
|
|
|
return Response({
|
2025-06-03 18:11:29 -04:00
|
|
|
"error": f"Failed to fetch image from Immich server",
|
2025-06-03 17:59:29 -04:00
|
|
|
"code": "immich_fetch_failed"
|
|
|
|
}, status=status.HTTP_502_BAD_GATEWAY)
|
2025-06-03 18:11:29 -04:00
|
|
|
except Exception:
|
2025-06-03 17:59:29 -04:00
|
|
|
return Response({
|
2025-06-03 18:11:29 -04:00
|
|
|
"error": f"Unexpected error processing Immich image",
|
2025-06-03 17:59:29 -04:00
|
|
|
"code": "immich_processing_error"
|
|
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
# Add content type and object ID to request data
|
|
|
|
request_data = request.data.copy()
|
|
|
|
request_data['content_type'] = content_type.id
|
|
|
|
request_data['object_id'] = object_id
|
|
|
|
|
|
|
|
# Create serializer with modified data
|
|
|
|
serializer = self.get_serializer(data=request_data)
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
|
|
# Save the image
|
|
|
|
serializer.save(
|
|
|
|
user=content_object.user if hasattr(content_object, 'user') else request.user,
|
|
|
|
content_type=content_type,
|
|
|
|
object_id=object_id
|
|
|
|
)
|
|
|
|
|
|
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
|
|
|
def update(self, request, *args, **kwargs):
|
|
|
|
if not request.user.is_authenticated:
|
|
|
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
instance = self.get_object()
|
|
|
|
content_object = instance.content_object
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
# Check ownership
|
|
|
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
|
|
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
|
|
|
return super().update(request, *args, **kwargs)
|
|
|
|
|
|
|
|
def destroy(self, request, *args, **kwargs):
|
|
|
|
if not request.user.is_authenticated:
|
|
|
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
|
|
|
|
instance = self.get_object()
|
2025-07-12 09:20:23 -04:00
|
|
|
content_object = instance.content_object
|
|
|
|
|
|
|
|
# Check ownership
|
|
|
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
|
|
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
|
|
|
return super().destroy(request, *args, **kwargs)
|
|
|
|
|
|
|
|
def partial_update(self, request, *args, **kwargs):
|
|
|
|
if not request.user.is_authenticated:
|
|
|
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
|
|
|
|
instance = self.get_object()
|
2025-07-12 09:20:23 -04:00
|
|
|
content_object = instance.content_object
|
|
|
|
|
|
|
|
# Check ownership
|
|
|
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
|
|
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
|
|
|
return super().partial_update(request, *args, **kwargs)
|
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
|
|
|
|
|
|
|
|
def get_queryset(self):
|
|
|
|
"""Get all images the user has access to"""
|
|
|
|
if not self.request.user.is_authenticated:
|
|
|
|
return ContentImage.objects.none()
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
# Get content type for Location to handle shared access
|
|
|
|
location_content_type = ContentType.objects.get_for_model(Location)
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
# Build queryset with proper permissions
|
2025-07-10 12:12:03 -04:00
|
|
|
queryset = ContentImage.objects.filter(
|
2025-07-12 09:20:23 -04:00
|
|
|
Q(content_object__user=self.request.user) | # User owns the content
|
|
|
|
Q(content_type=location_content_type, content_object__collections__shared_with=self.request.user) # Shared locations
|
2025-06-03 17:59:29 -04:00
|
|
|
).distinct()
|
2025-01-17 16:50:01 -05:00
|
|
|
|
2025-07-12 09:20:23 -04:00
|
|
|
return queryset
|
2025-01-17 16:50:01 -05:00
|
|
|
|
|
|
|
def perform_create(self, serializer):
|
2025-07-12 09:20:23 -04:00
|
|
|
# The content_type and object_id are already set in the create method
|
|
|
|
# Just ensure the user is set correctly
|
|
|
|
pass
|