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
```
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
@@ -22,7 +22,9 @@ Open http://localhost:5000. The SQLite database is stored in the persistent `inv
- CSV export of the currently visible inventory
- 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
Binary file not shown.
+83 -2
View File
@@ -1,17 +1,20 @@
import os
import shutil
import sqlite3
import tempfile
import uuid
import zipfile
from datetime import datetime, timezone
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
app = Flask(__name__)
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")))
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():
@@ -145,6 +148,52 @@ def files_from_request():
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("/")
def index():
return render_template("index.html")
@@ -187,6 +236,38 @@ def summary():
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")
def create_item():
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:
DATABASE_PATH: /data/inventory.db
volumes:
- inventory_data:/data
- ./data:/data
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'; });
$('#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); });
$('#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));
+2 -2
View File
@@ -13,8 +13,8 @@
<div class="app-shell">
<aside class="sidebar">
<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>
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>SQLite · Docker volume</small></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><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 · Project data</small></div>
</aside>
<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>