Compare commits
2 commits
5009cf2826
...
5709cac469
Author | SHA1 | Date | |
---|---|---|---|
5709cac469 | |||
b017108700 |
18 changed files with 294 additions and 92 deletions
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/<location>", 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
|
||||
|
|
|
@ -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")
|
32
projects/tentacles/src/python/tentacles/blueprints/job_ui.py
Normal file
32
projects/tentacles/src/python/tentacles/blueprints/job_ui.py
Normal file
|
@ -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("/")
|
|
@ -1,10 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""Blueprints for HTML serving 'ui'."""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
request,
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""Blueprints for HTML serving 'ui'."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
{% import "macros.html.j2" as macros %}
|
||||
<div class="panel files">
|
||||
<h2>Files</h2>
|
||||
{% with files = ctx.db.list_files(uid=ctx.uid) %}
|
||||
{% if files %}
|
||||
<ul>
|
||||
{% for file in files %}
|
||||
<li class="file">
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
<span class="file-controls">
|
||||
{{ macros.start_job(file.id) }}
|
||||
{{ macros.delete_file(file.id) }}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
You don't have any files. Upload something!
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<div class="panel file-upload">
|
||||
<h2>Upload a file</h2>
|
||||
<form method="post" action="/api/files/local" enctype="multipart/form-data">
|
||||
<input type="hidden" name="select" value="false" />
|
||||
<input type="hidden" name="start" value="false" />
|
||||
<input type="file" name="file" accept=".gcode,text/plain" />
|
||||
<span><input id="submit" type="submit" value="Upload"/></span>
|
||||
</form>
|
||||
</div>
|
|
@ -1,34 +1,8 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% block content %}
|
||||
<div class="panel queue">
|
||||
<h2>Queue</h2>
|
||||
{% with jobs = ctx.db.list_jobs(uid=request.uid) %}
|
||||
{% if jobs %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
<li></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
No pending tasks. {% if request.uid %}Start something!{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% include "jobs_list.html.j2" %}
|
||||
|
||||
{% if request.uid %}
|
||||
<div class="panel files">
|
||||
<h2>Files</h2>
|
||||
{% with files = ctx.db.list_files(uid=request.uid) %}
|
||||
{% if files %}
|
||||
<ul>
|
||||
{% for file in files %}
|
||||
<li></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
You don't have any files. Upload something!
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if ctx.uid %}
|
||||
{% include "files_list.html.j2" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% block content %}
|
||||
{% include "jobs_list.html.j2" %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,18 @@
|
|||
<div class="panel queue">
|
||||
<h2>Job queue</h2>
|
||||
{% with jobs = ctx.db.list_jobs(uid=ctx.uid) %}
|
||||
{% if jobs %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
<li class="job">
|
||||
<span class="job-id">{{job.id}}</span>
|
||||
<span class="job-filename">{{ctx.db.fetch_file(job.file_id).filename}}</span>
|
||||
<span class="job-status">{{ 'pending' if not job.printer_id else 'uploading' if not job.started_at else 'running' if not job.finished_at else job.state }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
No pending tasks. {% if ctx.uid %}Start something!{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
|
@ -0,0 +1,15 @@
|
|||
{% macro start_job(id) %}
|
||||
<form method="post" action="/jobs">
|
||||
<input type="hidden" name="action" value="queue" />
|
||||
<input type="hidden" name="file_id" value="{{ id }}" />
|
||||
<span><input id="submit" type="submit" value="Queue"/></span>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro delete_file(id) %}
|
||||
<form method="post" action="/files">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<input type="hidden" name="id" value="{{ id }}" />
|
||||
<span><input id="submit" type="submit" value="Delete"/></span>
|
||||
</form>
|
||||
{% endmacro %}
|
|
@ -1,31 +1,4 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% block content %}
|
||||
<div class="printers">
|
||||
<h2>Printers</h2>
|
||||
{% with printers = ctx.db.list_printers() %}
|
||||
{% if printers %}
|
||||
<ul>
|
||||
{% for printer in printers %}
|
||||
{% with id, name, url, _api_key, last_poll, status = printer %}
|
||||
<li class="printer row">
|
||||
<span class="printer-name">{{name}}</span>
|
||||
<span class="printer-url"><code>{{url}}</code></span>
|
||||
<span class="printer-status">{{status}}</span>
|
||||
<span class="printer-date">{{last_poll}}</span>
|
||||
{# FIXME: How should these action buttons work? #}
|
||||
<span class="printer-controls ml-auto">
|
||||
<a class="button" href="/printers/test?id={{id}}">Test</a>
|
||||
<a class="button" href="/printers/edit?id={{id}}">Edit</a>
|
||||
<a class="button" href="/printers/delete?id={{id}}">Remove</a>
|
||||
</span>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if ctx.is_admin %}<a class="button" href="/printers/add">Add a printer</a>{% endif %}
|
||||
{% else %}
|
||||
No printers available. {% if ctx.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% include "printers_list.html.j2" %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
<div class="printers">
|
||||
<h2>Printers</h2>
|
||||
{% with printers = ctx.db.list_printers() %}
|
||||
{% if printers %}
|
||||
<ul>
|
||||
{% for printer in printers %}
|
||||
{% with id, name, url, _api_key, last_poll, status = printer %}
|
||||
<li class="printer row">
|
||||
<span class="printer-name">{{name}}</span>
|
||||
<span class="printer-url"><code>{{url}}</code></span>
|
||||
<span class="printer-status">{{status}}</span>
|
||||
<span class="printer-date">{{last_poll}}</span>
|
||||
{# FIXME: How should these action buttons work? #}
|
||||
<span class="printer-controls ml-auto">
|
||||
<a class="button" href="/printers/test?id={{id}}">Test</a>
|
||||
<a class="button" href="/printers/edit?id={{id}}">Edit</a>
|
||||
<a class="button" href="/printers/delete?id={{id}}">Remove</a>
|
||||
</span>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if ctx.is_admin %}<a class="button" href="/printers/add">Add a printer</a>{% endif %}
|
||||
{% else %}
|
||||
No printers available. {% if ctx.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
|
@ -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,9 +115,21 @@ 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 TimeoutError:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
log.exception("Oop")
|
||||
|
||||
|
@ -132,6 +142,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)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue