From 02a04ae0537dabca066da7cd2a75a63110e747e2 Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Sun, 6 Sep 2026 17:05:10 +0200 Subject: [PATCH] Add job reuse, pause/resume, and delete actions --- app/photo_service.py | 39 +++++++- app/routes.py | 139 +++++++++++++++++++++++++++++ app/services.py | 135 ++++++++++++++++++++++++++++ app/templates/admin_inventory.html | 23 +++++ app/templates/admin_jobs.html | 29 +++++- app/templates/admin_photos.html | 17 ++++ app/templates/job_detail.html | 17 +++- 7 files changed, 395 insertions(+), 4 deletions(-) diff --git a/app/photo_service.py b/app/photo_service.py index f9e17b7..d72d544 100644 --- a/app/photo_service.py +++ b/app/photo_service.py @@ -6,7 +6,7 @@ from PIL import Image, UnidentifiedImageError from werkzeug.utils import secure_filename from app import db -from app.models import InstructionPhoto +from app.models import InstructionPhoto, Job, JobStep ALLOWED_IMAGE_FORMATS = { @@ -195,4 +195,39 @@ def instruction_photo_static_path(photo): return ( "uploads/step_photos/" f"{photo.stored_filename}" - ) \ No newline at end of file + ) + + +def delete_instruction_photo(photo): + photo_path = instruction_photo_static_path(photo) + + referenced_active_step = ( + JobStep.query.join( + Job, + JobStep.job_id == Job.id, + ) + .filter( + JobStep.photo_url == photo_path, + Job.status == "Active", + ) + .first() + ) + + if referenced_active_step is not None: + raise ValueError( + "This photo is used by a step in an active " + "job and cannot be deleted." + ) + + upload_folder = Path( + current_app.config[ + "STEP_PHOTO_UPLOAD_FOLDER" + ] + ) + + file_path = upload_folder / photo.stored_filename + + db.session.delete(photo) + db.session.commit() + + file_path.unlink(missing_ok=True) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index 20ce6bb..9a66285 100644 --- a/app/routes.py +++ b/app/routes.py @@ -14,6 +14,7 @@ from app.models import ( ) from app.photo_service import ( create_instruction_photo, + delete_instruction_photo, instruction_photo_static_path, resolve_instruction_photo, ) @@ -21,10 +22,15 @@ from app.services import ( activate_job, complete_current_step, create_station_part, + delete_job, + delete_station_inventory, fulfill_transfer_request, get_current_unit_and_step, get_or_create_part, get_or_create_station_inventory, + pause_job, + resume_job, + reuse_job, scan_job_barcode, set_station_inventory, ) @@ -298,6 +304,83 @@ def activate_job_route(job_id): ) +@bp.route( + "/admin/jobs//pause", + methods=["POST"], +) +def pause_job_route(job_id): + job = db.get_or_404(Job, job_id) + + try: + pause_job(job) + except ValueError as error: + abort(409, description=str(error)) + + return redirect( + url_for("main.job_detail", job_id=job.id) + ) + + +@bp.route( + "/admin/jobs//resume", + methods=["POST"], +) +def resume_job_route(job_id): + job = db.get_or_404(Job, job_id) + + try: + resume_job(job) + except ValueError as error: + abort(409, description=str(error)) + + return redirect( + url_for("main.job_detail", job_id=job.id) + ) + + +@bp.route( + "/admin/jobs//reuse", + methods=["POST"], +) +def reuse_job_route(job_id): + job = db.get_or_404(Job, job_id) + + new_job = reuse_job(job) + + return redirect( + url_for("main.job_detail", job_id=new_job.id) + ) + + +@bp.route( + "/admin/jobs//delete", + methods=["POST"], +) +def delete_job_route(job_id): + job = db.get_or_404(Job, job_id) + + try: + delete_job(job) + except ValueError as error: + jobs = Job.query.order_by(Job.id.desc()).all() + + return ( + render_template( + "admin_jobs.html", + jobs=jobs, + error=str(error), + form_data={ + "name": "", + "quantity": "", + "threshold_percent": "", + }, + ), + 409, + ) + + return redirect(url_for("main.admin_jobs")) + + @bp.route("/admin/photos", methods=["GET", "POST"]) def admin_photos(): if request.method == "POST": @@ -332,6 +415,32 @@ def admin_photos(): ) +@bp.route( + "/admin/photos//delete", + methods=["POST"], +) +def delete_photo_route(photo_id): + photo = db.get_or_404(InstructionPhoto, photo_id) + + try: + delete_instruction_photo(photo) + except ValueError as error: + photos = InstructionPhoto.query.order_by( + InstructionPhoto.uploaded_at.desc() + ).all() + + return ( + render_template( + "admin_photos.html", + photos=photos, + error=str(error), + ), + 409, + ) + + return redirect(url_for("main.admin_photos")) + + @bp.route("/admin/inventory", methods=["GET", "POST"]) def admin_inventory(): if request.method == "POST": @@ -460,6 +569,36 @@ def admin_inventory(): ) +@bp.route( + "/admin/inventory///delete", + methods=["POST"], +) +def delete_station_inventory_route(station_id, part_id): + try: + delete_station_inventory(station_id, part_id) + except ValueError as error: + stations = Station.query.order_by(Station.id).all() + parts = Part.query.order_by(Part.name).all() + + inventory_rows = StationInventory.query.order_by( + StationInventory.station_id, + StationInventory.part_id, + ).all() + + return ( + render_template( + "admin_inventory.html", + stations=stations, + parts=parts, + inventory_rows=inventory_rows, + error=str(error), + ), + 409, + ) + + return redirect(url_for("main.admin_inventory")) + + @bp.route("/warehouse/requests") def warehouse_requests(): transfer_requests = TransferRequest.query.order_by( diff --git a/app/services.py b/app/services.py index c312c6f..9b5508e 100644 --- a/app/services.py +++ b/app/services.py @@ -4,9 +4,11 @@ import uuid from app import db from app.models import ( Job, + JobStep, Part, ProductUnit, StationInventory, + StepPart, TransferRequest, UnitStepProgress, ) @@ -171,6 +173,44 @@ def set_station_inventory(station_id, part_id, quantity): return inventory +def delete_station_inventory(station_id, part_id): + referenced_active_step = ( + StepPart.query.join( + JobStep, + StepPart.step_id == JobStep.id, + ) + .join( + Job, + JobStep.job_id == Job.id, + ) + .filter( + StepPart.part_id == part_id, + JobStep.station_id == station_id, + Job.status == "Active", + ) + .first() + ) + + if referenced_active_step is not None: + raise ValueError( + "This part is required by an active job " + "at this station and cannot be removed." + ) + + inventory = StationInventory.query.filter_by( + station_id=station_id, + part_id=part_id, + ).first() + + if inventory is None: + raise ValueError( + "Station inventory entry not found." + ) + + db.session.delete(inventory) + db.session.commit() + + def activate_job(job): if job.status != "Draft": return False @@ -246,6 +286,101 @@ def activate_job(job): return True +def reuse_job(job): + base_name = job.name + suffix = 1 + new_name = f"{base_name} {suffix}" + + while Job.query.filter_by(name=new_name).first() is not None: + suffix += 1 + new_name = f"{base_name} {suffix}" + + new_job = Job( + name=new_name, + quantity=job.quantity, + threshold_percent=job.threshold_percent, + status="Draft", + ) + + db.session.add(new_job) + db.session.flush() + + for step in job.steps: + new_step = JobStep( + job=new_job, + step_number=step.step_number, + instruction=step.instruction, + station_id=step.station_id, + photo_url=step.photo_url, + ) + + db.session.add(new_step) + db.session.flush() + + for usage in step.parts: + db.session.add( + StepPart( + step=new_step, + part_id=usage.part_id, + quantity=usage.quantity, + ) + ) + + db.session.commit() + + return new_job + + +def delete_job(job): + if job.status == "Active": + raise ValueError( + "Active jobs cannot be deleted." + ) + + unit_ids = [ + unit.id + for unit in ProductUnit.query.filter_by( + job_id=job.id, + ).all() + ] + + if unit_ids: + UnitStepProgress.query.filter( + UnitStepProgress.product_unit_id.in_(unit_ids) + ).delete(synchronize_session=False) + + ProductUnit.query.filter_by( + job_id=job.id, + ).delete(synchronize_session=False) + + TransferRequest.query.filter_by( + job_id=job.id, + ).delete(synchronize_session=False) + + db.session.delete(job) + db.session.commit() + + +def pause_job(job): + if job.status != "Active": + raise ValueError( + "Only active jobs can be paused." + ) + + job.status = "Paused" + db.session.commit() + + +def resume_job(job): + if job.status != "Paused": + raise ValueError( + "Only paused jobs can be resumed." + ) + + job.status = "Active" + db.session.commit() + + def fulfill_transfer_request(request_id, transferred_quantity): transfer_request = db.session.get( TransferRequest, diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html index 31f9fb3..34f8646 100644 --- a/app/templates/admin_inventory.html +++ b/app/templates/admin_inventory.html @@ -34,6 +34,13 @@

Station Inventory

+ {% if error %} +

+ Action failed: + {{ error }} +

+ {% endif %} +

Add Part to Station Inventory

@@ -183,6 +190,7 @@ Part Unit Quantity + Actions {% for inventory in inventory_rows %} @@ -194,6 +202,21 @@ {{ inventory.part.name }} {{ inventory.part.unit }} {{ inventory.quantity }} + + + +
+ {% endfor %} diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html index 72b5bdd..7cc2d7c 100644 --- a/app/templates/admin_jobs.html +++ b/app/templates/admin_jobs.html @@ -31,7 +31,7 @@

Create Production Job

{% if error %}

- Cannot create job: + Action failed: {{ error }}

{% endif %} @@ -90,6 +90,7 @@ Name Quantity Status + Actions {% for job in jobs %} @@ -104,6 +105,32 @@ {{ job.quantity }} {{ job.status }} + + {% if job.status != "Active" %} +
+ +
+ +
+ +
+ {% else %} + Open job to pause + {% endif %} + {% endfor %} diff --git a/app/templates/admin_photos.html b/app/templates/admin_photos.html index 721644a..9b10917 100644 --- a/app/templates/admin_photos.html +++ b/app/templates/admin_photos.html @@ -34,6 +34,13 @@

Instruction Photos

+ {% if error %} +

+ Action failed: + {{ error }} +

+ {% endif %} +

Upload Instruction Photo

Uploaded: {{ photo.uploaded_at }}

+ + + +
{% endfor %} diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html index 57dcbe8..64699b8 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -35,6 +35,20 @@ > + {% elif job.status == "Active" %} +
+ +
+ {% elif job.status == "Paused" %} +
+ +
{% endif %} {% if transfer_requests %} @@ -250,7 +264,8 @@ {% else %}

- Production configuration is locked because this job is active. + Production configuration is locked because this + job is no longer a draft.

{% endif %} -- GitLab