From a939546b5ab59eed5e044d432688a652a2ce6562 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Tue, 30 Aug 2022 16:20:07 -0400 Subject: [PATCH 1/4] EXPERIMENTAL/UNPROVEN: Removed unused API calls and LAN submission warning modal, rejiggered queue abstract classes and implemented still-buggy drag-and-drop reordering of LAN queue items, plus drag back to local queue --- continuousprint/api.py | 62 ++----- continuousprint/queues/abstract.py | 27 ++- continuousprint/queues/lan.py | 154 +++++++++++------- continuousprint/queues/local.py | 43 +++-- continuousprint/queues/local_test.py | 2 + .../static/js/continuousprint_viewmodel.js | 52 +----- continuousprint/storage/queries.py | 11 -- continuousprint/storage/queries_test.py | 16 -- .../templates/continuousprint_tab.jinja2 | 30 +--- docs/contributing.md | 3 +- setup.py | 2 +- 11 files changed, 158 insertions(+), 244 deletions(-) diff --git a/continuousprint/api.py b/continuousprint/api.py index f045cf4..e7c862f 100644 --- a/continuousprint/api.py +++ b/continuousprint/api.py @@ -174,51 +174,26 @@ def add_job(self): self._get_queue(DEFAULT_QUEUE).add_job(data.get("name")).as_dict() ) - # PRIVATE API METHOD - may change without warning. - @octoprint.plugin.BlueprintPlugin.route("/set/mv", methods=["POST"]) - @restricted_access - @cpq_permission(Permission.EDITJOB) - def mv_set(self): - self._get_queue(DEFAULT_QUEUE).mv_set( - int(flask.request.form["id"]), - int( - flask.request.form["after_id"] - ), # Move to after this set (-1 for beginning of job) - int( - flask.request.form["dest_job"] - ), # Move to this job (null for new job at end) - ) - return json.dumps("ok") - # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/job/mv", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) def mv_job(self): - self._get_queue(DEFAULT_QUEUE).mv_job( - int(flask.request.form["id"]), - int( - flask.request.form["after_id"] - ), # Move to after this job (-1 for beginning of queue) - ) + src_id = flask.request.form["id"] + after_id = flask.request.form["after_id"] + if after_id == "": # Treat empty string as 'none' i.e. front of queue + after_id = None + sq = self._get_queue(flask.request.form["src_queue"]) + dq = self._get_queue(flask.request.form.get("dest_queue")) + + # Transfer into dest queue first + if dq != sq: + src_id = dq.import_job_from_view(sq.get_job_view(src_id)) + + # Finally, move the job + dq.mv_job(src_id, after_id) return json.dumps("ok") - # PRIVATE API METHOD - may change without warning. - @octoprint.plugin.BlueprintPlugin.route("/job/submit", methods=["POST"]) - @restricted_access - @cpq_permission(Permission.ADDJOB) - def submit_job(self): - j = queries.getJob(int(flask.request.form["id"])) - # Submit to the queue and remove from its origin - err = self._get_queue(flask.request.form["queue"]).submit_job(j) - if err is None: - self._logger.debug( - self._get_queue(DEFAULT_QUEUE).remove_jobs(job_ids=[j.id]) - ) - return self._state_json() - else: - return json.dumps(dict(error=str(err))) - # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/job/edit", methods=["POST"]) @restricted_access @@ -270,17 +245,6 @@ def rm_job(self): ) ) - # PRIVATE API METHOD - may change without warning. - @octoprint.plugin.BlueprintPlugin.route("/set/rm", methods=["POST"]) - @restricted_access - @cpq_permission(Permission.EDITJOB) - def rm_set(self): - return json.dumps( - self._get_queue(DEFAULT_QUEUE).rm_multi( - set_ids=flask.request.form.getlist("set_ids[]") - ) - ) - # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/job/reset", methods=["POST"]) @restricted_access diff --git a/continuousprint/queues/abstract.py b/continuousprint/queues/abstract.py index 56a4462..a964438 100644 --- a/continuousprint/queues/abstract.py +++ b/continuousprint/queues/abstract.py @@ -67,40 +67,35 @@ def reset_jobs(self, job_ids) -> dict: pass -class AbstractJobQueue(AbstractQueue): - """LAN queues (potentially others in the future) act on whole jobs and do not allow - edits to inner data""" - - @abstractmethod - def submit_job(self, j: JobView) -> bool: - pass - - class AbstractEditableQueue(AbstractQueue): - """Some queues (e.g. local to a single printer) are directly editable.""" + """Use for queues that are directly editable.""" @abstractmethod - def add_job(self, name="") -> JobView: + def mv_job(self, job_id, after_id): pass @abstractmethod - def add_set(self, job_id, data) -> SetView: + def edit_job(self, job_id, data): pass @abstractmethod - def mv_set(self, set_id, after_id, dest_job) -> SetView: + def get_job_view(self, job_id): pass @abstractmethod - def mv_job(self, job_id, after_id): + def import_job_from_view(self, job_view): pass + +class AbstractFactoryQueue(AbstractEditableQueue): + """Use for queues where you can construct new jobs/sets""" + @abstractmethod - def edit_job(self, job_id, data): + def add_job(self, name="") -> JobView: pass @abstractmethod - def rm_multi(self, job_ids, set_ids) -> dict: + def add_set(self, job_id, data) -> SetView: pass @abstractmethod diff --git a/continuousprint/queues/lan.py b/continuousprint/queues/lan.py index fb752c6..33ccd40 100644 --- a/continuousprint/queues/lan.py +++ b/continuousprint/queues/lan.py @@ -1,12 +1,14 @@ +import uuid +from bisect import bisect_left from peerprint.lan_queue import LANPrintQueue from ..storage.lan import LANJobView from ..storage.database import JobView from pathlib import Path -from .abstract import AbstractJobQueue, QueueData, Strategy +from .abstract import AbstractEditableQueue, QueueData, Strategy import dataclasses -class LANQueue(AbstractJobQueue): +class LANQueue(AbstractEditableQueue): def __init__( self, ns, @@ -72,61 +74,7 @@ def resolve_set(self, peer, hash_, path) -> str: gjob_dirpath = self._fileshare.fetch(peerstate["fs_addr"], hash_, unpack=True) return str(Path(gjob_dirpath) / path) - # --------- AbstractJobQueue implementation ------ - - def _validate_job(self, j: JobView) -> str: - peer_profiles = set( - [ - p.get("profile", dict()).get("name", "UNKNOWN") - for p in self.lan.q.getPeers().values() - ] - ) - - for s in j.sets: - sprof = set(s.profiles()) - # All sets in the job *must* have an assigned profile - if len(sprof) == 0: - return f"validation for job {j.name} failed - set {s.path} has no assigned profile" - - # At least one printer in the queue must have a compatible proile - if len(peer_profiles.intersection(sprof)) == 0: - return f"validation for job {j.name} failed - no match for set {s.path} with profiles {sprof} (connected printer profiles: {peer_profiles})" - - # All set paths must resolve to actual files - fullpath = self._path_on_disk(s.path, s.sd) - if fullpath is None or not Path(fullpath).exists(): - return f"validation for job {j.name} failed - file not found at {s.path} (is it stored on disk and not SD?)" - - def submit_job(self, j: JobView) -> bool: - err = self._validate_job(j) - if err is not None: - self._logger.warning(err) - return Exception(err) - filepaths = dict([(s.path, self._path_on_disk(s.path, s.sd)) for s in j.sets]) - manifest = j.as_dict() - if manifest.get("created") is None: - manifest["created"] = int(time.time()) - # Note: postJob strips fields from manifest in-place - hash_ = self._fileshare.post(manifest, filepaths) - self.lan.q.setJob(hash_, manifest) - - def reset_jobs(self, job_ids) -> dict: - for jid in job_ids: - j = self.lan.q.jobs.get(jid) - if j is None: - continue - (addr, manifest) = j - - manifest["remaining"] = manifest["count"] - for s in manifest.get("sets", []): - s["remaining"] = s["count"] - self.lan.q.setJob(jid, manifest, addr=addr) - - def remove_jobs(self, job_ids) -> dict: - for jid in job_ids: - self.lan.q.removeJob(jid) - - # --------- AbstractQueue implementation -------- + # --------- begin AbstractQueue -------- def _peek(self): if self.lan is None or self.lan.q is None: @@ -190,10 +138,8 @@ def as_dict(self) -> dict: jobs = [] peers = {} if self.lan.q is not None: + # Note: getJobs() returns jobs in list order jobs = self.lan.q.getJobs() - jobs.sort( - key=lambda j: j["created"] - ) # Always creation order - there is no reordering in lan queue peers = self.lan.q.getPeers() return dataclasses.asdict( @@ -206,3 +152,91 @@ def as_dict(self) -> dict: active_set=self._active_set(), ) ) + + def reset_jobs(self, job_ids) -> dict: + for jid in job_ids: + j = self.lan.q.jobs.get(jid) + if j is None: + continue + (addr, manifest) = j + + manifest["remaining"] = manifest["count"] + for s in manifest.get("sets", []): + s["remaining"] = s["count"] + self.lan.q.setJob(jid, manifest, addr=addr) + + def remove_jobs(self, job_ids) -> dict: + for jid in job_ids: + self.lan.q.removeJob(jid) + + # --------- end AbstractQueue ------ + + # --------- AbstractEditableQueue implementation ------ + + def get_job_view(self, job_id): + peer, manifest = self.lan.q.jobs[job_id] + manifest["peer_"] = peer + return LANJobView(manifest, self) + + def import_job_from_view(self, j): + err = self._validate_job(j) + if err is not None: + self._logger.warning(err) + return Exception(err) + filepaths = dict([(s.path, self._path_on_disk(s.path, s.sd)) for s in j.sets]) + manifest = j.as_dict() + if manifest.get("created") is None: + manifest["created"] = int(time.time()) + # Note: postJob strips fields from manifest in-place + manifest["hash"] = self._fileshare.post(manifest, filepaths) + if manifest.get("id") is None: + manifest["id"] = self._gen_uuid() + self.lan.q.setJob(manifest["id"], manifest) + return manifest["id"] + + def mv_job(self, job_id, after_id): + self.lan.q.jobs.mv(job_id, after_id) + + def _validate_job(self, j: JobView) -> str: + peer_profiles = set( + [ + p.get("profile", dict()).get("name", "UNKNOWN") + for p in self.lan.q.getPeers().values() + ] + ) + + for s in j.sets: + sprof = set(s.profiles()) + # All sets in the job *must* have an assigned profile + if len(sprof) == 0: + return f"validation for job {j.name} failed - set {s.path} has no assigned profile" + + # At least one printer in the queue must have a compatible proile + if len(peer_profiles.intersection(sprof)) == 0: + return f"validation for job {j.name} failed - no match for set {s.path} with profiles {sprof} (connected printer profiles: {peer_profiles})" + + # All set paths must resolve to actual files + fullpath = self._path_on_disk(s.path, s.sd) + if fullpath is None or not Path(fullpath).exists(): + return f"validation for job {j.name} failed - file not found at {s.path} (is it stored on disk and not SD?)" + + def _gen_uuid(self) -> str: + while True: + result = uuid.uuid4() + if not self.lan.q.hasJob(result): + return str(result) + + def edit_job(self, j: JobView) -> bool: + err = self._validate_job(j) + if err is not None: + self._logger.warning(err) + return Exception(err) + filepaths = dict([(s.path, self._path_on_disk(s.path, s.sd)) for s in j.sets]) + manifest = j.as_dict() + if manifest.get("created") is None: + manifest["created"] = int(time.time()) + # Note: postJob strips fields from manifest in-place + manifest["hash"] = self._fileshare.post(manifest, filepaths) + if manifest.get("id") is None: + manifest["id"] = self._gen_uuid() + self.lan.q.setJob(manifest["id"], manifest) diff --git a/continuousprint/queues/local.py b/continuousprint/queues/local.py index 33d0d12..d8f0afe 100644 --- a/continuousprint/queues/local.py +++ b/continuousprint/queues/local.py @@ -1,4 +1,4 @@ -from .abstract import Strategy, QueueData, AbstractEditableQueue +from .abstract import Strategy, QueueData, AbstractFactoryQueue import tempfile import os from ..storage.database import JobView, SetView @@ -7,7 +7,7 @@ import dataclasses -class LocalQueue(AbstractEditableQueue): +class LocalQueue(AbstractFactoryQueue): def __init__( self, queries, @@ -98,7 +98,7 @@ def as_dict(self) -> dict: ) def remove_jobs(self, job_ids): - return self.rm_multi(job_ids=job_ids) + return self.queries.remove(job_ids=job_ids) def reset_jobs(self, job_ids): return self.queries.resetJobs(job_ids) @@ -107,14 +107,22 @@ def reset_jobs(self, job_ids): # -------------- begin AbstractEditableQueue ----------- - def add_job(self, name="") -> JobView: - return self.queries.newEmptyJob(self.ns, name) - - def add_set(self, job_id, data) -> SetView: - return self.queries.appendSet(self.ns, job_id, data) - - def mv_set(self, set_id, after_id, dest_job): - return self.queries.moveSet(set_id, after_id, dest_job) + def get_job_view(self, job_id): + return self.queries.getJob(job_id) + + def import_job_from_view(self, v): + manifest = v.as_dict() + # TODO make transaction, move to storage/queries.py + j = self.add_job() + for (k, v) in manifest.items(): + if k in ("peer_", "sets", "id", "acquired"): + continue + setattr(j, k, v) + j.save() + for s in manifest["sets"]: + del s["id"] + self.add_set(j.id, s) + return j.id def mv_job(self, job_id, after_id): return self.queries.moveJob(job_id, after_id) @@ -122,8 +130,15 @@ def mv_job(self, job_id, after_id): def edit_job(self, job_id, data): return self.queries.updateJob(job_id, data) - def rm_multi(self, job_ids=[], set_ids=[]) -> dict: - return self.queries.remove(job_ids=job_ids, set_ids=set_ids) + # ------------------- end AbstractEditableQueue --------------- + + # ------------ begin AbstractFactoryQueue ------ + + def add_job(self, name="") -> JobView: + return self.queries.newEmptyJob(self.ns, name) + + def add_set(self, job_id, data) -> SetView: + return self.queries.appendSet(self.ns, job_id, data) def import_job(self, gjob_path: str, draft=True) -> dict: out_dir = str(Path(gjob_path).stem) @@ -150,4 +165,4 @@ def export_job(self, job_id: int, dest_dir: str) -> str: os.rename(tf.name, path) return path - # ------------------- end AbstractEditableQueue --------------- + # ------------------- end AbstractFactoryQueue --------------- diff --git a/continuousprint/queues/local_test.py b/continuousprint/queues/local_test.py index 7e27306..f18b5fe 100644 --- a/continuousprint/queues/local_test.py +++ b/continuousprint/queues/local_test.py @@ -115,6 +115,8 @@ def as_dict(self): ) +# TODO test mv_job + # TODO test SD card behavior on importing/exporting and printing # class TestSD(unittest.TestCase): # def testSDExport(self): diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index df956db..d035914 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -227,35 +227,22 @@ function CPViewModel(parameters) { self.draggingSet(false); self.draggingJob(false); - // If we're dragging a job out of the local queue and into a network queue, - // we must warn the user about the irreversable action before making the change. - // This fully replaces the default sort action - if (evt.from.classList.contains("local") && !evt.to.classList.contains("local")) { - let targetQueue = ko.dataFor(evt.to); - // Undo the move done by CPSortable and trigger updates - // This is inefficient (i.e. could instead prevent the transfer) but that - // would require substantial edits to the CPSortable library. - targetQueue.jobs.splice(evt.newIndex, 1); - src.jobs.splice(evt.oldIndex, 0, vm); - src.jobs.valueHasMutated(); - targetQueue.jobs.valueHasMutated(); - - return self.showSubmitJobDialog(vm, targetQueue); - } - // Sadly there's no "destination job" information, so we have to // infer the index of the job based on the rendered HTML given by evt.to if (vm.constructor.name === "CPJob") { let jobs = self.defaultQueue.jobs(); - let dest_idx = jobs.indexOf(vm); + let destq = ko.dataFor(evt.to); + let dest_idx = destq.jobs().indexOf(vm); let ids = [] for (let j of jobs) { ids.push(j.id()); } self.api.mv(self.api.JOB, { + src_queue: src.name, + dest_queue: ko.dataFor(evt.to).name, id: vm.id(), - after_id: (dest_idx > 0) ? jobs[dest_idx-1].id() : -1 + after_id: (dest_idx > 0) ? destq.jobs()[dest_idx-1].id() : null }, (result) => {}); } }; @@ -343,35 +330,6 @@ function CPViewModel(parameters) { self.hasSpoolManager(statusCode !== 404); }); - - self.dialog = $("#cpq_submitDialog"); - self.jobSendDetails = ko.observable(); - self.jobSendTitle = ko.computed(function() { - let details = self.jobSendDetails(); - if (details === undefined) { - return ""; - } - return `Send ${details[0]._name()} to ${details[1].name}?`; - }); - self.submitJob = function() { - let details = self.jobSendDetails(); - self.api.submit(self.api.JOB, {id: details[0].id(), queue: details[1].name}, (result) => { - if (result.error) { - self.onDataUpdaterPluginMessage("continuousprint", {type: "error", msg: result.error}); - } else { - self._setState(result); - } - }); - self.dialog.modal('hide'); - } - self.showSubmitJobDialog = function(job, queue) { - self.jobSendDetails([job, queue]); - self.dialog.modal({}).css({ - width: 'auto', - 'margin-left': function() { return -($(this).width() /2); } - }); - } - self.humanize = function(num) { // Humanizes numbers by condensing and adding units if (num < 1000) { diff --git a/continuousprint/storage/queries.py b/continuousprint/storage/queries.py index f1c9908..99320bb 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -274,17 +274,6 @@ def moveJob(src_id: int, dest_id: int): return _moveImpl(Job, j, dest_id) -def moveSet(src_id: int, dest_id: int, dest_job: int): - s = Set.get(id=src_id) - if dest_job == -1: - j = newEmptyJob(s.job.queue) - else: - j = Job.get(id=dest_job) - s.job = j - s.save() - _moveImpl(Set, s, dest_id) - - def newEmptyJob(q, name="", rank=_rankEnd): if type(q) == str: q = Queue.get(name=q) diff --git a/continuousprint/storage/queries_test.py b/continuousprint/storage/queries_test.py index 3ced028..5ae03f8 100644 --- a/continuousprint/storage/queries_test.py +++ b/continuousprint/storage/queries_test.py @@ -300,22 +300,6 @@ def testMoveJob(self): q.moveJob(*moveArgs) self.assertEqual([j.id for j in q.getJobsAndSets(DEFAULT_QUEUE)], want) - def testMoveSet(self): - for (desc, moveArgs, want) in [ - ("FirstToLast", (1, 2, 1), [2, 1, 3, 4]), - ("LastToFirst", (2, -1, 1), [2, 1, 3, 4]), - ("DiffJob", (1, 3, 2), [2, 3, 1, 4]), - ("NewJob", (1, -1, -1), [2, 3, 4, 1]), - ]: - with self.subTest(f"{desc}: moveSet({moveArgs})"): - q.moveSet(*moveArgs) - set_order = [ - (s["id"], s["rank"]) - for j in q.getJobsAndSets(DEFAULT_QUEUE) - for s in j.as_dict()["sets"] - ] - self.assertEqual(set_order, [(w, ANY) for w in want]) - def testGetNextJobAfterDecrement(self): j = q.getNextJobInQueue(DEFAULT_QUEUE, PROFILE) s = j.sets[0] diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index 1ef1937..e38696d 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -1,31 +1,3 @@ -