Add backup and restore of data

This commit is contained in:
2026-08-27 00:35:04 +03:00
parent 709c645af2
commit e532335b1b
13 changed files with 93 additions and 10 deletions
+4 -2
View File
@@ -8,7 +8,7 @@ A Dockerized inventory system for computers, audio equipment, graphics hardware,
docker compose up --build docker compose up --build
``` ```
Open http://localhost:5000. The SQLite database is stored in the persistent `inventory_data` Docker volume. Open http://localhost:5000. The SQLite database is stored in the project-local `data` folder.
## Features ## Features
@@ -22,7 +22,9 @@ Open http://localhost:5000. The SQLite database is stored in the persistent `inv
- CSV export of the currently visible inventory - CSV export of the currently visible inventory
- Responsive web UI for desktop and mobile - Responsive web UI for desktop and mobile
Uploaded pictures are stored in `/data/uploads` alongside the SQLite database, so they persist in the `inventory_data` Docker volume. Existing databases are migrated automatically when the application starts. The project-local `data` folder is mounted at `/data`. The SQLite database is stored at `data/inventory.db`, and uploaded pictures are stored in `data/uploads`. Keep this folder with the project when moving it to another computer. Existing databases are migrated automatically when the application starts.
Use **Backup data** in the sidebar to download a ZIP containing the database and uploaded pictures. On another computer, start the application and use **Restore data** to upload that ZIP. Restoring replaces the current inventory and pictures.
## Local development ## Local development
Binary file not shown.
+83 -2
View File
@@ -1,17 +1,20 @@
import os import os
import shutil
import sqlite3 import sqlite3
import tempfile
import uuid import uuid
import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, render_template, request, send_from_directory from flask import Flask, jsonify, render_template, request, send_file, send_from_directory
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
app = Flask(__name__) app = Flask(__name__)
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db"))) DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db")))
UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads"))) UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads")))
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
def get_db(): def get_db():
@@ -145,6 +148,52 @@ def files_from_request():
return request.files.getlist("images") or request.files.getlist("image") return request.files.getlist("images") or request.files.getlist("image")
def add_directory_to_zip(archive, directory, archive_prefix):
if not directory.exists():
return
for path in directory.rglob("*"):
if path.is_file():
archive.write(path, f"{archive_prefix}/{path.relative_to(directory).as_posix()}")
def valid_backup_member(member):
path = Path(member.filename)
return not path.is_absolute() and ".." not in path.parts and (member.filename == "inventory.db" or member.filename.startswith("uploads/"))
def restore_backup(backup):
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_path = Path(temporary_directory)
archive_path = temporary_path / "backup.zip"
backup.save(archive_path)
with zipfile.ZipFile(archive_path) as archive:
members = archive.infolist()
if not members or not all(valid_backup_member(member) for member in members):
raise ValueError("The backup file has an invalid format.")
if "inventory.db" not in archive.namelist():
raise ValueError("The backup does not contain inventory.db.")
database_path = temporary_path / "inventory.db"
uploads_path = temporary_path / "uploads"
archive.extract("inventory.db", temporary_path)
archive.extractall(temporary_path, [member for member in members if member.filename.startswith("uploads/")])
with sqlite3.connect(database_path) as connection:
if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("The backup database failed its integrity check.")
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
staged_database_path = Path(DATABASE_PATH).with_name(f".{Path(DATABASE_PATH).name}.restore")
shutil.copy2(database_path, staged_database_path)
os.replace(staged_database_path, DATABASE_PATH)
if UPLOAD_FOLDER.exists():
shutil.rmtree(UPLOAD_FOLDER)
if uploads_path.exists():
staged_uploads_path = UPLOAD_FOLDER.parent / f".{UPLOAD_FOLDER.name}.restore"
shutil.copytree(uploads_path, staged_uploads_path, dirs_exist_ok=True)
os.replace(staged_uploads_path, UPLOAD_FOLDER)
else:
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
@app.route("/") @app.route("/")
def index(): def index():
return render_template("index.html") return render_template("index.html")
@@ -187,6 +236,38 @@ def summary():
return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance}) return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance})
@app.get("/api/backup")
def backup():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temporary_database:
temporary_database_path = Path(temporary_database.name)
try:
with get_db() as source, sqlite3.connect(temporary_database_path) as destination:
source.backup(destination)
archive = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
archive_path = Path(archive.name)
archive.close()
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_archive:
zip_archive.write(temporary_database_path, "inventory.db")
add_directory_to_zip(zip_archive, UPLOAD_FOLDER, "uploads")
response = send_file(archive_path, as_attachment=True, download_name=f"stockroom-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip", mimetype="application/zip")
response.call_on_close(lambda: archive_path.unlink(missing_ok=True))
return response
finally:
temporary_database_path.unlink(missing_ok=True)
@app.post("/api/restore")
def restore():
backup_file = request.files.get("backup")
if not backup_file or not backup_file.filename:
return jsonify({"error": "Choose a backup ZIP file to restore."}), 400
try:
restore_backup(backup_file)
except (ValueError, zipfile.BadZipFile, sqlite3.DatabaseError) as error:
return jsonify({"error": str(error) or "The backup could not be restored."}), 400
return jsonify({"restored": True})
@app.post("/api/items") @app.post("/api/items")
def create_item(): def create_item():
data = item_payload(request_data()) data = item_payload(request_data())
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

+1 -4
View File
@@ -6,8 +6,5 @@ services:
environment: environment:
DATABASE_PATH: /data/inventory.db DATABASE_PATH: /data/inventory.db
volumes: volumes:
- inventory_data:/data - ./data:/data
restart: unless-stopped restart: unless-stopped
volumes:
inventory_data:
+3
View File
@@ -48,4 +48,7 @@ ensureImageControls();
$('#imageInput').addEventListener('change', () => { const files = [...$('#imageInput').files]; $('#imageName').textContent = files.length ? `${files.length} new picture${files.length === 1 ? '' : 's'} selected` : 'Up to 5 pictures'; }); $('#imageInput').addEventListener('change', () => { const files = [...$('#imageInput').files]; $('#imageName').textContent = files.length ? `${files.length} new picture${files.length === 1 ? '' : 's'} selected` : 'Up to 5 pictures'; });
$('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); if (!$('#imageInput').files.length) formData.delete('images'); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: formData }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } }); $('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); if (!$('#imageInput').files.length) formData.delete('images'); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: formData }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } });
$('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); }); $('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); });
$('#backupButton').addEventListener('click', async () => { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup could not be created.'); const link = document.createElement('a'); link.href = URL.createObjectURL(await response.blob()); link.download = 'stockroom-backup.zip'; link.click(); URL.revokeObjectURL(link.href); toast('Backup downloaded.'); } catch (error) { toast(error.message); } });
$('#restoreButton').addEventListener('click', () => $('#restoreInput').click());
$('#restoreInput').addEventListener('change', async () => { const file = $('#restoreInput').files[0]; if (!file || !confirm('Restore this backup? Current inventory and pictures will be replaced.')) return; const formData = new FormData(); formData.append('backup', file); try { await request('/api/restore', { method: 'POST', body: formData }); toast('Backup restored. Reloading...'); setTimeout(() => window.location.reload(), 700); } catch (error) { toast(error.message); } $('#restoreInput').value = ''; });
load().catch((error) => toast(error.message)); load().catch((error) => toast(error.message));
+2 -2
View File
@@ -13,8 +13,8 @@
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div> <div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div>
<nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button><button class="nav-item" id="exportButton"><span class="nav-icon">↓</span>Export CSV</button></nav> <nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button><button class="nav-item" id="exportButton"><span class="nav-icon">↓</span>Export CSV</button><button class="nav-item" id="backupButton"><span class="nav-icon">⇩</span>Backup data</button><button class="nav-item" id="restoreButton"><span class="nav-icon">⇧</span>Restore data</button><input id="restoreInput" class="hidden" type="file" accept=".zip,application/zip"></nav>
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>SQLite · Docker volume</small></div> <div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>SQLite · Project data</small></div>
</aside> </aside>
<main class="main-content"> <main class="main-content">
<header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div><button class="primary-button" id="addButton"><span>+</span> Add item</button></header> <header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div><button class="primary-button" id="addButton"><span>+</span> Add item</button></header>