Register/Login/Logout somewhat working
This commit is contained in:
parent
e666189e66
commit
b524cf941e
22 changed files with 581 additions and 109 deletions
|
@ -7,5 +7,8 @@ py_project(
|
||||||
py_requirement("click"),
|
py_requirement("click"),
|
||||||
py_requirement("flask"),
|
py_requirement("flask"),
|
||||||
py_requirement("jinja2"),
|
py_requirement("jinja2"),
|
||||||
]
|
],
|
||||||
|
main_data = [
|
||||||
|
"//projects/tentacles/src/python/tentacles/static/css",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,7 +6,7 @@ A simple queue system for OctoPrint, designed to receive jobs and forward them t
|
||||||
|
|
||||||
Username+Password users
|
Username+Password users
|
||||||
|
|
||||||
API keys mapped to users
|
API keys mapped to users (same thing as sessions actually)
|
||||||
|
|
||||||
Users mapped to job priority
|
Users mapped to job priority
|
||||||
|
|
||||||
|
@ -34,7 +34,8 @@ Checking bed status on a printer
|
||||||
-X POST \
|
-X POST \
|
||||||
-H 'Accept: application/json' \
|
-H 'Accept: application/json' \
|
||||||
-H 'Content-Type: application/json; charset=UTF-8' \
|
-H 'Content-Type: application/json; charset=UTF-8' \
|
||||||
-H 'Authorization: Bearer ...' \
|
-H 'Authorization: Bearer ...No creds
|
||||||
|
' \
|
||||||
--data-raw '{"command":"check_bed","reference":"reference_2023-05-11T05:22:40.212Z.jpg"}' \
|
--data-raw '{"command":"check_bed","reference":"reference_2023-05-11T05:22:40.212Z.jpg"}' \
|
||||||
| jq .
|
| jq .
|
||||||
|
|
||||||
|
|
2
projects/tentacles/config.toml
Normal file
2
projects/tentacles/config.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[db]
|
||||||
|
uri = "tentacles.sqlite3"
|
|
@ -4,8 +4,13 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
import click
|
import click
|
||||||
import flask
|
from flask import Flask, request, session, current_app
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from tentacles.blueprints import ui, api
|
||||||
|
from tentacles.store import Store
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
|
@ -13,11 +18,49 @@ def cli():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def open_db():
|
||||||
|
current_app.db = Store(current_app.config.get("db", {}).get("uri"))
|
||||||
|
current_app.db.connect()
|
||||||
|
|
||||||
|
|
||||||
|
def commit_db(resp):
|
||||||
|
current_app.db.commit()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def user_session():
|
||||||
|
if (session_id := request.cookies.get("sid", "")) and (
|
||||||
|
uid := current_app.db.try_key(session_id)
|
||||||
|
):
|
||||||
|
request.sid = session_id
|
||||||
|
request.uid = uid
|
||||||
|
_, gid, name, _ = current_app.db.fetch_user(uid)
|
||||||
|
request.gid = gid
|
||||||
|
request.username = name
|
||||||
|
else:
|
||||||
|
request.sid = request.uid = request.gid = request.username = None
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def serve():
|
@click.option("--hostname", "hostname", type=str, default="0.0.0.0")
|
||||||
app = flask.Flask()
|
@click.option("--port", "port", type=int, default=8080)
|
||||||
app.register_blueprint()
|
@click.option("--config", type=Path)
|
||||||
app.run()
|
def serve(hostname: str, port: int, config: Path):
|
||||||
|
app = Flask(__name__)
|
||||||
|
if config:
|
||||||
|
with open(config, "rb") as fp:
|
||||||
|
app.config.update(tomllib.load(fp))
|
||||||
|
|
||||||
|
print(app.config)
|
||||||
|
|
||||||
|
app.before_first_request(open_db)
|
||||||
|
app.before_request(user_session)
|
||||||
|
app.after_request(commit_db)
|
||||||
|
|
||||||
|
app.register_blueprint(ui.BLUEPRINT)
|
||||||
|
app.register_blueprint(api.BLUEPRINT)
|
||||||
|
|
||||||
|
app.run(host=hostname, port=port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""Blueprints for the Tentacles app."""
|
|
||||||
|
|
||||||
from flask import Blueprint
|
|
||||||
|
|
||||||
BLUEPRINT = Blueprint(__name__, __qualname__, template_folder=__package__)
|
|
72
projects/tentacles/src/python/tentacles/blueprints/api.py
Normal file
72
projects/tentacles/src/python/tentacles/blueprints/api.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""API endpoints supporting the 'ui'."""
|
||||||
|
|
||||||
|
from importlib.resources import files
|
||||||
|
from sys import meta_path, prefix
|
||||||
|
|
||||||
|
from flask import Blueprint, request, Request, redirect
|
||||||
|
|
||||||
|
BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
# Printers
|
||||||
|
#
|
||||||
|
# The trick here is handling multipart uploads.
|
||||||
|
@BLUEPRINT.route("/printer", methods=["POST"])
|
||||||
|
def create_printer():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/printer", methods=["GET"])
|
||||||
|
def list_printers():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/printer", methods=["DELETE"])
|
||||||
|
def delete_printer():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
# Files
|
||||||
|
#
|
||||||
|
# The trick here is handling multipart uploads.
|
||||||
|
@BLUEPRINT.route("/file", methods=["POST"])
|
||||||
|
def create_file():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/file", methods=["GET"])
|
||||||
|
def get_files():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/file", methods=["DELETE"])
|
||||||
|
def delete_file():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
# Jobs
|
||||||
|
@BLUEPRINT.route("/job", methods=["POST"])
|
||||||
|
def create_job():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/job", methods=["GET"])
|
||||||
|
def get_jobs():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/job", methods=["DELETE"])
|
||||||
|
def delete_job():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
# API tokens
|
||||||
|
@BLUEPRINT.route("/token", methods=["GET"])
|
||||||
|
def get_tokens():
|
||||||
|
pass
|
86
projects/tentacles/src/python/tentacles/blueprints/ui.py
Normal file
86
projects/tentacles/src/python/tentacles/blueprints/ui.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Blueprints for HTML serving 'ui'."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
current_app,
|
||||||
|
request,
|
||||||
|
Request,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
BLUEPRINT = Blueprint("ui", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_logged_in(authorization):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/")
|
||||||
|
def root():
|
||||||
|
return (
|
||||||
|
render_template(
|
||||||
|
"index.html.j2",
|
||||||
|
request=request,
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if is_logged_in(request.authorization):
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
elif request.method == "POST":
|
||||||
|
if sid := current_app.db.try_login(
|
||||||
|
request.form["username"], request.form["password"], timedelta(days=1)
|
||||||
|
):
|
||||||
|
resp = redirect("/")
|
||||||
|
resp.set_cookie("sid", sid)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
else:
|
||||||
|
return render_template("login.html.j2", error="Incorrect username/password")
|
||||||
|
|
||||||
|
else:
|
||||||
|
return render_template("login.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
if is_logged_in(request.authorization):
|
||||||
|
return redirect("/")
|
||||||
|
elif request.method == "POST":
|
||||||
|
try:
|
||||||
|
if uid := current_app.db.try_create_user(
|
||||||
|
request.form["username"], request.form["password"]
|
||||||
|
):
|
||||||
|
return render_template(
|
||||||
|
"login.html.j2", message="Success, please log in"
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"login.html.j2", error="Unable to register that username"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return render_template("login.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/logout")
|
||||||
|
def logout():
|
||||||
|
# Invalidate the user's authorization
|
||||||
|
current_app.db.delete_key(request.sid)
|
||||||
|
resp = redirect("/")
|
||||||
|
resp.set_cookie("sid", "")
|
||||||
|
return resp
|
76
projects/tentacles/src/python/tentacles/schema.sql
Normal file
76
projects/tentacles/src/python/tentacles/schema.sql
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, name TEXT
|
||||||
|
, priority INTEGER CHECK(priority IS NOT NULL AND priority > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'root', 20);
|
||||||
|
INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'users', 10);
|
||||||
|
INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'guests', 0);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, group_id INTEGER
|
||||||
|
, name TEXT
|
||||||
|
, hash TEXT
|
||||||
|
, FOREIGN KEY(group_id) REFERENCES groups(id)
|
||||||
|
, UNIQUE(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
-- Keys represent API keys and auth sessions
|
||||||
|
CREATE TABLE IF NOT EXISTS user_keys (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(48))))
|
||||||
|
, user_id INTEGER
|
||||||
|
, expiration TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
-- Printers represent physical devices (really octoprint API targets) that jobs can go to
|
||||||
|
CREATE TABLE IF NOT EXISTs printer_statuses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, name TEXT
|
||||||
|
, UNIQUE(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO printer_statuses (id, name) values (-1, 'error');
|
||||||
|
INSERT OR IGNORE INTO printer_statuses (id, name) values (0, 'disconnected');
|
||||||
|
INSERT OR IGNORE INTO printer_statuses (id, name) values (1, 'connected');
|
||||||
|
INSERT OR IGNORE INTO printer_statuses (id, name) values (2, 'idle');
|
||||||
|
INSERT OR IGNORE INTO printer_statuses (id, name) values (3, 'running');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS printers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, url TEXT
|
||||||
|
, api_key TEXT
|
||||||
|
, status_id INTEGER
|
||||||
|
, last_poll_date TEXT
|
||||||
|
, FOREIGN KEY(status_id) REFERENCES printer_statuses(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
-- Files are printables
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, user_id INTEGER
|
||||||
|
, filename TEXT
|
||||||
|
, upload_date TEXT
|
||||||
|
, FOREIGN KEY(user_id) REFERENCES user(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
-- A job is a request for a copy of a file to be run off
|
||||||
|
-- For simplicity, jobs also serve as scheduling records
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, user_id INTEGER NOT NULL
|
||||||
|
, file_id INTEGER NOT NULL
|
||||||
|
, priority INTEGER CHECK(priority IS NOT NULL AND 0 <= priority)
|
||||||
|
, started_at TEXT
|
||||||
|
, finished_at TEXT
|
||||||
|
, state TEXT
|
||||||
|
, printer_id INTEGER
|
||||||
|
, FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
, FOREIGN KEY(file_id) REFERENCES files(id)
|
||||||
|
, FOREIGN KEY(printer_id) REFERENCES printer(id)
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
#!/usr/bin/env python3
|
20
projects/tentacles/src/python/tentacles/static/css/BUILD
Normal file
20
projects/tentacles/src/python/tentacles/static/css/BUILD
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
sass_library(
|
||||||
|
name = "lib",
|
||||||
|
srcs = glob(["**/_*.scss"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
sass_binary(
|
||||||
|
name = "style",
|
||||||
|
src = "style.scss",
|
||||||
|
deps = [
|
||||||
|
":lib"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
filegroup(
|
||||||
|
name = "css",
|
||||||
|
srcs = [
|
||||||
|
":style",
|
||||||
|
],
|
||||||
|
visibility = ["//projects/tentacles:__pkg__"],
|
||||||
|
)
|
|
@ -0,0 +1,9 @@
|
||||||
|
$black: #171426;
|
||||||
|
$beige: #F4F8EE;
|
||||||
|
$red: #BB2D2E;
|
||||||
|
$orange: #CA4F1F;
|
||||||
|
$yellow: #EDB822;
|
||||||
|
$secondary_blue: #288BC2;
|
||||||
|
$secondary_green: #A5C426;
|
||||||
|
$secondary_light_grey: #CACBCA;
|
||||||
|
$secondary_dark_grey: #9A9A9A;
|
|
@ -0,0 +1,89 @@
|
||||||
|
// @use 'tirefire' as tfi;
|
||||||
|
|
||||||
|
$black: #171426;
|
||||||
|
$beige: #F4F8EE;
|
||||||
|
$red: #BB2D2E;
|
||||||
|
$orange: #CA4F1F;
|
||||||
|
$yellow: #EDB822;
|
||||||
|
$secondary_blue: #288BC2;
|
||||||
|
$secondary_green: #A5C426;
|
||||||
|
$secondary_light_grey: #CACBCA;
|
||||||
|
$secondary_dark_grey: #9A9A9A;
|
||||||
|
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aaux Next';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/AauxNextBlk.woff') format('woff');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aaux Next';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/aauxnextbdwebfont.woff') format('woff');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aaux Next';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/aauxnextltwebfont.woff') format('woff');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aaux Next';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/aauxnextmdwebfont.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Aaux Next', sans-serif;
|
||||||
|
background-color: $beige;
|
||||||
|
color: $black;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: $orange 10px solid;
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: $secondary_green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover a {
|
||||||
|
color: $secondary_blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 175px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -5,80 +5,13 @@ from hashlib import sha3_256
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from textwrap import indent
|
from textwrap import indent
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
from collections import namedtuple
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
PRELUDE = """
|
with files(__package__).joinpath("schema.sql").open("r") as fp:
|
||||||
CREATE TABLE IF NOT EXISTS groups (
|
PRELUDE = fp.read()
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, name TEXT
|
|
||||||
, priority INTEGER CHECK(priority IS NOT NULL AND priority > 0)
|
|
||||||
DEFAULT 100
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, group_id INTEGER
|
|
||||||
, name TEXT
|
|
||||||
, hash TEXT
|
|
||||||
, FOREIGN KEY(group_id) REFERENCES groups(id)
|
|
||||||
, UNIQUE(name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_keys (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(48))))
|
|
||||||
, user_id INTEGER
|
|
||||||
, expiration TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS files (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, user_id INTEGER
|
|
||||||
, filename TEXT
|
|
||||||
, upload_date TEXT
|
|
||||||
, FOREIGN KEY(user_id) REFERENCES user(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, user_id INTEGER
|
|
||||||
, FOREIGN KEY(user_id) REFERENCES user(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTs printer_statuses (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, name TEXT
|
|
||||||
, UNIQUE(name)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO printer_statuses (name) values ('disconnected');
|
|
||||||
INSERT OR IGNORE INTO printer_statuses (name) values ('connected');
|
|
||||||
INSERT OR IGNORE INTO printer_statuses (name) values ('idle');
|
|
||||||
INSERT OR IGNORE INTO printer_statuses (name) values ('running');
|
|
||||||
INSERT OR IGNORE INTO printer_statuses (name) values ('error');
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS printers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, url TEXT
|
|
||||||
, api_key TEXT
|
|
||||||
, status_id INTEGER
|
|
||||||
, last_poll_date TEXT
|
|
||||||
, FOREIGN KEY(status_id) REFERENCES printer_statuses(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS runs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
, job_id INTEGER
|
|
||||||
, started_at TEXT
|
|
||||||
, finished_at TEXT
|
|
||||||
, state TEXT
|
|
||||||
, printer_id INTEGER
|
|
||||||
, FOREIGN KEY(job_id) REFERENCES job(id)
|
|
||||||
, FOREIGN KEY(printer_id) REFERENCES printer(id)
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def requires_conn(f):
|
def requires_conn(f):
|
||||||
|
@ -90,6 +23,28 @@ def requires_conn(f):
|
||||||
return _helper
|
return _helper
|
||||||
|
|
||||||
|
|
||||||
|
def fmap(ctor):
|
||||||
|
"""Fmap a constructor over the records returned from a query function."""
|
||||||
|
|
||||||
|
def _h1(f):
|
||||||
|
def _h2(*args, **kwargs):
|
||||||
|
res = f(*args, **kwargs)
|
||||||
|
if isinstance(res, list):
|
||||||
|
return [ctor(*it) for it in res]
|
||||||
|
elif isinstance(res, tuple):
|
||||||
|
return ctor(*res)
|
||||||
|
|
||||||
|
return _h2
|
||||||
|
|
||||||
|
return _h1
|
||||||
|
|
||||||
|
|
||||||
|
def one(it, *args, **kwargs):
|
||||||
|
assert not args
|
||||||
|
assert not kwargs
|
||||||
|
return it
|
||||||
|
|
||||||
|
|
||||||
class Store(object):
|
class Store(object):
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
self._path = path
|
self._path = path
|
||||||
|
@ -106,12 +61,17 @@ class Store(object):
|
||||||
f"Unable to execute startup script:\n{indent(hunk, ' > ')}"
|
f"Unable to execute startup script:\n{indent(hunk, ' > ')}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
@requires_conn
|
||||||
|
def commit(self):
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self._conn:
|
if self._conn:
|
||||||
self._conn.commit()
|
self.commit()
|
||||||
self._conn.close()
|
self._conn.close()
|
||||||
self._conn = None
|
self._conn = None
|
||||||
|
|
||||||
|
@fmap(one)
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def try_create_user(self, username, password):
|
def try_create_user(self, username, password):
|
||||||
"""Attempt to create a new user."""
|
"""Attempt to create a new user."""
|
||||||
|
@ -121,7 +81,19 @@ class Store(object):
|
||||||
return self._conn.execute(
|
return self._conn.execute(
|
||||||
"INSERT INTO users (name, hash) VALUES (?, ?) RETURNING (id)",
|
"INSERT INTO users (name, hash) VALUES (?, ?) RETURNING (id)",
|
||||||
[username, digest.hexdigest()],
|
[username, digest.hexdigest()],
|
||||||
).fetchone()[0]
|
).fetchone()
|
||||||
|
|
||||||
|
@requires_conn
|
||||||
|
def fetch_user(self, uid: int):
|
||||||
|
return self._conn.execute("SELECT * FROM users WHERE id = ?", [uid]).fetchone()
|
||||||
|
|
||||||
|
@fmap(one)
|
||||||
|
@requires_conn
|
||||||
|
def fetch_user_priority(self, uid: int):
|
||||||
|
return self._conn.execute(
|
||||||
|
"SELECT priority FROM groups g INNER JOIN users u ON g.id = u.group_id WHERE u.id = ?",
|
||||||
|
[uid],
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def list_users(self):
|
def list_users(self):
|
||||||
|
@ -130,12 +102,13 @@ class Store(object):
|
||||||
################################################################################
|
################################################################################
|
||||||
# Sessions / 'keys'
|
# Sessions / 'keys'
|
||||||
|
|
||||||
|
@fmap(one)
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def _create_session(self, uid: int, ttl: Optional[timedelta]):
|
def _create_session(self, uid: int, ttl: Optional[timedelta]):
|
||||||
return self._conn.execute(
|
return self._conn.execute(
|
||||||
"INSERT INTO user_keys (user_id, expiration) VALUES (?, ?) RETURNING (id)",
|
"INSERT INTO user_keys (user_id, expiration) VALUES (?, ?) RETURNING (id)",
|
||||||
[uid, (datetime.now() + ttl).isoformat() if ttl else None],
|
[uid, (datetime.now() + ttl).isoformat() if ttl else None],
|
||||||
).fetchone()[0]
|
).fetchone()
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def try_login(self, username: str, password: str, ttl: timedelta) -> Optional[str]:
|
def try_login(self, username: str, password: str, ttl: timedelta) -> Optional[str]:
|
||||||
|
@ -177,16 +150,15 @@ class Store(object):
|
||||||
"SELECT * FROM user_keys WHERE id = ?", [kid]
|
"SELECT * FROM user_keys WHERE id = ?", [kid]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
@fmap(one)
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def try_key(self, kid: str):
|
def try_key(self, kid: str):
|
||||||
"""Try to find the mapped user for a session."""
|
"""Try to find the mapped user for a session."""
|
||||||
|
|
||||||
res = self._conn.execute(
|
return self._conn.execute(
|
||||||
"SELECT user_id FROM user_keys WHERE expiration IS NULL OR unixepoch(expiration) > unixepoch('now') and id = ?",
|
"SELECT user_id FROM user_keys WHERE expiration IS NULL OR unixepoch(expiration) > unixepoch('now') and id = ?",
|
||||||
[kid],
|
[kid],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if res:
|
|
||||||
return res[0]
|
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def refresh_key(self, kid: str, ttl: timedelta):
|
def refresh_key(self, kid: str, ttl: timedelta):
|
||||||
|
@ -227,39 +199,79 @@ class Store(object):
|
||||||
# Files
|
# Files
|
||||||
#
|
#
|
||||||
# A record of local files on disk, and the users who own then.
|
# A record of local files on disk, and the users who own then.
|
||||||
|
@fmap(one)
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def create_file(self, uid: int, path: Path):
|
def create_file(self, uid: int, name: str, path: Path) -> int:
|
||||||
pass
|
return self._conn.execute(
|
||||||
|
"INSERT INTO files (user_id, filename, upload_date) VALUES (?, ?, datetime('now')) RETURNING (id)",
|
||||||
|
[uid, name],
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def list_files(self, uid: int):
|
def list_files(self, uid: int):
|
||||||
pass
|
return self._conn.execute(
|
||||||
|
"SELECT * FROM files WHERE user_id = ?", [uid]
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def delete_file(self, uid: int, fid: int):
|
def delete_file(self, uid: int, fid: int):
|
||||||
pass
|
self._conn.execute("DELETE FROM files WHERE user_id = ? AND id = ?", [uid, fid])
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Job
|
# Job
|
||||||
#
|
#
|
||||||
# A request by a user for a given file to be printed.
|
# A request by a user for a given file to be printed.
|
||||||
|
@fmap(one)
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def create_job(self, uid: int):
|
def create_job(self, uid: int, fid: int, relative_priority: int = 0):
|
||||||
pass
|
"""Create a job mapped to a user with a file to print and a priority.
|
||||||
|
|
||||||
|
Note that the user may provide a sub-priority within their group queue. This allows users to create jobs with
|
||||||
|
higher priority than existing jobs as a means of controlling the queue order.
|
||||||
|
|
||||||
|
May want a different (eg. more explicit) queue ordering mechanism here. Emulating the Netflix queue behavior of
|
||||||
|
being able to drag some jobs ahead of others? How to model that?
|
||||||
|
|
||||||
|
"""
|
||||||
|
assert 0 <= relative_priority <= 9
|
||||||
|
return self._conn.execute(
|
||||||
|
"INSERT INTO jobs (user_id, file_id, priority) VALUES (?, ?, ?) RETURNING (id)",
|
||||||
|
[uid, fid, self.fetch_user_priority(uid) + relative_priority],
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def list_jobs(self, uid: int):
|
def list_jobs(self, uid: Optional[int] = None):
|
||||||
pass
|
"""Enumerate jobs in priority order."""
|
||||||
|
cond = f"AND user_id = {uid}" if uid else ""
|
||||||
|
return self._conn.execute(
|
||||||
|
f"SELECT * FROM jobs WHERE started_at IS NULL {cond} ORDER BY priority DESC",
|
||||||
|
[],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
@requires_conn
|
||||||
|
def fetch_job(self, uid: int, jid: int) -> Optional[tuple]:
|
||||||
|
return self._conn.execute(
|
||||||
|
"SELECT * FROM jobs WHERE user_id = ? AND id = ?", [uid, jid]
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
@requires_conn
|
||||||
|
def alter_job(self, job: tuple):
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"file_id",
|
||||||
|
"priority",
|
||||||
|
"started_at",
|
||||||
|
"finished_at",
|
||||||
|
"state",
|
||||||
|
"printer_id",
|
||||||
|
]
|
||||||
|
assert len(job) == len(fields)
|
||||||
|
return self._conn.execute(
|
||||||
|
f"INSERT OR REPLACE INTO jobs ({', '.join(fields)}) VALUES ({', '.join(['?'] * len(fields))})",
|
||||||
|
job,
|
||||||
|
)
|
||||||
|
|
||||||
@requires_conn
|
@requires_conn
|
||||||
def delete_job(self, uid: int, jid: int):
|
def delete_job(self, uid: int, jid: int):
|
||||||
pass
|
self._conn.execute("DELETE FROM jobs WHERE user_id = ? and id = ?", [uid, jid])
|
||||||
|
|
||||||
################################################################################
|
|
||||||
# Run
|
|
||||||
#
|
|
||||||
# A record tha that a Job has been assigned to a Printer and is running.
|
|
||||||
# Could perhaps be eliminated in favor of rolling state into the Job.
|
|
||||||
@requires_conn
|
|
||||||
def list_runs(self, uid: int):
|
|
||||||
pass
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
#!/usr/bin/env python3
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css" />
|
||||||
|
<title>Tentacles{% block title %}{% endblock %}</title>
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<a href="https://freecodecamp.org" class="logo">
|
||||||
|
<img src="https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg" alt="freeCodeCamp logo">
|
||||||
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li class="nav-item"><a href="#">Curriculum</a></li>
|
||||||
|
<li class="nav-item"><a href="#">Forum</a></li>
|
||||||
|
<li class="nav-item"><a href="#">News</a></li>
|
||||||
|
<li class="nav-item"><a href="#">Sign in</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div id="flashes">
|
||||||
|
<ul class=flashes>
|
||||||
|
{% for message in messages %}
|
||||||
|
<li>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<div id="content">
|
||||||
|
{% block content %}Oops, an empty page :/{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div id="footer">
|
||||||
|
{% block footer %}
|
||||||
|
© Copyright 2023 by <a href="https://arrdem.com/">@arrdem</a>.
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,4 @@
|
||||||
|
{% extends "base.html.j2" %}
|
||||||
|
{% block content %}
|
||||||
|
<p>Hello, {% if request.uid %}{{ request.username }}{% else %}world{% endif %}!</p>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "base.html.j2" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Log in</h1>
|
||||||
|
<form method="post">
|
||||||
|
<p>Username: <input type="text" name="username">
|
||||||
|
<p>Password: <input type="password" name="password">
|
||||||
|
<p><input type="submit" value=Login>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "base.html.j2" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Register</h1>
|
||||||
|
<form method="post">
|
||||||
|
<p>Username: <input type="text" name="username">
|
||||||
|
<p>Password: <input type="password" name="password">
|
||||||
|
<p><input type="submit" value=Login>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
Loading…
Reference in a new issue