From b01710870054fbe2a8832fb7765a588b82a2c200 Mon Sep 17 00:00:00 2001 From: Reid 'arrdem' McKenzie Date: Sun, 28 May 2023 00:15:36 -0600 Subject: [PATCH] Pushing jobs to printers works now --- projects/tentacles/config.toml | 1 + .../src/python/tentacles/__main__.py | 4 +- .../src/python/tentacles/blueprints/api.py | 106 +++++++++++++++--- .../python/tentacles/blueprints/file_ui.py | 31 +++++ .../src/python/tentacles/blueprints/job_ui.py | 32 ++++++ .../python/tentacles/blueprints/printer_ui.py | 3 - .../python/tentacles/blueprints/user_ui.py | 2 - .../src/python/tentacles/blueprints/util.py | 11 ++ .../tentacles/src/python/tentacles/schema.sql | 1 + .../tentacles/src/python/tentacles/store.py | 11 +- .../tentacles/templates/files_list.html.j2 | 31 +++++ .../python/tentacles/templates/index.html.j2 | 32 +----- .../python/tentacles/templates/jobs.html.j2 | 4 + .../tentacles/templates/jobs_list.html.j2 | 18 +++ .../python/tentacles/templates/macros.html.j2 | 15 +++ .../tentacles/templates/printers.html.j2 | 29 +---- .../tentacles/templates/printers_list.html.j2 | 28 +++++ .../tentacles/src/python/tentacles/workers.py | 24 ++-- 18 files changed, 291 insertions(+), 92 deletions(-) create mode 100644 projects/tentacles/src/python/tentacles/blueprints/file_ui.py create mode 100644 projects/tentacles/src/python/tentacles/blueprints/job_ui.py create mode 100644 projects/tentacles/src/python/tentacles/templates/files_list.html.j2 create mode 100644 projects/tentacles/src/python/tentacles/templates/jobs.html.j2 create mode 100644 projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2 create mode 100644 projects/tentacles/src/python/tentacles/templates/macros.html.j2 create mode 100644 projects/tentacles/src/python/tentacles/templates/printers_list.html.j2 diff --git a/projects/tentacles/config.toml b/projects/tentacles/config.toml index cbd6019..2e4c34c 100644 --- a/projects/tentacles/config.toml +++ b/projects/tentacles/config.toml @@ -1,4 +1,5 @@ SECRET_KEY = "SgvzxsO5oPBGInmqsyyGQWAJXkS9" +UPLOAD_FOLDER = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tmp" [db] uri = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tentacles.sqlite3" diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py index f334e63..4001c7c 100644 --- a/projects/tentacles/src/python/tentacles/__main__.py +++ b/projects/tentacles/src/python/tentacles/__main__.py @@ -8,7 +8,7 @@ from flask import Flask, request import tomllib from datetime import datetime -from tentacles.blueprints import user_ui, printer_ui, api +from tentacles.blueprints import user_ui, printer_ui, job_ui, file_ui, api from tentacles.store import Store from tentacles.globals import _ctx, Ctx, ctx from tentacles.workers import create_workers @@ -78,6 +78,8 @@ def serve(hostname: str, port: int, config: Path): # Blueprints app.register_blueprint(user_ui.BLUEPRINT) app.register_blueprint(printer_ui.BLUEPRINT) + app.register_blueprint(job_ui.BLUEPRINT) + app.register_blueprint(file_ui.BLUEPRINT) app.register_blueprint(api.BLUEPRINT) # Shove our middleware in there diff --git a/projects/tentacles/src/python/tentacles/blueprints/api.py b/projects/tentacles/src/python/tentacles/blueprints/api.py index 1a0111f..680ede1 100644 --- a/projects/tentacles/src/python/tentacles/blueprints/api.py +++ b/projects/tentacles/src/python/tentacles/blueprints/api.py @@ -2,8 +2,14 @@ """API endpoints supporting the 'ui'.""" +import os +from typing import Optional -from flask import Blueprint +from tentacles.globals import ctx +from tentacles.blueprints.util import requires_admin, requires_auth + +from flask import Blueprint, request, current_app +from hashlib import sha3_256 BLUEPRINT = Blueprint("api", __name__, url_prefix="/api") @@ -12,17 +18,25 @@ BLUEPRINT = Blueprint("api", __name__, url_prefix="/api") # Printers # # The trick here is handling multipart uploads. -@BLUEPRINT.route("/printer", methods=["POST"]) +@requires_admin +@BLUEPRINT.route("/printers", methods=["POST"]) def create_printer(): pass -@BLUEPRINT.route("/printer", methods=["GET"]) +@requires_auth +@BLUEPRINT.route("/printers", methods=["GET"]) +@BLUEPRINT.route("/printers/", methods=["GET"]) def list_printers(): - pass + return { + "printers": [ + {"id": p.id, "name": p.name, "url": p.url} for p in ctx.db.list_printers() + ] + }, 200 -@BLUEPRINT.route("/printer", methods=["DELETE"]) +@requires_admin +@BLUEPRINT.route("/printers", methods=["DELETE"]) def delete_printer(): pass @@ -31,40 +45,100 @@ def delete_printer(): # Files # # The trick here is handling multipart uploads. -@BLUEPRINT.route("/file", methods=["POST"]) -def create_file(): - pass +@requires_auth +@BLUEPRINT.route("/files", methods=["POST"]) +@BLUEPRINT.route("/files/", methods=["POST"]) +def create_file(location: Optional[str] = None): + # This is the important one, because it's the one that PrusaSlicer et all use to upload jobs. + + print(request) + print(request.headers) + print(request.files) + print(request.form) + + if "file" not in request.files: + return {"status": "error", "error": "No file to upload"}, 400 + + file = request.files["file"] + # If the user does not select a file, the browser submits an + # empty file without a filename. + if not file.filename: + return {"status": "error", "error": "No filename provided"}, 400 + + elif not file.filename.endswith(".gcode"): + return {"status": "error", "error": "Non-gcode file specified"}, 400 + + else: + digest = sha3_256() + digest.update(file.filename.encode()) + sanitized_filename = digest.hexdigest() + ".gcode" + sanitized_path = os.path.join( + current_app.config["UPLOAD_FOLDER"], sanitized_filename + ) + file.save(sanitized_path) + ctx.db.create_file(ctx.uid, file.filename, sanitized_path) + + return {"status": "ok"}, 202 -@BLUEPRINT.route("/file", methods=["GET"]) +@requires_auth +@BLUEPRINT.route("/files", methods=["GET"]) +@BLUEPRINT.route("/files/", methods=["GET"]) def get_files(): - pass + return { + "files": [ + { + "id": f.id, + "filename": f.filename, + "path": f.path, + "owner": ctx.uid, + "upload_date": f.upload_date, + } + for f in ctx.db.list_files(ctx.uid) + ] + }, 200 -@BLUEPRINT.route("/file", methods=["DELETE"]) +@requires_auth +@BLUEPRINT.route("/files", methods=["DELETE"]) def delete_file(): pass #################################################################################################### # Jobs -@BLUEPRINT.route("/job", methods=["POST"]) +@requires_auth +@BLUEPRINT.route("/jobs", methods=["POST"]) def create_job(): pass -@BLUEPRINT.route("/job", methods=["GET"]) +@requires_auth +@BLUEPRINT.route("/jobs", methods=["GET"]) def get_jobs(): - pass + return { + "jobs": [ + { + "id": j.id, + "file_id": j.file_id, + "started_at": j.started_at, + "finished_at": j.finished_at, + "printer_id": j.printer_id, + } + for j in ctx.db.list_jobs() + ] + }, 200 -@BLUEPRINT.route("/job", methods=["DELETE"]) +@requires_auth +@BLUEPRINT.route("/jobs", methods=["DELETE"]) def delete_job(): pass #################################################################################################### # API tokens -@BLUEPRINT.route("/token", methods=["GET"]) +@requires_auth +@BLUEPRINT.route("/tokens", methods=["GET"]) def get_tokens(): pass diff --git a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py new file mode 100644 index 0000000..c189715 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +import logging +from datetime import timedelta +import re + +from tentacles.globals import ctx +from .util import requires_auth + +from flask import ( + Blueprint, + current_app, + request, + redirect, + render_template, + flash, +) + +from .util import salt, is_logged_in + +log = logging.getLogger(__name__) +BLUEPRINT = Blueprint("files", __name__) + + +@requires_auth +@BLUEPRINT.route("/files", methods=["GET", "POST"]) +def files(): + if request.method == "POST": + flash("Not supported yet", category="warning") + + return render_template("files.html.j2") diff --git a/projects/tentacles/src/python/tentacles/blueprints/job_ui.py b/projects/tentacles/src/python/tentacles/blueprints/job_ui.py new file mode 100644 index 0000000..0c875fe --- /dev/null +++ b/projects/tentacles/src/python/tentacles/blueprints/job_ui.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +import logging +from datetime import timedelta +import re + +from tentacles.globals import ctx +from .util import requires_auth + +from flask import ( + Blueprint, + current_app, + request, + redirect, + render_template, + flash, +) + +from .util import salt, is_logged_in + +log = logging.getLogger(__name__) +BLUEPRINT = Blueprint("jobs", __name__) + + +@requires_auth +@BLUEPRINT.route("/jobs", methods=["GET", "POST"]) +def jobs(): + if request.method == "POST": + ctx.db.create_job(ctx.uid, int(request.form.get("file_id"))) + flash("Job created!", category="info") + + return redirect("/") diff --git a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py index d8e10db..1ac0b54 100644 --- a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py +++ b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py @@ -1,10 +1,7 @@ #!/usr/bin/env python3 -"""Blueprints for HTML serving 'ui'.""" - import logging - from flask import ( Blueprint, request, diff --git a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py index 30e2ea3..77d1230 100644 --- a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py +++ b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -"""Blueprints for HTML serving 'ui'.""" - import logging from datetime import timedelta import re diff --git a/projects/tentacles/src/python/tentacles/blueprints/util.py b/projects/tentacles/src/python/tentacles/blueprints/util.py index ee40282..a50d740 100644 --- a/projects/tentacles/src/python/tentacles/blueprints/util.py +++ b/projects/tentacles/src/python/tentacles/blueprints/util.py @@ -31,3 +31,14 @@ def requires_admin(f): return f(*args, **kwargs) return _helper + + +def requires_auth(f): + def _helper(*args, **kwargs): + if not ctx.uid: + flash("Please log in first", category="error") + redirect("/") + else: + return f(*args, **kwargs) + + return _helper diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql index b32e9a7..aebda76 100644 --- a/projects/tentacles/src/python/tentacles/schema.sql +++ b/projects/tentacles/src/python/tentacles/schema.sql @@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT , user_id INTEGER , filename TEXT + , path TEXT , upload_date TEXT , FOREIGN KEY(user_id) REFERENCES user(id) ); diff --git a/projects/tentacles/src/python/tentacles/store.py b/projects/tentacles/src/python/tentacles/store.py index f67151a..7baac77 100644 --- a/projects/tentacles/src/python/tentacles/store.py +++ b/projects/tentacles/src/python/tentacles/store.py @@ -274,7 +274,7 @@ class Store(object): """ SELECT p.id FROM printers p - LEFT JOIN jobs j ON p.id = j.printer_id + LEFT JOIN (SELECT id, printer_id FROM jobs WHERE finished_at IS NULL) j ON p.id = j.printer_id WHERE j.id IS NULL """ ).fetchall() @@ -298,8 +298,8 @@ class Store(object): @requires_conn def create_file(self, uid: int, name: str, path: Path) -> int: return self._conn.execute( - "INSERT INTO files (user_id, filename, upload_date) VALUES (?, ?, datetime('now')) RETURNING (id)", - [uid, name], + "INSERT INTO files (user_id, filename, path, upload_date) VALUES (?, ?, ?, datetime('now')) RETURNING (id)", + [uid, name, path], ).fetchone() @requires_conn @@ -341,11 +341,11 @@ class Store(object): @requires_conn def list_jobs(self, uid: Optional[int] = None): """Enumerate jobs in priority order.""" - cond = f"AND user_id = {uid}" if uid else "" + cond = f"user_id = {uid}" if uid else "TRUE" return self._conn.execute( f""" SELECT * FROM jobs - WHERE started_at IS NULL AND printer_id IS NULL {cond} + WHERE {cond} ORDER BY priority DESC """, [], @@ -377,6 +377,7 @@ class Store(object): [], ).fetchall() + @fmap(one) @requires_conn def poll_job_queue(self): return self._conn.execute( diff --git a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 new file mode 100644 index 0000000..138a58f --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 @@ -0,0 +1,31 @@ +{% import "macros.html.j2" as macros %} +
+

Files

+ {% with files = ctx.db.list_files(uid=ctx.uid) %} + {% if files %} +
    + {% for file in files %} +
  • + {{ file.filename }} + + {{ macros.start_job(file.id) }} + {{ macros.delete_file(file.id) }} + +
  • + {% endfor %} +
+ {% else %} + You don't have any files. Upload something! + {% endif %} + {% endwith %} +
+ +
+

Upload a file

+
+ + + + +
+
diff --git a/projects/tentacles/src/python/tentacles/templates/index.html.j2 b/projects/tentacles/src/python/tentacles/templates/index.html.j2 index f8837a8..7bf7914 100644 --- a/projects/tentacles/src/python/tentacles/templates/index.html.j2 +++ b/projects/tentacles/src/python/tentacles/templates/index.html.j2 @@ -1,34 +1,8 @@ {% extends "base.html.j2" %} {% block content %} -
-

Queue

- {% with jobs = ctx.db.list_jobs(uid=request.uid) %} - {% if jobs %} -
    - {% for job in jobs %} -
  • - {% endfor %} -
- {% else %} - No pending tasks. {% if request.uid %}Start something!{% endif %} - {% endif %} - {% endwith %} -
+ {% include "jobs_list.html.j2" %} - {% if request.uid %} -
-

Files

- {% with files = ctx.db.list_files(uid=request.uid) %} - {% if files %} -
    - {% for file in files %} -
  • - {% endfor %} -
- {% else %} - You don't have any files. Upload something! - {% endif %} - {% endwith %} -
+ {% if ctx.uid %} + {% include "files_list.html.j2" %} {% endif %} {% endblock %} diff --git a/projects/tentacles/src/python/tentacles/templates/jobs.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs.html.j2 new file mode 100644 index 0000000..b45ef01 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/jobs.html.j2 @@ -0,0 +1,4 @@ +{% extends "base.html.j2" %} +{% block content %} +{% include "jobs_list.html.j2" %} +{% endblock %} diff --git a/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2 new file mode 100644 index 0000000..5c932a8 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2 @@ -0,0 +1,18 @@ +
+

Job queue

+ {% with jobs = ctx.db.list_jobs(uid=ctx.uid) %} + {% if jobs %} +
    + {% for job in jobs %} +
  • + {{job.id}} + {{ctx.db.fetch_file(job.file_id).filename}} + {{ 'pending' if not job.printer_id else 'uploading' if not job.started_at else 'running' if not job.finished_at else job.state }} +
  • + {% endfor %} +
+ {% else %} + No pending tasks. {% if ctx.uid %}Start something!{% endif %} + {% endif %} + {% endwith %} +
diff --git a/projects/tentacles/src/python/tentacles/templates/macros.html.j2 b/projects/tentacles/src/python/tentacles/templates/macros.html.j2 new file mode 100644 index 0000000..11d8182 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/macros.html.j2 @@ -0,0 +1,15 @@ +{% macro start_job(id) %} +
+ + + +
+{% endmacro %} + +{% macro delete_file(id) %} +
+ + + +
+{% endmacro %} diff --git a/projects/tentacles/src/python/tentacles/templates/printers.html.j2 b/projects/tentacles/src/python/tentacles/templates/printers.html.j2 index 60a136f..1081f64 100644 --- a/projects/tentacles/src/python/tentacles/templates/printers.html.j2 +++ b/projects/tentacles/src/python/tentacles/templates/printers.html.j2 @@ -1,31 +1,4 @@ {% extends "base.html.j2" %} {% block content %} -
-

Printers

- {% with printers = ctx.db.list_printers() %} - {% if printers %} -
    - {% for printer in printers %} - {% with id, name, url, _api_key, last_poll, status = printer %} -
  • - {{name}} - {{url}} - {{status}} - {{last_poll}} - {# FIXME: How should these action buttons work? #} - - Test - Edit - Remove - -
  • - {% endwith %} - {% endfor %} -
- {% if ctx.is_admin %}Add a printer{% endif %} - {% else %} - No printers available. {% if ctx.is_admin %}Configure one!{% else %}Ask the admin to configure one!{% endif %} - {% endif %} - {% endwith %} -
+{% include "printers_list.html.j2" %} {% endblock %} diff --git a/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2 new file mode 100644 index 0000000..334d08c --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2 @@ -0,0 +1,28 @@ +
+

Printers

+ {% with printers = ctx.db.list_printers() %} + {% if printers %} +
    + {% for printer in printers %} + {% with id, name, url, _api_key, last_poll, status = printer %} +
  • + {{name}} + {{url}} + {{status}} + {{last_poll}} + {# FIXME: How should these action buttons work? #} + + Test + Edit + Remove + +
  • + {% endwith %} + {% endfor %} +
+ {% if ctx.is_admin %}Add a printer{% endif %} + {% else %} + No printers available. {% if ctx.is_admin %}Configure one!{% else %}Ask the admin to configure one!{% endif %} + {% endif %} + {% endwith %} +
diff --git a/projects/tentacles/src/python/tentacles/workers.py b/projects/tentacles/src/python/tentacles/workers.py index 243398a..73d603b 100644 --- a/projects/tentacles/src/python/tentacles/workers.py +++ b/projects/tentacles/src/python/tentacles/workers.py @@ -15,6 +15,7 @@ import logging from contextlib import closing from urllib import parse as urlparse from tentacles.store import Store +from pathlib import Path from octorest import OctoRest as _OR from requests import Response @@ -100,12 +101,9 @@ def assign_jobs(db_factory: Callable[[], Store]) -> None: with closing(db_factory()) as db: for printer_id in db.list_idle_printers(): - printer = db.fetch_printer(printer_id) - if printer.status != "idle": - continue - - if next_job_id := db.poll_job_queue(): - db.assign_job(next_job_id, printer_id) + if job_id := db.poll_job_queue(): + db.assign_job(job_id, printer_id) + print(f"Mapped job {job_id} to printer {printer_id}") @corn_job(timedelta(seconds=5)) @@ -117,8 +115,17 @@ def push_jobs(db_factory: Callable[[], Store]) -> None: printer = db.fetch_printer(job.printer_id) file = db.fetch_file(job.file_id) try: - client = OctoRest(printer.url, printer.api_key) - client.upload(file.filename, select=True, print=True) + client = OctoRest(url=printer.url, apikey=printer.api_key) + try: + client.upload(file.path) + except HTTPError as e: + if e.response.status_code == 409: + pass + else: + raise + + client.select(Path(file.path).name) + client.start() db.start_job(job.id) except Exception: log.exception("Oop") @@ -132,6 +139,7 @@ def pull_jobs(db_factory: Callable[[], Store]) -> None: for job in db.list_running_jobs(): printer = db.fetch_printer(job.printer_id) if printer.status != "running": + print(f"Job {job.id} finished {printer.status}") db.finish_job(job.id, printer.status)