Compare commits

..

2 commits

18 changed files with 294 additions and 92 deletions

View file

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

View file

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

View file

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

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
"""Blueprints for HTML serving 'ui'."""
import logging
from flask import (
Blueprint,
request,

View file

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

View file

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

View file

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

View file

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

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" %}
{% 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 %}

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" %}
{% 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 %}

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