diff --git a/backend/server/integrations/migrations/0001_initial.py b/backend/server/integrations/migrations/0001_initial.py index 73015d1..0b05b2a 100644 --- a/backend/server/integrations/migrations/0001_initial.py +++ b/backend/server/integrations/migrations/0001_initial.py @@ -1,5 +1,6 @@ # Generated by Django 5.0.8 on 2024-12-31 15:02 +import uuid import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -17,10 +18,11 @@ class Migration(migrations.Migration): migrations.CreateModel( name='ImmichIntegration', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), ('server_url', models.CharField(max_length=255)), ('api_key', models.CharField(max_length=255)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), ] + diff --git a/backend/server/integrations/migrations/0003_alter_immichintegration_user.py b/backend/server/integrations/migrations/0003_alter_immichintegration_user.py new file mode 100644 index 0000000..30bd443 --- /dev/null +++ b/backend/server/integrations/migrations/0003_alter_immichintegration_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.8 on 2025-01-02 17:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0002_alter_immichintegration_user'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='immichintegration', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py index 0d7a0a4..9db8a07 100644 --- a/backend/server/integrations/models.py +++ b/backend/server/integrations/models.py @@ -1,12 +1,15 @@ from django.db import models from django.contrib.auth import get_user_model +import uuid User = get_user_model() class ImmichIntegration(models.Model): server_url = models.CharField(max_length=255) api_key = models.CharField(max_length=255) - user = models.OneToOneField(User, on_delete=models.CASCADE) + user = models.ForeignKey( + User, on_delete=models.CASCADE) + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) def __str__(self): return self.user.username + ' - ' + self.server_url \ No newline at end of file diff --git a/backend/server/integrations/serializers.py b/backend/server/integrations/serializers.py new file mode 100644 index 0000000..cc92d21 --- /dev/null +++ b/backend/server/integrations/serializers.py @@ -0,0 +1,13 @@ +from .models import ImmichIntegration +from rest_framework import serializers + +class ImmichIntegrationSerializer(serializers.ModelSerializer): + class Meta: + model = ImmichIntegration + fields = '__all__' + read_only_fields = ['id', 'user'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation.pop('user', None) + return representation diff --git a/backend/server/integrations/urls.py b/backend/server/integrations/urls.py index df405f1..a15bbd0 100644 --- a/backend/server/integrations/urls.py +++ b/backend/server/integrations/urls.py @@ -1,11 +1,12 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from integrations.views import ImmichIntegrationView, IntegrationView +from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet # Create the router and register the ViewSet router = DefaultRouter() router.register(r'immich', ImmichIntegrationView, basename='immich') router.register(r'', IntegrationView, basename='integrations') +router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset') # Include the router URLs urlpatterns = [ diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views.py index 7a05fa3..673fad5 100644 --- a/backend/server/integrations/views.py +++ b/backend/server/integrations/views.py @@ -1,6 +1,8 @@ import os from rest_framework.response import Response from rest_framework import viewsets, status + +from .serializers import ImmichIntegrationSerializer from .models import ImmichIntegration from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated @@ -148,3 +150,83 @@ class ImmichIntegrationView(viewsets.ViewSet): }, status=status.HTTP_503_SERVICE_UNAVAILABLE ) + +class ImmichIntegrationViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = ImmichIntegrationSerializer + queryset = ImmichIntegration.objects.all() + + def get_queryset(self): + return ImmichIntegration.objects.filter(user=self.request.user) + + def create(self, request): + """ + RESTful POST method for creating a new Immich integration. + """ + + # Check if the user already has an integration + user_integrations = ImmichIntegration.objects.filter(user=request.user) + if user_integrations.exists(): + return Response( + { + 'message': 'You already have an active Immich integration.', + 'error': True, + 'code': 'immich.integration_exists' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response( + serializer.data, + status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, pk=None): + """ + RESTful DELETE method for deleting an existing Immich integration. + """ + integration = ImmichIntegration.objects.filter(user=request.user, id=pk).first() + if not integration: + return Response( + { + 'message': 'Integration not found.', + 'error': True, + 'code': 'immich.integration_not_found' + }, + status=status.HTTP_404_NOT_FOUND + ) + integration.delete() + return Response( + { + 'message': 'Integration deleted successfully.' + }, + status=status.HTTP_200_OK + ) + + def list(self, request, *args, **kwargs): + # If the user has an integration, we only want to return that integration + + user_integrations = ImmichIntegration.objects.filter(user=request.user) + if user_integrations.exists(): + integration = user_integrations.first() + serializer = self.serializer_class(integration) + return Response( + serializer.data, + status=status.HTTP_200_OK + ) + else: + return Response( + { + 'message': 'No integration found.', + 'error': True, + 'code': 'immich.integration_not_found' + }, + status=status.HTTP_404_NOT_FOUND + ) \ No newline at end of file diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index 0d903ee..b77b8ea 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -191,7 +191,7 @@ {#if type != 'link'} - {#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))} + {#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))} {/if} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1a1ce0e..75cee6e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -196,3 +196,9 @@ export type Category = { user_id: string; num_adventures?: number | null; }; + +export type ImmichIntegration = { + id: string; + server_url: string; + api_key: string; +}; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a658e68..a065e79 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -509,6 +509,7 @@ "query_required": "Query is required", "server_down": "The Immich server is currently down or unreachable", "no_items_found": "No items found", - "imageid_required": "Image ID is required" + "imageid_required": "Image ID is required", + "load_more": "Load More" } } diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index beb8432..ca29ee6 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -1,7 +1,7 @@ import { fail, redirect, type Actions } from '@sveltejs/kit'; import type { PageServerLoad } from '../$types'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -import type { User } from '$lib/types'; +import type { ImmichIntegration, User } from '$lib/types'; import { fetchCSRFToken } from '$lib/index.server'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; @@ -56,11 +56,22 @@ export const load: PageServerLoad = async (event) => { let mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse; let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean; + let immichIntegration: ImmichIntegration | null = null; + let immichIntegrationsFetch = await fetch(`${endpoint}/api/integrations/immich/`, { + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (immichIntegrationsFetch.ok) { + immichIntegration = await immichIntegrationsFetch.json(); + } + return { props: { user, emails, - authenticators + authenticators, + immichIntegration } }; }; diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0c0b10a..aa3bc08 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -2,14 +2,16 @@ import { enhance } from '$app/forms'; import { page } from '$app/stores'; import { addToast } from '$lib/toasts'; - import type { User } from '$lib/types.js'; + import type { ImmichIntegration, User } from '$lib/types.js'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; import { t } from 'svelte-i18n'; import TotpModal from '$lib/components/TOTPModal.svelte'; import { appTitle, appVersion } from '$lib/config.js'; + import ImmichLogo from '$lib/assets/immich.svg'; export let data; + console.log(data); let user: User; let emails: typeof data.props.emails; if (data.user) { @@ -19,6 +21,14 @@ let new_email: string = ''; + let immichIntegration = data.props.immichIntegration; + + let newImmichIntegration: ImmichIntegration = { + server_url: '', + api_key: '', + id: '' + }; + let isMFAModalOpen: boolean = false; onMount(async () => { @@ -131,6 +141,54 @@ } } + async function enableImmichIntegration() { + if (!immichIntegration?.id) { + let res = await fetch('/api/integrations/immich/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newImmichIntegration) + }); + let data = await res.json(); + if (res.ok) { + addToast('success', $t('settings.immich_enabled')); + immichIntegration = data; + } else { + addToast('error', $t('settings.immich_error')); + } + } else { + let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newImmichIntegration) + }); + let data = await res.json(); + if (res.ok) { + addToast('success', $t('settings.immich_updated')); + immichIntegration = data; + } else { + addToast('error', $t('settings.immich_error')); + } + } + } + + async function disableImmichIntegration() { + if (immichIntegration && immichIntegration.id) { + let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, { + method: 'DELETE' + }); + if (res.ok) { + addToast('success', $t('settings.immich_disabled')); + immichIntegration = null; + } else { + addToast('error', $t('settings.immich_error')); + } + } + } + async function disableMfa() { const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { method: 'DELETE' @@ -354,6 +412,72 @@ + +
+

+ Immich Integration Immich Logo +

+
+

+ Integrate your Immich account with AdventureLog to allow you to search your photos library + and import photos for your adventures. +

+ {#if immichIntegration} +
+
Integration Enabled
+
+ + +
+
+ {/if} + {#if !immichIntegration || newImmichIntegration.id} +
+
+ + + {#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')} +

+ Note: this must be the URL to the Immich API server so it likely ends with /api + unless you have a custom config. +

+ {/if} +
+
+ + +
+ +
+ {/if} +
+
+

{$t('adventures.visited_region_check')}