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
+
+
{% 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 %}