diff --git a/inventory/__init__.py b/inventory/__init__.py index c83b6cd..9e61f97 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -1,5 +1,6 @@ -from flask import Flask +from flask import Flask, current_app from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.engine.url import make_url import logging import os @@ -13,6 +14,14 @@ if not logger.handlers: handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) logger.addHandler(handler) +def is_in_memory_sqlite(): + uri = current_app.config.get("SQLALCHEMY_DATABASE_URI") + if not uri: + return False + url = make_url(uri) + print(url, url.database) + return url.get_backend_name() == "sqlite" and url.database == ":memory:" + def create_app(): from config import Config app = Flask(__name__) @@ -23,7 +32,8 @@ def create_app(): with app.app_context(): from . import models - # db.create_all() + if is_in_memory_sqlite(): + db.create_all() from .routes import main from .routes.images import image_bp diff --git a/inventory/models/image_links.py b/inventory/models/image_links.py index a949979..04c4d46 100644 --- a/inventory/models/image_links.py +++ b/inventory/models/image_links.py @@ -3,5 +3,5 @@ from .. import db worklog_images = db.Table( 'worklog_images', db.Column('worklog_id', db.Integer, db.ForeignKey('work_log.id'), primary_key=True), - db.Column('image_id', db.Integer, db.ForeignKey('images.id'), primary_key=True), + db.Column('image_id', db.Integer, db.ForeignKey('images.id', ondelete='CASCADE'), primary_key=True), ) \ No newline at end of file diff --git a/inventory/models/inventory.py b/inventory/models/inventory.py index 6eb0b54..b307842 100644 --- a/inventory/models/inventory.py +++ b/inventory/models/inventory.py @@ -1,6 +1,6 @@ from typing import Any, List, Optional, TYPE_CHECKING -from inventory.models.image import Image +from .image import Image if TYPE_CHECKING: from .brands import Brand from .items import Item @@ -34,14 +34,14 @@ class Inventory(db.Model, ImageAttachable): location_id: Mapped[Optional[str]] = mapped_column(ForeignKey("rooms.id")) barcode: Mapped[Optional[str]] = mapped_column(Unicode(255)) shared: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))')) - image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id'), nullable=True) + image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id', ondelete='SET NULL'), nullable=True) location: Mapped[Optional['Room']] = relationship('Room', back_populates='inventory') owner = relationship('User', back_populates='inventory') brand: Mapped[Optional['Brand']] = relationship('Brand', back_populates='inventory') item: Mapped['Item'] = relationship('Item', back_populates='inventory') work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='work_item') - image: Mapped[Optional['Image']] = relationship('Image', back_populates='inventory') + image: Mapped[Optional['Image']] = relationship('Image', back_populates='inventory', passive_deletes=True) def __init__(self, timestamp: datetime.datetime, condition: str, type_id: Optional[int] = None, name: Optional[str] = None, serial: Optional[str] = None, diff --git a/inventory/models/users.py b/inventory/models/users.py index e850139..1e8c5f4 100644 --- a/inventory/models/users.py +++ b/inventory/models/users.py @@ -21,14 +21,14 @@ class User(db.Model, ImageAttachable): first_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True) location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("rooms.id"), nullable=True) supervisor_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id")) - image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id'), nullable=True) + image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id', ondelete='SET NULL'), nullable=True) supervisor: Mapped[Optional['User']] = relationship('User', remote_side='User.id', back_populates='subordinates') subordinates: Mapped[List['User']] = relationship('User', back_populates='supervisor') work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='contact') location: Mapped[Optional['Room']] = relationship('Room', back_populates='users') inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='owner') - image: Mapped[Optional['Image']] = relationship('Image', back_populates='user') + image: Mapped[Optional['Image']] = relationship('Image', back_populates='user', passive_deletes=True) @property def full_name(self) -> str: diff --git a/inventory/models/work_log.py b/inventory/models/work_log.py index ee13d28..46ef685 100644 --- a/inventory/models/work_log.py +++ b/inventory/models/work_log.py @@ -35,7 +35,7 @@ class WorkLog(db.Model, ImageAttachable): cascade='all, delete-orphan', order_by='WorkNote.timestamp' ) - images: Mapped[List['Image']] = relationship('Image', secondary=worklog_images, back_populates='worklogs') + images: Mapped[List['Image']] = relationship('Image', secondary=worklog_images, back_populates='worklogs', passive_deletes=True) def __init__( self, diff --git a/inventory/routes/helpers.py b/inventory/routes/helpers.py index 1fe5384..372e283 100644 --- a/inventory/routes/helpers.py +++ b/inventory/routes/helpers.py @@ -1,4 +1,5 @@ import hashlib +import os from flask import url_for from werkzeug.utils import secure_filename @@ -69,16 +70,14 @@ worklog_headers = { def link(text, endpoint, **values): return {"text": text, "url": url_for(endpoint, **values)} -def generate_hashed_filename(file_storage, model_name: str) -> str: - # Hash file contents - file_bytes = file_storage.read() - sha = hashlib.sha256(file_bytes).hexdigest() - # Reset the stream so Flask can read it again later - file_storage.stream.seek(0) +def generate_hashed_filename(file) -> str: + content = file.read() + file.seek(0) # Reset after reading - original_name = secure_filename(file_storage.filename) - return f"{model_name}/{sha}_{original_name}" + hash = hashlib.sha256(content).hexdigest() + ext = os.path.splitext(file.filename)[1] + return f"{hash}_{file.filename}" def get_image_attachable_class_by_name(name: str): for cls in ImageAttachable.__subclasses__(): diff --git a/inventory/routes/images.py b/inventory/routes/images.py index 33ec711..98da13b 100644 --- a/inventory/routes/images.py +++ b/inventory/routes/images.py @@ -12,19 +12,11 @@ image_bp = Blueprint("image_api", __name__) def save_image(file, model: str) -> str: assert current_app.static_folder - hashed_name = generate_hashed_filename(file, model) - rel_path = posixpath.join("uploads", "images", hashed_name) - abs_path = os.path.join(current_app.static_folder, "uploads", "images", rel_path) + filename = generate_hashed_filename(file) + rel_path = posixpath.join("uploads", "images", model, filename) + abs_path = os.path.join(current_app.static_folder, rel_path) - dir_path = os.path.dirname(abs_path) - if not os.path.exists(dir_path): - os.makedirs(dir_path, exist_ok=True) - - print("Saving file:") - print(" - Model:", model) - print(" - Relative path:", rel_path) - print(" - Absolute path:", abs_path) - print(" - Directory exists?", os.path.exists(dir_path)) + os.makedirs(os.path.dirname(abs_path), exist_ok=True) file.save(abs_path) return rel_path @@ -86,3 +78,11 @@ def delete_image(image_id): image = db.session.get(Image, image_id) if not image: return jsonify({"success": False, "error": "Image not found"}) + + abs_path = os.path.join(current_app.static_folder, image.filename.replace("\\", "/")) + if os.path.exists(abs_path): + os.remove(abs_path) + + db.session.delete(image) + db.session.commit() + return jsonify({"success": True}) diff --git a/inventory/static/js/widget.js b/inventory/static/js/widget.js index be8eea6..c787927 100644 --- a/inventory/static/js/widget.js +++ b/inventory/static/js/widget.js @@ -84,8 +84,26 @@ const ImageWidget = (() => { }); } + function deleteImage(inventoryId, imageId) { + if (!confirm("Are you sure you want to delete this image?")) return; + + fetch(`/api/images/${imageId}`, { + method: "DELETE" + }).then(response => response.json()).then(data => { + if (data.success) { + renderToast({ message: "Image deleted.", type: "success" }); + location.reload(); // Update view + } else { + renderToast({ message: `Failed to delete: ${data.error}`, type: "danger" }); + } + }).catch(err => { + renderToast({ message: `Error deleting image: ${err}`, type: "danger" }); + }); + } + return { - submitImageUpload + submitImageUpload, + deleteImage } })(); diff --git a/inventory/templates/fragments/_image_fragment.html b/inventory/templates/fragments/_image_fragment.html index 4ada142..25bc6f0 100644 --- a/inventory/templates/fragments/_image_fragment.html +++ b/inventory/templates/fragments/_image_fragment.html @@ -12,6 +12,16 @@