Compare commits

...

2 commits

18 changed files with 294 additions and 92 deletions

View file

@ -1,4 +1,5 @@
SECRET_KEY = "SgvzxsO5oPBGInmqsyyGQWAJXkS9" SECRET_KEY = "SgvzxsO5oPBGInmqsyyGQWAJXkS9"
UPLOAD_FOLDER = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tmp"
[db] [db]
uri = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tentacles.sqlite3" uri = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tentacles.sqlite3"

View file

@ -8,7 +8,7 @@ from flask import Flask, request
import tomllib import tomllib
from datetime import datetime 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.store import Store
from tentacles.globals import _ctx, Ctx, ctx from tentacles.globals import _ctx, Ctx, ctx
from tentacles.workers import create_workers from tentacles.workers import create_workers
@ -78,6 +78,8 @@ def serve(hostname: str, port: int, config: Path):
# Blueprints # Blueprints
app.register_blueprint(user_ui.BLUEPRINT) app.register_blueprint(user_ui.BLUEPRINT)
app.register_blueprint(printer_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) app.register_blueprint(api.BLUEPRINT)
# Shove our middleware in there # Shove our middleware in there

View file

@ -2,8 +2,14 @@
"""API endpoints supporting the 'ui'.""" """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") BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
@ -12,17 +18,25 @@ BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
# Printers # Printers
# #
# The trick here is handling multipart uploads. # The trick here is handling multipart uploads.
@BLUEPRINT.route("/printer", methods=["POST"]) @requires_admin
@BLUEPRINT.route("/printers", methods=["POST"])
def create_printer(): def create_printer():
pass pass
@BLUEPRINT.route("/printer", methods=["GET"]) @requires_auth
@BLUEPRINT.route("/printers", methods=["GET"])
@BLUEPRINT.route("/printers/", methods=["GET"])
def list_printers(): 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(): def delete_printer():
pass pass
@ -31,40 +45,100 @@ def delete_printer():
# Files # Files
# #
# The trick here is handling multipart uploads. # The trick here is handling multipart uploads.
@BLUEPRINT.route("/file", methods=["POST"]) @requires_auth
def create_file(): @BLUEPRINT.route("/files", methods=["POST"])
pass @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(): 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(): def delete_file():
pass pass
#################################################################################################### ####################################################################################################
# Jobs # Jobs
@BLUEPRINT.route("/job", methods=["POST"]) @requires_auth
@BLUEPRINT.route("/jobs", methods=["POST"])
def create_job(): def create_job():
pass pass
@BLUEPRINT.route("/job", methods=["GET"]) @requires_auth
@BLUEPRINT.route("/jobs", methods=["GET"])
def get_jobs(): 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(): def delete_job():
pass pass
#################################################################################################### ####################################################################################################
# API tokens # API tokens
@BLUEPRINT.route("/token", methods=["GET"]) @requires_auth
@BLUEPRINT.route("/tokens", methods=["GET"])
def get_tokens(): def get_tokens():
pass pass

View file

@ -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")

View 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("/")

View file

@ -1,10 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Blueprints for HTML serving 'ui'."""
import logging import logging
from flask import ( from flask import (
Blueprint, Blueprint,
request, request,

View file

@ -1,7 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Blueprints for HTML serving 'ui'."""
import logging import logging
from datetime import timedelta from datetime import timedelta
import re import re

View file

@ -31,3 +31,14 @@ def requires_admin(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return _helper 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

View file

@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT id INTEGER PRIMARY KEY AUTOINCREMENT
, user_id INTEGER , user_id INTEGER
, filename TEXT , filename TEXT
, path TEXT
, upload_date TEXT , upload_date TEXT
, FOREIGN KEY(user_id) REFERENCES user(id) , FOREIGN KEY(user_id) REFERENCES user(id)
); );

View file

@ -274,7 +274,7 @@ class Store(object):
""" """
SELECT p.id SELECT p.id
FROM printers p 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 WHERE j.id IS NULL
""" """
).fetchall() ).fetchall()
@ -298,8 +298,8 @@ class Store(object):
@requires_conn @requires_conn
def create_file(self, uid: int, name: str, path: Path) -> int: def create_file(self, uid: int, name: str, path: Path) -> int:
return self._conn.execute( return self._conn.execute(
"INSERT INTO files (user_id, filename, upload_date) VALUES (?, ?, datetime('now')) RETURNING (id)", "INSERT INTO files (user_id, filename, path, upload_date) VALUES (?, ?, ?, datetime('now')) RETURNING (id)",
[uid, name], [uid, name, path],
).fetchone() ).fetchone()
@requires_conn @requires_conn
@ -341,11 +341,11 @@ class Store(object):
@requires_conn @requires_conn
def list_jobs(self, uid: Optional[int] = None): def list_jobs(self, uid: Optional[int] = None):
"""Enumerate jobs in priority order.""" """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( return self._conn.execute(
f""" f"""
SELECT * FROM jobs SELECT * FROM jobs
WHERE started_at IS NULL AND printer_id IS NULL {cond} WHERE {cond}
ORDER BY priority DESC ORDER BY priority DESC
""", """,
[], [],
@ -377,6 +377,7 @@ class Store(object):
[], [],
).fetchall() ).fetchall()
@fmap(one)
@requires_conn @requires_conn
def poll_job_queue(self): def poll_job_queue(self):
return self._conn.execute( return self._conn.execute(

View file

@ -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>

View file

@ -1,34 +1,8 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
<div class="panel queue"> {% include "jobs_list.html.j2" %}
<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>
{% if request.uid %} {% if ctx.uid %}
<div class="panel files"> {% include "files_list.html.j2" %}
<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>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,4 @@
{% extends "base.html.j2" %}
{% block content %}
{% include "jobs_list.html.j2" %}
{% endblock %}

View file

@ -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>

View file

@ -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 %}

View file

@ -1,31 +1,4 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
<div class="printers"> {% include "printers_list.html.j2" %}
<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>
{% endblock %} {% endblock %}

View file

@ -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>

View file

@ -15,6 +15,7 @@ import logging
from contextlib import closing from contextlib import closing
from urllib import parse as urlparse from urllib import parse as urlparse
from tentacles.store import Store from tentacles.store import Store
from pathlib import Path
from octorest import OctoRest as _OR from octorest import OctoRest as _OR
from requests import Response from requests import Response
@ -100,12 +101,9 @@ def assign_jobs(db_factory: Callable[[], Store]) -> None:
with closing(db_factory()) as db: with closing(db_factory()) as db:
for printer_id in db.list_idle_printers(): for printer_id in db.list_idle_printers():
printer = db.fetch_printer(printer_id) if job_id := db.poll_job_queue():
if printer.status != "idle": db.assign_job(job_id, printer_id)
continue print(f"Mapped job {job_id} to printer {printer_id}")
if next_job_id := db.poll_job_queue():
db.assign_job(next_job_id, printer_id)
@corn_job(timedelta(seconds=5)) @corn_job(timedelta(seconds=5))
@ -117,9 +115,21 @@ def push_jobs(db_factory: Callable[[], Store]) -> None:
printer = db.fetch_printer(job.printer_id) printer = db.fetch_printer(job.printer_id)
file = db.fetch_file(job.file_id) file = db.fetch_file(job.file_id)
try: try:
client = OctoRest(printer.url, printer.api_key) client = OctoRest(url=printer.url, apikey=printer.api_key)
client.upload(file.filename, select=True, print=True) 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) db.start_job(job.id)
except TimeoutError:
pass
except Exception: except Exception:
log.exception("Oop") log.exception("Oop")
@ -132,6 +142,7 @@ def pull_jobs(db_factory: Callable[[], Store]) -> None:
for job in db.list_running_jobs(): for job in db.list_running_jobs():
printer = db.fetch_printer(job.printer_id) printer = db.fetch_printer(job.printer_id)
if printer.status != "running": if printer.status != "running":
print(f"Job {job.id} finished {printer.status}")
db.finish_job(job.id, printer.status) db.finish_job(job.id, printer.status)