Add possibility to upload images for inventory units

This commit is contained in:
2026-07-29 21:12:28 +03:00
parent 9b717e0f09
commit 709c645af2
5 changed files with 165 additions and 15 deletions
+4
View File
@@ -15,11 +15,15 @@ Open http://localhost:5000. The SQLite database is stored in the persistent `inv
- Search by name, brand, model, serial number, asset tag, or location - Search by name, brand, model, serial number, asset tag, or location
- Filter by item type and status - Filter by item type and status
- Add, edit, and delete inventory records - Add, edit, and delete inventory records
- Upload and display up to five device pictures per item (JPG, PNG, GIF, or WebP; up to 10 MB per request)
- Add more pictures or delete individual pictures while editing an item
- Computer fields: CPU, RAM, GPU, and disk/storage - Computer fields: CPU, RAM, GPU, and disk/storage
- Media field: format - Media field: format
- 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.
## Local development ## Local development
```bash ```bash
Binary file not shown.
+149 -11
View File
@@ -1,12 +1,17 @@
import os import os
import sqlite3 import sqlite3
import uuid
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 from flask import Flask, jsonify, render_template, request, send_from_directory
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")))
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024
def get_db(): def get_db():
@@ -18,6 +23,7 @@ def get_db():
def initialize_db(): def initialize_db():
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True) Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
with get_db() as connection: with get_db() as connection:
connection.executescript( connection.executescript(
""" """
@@ -38,14 +44,33 @@ def initialize_db():
gpu TEXT, gpu TEXT,
storage TEXT, storage TEXT,
media_format TEXT, media_format TEXT,
image_filename TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_inventory_search ON inventory_items(name, brand, model, serial_number, asset_tag); CREATE INDEX IF NOT EXISTS idx_inventory_search ON inventory_items(name, brand, model, serial_number, asset_tag);
CREATE INDEX IF NOT EXISTS idx_inventory_type ON inventory_items(item_type); CREATE INDEX IF NOT EXISTS idx_inventory_type ON inventory_items(item_type);
CREATE INDEX IF NOT EXISTS idx_inventory_status ON inventory_items(status); CREATE INDEX IF NOT EXISTS idx_inventory_status ON inventory_items(status);
CREATE TABLE IF NOT EXISTS inventory_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id);
""" """
) )
columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")}
if "image_filename" not in columns:
connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT")
connection.execute(
"""INSERT INTO inventory_images (item_id, filename, original_name, created_at)
SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now'))
FROM inventory_items
WHERE image_filename IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)"""
)
def clean(value): def clean(value):
@@ -72,8 +97,52 @@ def item_payload(data):
} }
def serialize(row): def serialize(row, connection):
return dict(row) item = dict(row)
item["images"] = [
{"id": image["id"], "filename": image["filename"], "original_name": image["original_name"], "url": f"/media/{image['filename']}"}
for image in connection.execute("SELECT id, filename, original_name FROM inventory_images WHERE item_id = ? ORDER BY id", (row["id"],)).fetchall()
]
item["image_url"] = item["images"][0]["url"] if item["images"] else None
return item
def request_data():
return request.form if request.form else (request.get_json(silent=True) or {})
def save_image(image):
if not image or not image.filename:
return None
original_name = secure_filename(image.filename)
extension = Path(original_name).suffix.lower().lstrip(".")
if extension not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("Images must be JPG, PNG, GIF, or WebP files.")
filename = f"{uuid.uuid4().hex}.{extension}"
image.save(UPLOAD_FOLDER / filename)
return filename, original_name
def save_images(files):
saved = []
try:
for image in files:
if image and image.filename:
saved.append(save_image(image))
return saved
except ValueError:
for filename, _ in saved:
delete_image(filename)
raise
def delete_image(filename):
if filename:
(UPLOAD_FOLDER / filename).unlink(missing_ok=True)
def files_from_request():
return request.files.getlist("images") or request.files.getlist("image")
@app.route("/") @app.route("/")
@@ -81,6 +150,11 @@ def index():
return render_template("index.html") return render_template("index.html")
@app.get("/media/<path:filename>")
def media(filename):
return send_from_directory(UPLOAD_FOLDER, filename)
@app.get("/api/items") @app.get("/api/items")
def list_items(): def list_items():
query = clean(request.args.get("q")) query = clean(request.args.get("q"))
@@ -100,7 +174,7 @@ def list_items():
where = f"WHERE {' AND '.join(clauses)}" if clauses else "" where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with get_db() as connection: with get_db() as connection:
rows = connection.execute(f"SELECT * FROM inventory_items {where} ORDER BY updated_at DESC, id DESC", params).fetchall() rows = connection.execute(f"SELECT * FROM inventory_items {where} ORDER BY updated_at DESC, id DESC", params).fetchall()
return jsonify([serialize(row) for row in rows]) return jsonify([serialize(row, connection) for row in rows])
@app.get("/api/summary") @app.get("/api/summary")
@@ -115,9 +189,16 @@ def summary():
@app.post("/api/items") @app.post("/api/items")
def create_item(): def create_item():
data = item_payload(request.get_json(silent=True) or {}) data = item_payload(request_data())
if not data["name"] or not data["item_type"]: if not data["name"] or not data["item_type"]:
return jsonify({"error": "Name and type are required."}), 400 return jsonify({"error": "Name and type are required."}), 400
selected_files = [image for image in files_from_request() if image and image.filename]
if len(selected_files) > 5:
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
try:
images = save_images(selected_files)
except ValueError as error:
return jsonify({"error": str(error)}), 400
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
try: try:
with get_db() as connection: with get_db() as connection:
@@ -127,41 +208,98 @@ def create_item():
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*data.values(), now, now), (*data.values(), now, now),
) )
for filename, original_name in images:
connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (cursor.lastrowid, filename, original_name, now))
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone() row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone()
return jsonify(serialize(row)), 201 result = serialize(row, connection)
except sqlite3.IntegrityError as error: return jsonify(result), 201
except sqlite3.IntegrityError:
for filename, _ in images:
delete_image(filename)
return jsonify({"error": "Serial number or asset tag already exists."}), 409 return jsonify({"error": "Serial number or asset tag already exists."}), 409
@app.put("/api/items/<int:item_id>") @app.put("/api/items/<int:item_id>")
def update_item(item_id): def update_item(item_id):
data = item_payload(request.get_json(silent=True) or {}) data = item_payload(request_data())
if not data["name"] or not data["item_type"]: if not data["name"] or not data["item_type"]:
return jsonify({"error": "Name and type are required."}), 400 return jsonify({"error": "Name and type are required."}), 400
data["updated_at"] = datetime.now(timezone.utc).isoformat() data["updated_at"] = datetime.now(timezone.utc).isoformat()
new_images = files_from_request()
saved_images = []
try: try:
with get_db() as connection: with get_db() as connection:
current = connection.execute("SELECT image_filename FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
if not current:
return jsonify({"error": "Item not found."}), 404
existing_count = connection.execute("SELECT COUNT(*) FROM inventory_images WHERE item_id = ?", (item_id,)).fetchone()[0]
if existing_count + len([image for image in new_images if image and image.filename]) > 5:
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
saved_images = save_images(new_images)
result = connection.execute( result = connection.execute(
"""UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""", """UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""",
(*data.values(), item_id), (*data.values(), item_id),
) )
if result.rowcount == 0: for filename, original_name in saved_images:
return jsonify({"error": "Item not found."}), 404 connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (item_id, filename, original_name, data["updated_at"]))
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone() row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
return jsonify(serialize(row)) result = serialize(row, connection)
return jsonify(result)
except ValueError as error:
return jsonify({"error": str(error)}), 400
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
for filename, _ in saved_images:
delete_image(filename)
return jsonify({"error": "Serial number or asset tag already exists."}), 409 return jsonify({"error": "Serial number or asset tag already exists."}), 409
@app.delete("/api/items/<int:item_id>") @app.delete("/api/items/<int:item_id>")
def delete_item(item_id): def delete_item(item_id):
with get_db() as connection: with get_db() as connection:
images = connection.execute("SELECT filename FROM inventory_images WHERE item_id = ?", (item_id,)).fetchall()
result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,)) result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,))
if result.rowcount == 0: if result.rowcount == 0:
return jsonify({"error": "Item not found."}), 404 return jsonify({"error": "Item not found."}), 404
for image in images:
delete_image(image["filename"])
return jsonify({"deleted": True}) return jsonify({"deleted": True})
@app.post("/api/items/<int:item_id>/images")
def add_images(item_id):
files = files_from_request()
with get_db() as connection:
if not connection.execute("SELECT id FROM inventory_items WHERE id = ?", (item_id,)).fetchone():
return jsonify({"error": "Item not found."}), 404
current_count = connection.execute("SELECT COUNT(*) FROM inventory_images WHERE item_id = ?", (item_id,)).fetchone()[0]
selected_files = [image for image in files if image and image.filename]
if current_count + len(selected_files) > 5:
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
try:
saved = save_images(selected_files)
except ValueError as error:
return jsonify({"error": str(error)}), 400
now = datetime.now(timezone.utc).isoformat()
with get_db() as connection:
for filename, original_name in saved:
connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (item_id, filename, original_name, now))
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
return jsonify(serialize(row, connection))
@app.delete("/api/items/<int:item_id>/images/<int:image_id>")
def remove_image(item_id, image_id):
with get_db() as connection:
image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone()
if not image:
return jsonify({"error": "Picture not found."}), 404
connection.execute("DELETE FROM inventory_images WHERE id = ?", (image_id,))
item = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
result = serialize(item, connection)
delete_image(image["filename"])
return jsonify(result)
initialize_db() initialize_db()
if __name__ == "__main__": if __name__ == "__main__":
+10 -4
View File
@@ -2,7 +2,8 @@ const state = { items: [], editingId: null };
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
async function request(url, options = {}) { async function request(url, options = {}) {
const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
const response = await fetch(url, { headers, ...options });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Something went wrong.'); if (!response.ok) throw new Error(data.error || 'Something went wrong.');
return data; return data;
@@ -23,15 +24,18 @@ async function load() {
function renderItems(items) { function renderItems(items) {
const body = $('#inventoryBody'); const body = $('#inventoryBody');
$('#emptyState').style.display = items.length ? 'none' : 'block'; $('#emptyState').style.display = items.length ? 'none' : 'block';
body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions"><button class="small-action" onclick="editItem(${item.id})">Edit</button><button class="small-action delete" onclick="removeItem(${item.id})">Delete</button></div></td></tr>`).join(''); body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><div class="item-cell">${item.images?.length ? `<img class="item-thumb" src="${escapeHtml(item.images[0].url)}" alt="">${item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}` : '<div class="item-thumb placeholder">◌</div>'}<div><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></div></div></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions"><button class="small-action" onclick="editItem(${item.id})">Edit</button><button class="small-action delete" onclick="removeItem(${item.id})">Delete</button></div></td></tr>`).join('');
} }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function openModal(item = null) { state.editingId = item?.id || null; $('#modalTitle').textContent = item ? 'Edit item' : 'Add item'; $('#itemForm').reset(); if (item) Object.entries(item).forEach(([key, value]) => { const field = $(`[name="${key}"]`); if (field) field.value = value || ''; }); $('#modal').classList.add('open'); $('[name="name"]').focus(); } function ensureImageControls() { if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', '<label class="full image-field">Device pictures<input id="imageInput" name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple><span id="imageName">Up to 5 pictures</span><div id="imageGallery" class="image-gallery"></div></label>'); }
function renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => `<div class="gallery-item"><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.original_name || 'Device picture')}"><button type="button" class="gallery-delete" onclick="deleteImage(${image.id})" aria-label="Delete picture">×</button></div>`).join(''); }
function openModal(item = null) { ensureImageControls(); state.editingId = item?.id || null; $('#modalTitle').textContent = item ? 'Edit item' : 'Add item'; $('#itemForm').reset(); $('#imageName').textContent = item?.images?.length ? `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.` : 'Up to 5 pictures'; renderImageGallery(item?.images || []); $('#modal').classList.add('open'); $('[name="name"]').focus(); if (item) Object.entries(item).forEach(([key, value]) => { const field = $(`[name="${key}"]`); if (field && key !== 'images') field.value = value || ''; }); }
function closeModal() { $('#modal').classList.remove('open'); } function closeModal() { $('#modal').classList.remove('open'); }
function toast(message) { const element = $('#toast'); element.textContent = message; element.classList.add('show'); setTimeout(() => element.classList.remove('show'), 2500); } function toast(message) { const element = $('#toast'); element.textContent = message; element.classList.add('show'); setTimeout(() => element.classList.remove('show'), 2500); }
window.editItem = (id) => openModal(state.items.find((item) => item.id === id)); window.editItem = (id) => openModal(state.items.find((item) => item.id === id));
window.deleteImage = async (imageId) => { if (!state.editingId || !confirm('Delete this picture?')) return; try { const item = await request(`/api/items/${state.editingId}/images/${imageId}`, { method: 'DELETE' }); renderImageGallery(item.images); $('#imageName').textContent = `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.`; state.items = state.items.map((entry) => entry.id === item.id ? item : entry); renderItems(state.items); toast('Picture deleted.'); } catch (error) { toast(error.message); } };
window.removeItem = async (id) => { const item = state.items.find((entry) => entry.id === id); if (!confirm(`Delete "${item.name}"?`)) return; try { await request(`/api/items/${id}`, { method: 'DELETE' }); toast('Item removed from inventory.'); await load(); } catch (error) { toast(error.message); } }; window.removeItem = async (id) => { const item = state.items.find((entry) => entry.id === id); if (!confirm(`Delete "${item.name}"?`)) return; try { await request(`/api/items/${id}`, { method: 'DELETE' }); toast('Item removed from inventory.'); await load(); } catch (error) { toast(error.message); } };
$('#addButton').addEventListener('click', () => openModal()); $('#addButton').addEventListener('click', () => openModal());
@@ -40,6 +44,8 @@ $('#closeModal').addEventListener('click', closeModal);
$('#cancelButton').addEventListener('click', closeModal); $('#cancelButton').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); }); $('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); });
['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load)); ['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load));
$('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); const payload = Object.fromEntries(formData.entries()); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: JSON.stringify(payload) }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } }); 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); }); $('#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); });
load().catch((error) => toast(error.message)); load().catch((error) => toast(error.message));
+2
View File
File diff suppressed because one or more lines are too long