From 1e6b7787b39c1341bec321f1eafc3803e0c829ca Mon Sep 17 00:00:00 2001 From: Dilan Gilluly Date: Thu, 16 Jul 2026 22:38:01 -0400 Subject: [PATCH] (add): Backup/Restore --- api/app/data/baskets.py | 4 +- api/app/routers.py | 16 ++- api/app/system/backuprestore.py | 37 +++++ .../src/lib/client/components/PagerBar.svelte | 7 + client/src/lib/client/styles.js | 2 +- .../routes/api/backuprestore/local/+server.js | 58 ++++++++ .../backuprestore/push/[target]/+server.js | 32 +++++ .../api/backuprestore/remote/+server.js | 36 +++++ .../src/routes/baskets/[prefix]/+page.svelte | 9 +- .../src/routes/drawing/[prefix]/+page.svelte | 9 +- client/src/routes/settings/+page.svelte | 1 + .../settings/backuprestore/+page.server.js | 6 + .../settings/backuprestore/+page.svelte | 131 ++++++++++++++++++ .../src/routes/tickets/[prefix]/+page.svelte | 7 + 14 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 api/app/system/backuprestore.py create mode 100644 client/src/routes/api/backuprestore/local/+server.js create mode 100644 client/src/routes/api/backuprestore/push/[target]/+server.js create mode 100644 client/src/routes/api/backuprestore/remote/+server.js create mode 100644 client/src/routes/settings/backuprestore/+page.server.js create mode 100644 client/src/routes/settings/backuprestore/+page.svelte diff --git a/api/app/data/baskets.py b/api/app/data/baskets.py index 6b82614..f7afa0a 100644 --- a/api/app/data/baskets.py +++ b/api/app/data/baskets.py @@ -7,8 +7,8 @@ from fastapi import APIRouter, Header class Basket: prefix: str b_id: int - description: str = "" - donors: str = "" + description: str | None = "" + donors: str | None = "" winning_ticket: int = 0 class BasketRepo(RepoTemplate): diff --git a/api/app/routers.py b/api/app/routers.py index 52176af..7104c98 100644 --- a/api/app/routers.py +++ b/api/app/routers.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from system.auth import auth_router +from system.backuprestore import backuprestore_router from system.prefixes import prefix_router from data.tickets import tickets_router from data.baskets import basket_router @@ -8,10 +9,11 @@ from data.reports import reports_router from data.search import search_router def imp_routers(app: FastAPI): - app.include_router(auth_router) - app.include_router(prefix_router) - app.include_router(tickets_router) - app.include_router(basket_router) - app.include_router(drawing_router) - app.include_router(reports_router) - app.include_router(search_router) + app.include_router(auth_router) + app.include_router(prefix_router) + app.include_router(tickets_router) + app.include_router(basket_router) + app.include_router(drawing_router) + app.include_router(reports_router) + app.include_router(search_router) + app.include_router(backuprestore_router) diff --git a/api/app/system/backuprestore.py b/api/app/system/backuprestore.py new file mode 100644 index 0000000..973a037 --- /dev/null +++ b/api/app/system/backuprestore.py @@ -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.") diff --git a/client/src/lib/client/components/PagerBar.svelte b/client/src/lib/client/components/PagerBar.svelte index e6c200d..f033748 100644 --- a/client/src/lib/client/components/PagerBar.svelte +++ b/client/src/lib/client/components/PagerBar.svelte @@ -36,9 +36,13 @@ type="number" id="id_from" class={iS.normal} + title="Alt + Q" onclick={(e) => { e.target.select(); }} + onchange={() => { + if (functions.pagerFromUpdate) functions.pagerFromUpdate(); + }} bind:value={pager.idFrom} />
-
@@ -46,6 +50,7 @@ type="number" id="id_to" class={iS.normal} + title="Alt + W" onclick={(e) => { e.target.select(); }} @@ -62,6 +67,7 @@ {#if functions.prevPage} { functions.nextPage(); }}>Next Page { + 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.' }); +}; diff --git a/client/src/routes/api/backuprestore/push/[target]/+server.js b/client/src/routes/api/backuprestore/push/[target]/+server.js new file mode 100644 index 0000000..44f0c7b --- /dev/null +++ b/client/src/routes/api/backuprestore/push/[target]/+server.js @@ -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.'); + }; +}; diff --git a/client/src/routes/api/backuprestore/remote/+server.js b/client/src/routes/api/backuprestore/remote/+server.js new file mode 100644 index 0000000..734b51b --- /dev/null +++ b/client/src/routes/api/backuprestore/remote/+server.js @@ -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([]); + } +}; diff --git a/client/src/routes/baskets/[prefix]/+page.svelte b/client/src/routes/baskets/[prefix]/+page.svelte index b3bce78..b429507 100644 --- a/client/src/routes/baskets/[prefix]/+page.svelte +++ b/client/src/routes/baskets/[prefix]/+page.svelte @@ -21,7 +21,7 @@ curIdx = idx; const elemIdx = document.getElementById(`${idx}_first`); if (elemIdx) { - elemIdx.focus(); + elemIdx.select(); } }; @@ -44,6 +44,13 @@ items = [...resData]; setTimeout(() => focusIdx(0)); }, + pagerFromUpdate(){ + if (itemsLength === 0) { + pager.idTo = pager.idFrom; + } else { + pager.idTo = pager.idFrom + (itemsLength - 1); + } + }, save: async () => { if (itemsBuffer.length > 0) { const res = await fetch('/api/baskets', { diff --git a/client/src/routes/drawing/[prefix]/+page.svelte b/client/src/routes/drawing/[prefix]/+page.svelte index c3056ef..81d9276 100644 --- a/client/src/routes/drawing/[prefix]/+page.svelte +++ b/client/src/routes/drawing/[prefix]/+page.svelte @@ -21,7 +21,7 @@ curIdx = idx; const elemIdx = document.getElementById(`${idx}_first`); if (elemIdx) { - elemIdx.focus(); + elemIdx.select(); } }; @@ -44,6 +44,13 @@ items = [...resData]; setTimeout(() => focusIdx(0)); }, + pagerFromUpdate(){ + if (itemsLength === 0) { + pager.idTo = pager.idFrom; + } else { + pager.idTo = pager.idFrom + (itemsLength - 1); + } + }, save: async () => { if (itemsBuffer.length > 0) { const res = await fetch('/api/drawing', { diff --git a/client/src/routes/settings/+page.svelte b/client/src/routes/settings/+page.svelte index f637778..5ec6e14 100644 --- a/client/src/routes/settings/+page.svelte +++ b/client/src/routes/settings/+page.svelte @@ -29,6 +29,7 @@ Auth Keys {/if} Prefixes + Backup/Restore

{pageTitle}

diff --git a/client/src/routes/settings/backuprestore/+page.server.js b/client/src/routes/settings/backuprestore/+page.server.js new file mode 100644 index 0000000..7fb9c8f --- /dev/null +++ b/client/src/routes/settings/backuprestore/+page.server.js @@ -0,0 +1,6 @@ +import { getSettings } from "$lib/server/settings/index.js" + +export const load = async () => { + const s = getSettings(); + return { remoteServer: s.remote_server }; +} diff --git a/client/src/routes/settings/backuprestore/+page.svelte b/client/src/routes/settings/backuprestore/+page.svelte new file mode 100644 index 0000000..cd79117 --- /dev/null +++ b/client/src/routes/settings/backuprestore/+page.svelte @@ -0,0 +1,131 @@ + + + + {pageTitle} + + +
+ + Back to Settings +
Other Settings
+ Prefixes + {#if remoteServer} + Auth Keys + {/if} +
+

{pageTitle}

+
+

Backup File Downloads

+
+ + {#if remoteServer} + + {/if} +
+
+
+

Backup File Upload

+
+ + + {#if remoteServer} + + {/if} +
+
+ {#if remoteServer} +
+

Push Data to Server

+
+ + + +
+
+ {/if} +
+ Status: {results} +
+
diff --git a/client/src/routes/tickets/[prefix]/+page.svelte b/client/src/routes/tickets/[prefix]/+page.svelte index 7685621..410afbf 100644 --- a/client/src/routes/tickets/[prefix]/+page.svelte +++ b/client/src/routes/tickets/[prefix]/+page.svelte @@ -44,6 +44,13 @@ items = [...resData]; setTimeout(() => focusIdx(0)); }, + pagerFromUpdate(){ + if (itemsLength === 0) { + pager.idTo = pager.idFrom; + } else { + pager.idTo = pager.idFrom + (itemsLength - 1); + } + }, save: async () => { if (itemsBuffer.length > 0) { const res = await fetch('/api/tickets', {