From 4bd9745c383c2cf1753c9981ad003dc1125a21ff Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Tue, 11 Aug 2026 17:54:27 +0200 Subject: [PATCH 1/4] fix: validate production job input --- app/routes.py | 93 +++++++++++++++++++++++++---------- app/templates/admin_jobs.html | 23 +++++++-- tests/test_validation.py | 72 +++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 tests/test_validation.py diff --git a/app/routes.py b/app/routes.py index fa63baf..7930bdc 100644 --- a/app/routes.py +++ b/app/routes.py @@ -30,40 +30,83 @@ def index(): @bp.route("/admin/jobs", methods=["GET", "POST"]) def admin_jobs(): + error = None + + form_data = { + "name": "", + "quantity": "", + "threshold_percent": "", + } + if request.method == "POST": - name = request.form["name"].strip() - quantity = int(request.form["quantity"]) + form_data = { + "name": request.form.get("name", "").strip(), + "quantity": request.form.get( + "quantity", + "", + ).strip(), + "threshold_percent": request.form.get( + "threshold_percent", + "", + ).strip(), + } - threshold_value = request.form.get( - "threshold_percent", - "", - ).strip() + try: + quantity = int(form_data["quantity"]) + except (TypeError, ValueError): + quantity = None - threshold_percent = ( - float(threshold_value) - if threshold_value - else 20.0 - ) + threshold_value = form_data["threshold_percent"] - job = Job( - name=name, - quantity=quantity, - threshold_percent=threshold_percent, - status="Draft", - ) + if threshold_value: + try: + threshold_percent = float(threshold_value) + except (TypeError, ValueError): + threshold_percent = None + else: + threshold_percent = 20.0 + + if not form_data["name"]: + error = "Job name is required." + elif quantity is None or quantity < 1: + error = "Production quantity must be at least 1." + elif ( + threshold_percent is None + or threshold_percent < 0 + or threshold_percent > 100 + ): + error = ( + "Low-stock threshold must be between " + "0 and 100 percent." + ) - db.session.add(job) - db.session.commit() + if error is None: + job = Job( + name=form_data["name"], + quantity=quantity, + threshold_percent=threshold_percent, + status="Draft", + ) - return redirect( - url_for("main.job_detail", job_id=job.id) - ) + db.session.add(job) + db.session.commit() + + return redirect( + url_for("main.job_detail", job_id=job.id) + ) jobs = Job.query.order_by(Job.id.desc()).all() - return render_template( - "admin_jobs.html", - jobs=jobs, + status_code = 400 if error else 200 + + return ( + render_template( + "admin_jobs.html", + jobs=jobs, + error=error, + form_data=form_data, + ), + status_code, ) diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html index 2ee97ff..df58043 100644 --- a/app/templates/admin_jobs.html +++ b/app/templates/admin_jobs.html @@ -17,16 +17,33 @@

Production Jobs

Create Production Job

+ {% if error %} +

+ Cannot create job: + {{ error }} +

+ {% endif %}


- +


- +

@@ -37,13 +54,13 @@ min="0" max="100" step="0.1" + value="{{ form_data.threshold_percent }}" placeholder="Default: 20" >

-

Existing Jobs

{% if jobs %} diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..ff8fc49 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,72 @@ +import pytest + +from app import create_app, db +from app.models import Job + + +@pytest.fixture +def app(): + test_app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + + yield test_app + + +@pytest.mark.parametrize( + ("form_data", "expected_message"), + [ + ( + { + "name": "", + "quantity": "1", + "threshold_percent": "20", + }, + "Job name is required.", + ), + ( + { + "name": "Invalid Quantity", + "quantity": "0", + "threshold_percent": "20", + }, + "Production quantity must be at least 1.", + ), + ( + { + "name": "Non-numeric Quantity", + "quantity": "not-a-number", + "threshold_percent": "20", + }, + "Production quantity must be at least 1.", + ), + ( + { + "name": "Invalid Threshold", + "quantity": "1", + "threshold_percent": "101", + }, + "Low-stock threshold must be between 0 and 100 percent.", + ), + ], +) +def test_invalid_job_input_is_rejected( + app, + form_data, + expected_message, +): + client = app.test_client() + + response = client.post( + "/admin/jobs", + data=form_data, + ) + + assert response.status_code == 400 + assert expected_message.encode() in response.data + + with app.app_context(): + assert Job.query.count() == 0 \ No newline at end of file -- GitLab From 9c38b42c35f922b7660e4afe2ddb50ed51047ca4 Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Tue, 11 Aug 2026 18:11:04 +0200 Subject: [PATCH 2/4] fix: validate production workflow operations --- app/routes.py | 161 ++++++++++++++++++++++++++++++----- app/services.py | 31 ++++++- tests/test_validation.py | 177 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 345 insertions(+), 24 deletions(-) diff --git a/app/routes.py b/app/routes.py index 7930bdc..90aff62 100644 --- a/app/routes.py +++ b/app/routes.py @@ -116,14 +116,64 @@ def job_detail(job_id): stations = Station.query.order_by(Station.id).all() if request.method == "POST": - station_id = request.form["station_id"] + if job.status != "Draft": + abort( + 409, + description=( + "Production configuration is locked " + "after activation." + ), + ) + + station_id = request.form.get( + "station_id", + "", + ).strip() + station = db.session.get(Station, station_id) + part_name = request.form.get( + "part_name", + "", + ).strip() + + instruction = request.form.get( + "instruction", + "", + ).strip() + + try: + part_quantity = int( + request.form.get("part_quantity", "") + ) + except (TypeError, ValueError): + part_quantity = None + if station is None: - abort(400) + abort( + 400, + description="A valid station is required.", + ) - part_name = request.form["part_name"].strip() - part_quantity = int(request.form["part_quantity"]) + if not instruction: + abort( + 400, + description="Production instruction is required.", + ) + + if not part_name: + abort( + 400, + description="Required part name is required.", + ) + + if part_quantity is None or part_quantity < 1: + abort( + 400, + description=( + "Required part quantity must be at least 1." + ), + ) part = Part.query.filter_by(name=part_name).first() @@ -139,7 +189,7 @@ def job_detail(job_id): step = JobStep( job=job, step_number=next_step_number, - instruction=request.form["instruction"].strip(), + instruction=instruction, station=station, photo_url=request.form.get( "photo_url", @@ -186,6 +236,21 @@ def job_detail(job_id): def activate_job_route(job_id): job = db.get_or_404(Job, job_id) + if job.status != "Draft": + abort( + 409, + description="Only draft jobs can be activated.", + ) + + if not job.steps: + abort( + 400, + description=( + "Add at least one production step " + "before activation." + ), + ) + activate_job(job) return redirect( @@ -196,15 +261,58 @@ def activate_job_route(job_id): @bp.route("/admin/inventory", methods=["GET", "POST"]) def admin_inventory(): if request.method == "POST": - station_id = request.form["station_id"] - part_id = int(request.form["part_id"]) - quantity = int(request.form["quantity"]) - - set_station_inventory( - station_id, - part_id, - quantity, - ) + station_id = request.form.get( + "station_id", + "", + ).strip() + + try: + part_id = int( + request.form.get("part_id", "") + ) + quantity = int( + request.form.get("quantity", "") + ) + except (TypeError, ValueError): + abort( + 400, + description=( + "Part and quantity must be valid numbers." + ), + ) + + station = db.session.get(Station, station_id) + part = db.session.get(Part, part_id) + + if station is None: + abort( + 400, + description="A valid station is required.", + ) + + if part is None: + abort( + 400, + description="A valid part is required.", + ) + + if quantity < 0: + abort( + 400, + description=( + "Station inventory quantity " + "cannot be negative." + ), + ) + + try: + set_station_inventory( + station_id, + part_id, + quantity, + ) + except ValueError as error: + abort(400, description=str(error)) return redirect( url_for("main.admin_inventory") @@ -243,14 +351,25 @@ def warehouse_requests(): methods=["POST"], ) def fulfill_request_route(request_id): - transferred_quantity = int( - request.form["transferred_quantity"] - ) + try: + transferred_quantity = int( + request.form.get("transferred_quantity", "") + ) + except (TypeError, ValueError): + abort( + 400, + description=( + "Transferred quantity must be a valid number." + ), + ) - fulfill_transfer_request( - request_id, - transferred_quantity, - ) + try: + fulfill_transfer_request( + request_id, + transferred_quantity, + ) + except ValueError as error: + abort(400, description=str(error)) return redirect( url_for("main.warehouse_requests") diff --git a/app/services.py b/app/services.py index 5c9c4e7..bd26665 100644 --- a/app/services.py +++ b/app/services.py @@ -45,6 +45,11 @@ def get_or_create_station_inventory(station_id, part_id): def set_station_inventory(station_id, part_id, quantity): + if quantity < 0: + raise ValueError( + "Station inventory quantity cannot be negative" + ) + inventory = get_or_create_station_inventory( station_id, part_id, @@ -57,6 +62,12 @@ def set_station_inventory(station_id, part_id, quantity): def activate_job(job): + if job.status != "Draft": + return False + + if not job.steps: + return False + requirements = calculate_requirements(job) stock_missing = False @@ -135,7 +146,25 @@ def fulfill_transfer_request(request_id, transferred_quantity): raise ValueError("Transfer request not found") if transferred_quantity <= 0: - raise ValueError("Transferred quantity must be greater than zero") + raise ValueError( + "Transferred quantity must be greater than zero" + ) + + if transfer_request.status == "Fulfilled": + raise ValueError( + "Transfer request is already fulfilled" + ) + + remaining_quantity = ( + transfer_request.requested_quantity + - transfer_request.transferred_quantity + ) + + if transferred_quantity > remaining_quantity: + raise ValueError( + "Transferred quantity cannot exceed " + "the remaining requested quantity" + ) inventory = get_or_create_station_inventory( transfer_request.station_id, diff --git a/tests/test_validation.py b/tests/test_validation.py index ff8fc49..5611c5a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,7 +1,15 @@ import pytest from app import create_app, db -from app.models import Job +from app.models import ( + Job, + JobStep, + Part, + ProductUnit, + StationInventory, + TransferRequest, +) +from app.services import set_station_inventory @pytest.fixture @@ -69,4 +77,169 @@ def test_invalid_job_input_is_rejected( assert expected_message.encode() in response.data with app.app_context(): - assert Job.query.count() == 0 \ No newline at end of file + assert Job.query.count() == 0 +def test_job_without_steps_cannot_be_activated(app): + with app.app_context(): + job = Job( + name="No Steps", + quantity=1, + threshold_percent=20.0, + status="Draft", + ) + + db.session.add(job) + db.session.commit() + + job_id = job.id + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}/activate" + ) + + assert response.status_code == 400 + + with app.app_context(): + job = db.session.get(Job, job_id) + + assert job.status == "Draft" + assert job.barcode is None + assert ProductUnit.query.count() == 0 + + +def test_invalid_step_quantity_is_rejected(app): + with app.app_context(): + job = Job( + name="Invalid Step", + quantity=1, + threshold_percent=20.0, + status="Draft", + ) + + db.session.add(job) + db.session.commit() + + job_id = job.id + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-01", + "instruction": "Attach a part", + "photo_url": "", + "part_name": "Part Invalid", + "part_quantity": "0", + }, + ) + + assert response.status_code == 400 + + with app.app_context(): + assert JobStep.query.count() == 0 + assert Part.query.count() == 0 + + +def test_negative_station_inventory_is_rejected(app): + with app.app_context(): + part = Part( + name="Validation Part", + unit="pcs", + ) + + db.session.add(part) + db.session.commit() + + part_id = part.id + + client = app.test_client() + + response = client.post( + "/admin/inventory", + data={ + "station_id": "ST-01", + "part_id": str(part_id), + "quantity": "-1", + }, + ) + + assert response.status_code == 400 + + with app.app_context(): + assert StationInventory.query.count() == 0 + + with pytest.raises( + ValueError, + match="cannot be negative", + ): + set_station_inventory( + "ST-01", + part_id, + -1, + ) + + +@pytest.mark.parametrize( + "transferred_quantity", + [ + "not-a-number", + "0", + "6", + ], +) +def test_invalid_transfer_fulfillment_is_rejected( + app, + transferred_quantity, +): + with app.app_context(): + part = Part( + name="Transfer Validation Part", + unit="pcs", + ) + + job = Job( + name="Transfer Validation", + quantity=1, + threshold_percent=20.0, + status="Active", + ) + + db.session.add_all([part, job]) + db.session.flush() + + transfer_request = TransferRequest( + job_id=job.id, + station_id="ST-01", + part_id=part.id, + requested_quantity=5, + transferred_quantity=0, + status="Pending", + ) + + db.session.add(transfer_request) + db.session.commit() + + request_id = transfer_request.id + + client = app.test_client() + + response = client.post( + f"/warehouse/requests/{request_id}/fulfill", + data={ + "transferred_quantity": transferred_quantity, + }, + ) + + assert response.status_code == 400 + + with app.app_context(): + transfer_request = db.session.get( + TransferRequest, + request_id, + ) + + assert transfer_request.transferred_quantity == 0 + assert transfer_request.status == "Pending" + assert StationInventory.query.count() == 0 \ No newline at end of file -- GitLab From 1b6d2dd7d3f8310eee72f79fe73925e7bdf37f53 Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Tue, 11 Aug 2026 18:37:56 +0200 Subject: [PATCH 3/4] style: add shared responsive interface styling --- app/static/style.css | 171 +++++++++++++++++++++++++++ app/templates/admin_inventory.html | 8 ++ app/templates/admin_jobs.html | 8 ++ app/templates/job_detail.html | 8 ++ app/templates/transfer_requests.html | 8 ++ app/templates/worker_kiosk.html | 12 +- 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 app/static/style.css diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..8da6a27 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,171 @@ +:root { + color-scheme: light; + --background: #f3f6f9; + --surface: #ffffff; + --primary: #1f4f78; + --primary-dark: #163a59; + --border: #ccd6df; + --text: #1f2933; + --success-background: #e8f6ec; + --success-border: #3b8c55; + --error-background: #fdecec; + --error-border: #c94242; +} + +html { + box-sizing: border-box; +} + +body { + max-width: 1100px; + margin: 0 auto; + padding: 24px; + background: var(--background); + color: var(--text); + font-family: Arial, Helvetica, sans-serif; + line-height: 1.5; +} + +body > p:first-of-type { + padding: 12px 16px; + border-radius: 8px; + background: var(--primary); + color: #ffffff; +} + +body > p:first-of-type a { + color: #ffffff; + font-weight: 600; +} + +a { + color: var(--primary); +} + +h1 { + margin-top: 28px; + color: var(--primary-dark); +} + +h2, +h3 { + color: var(--primary); +} + +form { + max-width: 720px; + margin: 16px 0; + padding: 18px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +label { + font-weight: 600; +} + +input, +select { + width: 100%; + max-width: 520px; + margin-top: 4px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 5px; + background: #ffffff; + color: var(--text); +} + +button { + padding: 10px 16px; + border: 0; + border-radius: 5px; + background: var(--primary); + color: #ffffff; + font-weight: 700; + cursor: pointer; +} + +button:hover, +button:focus { + background: var(--primary-dark); +} + +table { + width: 100%; + margin: 16px 0; + border-collapse: collapse; + background: var(--surface); +} + +th, +td { + padding: 10px; + border: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +th { + background: #e4ebf1; + color: var(--primary-dark); +} + +td form { + display: flex; + gap: 8px; + align-items: center; + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + +td form input { + width: 110px; + margin: 0; +} + +ul { + padding: 16px 16px 16px 36px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +[role="alert"] { + padding: 12px 16px; + border-left: 5px solid var(--error-border); + border-radius: 5px; + background: var(--error-background); +} + +[role="status"] { + padding: 12px 16px; + border-left: 5px solid var(--success-border); + border-radius: 5px; + background: var(--success-background); +} + +hr { + margin: 28px 0; + border: 0; + border-top: 1px solid var(--border); +} + +@media (max-width: 700px) { + body { + padding: 14px; + } + + table { + display: block; + overflow-x: auto; + } + + body > p:first-of-type a { + display: inline-block; + margin: 4px 0; + } +} \ No newline at end of file diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html index f6d97a0..f073cb5 100644 --- a/app/templates/admin_inventory.html +++ b/app/templates/admin_inventory.html @@ -2,7 +2,15 @@ + MES - Station Inventory +

diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html index df58043..fae8c55 100644 --- a/app/templates/admin_jobs.html +++ b/app/templates/admin_jobs.html @@ -2,7 +2,15 @@ + MES - Production Jobs +

diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html index de6ce40..4fcace8 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -2,7 +2,15 @@ + MES - {{ job.name }} +

diff --git a/app/templates/transfer_requests.html b/app/templates/transfer_requests.html index 0853727..0e92684 100644 --- a/app/templates/transfer_requests.html +++ b/app/templates/transfer_requests.html @@ -2,7 +2,15 @@ + MES - Warehouse Requests +

diff --git a/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html index 1eaed70..3887dcf 100644 --- a/app/templates/worker_kiosk.html +++ b/app/templates/worker_kiosk.html @@ -1,8 +1,16 @@ - - Worker Kiosk + + + MES - Worker Kiosk +

-- GitLab From 798ec01d64d8a73402f5a10c3a368aa44299cffd Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Tue, 11 Aug 2026 18:41:07 +0200 Subject: [PATCH 4/4] test: add application smoke coverage --- tests/test_smoke.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/test_smoke.py diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..ab63aef --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,68 @@ +import pytest + +from app import create_app, db +from app.models import Job + + +@pytest.fixture +def app(): + test_app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + + yield test_app + + +@pytest.mark.parametrize( + "path", + [ + "/admin/jobs", + "/admin/inventory", + "/warehouse/requests", + "/kiosk", + "/static/style.css", + ], +) +def test_main_pages_load_successfully(app, path): + client = app.test_client() + + response = client.get(path) + + assert response.status_code == 200 + + +def test_job_detail_page_loads_successfully(app): + with app.app_context(): + job = Job( + name="Smoke Test Job", + quantity=1, + threshold_percent=20.0, + status="Draft", + ) + + db.session.add(job) + db.session.commit() + + job_id = job.id + + client = app.test_client() + + response = client.get( + f"/admin/jobs/{job_id}" + ) + + assert response.status_code == 200 + assert b"Smoke Test Job" in response.data + + +def test_stylesheet_is_served_as_css(app): + client = app.test_client() + + response = client.get("/static/style.css") + + assert response.status_code == 200 + assert response.mimetype == "text/css" + assert b"--primary" in response.data \ No newline at end of file -- GitLab