(add): Backup/Restore

This commit is contained in:
2026-07-16 22:38:01 -04:00
parent 76040c1f91
commit 1e6b7787b3
14 changed files with 343 additions and 12 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ from fastapi import APIRouter, Header
class Basket: class Basket:
prefix: str prefix: str
b_id: int b_id: int
description: str = "" description: str | None = ""
donors: str = "" donors: str | None = ""
winning_ticket: int = 0 winning_ticket: int = 0
class BasketRepo(RepoTemplate): class BasketRepo(RepoTemplate):
+2
View File
@@ -1,5 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from system.auth import auth_router from system.auth import auth_router
from system.backuprestore import backuprestore_router
from system.prefixes import prefix_router from system.prefixes import prefix_router
from data.tickets import tickets_router from data.tickets import tickets_router
from data.baskets import basket_router from data.baskets import basket_router
@@ -15,3 +16,4 @@ def imp_routers(app: FastAPI):
app.include_router(drawing_router) app.include_router(drawing_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(search_router) app.include_router(search_router)
app.include_router(backuprestore_router)
+37
View File
@@ -0,0 +1,37 @@
from data.baskets import Basket, BasketRepo
from data.tickets import Ticket, TicketRepo
from .prefixes import Prefix, PrefixRepo
from dataclasses import dataclass, field
from typing import List
from fastapi import APIRouter, Header
from .auth import AuthRepo
chunk_size = 300
@dataclass
class BackupFile:
prefixes: List[Prefix] = field(default_factory=list)
baskets: List[Basket] = field(default_factory=list)
tickets: List[Ticket] = field(default_factory=list)
@dataclass
class ReturnMessage:
message: str
backuprestore_router = APIRouter(prefix="/api/backuprestore")
@backuprestore_router.get("")
def get_backup_file(tam_key: str = Header("")):
AuthRepo().verify_key(tam_key)
return BackupFile(prefixes=PrefixRepo().get_all_prefixes(), baskets=BasketRepo().get_all_baskets(), tickets=TicketRepo().get_all_tickets())
@backuprestore_router.post("")
def post_backup_file(bf: BackupFile, tam_key: str = Header("")):
AuthRepo().verify_key(tam_key)
for i in range(0, len(bf.prefixes), chunk_size):
PrefixRepo().post_prefixes(bf.prefixes[i:i+chunk_size])
for i in range(0, len(bf.baskets), chunk_size):
BasketRepo().post_baskets(bf.baskets[i:i+chunk_size])
for i in range(0, len(bf.tickets), chunk_size):
TicketRepo().post_tickets(bf.tickets[i:i+chunk_size])
return ReturnMessage(message="Backup file imported successfully.")
@@ -36,9 +36,13 @@
type="number" type="number"
id="id_from" id="id_from"
class={iS.normal} class={iS.normal}
title="Alt + Q"
onclick={(e) => { onclick={(e) => {
e.target.select(); e.target.select();
}} }}
onchange={() => {
if (functions.pagerFromUpdate) functions.pagerFromUpdate();
}}
bind:value={pager.idFrom} bind:value={pager.idFrom}
/> />
<div>-</div> <div>-</div>
@@ -46,6 +50,7 @@
type="number" type="number"
id="id_to" id="id_to"
class={iS.normal} class={iS.normal}
title="Alt + W"
onclick={(e) => { onclick={(e) => {
e.target.select(); e.target.select();
}} }}
@@ -62,6 +67,7 @@
{#if functions.prevPage} {#if functions.prevPage}
<button <button
class={bS[prefix.color]} class={bS[prefix.color]}
title="Alt + B"
onclick={() => { onclick={() => {
functions.prevPage(); functions.prevPage();
}}>Prev Page</button }}>Prev Page</button
@@ -70,6 +76,7 @@
{#if functions.nextPage} {#if functions.nextPage}
<button <button
class={bS[prefix.color]} class={bS[prefix.color]}
title="Alt + N"
onclick={() => { onclick={() => {
functions.nextPage(); functions.nextPage();
}}>Next Page</button }}>Next Page</button
+1 -1
View File
@@ -43,7 +43,7 @@ export const iS = {
}; };
export const rBS = { export const rBS = {
white: 'even:bg-gray-100', white: 'even:bg-gray-200',
blue: 'even:bg-blue-100', blue: 'even:bg-blue-100',
yellow: 'even:bg-yellow-100', yellow: 'even:bg-yellow-100',
green: 'even:bg-green-100', green: 'even:bg-green-100',
@@ -0,0 +1,58 @@
import { db } from '$lib/server/db';
import { baskets, prefixes, tickets } from '$lib/server/db/schema';
import { json } from '@sveltejs/kit';
import { sql } from 'drizzle-orm';
const chunk_size = 300;
export const GET = async () => {
const data = {};
data.prefixes = await db.select().from(prefixes);
data.baskets = await db.select().from(baskets);
data.tickets = await db.select().from(tickets);
return json(data);
};
export const POST = async ({ request }) => {
const reqData = await request.json();
for (let i = 0; i < reqData.prefixes.length; i += chunk_size) {
const chunk = reqData.prefixes.slice(i, i + chunk_size);
await db
.insert(prefixes)
.values(chunk)
.onConflictDoUpdate({
target: prefixes.prefix,
set: { color: sql`EXCLUDED.color`, weight: sql`EXCLUDED.weight` }
});
}
for (let i = 0; i < reqData.baskets.length; i += chunk_size) {
const chunk = reqData.baskets.slice(i, i + chunk_size);
await db
.insert(baskets)
.values(chunk)
.onConflictDoUpdate({
target: [baskets.prefix, baskets.b_id],
set: {
description: sql`EXCLUDED.description`,
donors: sql`EXCLUDED.donors`,
winning_ticket: sql`EXCLUDED.winning_ticket`
}
});
}
for (let i = 0; i < reqData.tickets.length; i += chunk_size) {
const chunk = reqData.tickets.slice(i, i + chunk_size);
await db
.insert(tickets)
.values(chunk)
.onConflictDoNothing({
target: [tickets.prefix, tickets.t_id],
set: {
first_name: sql`EXCLUDED.first_name`,
last_name: sql`EXCLUDED.last_name`,
phone_number: sql`EXCLUDED.phone_number`,
pref: sql`EXCLUDED.pref`
}
});
}
return json({ details: 'Data loaded successfully.' });
};
@@ -0,0 +1,32 @@
import { db } from '$lib/server/db/index.js';
import { baskets, prefixes, tickets } from '$lib/server/db/schema';
import { getPath, getSettings } from '$lib/server/settings/index.js';
import { error } from '@sveltejs/kit';
export const HEAD = async ({ params }) => {
const target = params.target;
const pushData = {};
if (target === 'prefixes') {
pushData.prefixes = await db.select().from(prefixes);
} else if (target === 'tickets') {
pushData.tickets = await db.select().from(tickets);
} else if (target === 'baskets') {
pushData.baskets = await db.select().from(baskets);
} else {
throw error(400, 'Can only push prefixes, tickets, or baskets.');
}
const s = getSettings();
if (s.remote_server) {
const connStr = getPath(s);
const res = await fetch(`${connStr}/api/backuprestore`, {
method: 'POST',
headers: { 'TAM-KEY': s.remote_key, 'Content-Type': 'application/json' },
body: JSON.stringify(pushData)
});
if (!res.ok) throw error(res.status, res.statusText);
return new Response(null, { status: 200, statusText: 'Data pushed successfully.' });
} else {
throw error(500, 'Server not set.');
};
};
@@ -0,0 +1,36 @@
import { getPath, getSettings } from '$lib/server/settings';
import { error, json } from '@sveltejs/kit';
export const GET = async () => {
const s = getSettings();
if (s.remote_server) {
const connStr = getPath(s);
try {
const res = await fetch(`${connStr}/api/backuprestore`, {
headers: { TAM_KEY: s.remote_key }
});
if (!res.ok) throw error(res.status);
const data = await res.json();
return json(data);
} catch {
return json({});
}
} else {
return json({});
}
};
export const POST = async ({ request }) => {
const resData = await request.json();
const s = getSettings();
if (s.remote_server) {
const connStr = getPath(s);
const res = await fetch(`${connStr}/api/backuprestore`, {
method: 'POST',
headers: { 'TAM-KEY': s.remote_key, 'Content-Type': 'application/json' },
body: JSON.stringify(resData)
});
if (!res.ok) throw error(res.status);
return json([]);
}
};
@@ -21,7 +21,7 @@
curIdx = idx; curIdx = idx;
const elemIdx = document.getElementById(`${idx}_first`); const elemIdx = document.getElementById(`${idx}_first`);
if (elemIdx) { if (elemIdx) {
elemIdx.focus(); elemIdx.select();
} }
}; };
@@ -44,6 +44,13 @@
items = [...resData]; items = [...resData];
setTimeout(() => focusIdx(0)); setTimeout(() => focusIdx(0));
}, },
pagerFromUpdate(){
if (itemsLength === 0) {
pager.idTo = pager.idFrom;
} else {
pager.idTo = pager.idFrom + (itemsLength - 1);
}
},
save: async () => { save: async () => {
if (itemsBuffer.length > 0) { if (itemsBuffer.length > 0) {
const res = await fetch('/api/baskets', { const res = await fetch('/api/baskets', {
@@ -21,7 +21,7 @@
curIdx = idx; curIdx = idx;
const elemIdx = document.getElementById(`${idx}_first`); const elemIdx = document.getElementById(`${idx}_first`);
if (elemIdx) { if (elemIdx) {
elemIdx.focus(); elemIdx.select();
} }
}; };
@@ -44,6 +44,13 @@
items = [...resData]; items = [...resData];
setTimeout(() => focusIdx(0)); setTimeout(() => focusIdx(0));
}, },
pagerFromUpdate(){
if (itemsLength === 0) {
pager.idTo = pager.idFrom;
} else {
pager.idTo = pager.idFrom + (itemsLength - 1);
}
},
save: async () => { save: async () => {
if (itemsBuffer.length > 0) { if (itemsBuffer.length > 0) {
const res = await fetch('/api/drawing', { const res = await fetch('/api/drawing', {
+1
View File
@@ -29,6 +29,7 @@
<a href={resolve('/settings/auth-keys')} class={bS.gray}>Auth Keys</a> <a href={resolve('/settings/auth-keys')} class={bS.gray}>Auth Keys</a>
{/if} {/if}
<a href={resolve('/settings/prefixes')} class={bS.gray}>Prefixes</a> <a href={resolve('/settings/prefixes')} class={bS.gray}>Prefixes</a>
<a href={resolve('/settings/backuprestore')} class={bS.gray}>Backup/Restore</a>
</HeaderBar> </HeaderBar>
<h1 class="text-xl font-bold">{pageTitle}</h1> <h1 class="text-xl font-bold">{pageTitle}</h1>
<div class="flex flex-col gap-1 w-full py-1"> <div class="flex flex-col gap-1 w-full py-1">
@@ -0,0 +1,6 @@
import { getSettings } from "$lib/server/settings/index.js"
export const load = async () => {
const s = getSettings();
return { remoteServer: s.remote_server };
}
@@ -0,0 +1,131 @@
<script>
import { resolve } from '$app/paths';
import HeaderBar from '$lib/client/components/HeaderBar.svelte';
import { bS, iS } from '$lib/client/styles';
const pageTitle = 'Backup and Restore | TAM';
let { data } = $props();
let remoteServer = $derived(data.remoteServer);
let uploadFile = $state();
let contents = $state('');
let results = $state('');
function setResult(newResult) {
results = newResult;
setTimeout(() => (results = ''), 10000);
}
async function downloadBackupFile(target) {
let fetch_url;
if (target === 'local') {
fetch_url = '/api/backuprestore/local';
} else if (target === 'remote') {
fetch_url = '/api/backuprestore/remote';
}
const now = new Date();
const date = {
year: now.getFullYear(),
month: String(now.getMonth() + 1).padStart(2, '0'),
day: String(now.getDate()).padStart(2, '0'),
hour: String(now.getHours()).padStart(2, '0'),
minutes: String(now.getMinutes()).padStart(2, '0')
};
const res = await fetch(fetch_url);
if (res.ok) {
const data = await res.json();
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TAM_${date.year}-${date.month}-${date.day} ${date.hour}:${date.minutes}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
async function fileUpload(target) {
const reader = new FileReader();
reader.readAsText(uploadFile[0], 'utf-8');
reader.onload = function (e) {
contents = String(e.target.result);
};
console.log(contents);
setTimeout(async () => {
const res = await fetch(`/api/backuprestore/${target}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: contents
});
if (res.ok) setResult('File uploaded successfully. Check to see if your data exists.');
else setResult('Error uploading file.');
}, 100);
}
async function pushData(target) {
const res = await fetch(`/api/backuprestore/push/${target}`, { method: 'HEAD' });
const targetStr = target.charAt(0).toUpperCase() + target.slice(1);
if (res.ok) {
setResult(`${targetStr} pushed successfully.`);
} else {
setResult(`Error pushing ${targetStr}. Error [${res.status}] ${res.statusText}.`);
}
}
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<div id="app_container" class="p-1">
<HeaderBar>
<a href={resolve('/settings')} class={bS.gray}>Back to Settings</a>
<div>Other Settings</div>
<a href={resolve('/settings/prefixes')} class={bS.gray}>Prefixes</a>
{#if remoteServer}
<a href={resolve('/settings/auth-keys')} class={bS.gray}>Auth Keys</a>
{/if}
</HeaderBar>
<h1 class="text-xl font-bold">{pageTitle}</h1>
<div class="my-1 p-1 border border-black rounded">
<h2 class="text-lg font-bold">Backup File Downloads</h2>
<div class="flex flex-row gap-1">
<button class={bS.gray} onclick={downloadBackupFile('local')}>Local Data</button>
{#if remoteServer}
<button class={bS.gray} onclick={downloadBackupFile('remote')}>Remote Data</button>
{/if}
</div>
</div>
<div class="my-1 p-1 border border-black rounded">
<h2 class="text-lg font-bold">Backup File Upload</h2>
<div class="flex flex-row gap-1 items-center">
<input
type="file"
accept=".json"
class="{iS.normal} rounded file:bg-gray-300 file:border file:border-black file:px-2 file:py-1 file:rounded"
bind:files={uploadFile}
/>
<button class={bS.gray} onclick={() => fileUpload('local')}>Upload to Local</button>
{#if remoteServer}
<button class={bS.gray} onclick={() => fileUpload('remote')}>Upload to Remote</button>
{/if}
</div>
</div>
{#if remoteServer}
<div class="my-1 p-1 border border-black rounded">
<h2 class="text-lg font-bold">Push Data to Server</h2>
<div class="flex flex-row gap-1">
<button class={bS.gray} onclick={() => pushData('prefixes')}>Push Prefixes</button>
<button class={bS.gray} onclick={() => pushData('tickets')}>Push Tickets</button>
<button class={bS.gray} onclick={() => pushData('baskets')}>Push Baskets</button>
</div>
</div>
{/if}
<div class="my-1 p-1 border border-black rounded">
<span class="font-bold">Status: </span><span>{results}</span>
</div>
</div>
@@ -44,6 +44,13 @@
items = [...resData]; items = [...resData];
setTimeout(() => focusIdx(0)); setTimeout(() => focusIdx(0));
}, },
pagerFromUpdate(){
if (itemsLength === 0) {
pager.idTo = pager.idFrom;
} else {
pager.idTo = pager.idFrom + (itemsLength - 1);
}
},
save: async () => { save: async () => {
if (itemsBuffer.length > 0) { if (itemsBuffer.length > 0) {
const res = await fetch('/api/tickets', { const res = await fetch('/api/tickets', {