Initial commit

This commit is contained in:
2026-07-29 19:24:59 +03:00
commit 897af73757
9 changed files with 311 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data
ENV DATABASE_PATH=/data/inventory.db
ENV FLASK_APP=app.py
EXPOSE 5000
CMD ["python", "app.py"]
+30
View File
@@ -0,0 +1,30 @@
# Stockroom Inventory
A Dockerized inventory system for computers, audio equipment, graphics hardware, long-play records, CDs, displays, peripherals, and other assets.
## Run with Docker
```bash
docker compose up --build
```
Open http://localhost:5000. The SQLite database is stored in the persistent `inventory_data` Docker volume.
## Features
- Search by name, brand, model, serial number, asset tag, or location
- Filter by item type and status
- Add, edit, and delete inventory records
- Computer fields: CPU, RAM, GPU, and disk/storage
- Media field: format
- CSV export of the currently visible inventory
- Responsive web UI for desktop and mobile
## Local development
```bash
python -m venv .venv
.venv\\Scripts\\activate
pip install -r requirements.txt
python app.py
```
Binary file not shown.
+168
View File
@@ -0,0 +1,168 @@
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from flask import Flask, jsonify, render_template, request
app = Flask(__name__)
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db")))
def get_db():
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def initialize_db():
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
with get_db() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
item_type TEXT NOT NULL,
brand TEXT,
model TEXT,
serial_number TEXT UNIQUE,
asset_tag TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'Available',
location TEXT,
purchase_date TEXT,
notes TEXT,
cpu TEXT,
ram TEXT,
gpu TEXT,
storage TEXT,
media_format TEXT,
created_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_type ON inventory_items(item_type);
CREATE INDEX IF NOT EXISTS idx_inventory_status ON inventory_items(status);
"""
)
def clean(value):
return (value or "").strip()
def item_payload(data):
return {
"name": clean(data.get("name")),
"item_type": clean(data.get("item_type")),
"brand": clean(data.get("brand")),
"model": clean(data.get("model")),
"serial_number": clean(data.get("serial_number")) or None,
"asset_tag": clean(data.get("asset_tag")) or None,
"status": clean(data.get("status")) or "Available",
"location": clean(data.get("location")),
"purchase_date": clean(data.get("purchase_date")) or None,
"notes": clean(data.get("notes")),
"cpu": clean(data.get("cpu")),
"ram": clean(data.get("ram")),
"gpu": clean(data.get("gpu")),
"storage": clean(data.get("storage")),
"media_format": clean(data.get("media_format")),
}
def serialize(row):
return dict(row)
@app.route("/")
def index():
return render_template("index.html")
@app.get("/api/items")
def list_items():
query = clean(request.args.get("q"))
item_type = clean(request.args.get("type"))
status = clean(request.args.get("status"))
clauses = []
params = []
if query:
clauses.append("(name LIKE ? OR item_type LIKE ? OR brand LIKE ? OR model LIKE ? OR serial_number LIKE ? OR asset_tag LIKE ? OR location LIKE ?)")
params.extend([f"%{query}%"] * 7)
if item_type:
clauses.append("item_type = ?")
params.append(item_type)
if status:
clauses.append("status = ?")
params.append(status)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with get_db() as connection:
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])
@app.get("/api/summary")
def summary():
with get_db() as connection:
total = connection.execute("SELECT COUNT(*) FROM inventory_items").fetchone()[0]
available = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Available'").fetchone()[0]
checked_out = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Checked out'").fetchone()[0]
maintenance = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Maintenance'").fetchone()[0]
return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance})
@app.post("/api/items")
def create_item():
data = item_payload(request.get_json(silent=True) or {})
if not data["name"] or not data["item_type"]:
return jsonify({"error": "Name and type are required."}), 400
now = datetime.now(timezone.utc).isoformat()
try:
with get_db() as connection:
cursor = connection.execute(
"""INSERT INTO inventory_items
(name, item_type, brand, model, serial_number, asset_tag, status, location, purchase_date, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*data.values(), now, now),
)
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone()
return jsonify(serialize(row)), 201
except sqlite3.IntegrityError as error:
return jsonify({"error": "Serial number or asset tag already exists."}), 409
@app.put("/api/items/<int:item_id>")
def update_item(item_id):
data = item_payload(request.get_json(silent=True) or {})
if not data["name"] or not data["item_type"]:
return jsonify({"error": "Name and type are required."}), 400
data["updated_at"] = datetime.now(timezone.utc).isoformat()
try:
with get_db() as connection:
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=?""",
(*data.values(), item_id),
)
if result.rowcount == 0:
return jsonify({"error": "Item not found."}), 404
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
return jsonify(serialize(row))
except sqlite3.IntegrityError:
return jsonify({"error": "Serial number or asset tag already exists."}), 409
@app.delete("/api/items/<int:item_id>")
def delete_item(item_id):
with get_db() as connection:
result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,))
if result.rowcount == 0:
return jsonify({"error": "Item not found."}), 404
return jsonify({"deleted": True})
initialize_db()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)
+13
View File
@@ -0,0 +1,13 @@
services:
inventory:
build: .
ports:
- "5000:5000"
environment:
DATABASE_PATH: /data/inventory.db
volumes:
- inventory_data:/data
restart: unless-stopped
volumes:
inventory_data:
+1
View File
@@ -0,0 +1 @@
Flask==3.1.1
+45
View File
@@ -0,0 +1,45 @@
const state = { items: [], editingId: null };
const $ = (selector) => document.querySelector(selector);
async function request(url, options = {}) {
const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options });
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Something went wrong.');
return data;
}
async function load() {
const query = new URLSearchParams({ q: $('#searchInput').value, type: $('#typeFilter').value, status: $('#statusFilter').value });
const [items, summary] = await Promise.all([request(`/api/items?${query}`), request('/api/summary')]);
state.items = items;
$('#totalCount').textContent = summary.total;
$('#availableCount').textContent = summary.available;
$('#checkedOutCount').textContent = summary.checked_out;
$('#maintenanceCount').textContent = summary.maintenance;
$('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
renderItems(items);
}
function renderItems(items) {
const body = $('#inventoryBody');
$('#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('');
}
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 closeModal() { $('#modal').classList.remove('open'); }
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.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());
$('#emptyAddButton').addEventListener('click', () => openModal());
$('#closeModal').addEventListener('click', closeModal);
$('#cancelButton').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); });
['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); } });
$('#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));
+1
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stockroom | Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<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>
</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>
<section class="stats-grid">
<article class="stat-card accent"><div class="stat-label">Total assets <span class="stat-symbol">◉</span></div><strong id="totalCount">0</strong><small>Across all categories</small></article>
<article class="stat-card"><div class="stat-label">Available <span class="stat-symbol green">●</span></div><strong id="availableCount">0</strong><small>Ready to use</small></article>
<article class="stat-card"><div class="stat-label">Checked out <span class="stat-symbol amber">●</span></div><strong id="checkedOutCount">0</strong><small>Currently assigned</small></article>
<article class="stat-card"><div class="stat-label">Maintenance <span class="stat-symbol red">●</span></div><strong id="maintenanceCount">0</strong><small>Needs attention</small></article>
</section>
<section class="inventory-panel">
<div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div>
<div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select><select id="statusFilter"><option value="">All status</option><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></div>
<div class="table-wrap"><table><thead><tr><th>Item</th><th>Type</th><th>Identifiers</th><th>Location</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody id="inventoryBody"></tbody></table><div class="empty-state" id="emptyState"><div class="empty-icon">⌁</div><h3>No items found</h3><p>Adjust your search or add the first item to your inventory.</p><button class="secondary-button" id="emptyAddButton">Add first item</button></div></div>
</section>
</main>
</div>
<div class="modal-backdrop" id="modal"><section class="modal"><div class="modal-header"><div><p class="eyebrow">Inventory record</p><h2 id="modalTitle">Add item</h2></div><button class="icon-button" id="closeModal" aria-label="Close">×</button></div><form id="itemForm"><div class="form-grid"><label class="full">Item name *<input name="name" required placeholder="e.g. ThinkPad T14 Gen 3"></label><label>Type *<select name="item_type" required><option value="">Select type</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select></label><label>Status<select name="status"><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></label><label>Brand<input name="brand" placeholder="e.g. Lenovo"></label><label>Model<input name="model" placeholder="e.g. T14 Gen 3"></label><label>Serial number<input name="serial_number" placeholder="Serial or barcode"></label><label>Asset tag<input name="asset_tag" placeholder="e.g. AST-00142"></label><label>Location<input name="location" placeholder="e.g. Shelf A3"></label><label>Purchase date<input name="purchase_date" type="date"></label><div class="form-section full"><span>Technical details <small>optional</small></span></div><label>CPU<input name="cpu" placeholder="e.g. Intel Core i7-1260P"></label><label>RAM<input name="ram" placeholder="e.g. 16 GB"></label><label>GPU<input name="gpu" placeholder="e.g. NVIDIA RTX 3060"></label><label>Disk / storage<input name="storage" placeholder="e.g. 512 GB SSD"></label><label>Media format<input name="media_format" placeholder="e.g. 12-inch, 33 RPM"></label><label class="full">Notes<textarea name="notes" rows="3" placeholder="Condition, accessories, history..."></textarea></label></div><div class="form-actions"><button type="button" class="secondary-button" id="cancelButton">Cancel</button><button type="submit" class="primary-button" id="saveButton">Save item</button></div></form></section></div>
<div class="toast" id="toast"></div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>