Operations / Stockroom
diff --git a/README.md b/README.md index 12e9be3..d698b05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index 18b0e26..0f67022 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ diff --git a/app.py b/app.py index 1a468b5..19b9762 100644 --- a/app.py +++ b/app.py @@ -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()) diff --git a/data/inventory.db b/data/inventory.db new file mode 100644 index 0000000..300ed61 Binary files /dev/null and b/data/inventory.db differ diff --git a/data/uploads/355d4d11665e499b9a3aa930d52fd97f.jpg b/data/uploads/355d4d11665e499b9a3aa930d52fd97f.jpg new file mode 100644 index 0000000..6fb21e9 Binary files /dev/null and b/data/uploads/355d4d11665e499b9a3aa930d52fd97f.jpg differ diff --git a/data/uploads/8cc5e787943b4a499d0d5dab993276b3.jpg b/data/uploads/8cc5e787943b4a499d0d5dab993276b3.jpg new file mode 100644 index 0000000..acc3f7a Binary files /dev/null and b/data/uploads/8cc5e787943b4a499d0d5dab993276b3.jpg differ diff --git a/data/uploads/916b1006362c44d9879a4dc55d98d776.jpg b/data/uploads/916b1006362c44d9879a4dc55d98d776.jpg new file mode 100644 index 0000000..5e0c446 Binary files /dev/null and b/data/uploads/916b1006362c44d9879a4dc55d98d776.jpg differ diff --git a/data/uploads/bf8f68a6580346529e7a4af5e3c86f4a.jpg b/data/uploads/bf8f68a6580346529e7a4af5e3c86f4a.jpg new file mode 100644 index 0000000..bf35ad0 Binary files /dev/null and b/data/uploads/bf8f68a6580346529e7a4af5e3c86f4a.jpg differ diff --git a/data/uploads/c04aa6dfa9414dfc907cf363a29effce.jpg b/data/uploads/c04aa6dfa9414dfc907cf363a29effce.jpg new file mode 100644 index 0000000..5e0c446 Binary files /dev/null and b/data/uploads/c04aa6dfa9414dfc907cf363a29effce.jpg differ diff --git a/data/uploads/c661b4b4305e4e34a8c760be5bcb881a.jpg b/data/uploads/c661b4b4305e4e34a8c760be5bcb881a.jpg new file mode 100644 index 0000000..184a1ff Binary files /dev/null and b/data/uploads/c661b4b4305e4e34a8c760be5bcb881a.jpg differ diff --git a/docker-compose.yml b/docker-compose.yml index 8e4b647..ca2b17f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,5 @@ services: environment: DATABASE_PATH: /data/inventory.db volumes: - - inventory_data:/data + - ./data:/data restart: unless-stopped - -volumes: - inventory_data: diff --git a/static/app.js b/static/app.js index 6ff31da..37b835c 100644 --- a/static/app.js +++ b/static/app.js @@ -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)); diff --git a/templates/index.html b/templates/index.html index ff44015..8bea57d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -13,8 +13,8 @@
Operations / Stockroom