diff --git a/.gitignore b/.gitignore
index bd4d0dfc8915f0ef1c7c6a55e567dc37afd866dc..918b649809683aaae4b6ff14065b16d5ed4c292c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@ __pycache__/
*.pyc
instance/
.pytest_cache/
-.vscode/
\ No newline at end of file
+.vscode/
+app/static/uploads/
\ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
index f5d1c0aed19865919095c73b0af7ccd76c2706c6..784130b854e364484efe0c4f7534466b8f140f5c 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1,3 +1,5 @@
+from pathlib import Path
+
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
@@ -10,9 +12,25 @@ def create_app(test_config=None):
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///mes.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
+
+ app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
+
+ app.config["STEP_PHOTO_UPLOAD_FOLDER"] = str(
+ Path(app.static_folder)
+ / "uploads"
+ / "step_photos"
+ )
+
if test_config:
app.config.update(test_config)
+ Path(
+ app.config["STEP_PHOTO_UPLOAD_FOLDER"]
+ ).mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+
db.init_app(app)
from app.models import Station
diff --git a/app/models.py b/app/models.py
index 4c0be23c4b86ff6faa71083de5e0c721fec50dfc..eba7e1e7ecbc221bf12a35e1f42039dab294f1e9 100644
--- a/app/models.py
+++ b/app/models.py
@@ -26,6 +26,34 @@ class Part(db.Model):
return f""
+class InstructionPhoto(db.Model):
+ __tablename__ = "instruction_photos"
+
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(
+ db.String(100),
+ unique=True,
+ nullable=False,
+ )
+ stored_filename = db.Column(
+ db.String(100),
+ unique=True,
+ nullable=False,
+ )
+ original_filename = db.Column(
+ db.String(255),
+ nullable=False,
+ )
+ uploaded_at = db.Column(
+ db.DateTime,
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+
+ def __repr__(self):
+ return f""
+
+
class Job(db.Model):
__tablename__ = "jobs"
diff --git a/app/photo_service.py b/app/photo_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..f9e17b7cdec81bd42269d8d6c95a112c8cc7a50b
--- /dev/null
+++ b/app/photo_service.py
@@ -0,0 +1,198 @@
+from pathlib import Path
+import uuid
+
+from flask import current_app
+from PIL import Image, UnidentifiedImageError
+from werkzeug.utils import secure_filename
+
+from app import db
+from app.models import InstructionPhoto
+
+
+ALLOWED_IMAGE_FORMATS = {
+ "JPEG": {".jpg", ".jpeg"},
+ "PNG": {".png"},
+ "WEBP": {".webp"},
+}
+
+
+def normalize_photo_name(name):
+ return " ".join((name or "").split())
+
+
+def find_photo_by_name(name):
+ normalized_name = normalize_photo_name(name)
+
+ if not normalized_name:
+ return None
+
+ return InstructionPhoto.query.filter(
+ db.func.lower(InstructionPhoto.name)
+ == normalized_name.lower()
+ ).first()
+
+
+def validate_photo_upload(photo_file):
+ if photo_file is None or not photo_file.filename:
+ raise ValueError("Select an image file")
+
+ original_filename = secure_filename(
+ photo_file.filename
+ )
+
+ if not original_filename:
+ raise ValueError("Image filename is invalid")
+
+ extension = Path(
+ original_filename
+ ).suffix.lower()
+
+ allowed_extensions = {
+ extension
+ for extensions in ALLOWED_IMAGE_FORMATS.values()
+ for extension in extensions
+ }
+
+ if extension not in allowed_extensions:
+ raise ValueError(
+ "Image must be a JPEG, PNG, or WebP file"
+ )
+
+ try:
+ photo_file.stream.seek(0)
+
+ with Image.open(photo_file.stream) as image:
+ detected_format = image.format
+ image.verify()
+
+ except (UnidentifiedImageError, OSError, ValueError):
+ raise ValueError(
+ "Uploaded file is not a valid image"
+ ) from None
+
+ finally:
+ photo_file.stream.seek(0)
+
+ valid_extensions = ALLOWED_IMAGE_FORMATS.get(
+ detected_format,
+ )
+
+ if (
+ valid_extensions is None
+ or extension not in valid_extensions
+ ):
+ raise ValueError(
+ "Image content does not match its file extension"
+ )
+
+ return original_filename, extension
+
+
+def create_instruction_photo(name, photo_file):
+ normalized_name = normalize_photo_name(name)
+
+ if not normalized_name:
+ raise ValueError("Photo name is required")
+
+ if len(normalized_name) > 100:
+ raise ValueError(
+ "Photo name cannot exceed 100 characters"
+ )
+
+ if find_photo_by_name(normalized_name) is not None:
+ raise ValueError(
+ "A photo with this name already exists"
+ )
+
+ original_filename, extension = (
+ validate_photo_upload(photo_file)
+ )
+
+ stored_filename = (
+ f"{uuid.uuid4().hex}{extension}"
+ )
+
+ upload_folder = Path(
+ current_app.config[
+ "STEP_PHOTO_UPLOAD_FOLDER"
+ ]
+ )
+
+ upload_folder.mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+
+ destination = upload_folder / stored_filename
+
+ photo_file.save(destination)
+
+ photo = InstructionPhoto(
+ name=normalized_name,
+ stored_filename=stored_filename,
+ original_filename=original_filename,
+ )
+
+ db.session.add(photo)
+
+ try:
+ db.session.commit()
+ except Exception:
+ db.session.rollback()
+ destination.unlink(missing_ok=True)
+ raise
+
+ return photo
+
+
+def resolve_instruction_photo(
+ photo_name,
+ photo_file,
+):
+ normalized_name = normalize_photo_name(photo_name)
+
+ has_uploaded_file = bool(
+ photo_file is not None
+ and photo_file.filename
+ )
+
+ if not normalized_name and not has_uploaded_file:
+ return None
+
+ if has_uploaded_file and not normalized_name:
+ raise ValueError(
+ "Enter a photo name for the uploaded image"
+ )
+
+ if normalized_name and not has_uploaded_file:
+ photo = find_photo_by_name(normalized_name)
+
+ if photo is None:
+ raise ValueError(
+ "Select an existing photo or upload "
+ "a file for the new photo name"
+ )
+
+ return photo
+
+ existing_photo = find_photo_by_name(
+ normalized_name
+ )
+
+ if existing_photo is not None:
+ raise ValueError(
+ "This photo name already exists. "
+ "Select it without uploading another file"
+ )
+
+ return create_instruction_photo(
+ normalized_name,
+ photo_file,
+ )
+
+
+def instruction_photo_static_path(photo):
+ return (
+ "uploads/step_photos/"
+ f"{photo.stored_filename}"
+ )
\ No newline at end of file
diff --git a/app/routes.py b/app/routes.py
index 5ea611a3a70f22b47d2c2fbb33e531376250d2ec..b43f7f2cb012ffc3b22f785ccec27683b71a3763 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -2,6 +2,7 @@ from flask import Blueprint, abort, redirect, render_template, request, url_for
from app import db
from app.models import (
+ InstructionPhoto,
Job,
JobStep,
Part,
@@ -11,6 +12,11 @@ from app.models import (
StepPart,
TransferRequest,
)
+from app.photo_service import (
+ create_instruction_photo,
+ instruction_photo_static_path,
+ resolve_instruction_photo,
+)
from app.services import (
activate_job,
complete_current_step,
@@ -119,6 +125,10 @@ def job_detail(job_id):
stations = Station.query.order_by(Station.id).all()
parts = Part.query.order_by(Part.name).all()
+ photos = InstructionPhoto.query.order_by(
+ InstructionPhoto.name
+ ).all()
+
if request.method == "POST":
if job.status != "Draft":
abort(
@@ -146,6 +156,15 @@ def job_detail(job_id):
"",
).strip()
+ photo_name = request.form.get(
+ "photo_name",
+ "",
+ ).strip()
+
+ photo_file = request.files.get(
+ "photo_file"
+ )
+
try:
part_quantity = int(
request.form.get("part_quantity", "")
@@ -179,6 +198,14 @@ def job_detail(job_id):
),
)
+ try:
+ photo = resolve_instruction_photo(
+ photo_name,
+ photo_file,
+ )
+ except ValueError as error:
+ abort(400, description=str(error))
+
try:
part = get_or_create_part(part_name)
except ValueError as error:
@@ -189,6 +216,12 @@ def job_detail(job_id):
part.id,
)
+ photo_path = (
+ instruction_photo_static_path(photo)
+ if photo is not None
+ else None
+ )
+
next_step_number = max(
(step.step_number for step in job.steps),
default=0,
@@ -199,10 +232,7 @@ def job_detail(job_id):
step_number=next_step_number,
instruction=instruction,
station=station,
- photo_url=request.form.get(
- "photo_url",
- "",
- ).strip() or None,
+ photo_url=photo_path,
)
db.session.add(step)
@@ -233,10 +263,10 @@ def job_detail(job_id):
job=job,
stations=stations,
parts=parts,
+ photos=photos,
product_units=product_units,
transfer_requests=transfer_requests,
)
-
@bp.route(
"/admin/jobs//activate",
methods=["POST"],
@@ -264,6 +294,39 @@ def activate_job_route(job_id):
return redirect(
url_for("main.job_detail", job_id=job.id)
)
+@bp.route("/admin/photos", methods=["GET", "POST"])
+def admin_photos():
+ if request.method == "POST":
+ photo_name = request.form.get(
+ "photo_name",
+ "",
+ ).strip()
+
+ photo_file = request.files.get(
+ "photo_file"
+ )
+
+ try:
+ create_instruction_photo(
+ photo_name,
+ photo_file,
+ )
+ except ValueError as error:
+ abort(400, description=str(error))
+
+ return redirect(
+ url_for("main.admin_photos")
+ )
+
+ photos = InstructionPhoto.query.order_by(
+ InstructionPhoto.name
+ ).all()
+
+ return render_template(
+ "admin_photos.html",
+ photos=photos,
+ )
+
@bp.route("/admin/inventory", methods=["GET", "POST"])
diff --git a/app/static/style.css b/app/static/style.css
index 8da6a272eb352807d7631ae4f8aba44f1e2f2bf7..5e7d271253cb0ee958d98eb8d94d36b8c178bfab 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -168,4 +168,31 @@ hr {
display: inline-block;
margin: 4px 0;
}
+}
+.photo-library {
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ minmax(240px, 1fr)
+ );
+ gap: 18px;
+ margin: 16px 0;
+}
+
+.photo-card {
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.instruction-photo {
+ display: block;
+ width: 100%;
+ max-width: 520px;
+ max-height: 360px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ object-fit: contain;
+ background: #ffffff;
}
\ No newline at end of file
diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html
index 583040a47a44fd9bae5ab0532b74436024aac3bb..fac218471c02d0c000f363c25c6c6ba38cdeaf91 100644
--- a/app/templates/admin_inventory.html
+++ b/app/templates/admin_inventory.html
@@ -18,6 +18,10 @@
Production Jobs
|
+
+ Instruction Photos
+
+ |
Warehouse Requests
diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html
index fae8c5589f53d391b302a8c75b9bddb6821e2bb1..3db76c9183858f084cadde816b63ce1f0b635e5d 100644
--- a/app/templates/admin_jobs.html
+++ b/app/templates/admin_jobs.html
@@ -18,7 +18,10 @@
|
Station Inventory
|
- Warehouse Requests |
+ Instruction Photos
+ |
+ Warehouse Requests
+ |
Worker Kiosk
diff --git a/app/templates/admin_photos.html b/app/templates/admin_photos.html
new file mode 100644
index 0000000000000000000000000000000000000000..14a232896f3cad3b8ca7804cbcfdfae7a03be09c
--- /dev/null
+++ b/app/templates/admin_photos.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+ MES - Instruction Photos
+
+
+
+
+
+ Production Jobs
+
+ |
+
+ Station Inventory
+
+ |
+
+ Instruction Photos
+
+ |
+
+ Warehouse Requests
+
+ |
+
+ Worker Kiosk
+
+
+
+ Instruction Photos
+
+ Upload Instruction Photo
+
+
+
+ Stored Instruction Photos
+
+ {% if photos %}
+
+ {% for photo in photos %}
+
+ {{ photo.name }}
+
+
+
+
+ Original file:
+ {{ photo.original_filename }}
+
+
+
+ Uploaded:
+ {{ photo.uploaded_at }}
+
+
+ {% endfor %}
+
+ {% else %}
+ No instruction photos uploaded yet.
+ {% endif %}
+
+
\ No newline at end of file
diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html
index b87293c623b984167d05b5453167a2c005256c08..1337f13db0e5baa8e3c50e51cd5d5d85b02569d7 100644
--- a/app/templates/job_detail.html
+++ b/app/templates/job_detail.html
@@ -18,8 +18,11 @@
|
Station Inventory
|
- Warehouse Requests |
-Worker Kiosk
+ Instruction Photos
+ |
+ Warehouse Requests
+ |
+ Worker Kiosk
{{ job.name }}
@@ -103,10 +106,27 @@
{% if step.photo_url %}
-
- Photo:
- {{ step.photo_url }}
-
+ {% if step.photo_url.startswith(
+ 'uploads/step_photos/'
+ ) %}
+
+ Instruction Photo:
+
+
+
+ {% else %}
+
+ Legacy Photo Reference:
+ {{ step.photo_url }}
+
+ {% endif %}
{% endif %}
Required Parts:
@@ -129,7 +149,10 @@
Add Production Step
-