mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-05 05:05:17 +02:00
Refactor image cleanup command to use LocationImage model and update import/export view to include backup and restore functionality
This commit is contained in:
parent
15eece8882
commit
37b4ea179c
5 changed files with 236 additions and 108 deletions
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from adventures.models import AdventureImage, Attachment
|
from adventures.models import LocationImage, Attachment
|
||||||
from users.models import CustomUser
|
from users.models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,8 +21,8 @@ class Command(BaseCommand):
|
||||||
# Get all image and attachment file paths from database
|
# Get all image and attachment file paths from database
|
||||||
used_files = set()
|
used_files = set()
|
||||||
|
|
||||||
# Get AdventureImage file paths
|
# Get LocationImage file paths
|
||||||
for img in AdventureImage.objects.all():
|
for img in LocationImage.objects.all():
|
||||||
if img.image and img.image.name:
|
if img.image and img.image.name:
|
||||||
used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name))
|
used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name))
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
from adventures.models import (
|
from adventures.models import (
|
||||||
Location, Collection, Transportation, Note, Checklist, ChecklistItem,
|
Location, Collection, Transportation, Note, Checklist, ChecklistItem,
|
||||||
|
@ -23,6 +24,7 @@ from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
class BackupViewSet(viewsets.ViewSet):
|
class BackupViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
"""
|
"""
|
||||||
Simple ViewSet for handling backup and import operations
|
Simple ViewSet for handling backup and import operations
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,91 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { addToast } from '$lib/toasts';
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
let modal: HTMLDialogElement;
|
|
||||||
|
|
||||||
let url: string = '';
|
|
||||||
|
|
||||||
export let name: string | null = null;
|
|
||||||
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
|
||||||
if (modal) {
|
|
||||||
modal.showModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchImage() {
|
|
||||||
let res = await fetch(url);
|
|
||||||
let data = await res.blob();
|
|
||||||
if (!data) {
|
|
||||||
error = 'No image found at that URL.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
|
|
||||||
close();
|
|
||||||
dispatch('image', { file });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWikiImage() {
|
|
||||||
let res = await fetch(`/api/generate/img/?name=${name}`);
|
|
||||||
let data = await res.json();
|
|
||||||
if (data.source) {
|
|
||||||
let imageUrl = data.source;
|
|
||||||
let res = await fetch(imageUrl);
|
|
||||||
let blob = await res.blob();
|
|
||||||
let file = new File([blob], `${name}.jpg`, { type: 'image/jpeg' });
|
|
||||||
close();
|
|
||||||
dispatch('image', { file });
|
|
||||||
} else {
|
|
||||||
error = 'No image found for that Wikipedia article.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
dispatch('close');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
dispatch('close');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<dialog id="my_modal_1" class="modal">
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
|
||||||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
|
||||||
<h3 class="font-bold text-lg">Image Fetcher with URL</h3>
|
|
||||||
<form>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered w-full max-w-xs"
|
|
||||||
bind:value={url}
|
|
||||||
placeholder="Enter a URL"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-primary" on:click={fetchImage}>Submit</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h3 class="font-bold text-lg">Image Fetcher from Wikipedia</h3>
|
|
||||||
<form>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered w-full max-w-xs"
|
|
||||||
bind:value={name}
|
|
||||||
placeholder="Enter a Wikipedia Article Name"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-primary" on:click={fetchWikiImage}>Submit</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="text-red-500">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button class="btn btn-primary" on:click={close}>Close</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
|
@ -262,7 +262,7 @@ export const actions: Actions = {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
changeEmail: async (event) => {
|
restoreData: async (event) => {
|
||||||
if (!event.locals.user) {
|
if (!event.locals.user) {
|
||||||
return redirect(302, '/');
|
return redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
@ -270,28 +270,51 @@ export const actions: Actions = {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return redirect(302, '/');
|
return redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const new_email = formData.get('new_email') as string | null | undefined;
|
const file = formData.get('file') as File | null | undefined;
|
||||||
if (!new_email) {
|
const confirm = formData.get('confirm') as string | null | undefined;
|
||||||
return fail(400, { message: 'auth.email_required' });
|
|
||||||
} else {
|
if (!file || file.size === 0) {
|
||||||
|
return fail(400, { message: 'settings.no_file_selected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm !== 'yes') {
|
||||||
|
return fail(400, { message: 'settings.confirmation_required' });
|
||||||
|
}
|
||||||
|
|
||||||
let csrfToken = await fetchCSRFToken();
|
let csrfToken = await fetchCSRFToken();
|
||||||
let res = await fetch(`${endpoint}/auth/change-email/`, {
|
|
||||||
|
// Create FormData for the API request
|
||||||
|
const apiFormData = new FormData();
|
||||||
|
apiFormData.append('file', file);
|
||||||
|
apiFormData.append('confirm', 'yes');
|
||||||
|
|
||||||
|
let res = await fetch(`${endpoint}/api/backup/import/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Referer: event.url.origin, // Include Referer header
|
Referer: event.url.origin,
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: apiFormData
|
||||||
new_email
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return fail(res.status, await res.json());
|
const errorData = await res.json();
|
||||||
|
return fail(res.status, {
|
||||||
|
message: errorData.code
|
||||||
|
? `settings.restore_error_${errorData.code}`
|
||||||
|
: 'settings.generic_error',
|
||||||
|
details: errorData
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Restore error:', error);
|
||||||
|
return fail(500, { message: 'settings.generic_error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,12 +20,13 @@
|
||||||
emails = data.props.emails;
|
emails = data.props.emails;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_password_disable_setting: boolean = false;
|
|
||||||
let new_email: string = '';
|
let new_email: string = '';
|
||||||
let public_url: string = data.props.publicUrl;
|
let public_url: string = data.props.publicUrl;
|
||||||
let immichIntegration = data.props.immichIntegration;
|
let immichIntegration = data.props.immichIntegration;
|
||||||
let googleMapsEnabled = data.props.googleMapsEnabled;
|
let googleMapsEnabled = data.props.googleMapsEnabled;
|
||||||
let activeSection: string = 'profile';
|
let activeSection: string = 'profile';
|
||||||
|
let acknowledgeRestoreOverride: boolean = false;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
let newImmichIntegration: ImmichIntegration = {
|
let newImmichIntegration: ImmichIntegration = {
|
||||||
server_url: '',
|
server_url: '',
|
||||||
|
@ -41,6 +42,7 @@
|
||||||
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
|
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
|
||||||
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
|
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
|
||||||
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
|
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
|
||||||
|
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
|
||||||
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
|
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
|
||||||
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
|
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
|
||||||
];
|
];
|
||||||
|
@ -924,6 +926,198 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- import export -->
|
||||||
|
{#if activeSection === 'import_export'}
|
||||||
|
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="p-3 bg-accent/10 rounded-xl">
|
||||||
|
<span class="text-2xl">📦</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Backup & Restore</h2>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
Save your data or restore it from a previous backup file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Coverage -->
|
||||||
|
<div class="bg-base-200 rounded-xl p-4 mb-6">
|
||||||
|
<h4 class="text-sm font-semibold mb-3 text-base-content/70">What's Included</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<!-- Backed Up -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>📍 Locations</span>
|
||||||
|
<span>✅</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>🚶 Visits</span>
|
||||||
|
<span>✅</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>📚 Collections</span>
|
||||||
|
<span>✅</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>🖼️ Media</span>
|
||||||
|
<span>✅</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>🌍 World Travel Visits</span>
|
||||||
|
<span>✅</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Not Backed Up -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>⚙️ Settings</span>
|
||||||
|
<span>❌</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>👤 Profile</span>
|
||||||
|
<span>❌</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>🔗 Integrations</span>
|
||||||
|
<span>❌</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between opacity-30">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Backup Data -->
|
||||||
|
<div class="p-6 bg-base-200 rounded-xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">📤 Backup Your Data</h3>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Download a complete backup of your account data including locations,
|
||||||
|
collections, media, and visits.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<a class="btn btn-primary" href="/api/backup/export"> 💾 Download Backup </a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Data -->
|
||||||
|
<div class="p-6 bg-base-200 rounded-xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">📥 Restore Data</h3>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Upload a backup file to restore your data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Warning Alert -->
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold">⚠️ Data Override Warning</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
Restoring data will completely replace all existing data in your account.
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload Form -->
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/restoreData"
|
||||||
|
use:enhance
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="form-control">
|
||||||
|
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Select backup file</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
class="file-input file-input-bordered file-input-primary w-full"
|
||||||
|
accept=".zip"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Supported formats: .zip</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acknowledgment Checkbox -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="confirm"
|
||||||
|
value="yes"
|
||||||
|
class="checkbox checkbox-warning"
|
||||||
|
required
|
||||||
|
bind:checked={acknowledgeRestoreOverride}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="label-text font-medium text-warning"
|
||||||
|
>I acknowledge that this will override all my existing data</span
|
||||||
|
>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
This action is irreversible and will replace all locations, collections,
|
||||||
|
and visits in your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $page.form?.message && $page.form?.message.includes('restore')}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{$t($page.form?.message)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-warning"
|
||||||
|
disabled={!acknowledgeRestoreOverride}
|
||||||
|
>
|
||||||
|
🚀 Restore Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Admin Section -->
|
<!-- Admin Section -->
|
||||||
{#if activeSection === 'admin' && user.is_staff}
|
{#if activeSection === 'admin' && user.is_staff}
|
||||||
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue