307 lines
13 KiB
Python
307 lines
13 KiB
Python
import os
|
|
import sqlite3
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, jsonify, render_template, request, 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
|
|
|
|
|
|
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)
|
|
UPLOAD_FOLDER.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,
|
|
image_filename 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);
|
|
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):
|
|
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, connection):
|
|
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("/")
|
|
def index():
|
|
return render_template("index.html")
|
|
|
|
|
|
@app.get("/media/<path:filename>")
|
|
def media(filename):
|
|
return send_from_directory(UPLOAD_FOLDER, filename)
|
|
|
|
|
|
@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, connection) 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_data())
|
|
if not data["name"] or not data["item_type"]:
|
|
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()
|
|
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),
|
|
)
|
|
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()
|
|
result = serialize(row, connection)
|
|
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
|
|
|
|
|
|
@app.put("/api/items/<int:item_id>")
|
|
def update_item(item_id):
|
|
data = item_payload(request_data())
|
|
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()
|
|
new_images = files_from_request()
|
|
saved_images = []
|
|
try:
|
|
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(
|
|
"""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),
|
|
)
|
|
for filename, original_name in saved_images:
|
|
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()
|
|
result = serialize(row, connection)
|
|
return jsonify(result)
|
|
except ValueError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
except sqlite3.IntegrityError:
|
|
for filename, _ in saved_images:
|
|
delete_image(filename)
|
|
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:
|
|
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,))
|
|
if result.rowcount == 0:
|
|
return jsonify({"error": "Item not found."}), 404
|
|
for image in images:
|
|
delete_image(image["filename"])
|
|
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()
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=5000, debug=False)
|