1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-20 21:39:37 +02:00

feat: implement Immich integration with CRUD API, add serializer, and enhance frontend components

This commit is contained in:
Sean Morley 2025-01-02 13:34:51 -05:00
parent 5d12d103fc
commit cee9f16cf5
12 changed files with 317 additions and 8 deletions

View file

@ -1,5 +1,6 @@
# Generated by Django 5.0.8 on 2024-12-31 15:02 # Generated by Django 5.0.8 on 2024-12-31 15:02
import uuid
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -17,10 +18,11 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='ImmichIntegration', name='ImmichIntegration',
fields=[ 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)), ('server_url', models.CharField(max_length=255)),
('api_key', 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)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
), ),
] ]

View file

@ -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),
),
]

View file

@ -1,12 +1,15 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import uuid
User = get_user_model() User = get_user_model()
class ImmichIntegration(models.Model): class ImmichIntegration(models.Model):
server_url = models.CharField(max_length=255) server_url = models.CharField(max_length=255)
api_key = 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): def __str__(self):
return self.user.username + ' - ' + self.server_url return self.user.username + ' - ' + self.server_url

View file

@ -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

View file

@ -1,11 +1,12 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter 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 # Create the router and register the ViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'immich', ImmichIntegrationView, basename='immich') router.register(r'immich', ImmichIntegrationView, basename='immich')
router.register(r'', IntegrationView, basename='integrations') router.register(r'', IntegrationView, basename='integrations')
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
# Include the router URLs # Include the router URLs
urlpatterns = [ urlpatterns = [

View file

@ -1,6 +1,8 @@
import os import os
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import viewsets, status from rest_framework import viewsets, status
from .serializers import ImmichIntegrationSerializer
from .models import ImmichIntegration from .models import ImmichIntegration
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -148,3 +150,83 @@ class ImmichIntegrationView(viewsets.ViewSet):
}, },
status=status.HTTP_503_SERVICE_UNAVAILABLE 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
)

View file

@ -191,7 +191,7 @@
<!-- action options dropdown --> <!-- action options dropdown -->
{#if type != 'link'} {#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))}
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-neutral-200"> <div tabindex="0" role="button" class="btn btn-neutral-200">
<DotsHorizontal class="w-6 h-6" /> <DotsHorizontal class="w-6 h-6" />

View file

@ -228,6 +228,8 @@
let immichSearchValue: string = ''; let immichSearchValue: string = '';
let immichError: string = ''; let immichError: string = '';
let immichNext: string = '';
let immichPage: number = 1;
async function searchImmich() { async function searchImmich() {
let res = await fetch(`/api/integrations/immich/search/?query=${immichSearchValue}`); let res = await fetch(`/api/integrations/immich/search/?query=${immichSearchValue}`);
@ -245,6 +247,44 @@
} else { } else {
immichError = $t('immich.no_items_found'); immichError = $t('immich.no_items_found');
} }
if (data.next) {
immichNext =
'/api/integrations/immich/search?query=' +
immichSearchValue +
'&page=' +
(immichPage + 1);
} else {
immichNext = '';
}
}
}
async function loadMoreImmich() {
let res = await fetch(immichNext);
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.log(errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
console.log(data);
immichError = '';
if (data.results && data.results.length > 0) {
immichImages = [...immichImages, ...data.results];
} else {
immichError = $t('immich.no_items_found');
}
if (data.next) {
immichNext =
'/api/integrations/immich/search?query=' +
immichSearchValue +
'&page=' +
(immichPage + 1);
immichPage++;
} else {
immichNext = '';
}
} }
} }
@ -1077,6 +1117,11 @@ it would also work to just use on:click on the MapLibre component itself. -->
</button> </button>
</div> </div>
{/each} {/each}
{#if immichNext}
<button class="btn btn-neutral" on:click={loadMoreImmich}
>{$t('immich.load_more')}</button
>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -196,3 +196,9 @@ export type Category = {
user_id: string; user_id: string;
num_adventures?: number | null; num_adventures?: number | null;
}; };
export type ImmichIntegration = {
id: string;
server_url: string;
api_key: string;
};

View file

@ -509,6 +509,7 @@
"query_required": "Query is required", "query_required": "Query is required",
"server_down": "The Immich server is currently down or unreachable", "server_down": "The Immich server is currently down or unreachable",
"no_items_found": "No items found", "no_items_found": "No items found",
"imageid_required": "Image ID is required" "imageid_required": "Image ID is required",
"load_more": "Load More"
} }
} }

View file

@ -1,7 +1,7 @@
import { fail, redirect, type Actions } from '@sveltejs/kit'; import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from '../$types'; import type { PageServerLoad } from '../$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; 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'; import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; 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 mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse;
let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean; 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 { return {
props: { props: {
user, user,
emails, emails,
authenticators authenticators,
immichIntegration
} }
}; };
}; };

View file

@ -2,14 +2,16 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { addToast } from '$lib/toasts'; 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 { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import TotpModal from '$lib/components/TOTPModal.svelte'; import TotpModal from '$lib/components/TOTPModal.svelte';
import { appTitle, appVersion } from '$lib/config.js'; import { appTitle, appVersion } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg';
export let data; export let data;
console.log(data);
let user: User; let user: User;
let emails: typeof data.props.emails; let emails: typeof data.props.emails;
if (data.user) { if (data.user) {
@ -19,6 +21,14 @@
let new_email: string = ''; let new_email: string = '';
let immichIntegration = data.props.immichIntegration;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
id: ''
};
let isMFAModalOpen: boolean = false; let isMFAModalOpen: boolean = false;
onMount(async () => { 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() { async function disableMfa() {
const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', {
method: 'DELETE' method: 'DELETE'
@ -354,6 +412,72 @@
</div> </div>
</section> </section>
<!-- Immich Integration Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
Immich Integration <img
src={ImmichLogo}
alt="Immich Logo"
class="inline-block w-8 h-8 -mt-1"
/>
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md">
<p class="text-center">
Integrate your Immich account with AdventureLog to allow you to search your photos library
and import photos for your adventures.
</p>
{#if immichIntegration}
<div class="flex flex-col items-center justify-center mt-1 space-y-2">
<div class="badge badge-success">Integration Enabled</div>
<div class="flex space-x-2">
<button
class="btn btn-warning"
on:click={() => {
if (immichIntegration) newImmichIntegration = immichIntegration;
}}>Edit</button
>
<button class="btn btn-error" on:click={disableImmichIntegration}>Disable</button>
</div>
</div>
{/if}
{#if !immichIntegration || newImmichIntegration.id}
<div class="mt-4">
<div>
<label for="immich_url" class="text-sm font-medium">Immich Server URL</label>
<input
type="url"
id="immich_url"
name="immich_url"
bind:value={newImmichIntegration.server_url}
placeholder="Immich Server URL (e.g. https://immich.example.com/api)"
class="block w-full mt-1 input input-bordered input-primary"
/>
{#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')}
<p class="text-xs text-warning mt-1">
Note: this must be the URL to the Immich API server so it likely ends with /api
unless you have a custom config.
</p>
{/if}
</div>
<div class="mt-4">
<label for="immich_api_key" class="text-sm font-medium">Immich API Key</label>
<input
type="text"
id="immich_api_key"
name="immich_api_key"
bind:value={newImmichIntegration.api_key}
placeholder="Immich API Key"
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<button on:click={enableImmichIntegration} class="w-full mt-4 btn btn-primary py-2"
>{!immichIntegration?.id ? 'Enable Immich' : 'Edit Integration'}</button
>
</div>
{/if}
</div>
</section>
<!-- Visited Region Check Section --> <!-- Visited Region Check Section -->
<section class="text-center mt-8"> <section class="text-center mt-8">
<h2 class="text-2xl font-semibold">{$t('adventures.visited_region_check')}</h2> <h2 class="text-2xl font-semibold">{$t('adventures.visited_region_check')}</h2>