From e1cb99f2d17a3df0be6d592cd656c66bf1f209a7 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 11 Jul 2025 09:21:31 -0500 Subject: [PATCH] Implement PhotoAttachable interface in Inventory, User, and WorkLog models; add photo upload API endpoint with file handling and attachment logic. --- inventory/models/inventory.py | 3 +- inventory/models/photo.py | 12 +++++++- inventory/models/users.py | 3 +- inventory/models/work_log.py | 3 +- inventory/routes/helpers.py | 22 +++++++++++++ inventory/routes/photos.py | 58 +++++++++++++++++++++++++++++++++++ pyproject.toml | 7 +++-- 7 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 inventory/routes/photos.py diff --git a/inventory/models/inventory.py b/inventory/models/inventory.py index cfb0226..0b9803e 100644 --- a/inventory/models/inventory.py +++ b/inventory/models/inventory.py @@ -11,8 +11,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship import datetime from . import db +from .photo import PhotoAttachable -class Inventory(db.Model): +class Inventory(db.Model, PhotoAttachable): __tablename__ = 'inventory' __table_args__ = ( Index('Inventory$Barcode', 'barcode'), diff --git a/inventory/models/photo.py b/inventory/models/photo.py index 300f838..30c9fb0 100644 --- a/inventory/models/photo.py +++ b/inventory/models/photo.py @@ -10,6 +10,7 @@ from sqlalchemy import Integer, Unicode, DateTime, func from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db +from .photo_links import worklog_photos class Photo(db.Model): __tablename__ = 'photos' @@ -21,7 +22,16 @@ class Photo(db.Model): inventory: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='photo') user: Mapped[Optional['User']] = relationship('User', back_populates='photo') - worklogs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='photos') + worklogs: Mapped[List['WorkLog']] = relationship('WorkLog', secondary=worklog_photos, back_populates='photos') + + def __init__(self, filename: str, timestamp: Optional[datetime.datetime] = None, caption: Optional[str] = None): + self.filename = filename + self.caption = caption or "" + self.timestamp = timestamp def __repr__(self): return f"" + +class PhotoAttachable: + def attach_photo(self, photo: 'Photo') -> None: + raise NotImplementedError("This model doesn't know how to attach photos.") diff --git a/inventory/models/users.py b/inventory/models/users.py index dd8003a..64f95fc 100644 --- a/inventory/models/users.py +++ b/inventory/models/users.py @@ -9,8 +9,9 @@ from sqlalchemy import Boolean, ForeignKey, Identity, Integer, Unicode, text from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db +from .photo import PhotoAttachable -class User(db.Model): +class User(db.Model, PhotoAttachable): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True) diff --git a/inventory/models/work_log.py b/inventory/models/work_log.py index 1bd7ceb..8df655c 100644 --- a/inventory/models/work_log.py +++ b/inventory/models/work_log.py @@ -10,10 +10,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship import datetime from . import db +from .photo import PhotoAttachable from .photo_links import worklog_photos from .work_note import WorkNote -class WorkLog(db.Model): +class WorkLog(db.Model, PhotoAttachable): __tablename__ = 'work_log' id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True) diff --git a/inventory/routes/helpers.py b/inventory/routes/helpers.py index 586b975..b4cbf7c 100644 --- a/inventory/routes/helpers.py +++ b/inventory/routes/helpers.py @@ -1,7 +1,12 @@ +import hashlib + from flask import url_for +from werkzeug.utils import secure_filename from ..models import Inventory +from ..models.photo import PhotoAttachable + inventory_headers = { "Date Entered": lambda i: {"text": i.timestamp.strftime("%Y-%m-%d") if i.timestamp else None}, "Identifier": lambda i: {"text": i.identifier}, @@ -63,3 +68,20 @@ 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) + + original_name = secure_filename(file_storage.filename) + return f"{model_name}/{sha}_{original_name}" + +def get_photo_attachable_class_by_name(name: str): + for cls in PhotoAttachable.__subclasses__(): + if cls.__tablename__ == name: + return cls + return None diff --git a/inventory/routes/photos.py b/inventory/routes/photos.py new file mode 100644 index 0000000..93c06d1 --- /dev/null +++ b/inventory/routes/photos.py @@ -0,0 +1,58 @@ +import os + +import datetime +from flask import Blueprint, current_app, request, jsonify + +from .helpers import generate_hashed_filename, get_photo_attachable_class_by_name +from .. import db +from ..models import Photo + +photo_bp = Blueprint("photo_api", __name__) + +def save_photo(file, model: str) -> str: + # Generate unique path + rel_path = generate_hashed_filename(file, model) + + # Absolute path to save + abs_path = os.path.join(current_app.static_folder, rel_path) + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + + file.save(abs_path) + + return rel_path + +@photo_bp.route("/api/photos", methods=["POST"]) +def upload_photo(): + file = request.files.get("file") + model = request.form.get("model") + model_id = request.form.get("model_id") + caption = request.form.get("caption", "") + + if not file or not model or not model_id: + return jsonify({"success": False, "error": "Missing file, model, or model_id"}), 400 + + ModelClass = get_photo_attachable_class_by_name(model) + if not ModelClass: + return jsonify({"success": False, "error": f"Model '{model}' does not support photo attachments."}), 400 + + try: + model_id = int(model_id) + except ValueError: + return jsonify({"success": False, "error": "model_id must be an integer"}), 400 + + # Save file + rel_path = save_photo(file, model) + + # Create Photo row + photo = Photo(filename=rel_path, caption=caption) + db.session.add(photo) + + # Attach photo to model + target = db.session.get(ModelClass, model_id) + if not target: + return jsonify({"success": False, "error": f"No {model} found with ID {model_id}"}), 404 + + target.attach_photo(photo) + + db.session.commit() + return jsonify({"success": True, "id": photo.id}), 201 diff --git a/pyproject.toml b/pyproject.toml index 089df84..3455bb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,14 @@ version = "0.1.0" description = "A Flask app for tracking inventory." requires-python = ">=3.9" dependencies = [ - "python-dotenv", + "alembic", + "beautifulsoup4", "flask", "flask_sqlalchemy", "pandas", "pyodbc", - "beautifulsoup4", - "alembic" + "python-dotenv", + "Werkzeug" ] [build-system]