diff --git a/database_api/model_planitem.py b/database_api/model_planitem.py index 300df75a..797a5e17 100644 --- a/database_api/model_planitem.py +++ b/database_api/model_planitem.py @@ -38,6 +38,7 @@ class PlanState(enum.Enum): completed = 3 failed = 4 stopped = 5 + import_pending = 6 class PlanItem(db.Model): diff --git a/frontend_multi_user/src/app.py b/frontend_multi_user/src/app.py index cd2661c7..965c2e51 100644 --- a/frontend_multi_user/src/app.py +++ b/frontend_multi_user/src/app.py @@ -531,11 +531,12 @@ def _ensure_stopped_state() -> None: one enum name does not poison the attempt for the other. """ for type_name in ("taskstate", "planstate"): - try: - with self.db.engine.begin() as conn: - conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS 'stopped'")) - except Exception as exc: - logger.debug("ALTER TYPE %s: %s", type_name, exc) + for enum_value in ("stopped", "import_pending"): + try: + with self.db.engine.begin() as conn: + conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS '{enum_value}'")) + except Exception as exc: + logger.debug("ALTER TYPE %s ADD VALUE %s: %s", type_name, enum_value, exc) def _ensure_last_progress_at_column() -> None: insp = inspect(self.db.engine) diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 6744bb95..20c2aca0 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -949,6 +949,46 @@ def plan(): ) +@plan_routes_bp.route("/plan/import", methods=["GET", "POST"]) +@login_required +def plan_import(): + message = None + message_type = None + if request.method == "POST": + zip_file = request.files.get("zip_file") + if zip_file is None or zip_file.filename == "": + message = "No file selected." + message_type = "error" + elif not zip_file.filename.endswith(".zip"): + message = "Please upload a .zip file." + message_type = "error" + else: + zip_data = zip_file.read() + zip_size = len(zip_data) + max_zip_size = 50 * 1024 * 1024 # 50 MB + if zip_size > max_zip_size: + message = f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB." + message_type = "error" + else: + user_id = str(current_user.id) + plan = PlanItem( + prompt=f"[Imported from {zip_file.filename}]", + state=PlanState.import_pending, + user_id=user_id, + parameters={ + "trigger_source": "frontend import", + "import_filename": zip_file.filename, + "pipeline_version": PIPELINE_VERSION, + }, + run_zip_snapshot=zip_data, + ) + db.session.add(plan) + db.session.commit() + logger.info("Plan import: created plan %s from %r (%s bytes) for user %s", plan.id, zip_file.filename, zip_size, user_id) + return redirect(url_for("plan_routes.plan", id=str(plan.id))) + return render_template("plan_import.html", message=message, message_type=message_type) + + @plan_routes_bp.route("/plan/stop", methods=["POST"]) @login_required def plan_stop(): diff --git a/frontend_multi_user/templates/plan_import.html b/frontend_multi_user/templates/plan_import.html new file mode 100644 index 00000000..3c921440 --- /dev/null +++ b/frontend_multi_user/templates/plan_import.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Import Plan - PlanExe{% endblock %} +{% block head %} + +{% endblock %} + +{% block content %} +← Back to Plans +

Import Plan

+

Upload a plan zip file to import it.

+ +
+
+ + + + +
+ + {% if message %} +
+ {{ message }} +
+ {% endif %} +
+{% endblock %} diff --git a/frontend_multi_user/templates/plan_list.html b/frontend_multi_user/templates/plan_list.html index 43a0f043..36472193 100644 --- a/frontend_multi_user/templates/plan_list.html +++ b/frontend_multi_user/templates/plan_list.html @@ -103,6 +103,11 @@ color: #e65100; background: #fff3e0; } + .status-chip.status-import_pending { + border-color: #6a1b9a; + color: #6a1b9a; + background: #f3e5f5; + } .plan-cell-prompt { white-space: nowrap; @@ -131,7 +136,10 @@ {% endblock %} {% block content %} -

Plans

+
+

Plans

+ Import Plan +

Technical queue view · newest first · click row to inspect

{% if plan_rows %} diff --git a/worker_plan_database/app.py b/worker_plan_database/app.py index e486f0a1..0a1ba7a4 100644 --- a/worker_plan_database/app.py +++ b/worker_plan_database/app.py @@ -360,12 +360,13 @@ def ensure_stopped_state() -> None: the TaskState → PlanState Python rename (proposal 74). Fresh databases created after that rename will have ``planstate``. We try both names. """ - with db.engine.begin() as conn: - for type_name in ("taskstate", "planstate"): + for type_name in ("taskstate", "planstate"): + for enum_value in ("stopped", "import_pending"): try: - conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS 'stopped'")) + with db.engine.begin() as conn: + conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS '{enum_value}'")) except Exception as exc: - logger.debug("ALTER TYPE %s: %s", type_name, exc) + logger.debug("ALTER TYPE %s ADD VALUE %s: %s", type_name, enum_value, exc) def worker_process_started() -> None: planexe_worker_id = os.environ.get("PLANEXE_WORKER_ID")