From 8b17104a2cdb146b059e4a802ecd21c82b55649b Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Wed, 19 Aug 2026 11:45:00 +0200 Subject: [PATCH] feat: add reusable production step photo uploads --- .gitignore | 3 +- app/__init__.py | 18 ++ app/models.py | 28 ++ app/photo_service.py | 198 +++++++++++++ app/routes.py | 73 ++++- app/static/style.css | 27 ++ app/templates/admin_inventory.html | 4 + app/templates/admin_jobs.html | 5 +- app/templates/admin_photos.html | 114 ++++++++ app/templates/job_detail.html | 80 +++++- app/templates/transfer_requests.html | 12 +- app/templates/worker_kiosk.html | 35 ++- requirements.txt | 1 + tests/test_photo_upload.py | 399 +++++++++++++++++++++++++++ tests/test_smoke.py | 1 + tests/test_tc01_job_creation.py | 6 +- 16 files changed, 973 insertions(+), 31 deletions(-) create mode 100644 app/photo_service.py create mode 100644 app/templates/admin_photos.html create mode 100644 tests/test_photo_upload.py diff --git a/.gitignore b/.gitignore index bd4d0df..918b649 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 f5d1c0a..784130b 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 4c0be23..eba7e1e 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 0000000..f9e17b7 --- /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 5ea611a..b43f7f2 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 8da6a27..5e7d271 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 583040a..fac2184 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 fae8c55..3db76c9 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 0000000..14a2328 --- /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

+ +
+

+
+ +

+ +

+
+ +

+ +

+ + Allowed formats: JPEG, PNG and WebP. + Maximum upload size: 5 MB. + +

+ + +
+ +

Stored Instruction Photos

+ + {% if photos %} +
+ {% for photo in photos %} +
+

{{ photo.name }}

+ + {{ 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 b87293c..1337f13 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: +

+ + Instruction photo for step {{ step.step_number }} + {% else %} +

+ Legacy Photo Reference: + {{ step.photo_url }} +

+ {% endif %} {% endif %}

Required Parts:

@@ -129,7 +149,10 @@

Add Production Step

-
+


@@ -148,8 +171,47 @@

-
- +
+ + + + + {% for photo in photos %} + + {% endfor %} + +

+ +

+
+ + +

+ +

+ + Select an existing photo name, or enter + a new name and upload a JPEG, PNG or WebP + file. Maximum size: 5 MB. +

diff --git a/app/templates/transfer_requests.html b/app/templates/transfer_requests.html index 0e92684..83c2f24 100644 --- a/app/templates/transfer_requests.html +++ b/app/templates/transfer_requests.html @@ -16,13 +16,13 @@

Production Jobs | - - Station Inventory - + Station Inventory | - - Worker Kiosk - + Instruction Photos + | + Warehouse Requests + | + Worker Kiosk

Warehouse Transfer Requests

diff --git a/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html index c8348b9..6ea2ca1 100644 --- a/app/templates/worker_kiosk.html +++ b/app/templates/worker_kiosk.html @@ -14,9 +14,15 @@

- Production Jobs | - Station Inventory | + Production Jobs + | + Station Inventory + | + Instruction Photos + | Warehouse Requests + | + Worker Kiosk

Worker Kiosk

@@ -121,10 +127,27 @@

{% if step.photo_url %} -

- Instruction Photo: - {{ step.photo_url }} -

+ {% if step.photo_url.startswith( + 'uploads/step_photos/' + ) %} +

+ Instruction Photo: +

+ + Instruction photo for step {{ step.step_number }} + {% else %} +

+ Legacy Photo Reference: + {{ step.photo_url }} +

+ {% endif %} {% endif %}

Required Parts

diff --git a/requirements.txt b/requirements.txt index 884eae7..8e6659f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ itsdangerous==2.2.0 Jinja2==3.1.6 MarkupSafe==3.0.3 packaging==26.3 +Pillow==12.3.0 pluggy==1.6.0 Pygments==2.20.0 pytest==9.1.1 diff --git a/tests/test_photo_upload.py b/tests/test_photo_upload.py new file mode 100644 index 0000000..0630958 --- /dev/null +++ b/tests/test_photo_upload.py @@ -0,0 +1,399 @@ +from io import BytesIO +from pathlib import Path + +from PIL import Image +import pytest + +from app import create_app, db +from app.models import ( + InstructionPhoto, + Job, + JobStep, + Part, + ProductUnit, + StationInventory, +) +from app.services import activate_job + + +@pytest.fixture +def app(tmp_path): + test_app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "STEP_PHOTO_UPLOAD_FOLDER": str( + tmp_path / "step_photos" + ), + } + ) + + yield test_app + + +def create_image_file( + image_format="PNG", + filename="instruction.png", +): + image_stream = BytesIO() + + Image.new( + "RGB", + (32, 32), + color="blue", + ).save( + image_stream, + format=image_format, + ) + + image_stream.seek(0) + + return image_stream, filename + + +def create_draft_job(name="Photo Upload Test"): + job = Job( + name=name, + quantity=1, + threshold_percent=20.0, + status="Draft", + ) + + db.session.add(job) + db.session.commit() + + return job.id + + +def test_tc26_upload_photo_to_library(app): + client = app.test_client() + + response = client.post( + "/admin/photos", + data={ + "photo_name": "Frame Assembly", + "photo_file": create_image_file(), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 302 + + with app.app_context(): + photo = InstructionPhoto.query.one() + + assert photo.name == "Frame Assembly" + assert photo.original_filename == "instruction.png" + assert photo.stored_filename.endswith(".png") + assert photo.stored_filename != photo.original_filename + + stored_path = ( + Path( + app.config[ + "STEP_PHOTO_UPLOAD_FOLDER" + ] + ) + / photo.stored_filename + ) + + assert stored_path.is_file() + + page = client.get("/admin/photos") + + assert page.status_code == 200 + assert b"Frame Assembly" in page.data + assert b"instruction-photo" in page.data + + +def test_tc27_invalid_image_content_is_rejected(app): + client = app.test_client() + + response = client.post( + "/admin/photos", + data={ + "photo_name": "Invalid Image", + "photo_file": ( + BytesIO(b"this is not an image"), + "invalid.png", + ), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + with app.app_context(): + assert InstructionPhoto.query.count() == 0 + + upload_folder = Path( + app.config["STEP_PHOTO_UPLOAD_FOLDER"] + ) + + assert list(upload_folder.iterdir()) == [] + + +def test_tc28_unsupported_image_type_is_rejected(app): + client = app.test_client() + + response = client.post( + "/admin/photos", + data={ + "photo_name": "GIF Image", + "photo_file": create_image_file( + image_format="GIF", + filename="instruction.gif", + ), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + with app.app_context(): + assert InstructionPhoto.query.count() == 0 + + +def test_tc29_upload_and_link_photo_during_step_creation(app): + with app.app_context(): + job_id = create_draft_job( + "Direct Step Photo Test" + ) + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-02", + "instruction": "Install the frame", + "photo_name": "Install Frame", + "photo_file": create_image_file( + filename="frame.png", + ), + "part_name": "Frame Part", + "part_quantity": "1", + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 302 + + with app.app_context(): + photo = InstructionPhoto.query.one() + step = JobStep.query.one() + + expected_path = ( + "uploads/step_photos/" + f"{photo.stored_filename}" + ) + + assert step.photo_url == expected_path + + stored_path = ( + Path( + app.config[ + "STEP_PHOTO_UPLOAD_FOLDER" + ] + ) + / photo.stored_filename + ) + + assert stored_path.is_file() + + page = client.get(f"/admin/jobs/{job_id}") + + assert page.status_code == 200 + assert expected_path.encode() in page.data + assert b'class="instruction-photo"' in page.data + + +def test_tc30_existing_photo_can_be_linked_by_name(app): + client = app.test_client() + + upload_response = client.post( + "/admin/photos", + data={ + "photo_name": "Reusable Instruction", + "photo_file": create_image_file( + filename="reusable.png", + ), + }, + content_type="multipart/form-data", + ) + + assert upload_response.status_code == 302 + + with app.app_context(): + photo = InstructionPhoto.query.one() + stored_filename = photo.stored_filename + + job_id = create_draft_job( + "Reusable Photo Test" + ) + + step_response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-03", + "instruction": "Use stored instruction", + "photo_name": " reusable INSTRUCTION ", + "part_name": "Reusable Photo Part", + "part_quantity": "1", + }, + ) + + assert step_response.status_code == 302 + + with app.app_context(): + assert InstructionPhoto.query.count() == 1 + + step = JobStep.query.one() + + assert step.photo_url == ( + "uploads/step_photos/" + f"{stored_filename}" + ) + + +def test_tc31_unknown_photo_name_without_file_is_rejected(app): + with app.app_context(): + job_id = create_draft_job( + "Unknown Photo Test" + ) + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-01", + "instruction": "Unknown photo instruction", + "photo_name": "Photo Does Not Exist", + "part_name": "Unknown Photo Part", + "part_quantity": "1", + }, + ) + + assert response.status_code == 400 + + with app.app_context(): + assert InstructionPhoto.query.count() == 0 + assert JobStep.query.count() == 0 + assert Part.query.count() == 0 + + +def test_tc32_uploaded_file_requires_photo_name(app): + with app.app_context(): + job_id = create_draft_job( + "Missing Photo Name Test" + ) + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-01", + "instruction": "Missing photo name", + "photo_name": "", + "photo_file": create_image_file(), + "part_name": "Missing Name Part", + "part_quantity": "1", + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + with app.app_context(): + assert InstructionPhoto.query.count() == 0 + assert JobStep.query.count() == 0 + + +def test_tc33_worker_kiosk_displays_uploaded_photo(app): + with app.app_context(): + job_id = create_draft_job( + "Kiosk Photo Test" + ) + + client = app.test_client() + + step_response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-04", + "instruction": "Follow the displayed image", + "photo_name": "Kiosk Instruction", + "photo_file": create_image_file( + filename="kiosk.png", + ), + "part_name": "Kiosk Photo Part", + "part_quantity": "1", + }, + content_type="multipart/form-data", + ) + + assert step_response.status_code == 302 + + with app.app_context(): + job = db.session.get(Job, job_id) + step = JobStep.query.one() + part = Part.query.one() + + inventory = StationInventory.query.filter_by( + station_id="ST-04", + part_id=part.id, + ).one() + + inventory.quantity = 1 + db.session.commit() + + assert activate_job(job) is True + + barcode = job.barcode + photo_path = step.photo_url + + assert ProductUnit.query.count() == 1 + + kiosk_response = client.post( + "/kiosk", + data={ + "station_id": "ST-04", + "barcode": barcode, + "action": "scan", + }, + ) + + assert kiosk_response.status_code == 200 + assert b"Follow the displayed image" in ( + kiosk_response.data + ) + assert photo_path.encode() in kiosk_response.data + assert b'class="instruction-photo"' in ( + kiosk_response.data + ) + + +def test_tc34_upload_larger_than_limit_is_rejected(app): + client = app.test_client() + + oversized_file = BytesIO( + b"x" * (5 * 1024 * 1024 + 1024) + ) + + response = client.post( + "/admin/photos", + data={ + "photo_name": "Oversized Photo", + "photo_file": ( + oversized_file, + "oversized.png", + ), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 413 + + with app.app_context(): + assert InstructionPhoto.query.count() == 0 \ No newline at end of file diff --git a/tests/test_smoke.py b/tests/test_smoke.py index ab63aef..c07ef8d 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -21,6 +21,7 @@ def app(): [ "/admin/jobs", "/admin/inventory", + "/admin/photos", "/warehouse/requests", "/kiosk", "/static/style.css", diff --git a/tests/test_tc01_job_creation.py b/tests/test_tc01_job_creation.py index 0d21363..261ecfd 100644 --- a/tests/test_tc01_job_creation.py +++ b/tests/test_tc01_job_creation.py @@ -38,7 +38,7 @@ def test_tc01_create_production_job(): data={ "station_id": "ST-01", "instruction": "Attach Part A", - "photo_url": "photo-step-1.jpg", + "photo_name": "", "part_name": "Part A", "part_quantity": "1", }, @@ -53,7 +53,7 @@ def test_tc01_create_production_job(): assert "Draft" in page assert "Station 1" in page assert "Attach Part A" in page - assert "photo-step-1.jpg" in page + assert "Instruction Photo Name" in page assert "Part A x 1" in page with app.app_context(): @@ -65,6 +65,6 @@ def test_tc01_create_production_job(): assert step.station_id == "ST-01" assert step.instruction == "Attach Part A" - assert step.photo_url == "photo-step-1.jpg" + assert step.photo_url is None assert step.parts[0].part.name == "Part A" assert step.parts[0].quantity == 1 \ No newline at end of file -- GitLab