diff --git a/app/routes.py b/app/routes.py index fa63baf3f8e05c882c276b71eb25ef0add745b04..90aff62d20b1d9e676854844a7ae18e3561399c6 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, ) @@ -73,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() @@ -96,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", @@ -143,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( @@ -153,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") @@ -200,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 5c9c4e7af86eb6351ccf3edec5a01f8d5f527376..bd26665e1773f275dfacd30a014ebfac556b0669 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/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..8da6a272eb352807d7631ae4f8aba44f1e2f2bf7 --- /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 f6d97a0689f31828f41e480ca4f2d686972b0e85..f073cb5290a2cd91375aa74efdab47a2d60f410d 100644 --- a/app/templates/admin_inventory.html +++ b/app/templates/admin_inventory.html @@ -2,7 +2,15 @@
+diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html index 2ee97ff98865c6a36ad62921bc6fd651942723bb..fae8c5589f53d391b302a8c75b9bddb6821e2bb1 100644 --- a/app/templates/admin_jobs.html +++ b/app/templates/admin_jobs.html @@ -2,7 +2,15 @@
+@@ -17,16 +25,33 @@
+ Cannot create job: + {{ error }} +
+ {% endif %} -diff --git a/app/templates/transfer_requests.html b/app/templates/transfer_requests.html index 0853727a54f358972b7c2a0c9764ae87bad99200..0e92684e10443dac321273727ec7ef75b5becd59 100644 --- a/app/templates/transfer_requests.html +++ b/app/templates/transfer_requests.html @@ -2,7 +2,15 @@
+diff --git a/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html index 1eaed703576979b13e925f75fa1e1ee24d7b40a7..3887dcf203b960d992966ce4fd7ba9c994a525fc 100644 --- a/app/templates/worker_kiosk.html +++ b/app/templates/worker_kiosk.html @@ -1,8 +1,16 @@
- -diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000000000000000000000000000000000000..ab63aefc418b733af9f17145e48079fc8b9dc9f6 --- /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 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..5611c5ab92ac95c418c9224ffec43c356cae5203 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,245 @@ +import pytest + +from app import create_app, db +from app.models import ( + Job, + JobStep, + Part, + ProductUnit, + StationInventory, + TransferRequest, +) +from app.services import set_station_inventory + + +@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 +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