Get a user approval flow working
This commit is contained in:
parent
42ad82d96e
commit
d6d9a348b1
16 changed files with 396 additions and 116 deletions
|
@ -11,10 +11,10 @@ import cherrypy
|
||||||
import click
|
import click
|
||||||
from flask import Flask, request
|
from flask import Flask, request
|
||||||
from tentacles.blueprints import (
|
from tentacles.blueprints import (
|
||||||
|
admin_ui,
|
||||||
api,
|
api,
|
||||||
file_ui,
|
file_ui,
|
||||||
job_ui,
|
job_ui,
|
||||||
printer_ui,
|
|
||||||
user_ui,
|
user_ui,
|
||||||
)
|
)
|
||||||
from tentacles.db import Db
|
from tentacles.db import Db
|
||||||
|
@ -39,7 +39,6 @@ def custom_ctx(app, wsgi_app):
|
||||||
store = db_factory(app)
|
store = db_factory(app)
|
||||||
token = _ctx.set(Ctx(store))
|
token = _ctx.set(Ctx(store))
|
||||||
try:
|
try:
|
||||||
with store.savepoint():
|
|
||||||
return wsgi_app(environ, start_response)
|
return wsgi_app(environ, start_response)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
@ -88,11 +87,11 @@ def make_app():
|
||||||
app.before_request(user_session)
|
app.before_request(user_session)
|
||||||
|
|
||||||
# Blueprints
|
# Blueprints
|
||||||
app.register_blueprint(user_ui.BLUEPRINT)
|
app.register_blueprint(admin_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)
|
||||||
|
app.register_blueprint(file_ui.BLUEPRINT)
|
||||||
|
app.register_blueprint(job_ui.BLUEPRINT)
|
||||||
|
app.register_blueprint(user_ui.BLUEPRINT)
|
||||||
|
|
||||||
# Shove our middleware in there
|
# Shove our middleware in there
|
||||||
app.wsgi_app = custom_ctx(app, app.wsgi_app)
|
app.wsgi_app = custom_ctx(app, app.wsgi_app)
|
||||||
|
@ -106,6 +105,8 @@ def make_app():
|
||||||
@click.option("--trace/--no-trace", "trace", default=False)
|
@click.option("--trace/--no-trace", "trace", default=False)
|
||||||
@click.option("--config", type=Path)
|
@click.option("--config", type=Path)
|
||||||
def serve(hostname: str, port: int, config: Path, trace: bool):
|
def serve(hostname: str, port: int, config: Path, trace: bool):
|
||||||
|
logging.addLevelName(logging.DEBUG - 5, "TRACE")
|
||||||
|
logging.TRACE = logging.DEBUG - 5
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s %(threadName)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s %(threadName)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
@ -113,7 +114,7 @@ def serve(hostname: str, port: int, config: Path, trace: bool):
|
||||||
|
|
||||||
logging.getLogger("tentacles").setLevel(logging.DEBUG)
|
logging.getLogger("tentacles").setLevel(logging.DEBUG)
|
||||||
if trace:
|
if trace:
|
||||||
logging.getLogger("tentacles.db").setLevel(logging.DEBUG - 1)
|
logging.getLogger("tentacles.db").setLevel(logging.TRACE)
|
||||||
|
|
||||||
app = make_app()
|
app = make_app()
|
||||||
|
|
||||||
|
|
123
projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
Normal file
123
projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .api import requires_admin
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
from tentacles.globals import ctx
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
BLUEPRINT = Blueprint("admin", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin", methods=["GET"])
|
||||||
|
@requires_admin
|
||||||
|
def list_files():
|
||||||
|
if request.method == "POST":
|
||||||
|
flash("Not supported yet", category="warning")
|
||||||
|
|
||||||
|
return render_template("admin.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin/users", methods=["POST"])
|
||||||
|
@requires_admin
|
||||||
|
def manipulate_users():
|
||||||
|
uid = int(request.form.get("user_id", "-1"))
|
||||||
|
|
||||||
|
match request.form.get("action"):
|
||||||
|
case "approve":
|
||||||
|
row = ctx.db.approve_user(uid=uid)
|
||||||
|
ctx.db.enable_user(uid=uid)
|
||||||
|
ctx.db.create_email(
|
||||||
|
uid=uid,
|
||||||
|
subject="Tentacles account approved!",
|
||||||
|
body=render_template(
|
||||||
|
"approval_email.html.j2",
|
||||||
|
username=row.name,
|
||||||
|
base_url=request.root_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
flash(f"Approved {row.name}", category="success")
|
||||||
|
|
||||||
|
case "enable":
|
||||||
|
row = ctx.db.enable_user(uid=uid)
|
||||||
|
if row:
|
||||||
|
flash(f"Enabled {row.name}", category="success")
|
||||||
|
|
||||||
|
case "disable":
|
||||||
|
row = ctx.db.disable_user(uid=uid)
|
||||||
|
if row:
|
||||||
|
flash(f"Disabled {row.name}", category="success")
|
||||||
|
|
||||||
|
case "passwdchng":
|
||||||
|
ctx.db.set_user_status(uid=uid, status="passwdchng")
|
||||||
|
|
||||||
|
case _:
|
||||||
|
print(request.form)
|
||||||
|
flash("Not supported yet", category="warning")
|
||||||
|
return render_template("admin.html.j2"), 400
|
||||||
|
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin/users", methods=["GET"])
|
||||||
|
def get_users():
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin/printers")
|
||||||
|
@requires_admin
|
||||||
|
def printers():
|
||||||
|
return render_template("printers.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin/printers/add", methods=["GET"])
|
||||||
|
@requires_admin
|
||||||
|
def add_printer():
|
||||||
|
return render_template("add_printer.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin/printers", methods=["POST"])
|
||||||
|
@requires_admin
|
||||||
|
def handle_add_printer():
|
||||||
|
try:
|
||||||
|
assert request.form["name"]
|
||||||
|
assert request.form["url"]
|
||||||
|
assert request.form["api_key"]
|
||||||
|
ctx.db.try_create_printer(
|
||||||
|
name=request.form["name"],
|
||||||
|
url=request.form["url"],
|
||||||
|
api_key=request.form["api_key"],
|
||||||
|
sid=0, # Disconnected
|
||||||
|
)
|
||||||
|
flash("Printer created")
|
||||||
|
return redirect("/admin/printers")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Failed to create printer")
|
||||||
|
flash(f"Unable to create printer", category="error")
|
||||||
|
|
||||||
|
return render_template("printers.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/admin/files", methods=["POST"])
|
||||||
|
@requires_admin
|
||||||
|
def manipulate_files():
|
||||||
|
fid = int(request.form.get("file_id", "-1"))
|
||||||
|
|
||||||
|
match request.form.get("action"):
|
||||||
|
case _:
|
||||||
|
print(request.form)
|
||||||
|
flash("Not supported yet", category="warning")
|
||||||
|
return render_template("admin.html.j2"), 400
|
||||||
|
|
||||||
|
return redirect("/admin")
|
|
@ -12,6 +12,7 @@ from flask import (
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
|
send_file,
|
||||||
)
|
)
|
||||||
from tentacles.globals import ctx
|
from tentacles.globals import ctx
|
||||||
|
|
||||||
|
@ -41,6 +42,15 @@ def manipulate_files():
|
||||||
flash(resp.get("error"), category="error")
|
flash(resp.get("error"), category="error")
|
||||||
return render_template("files.html.j2"), code
|
return render_template("files.html.j2"), code
|
||||||
|
|
||||||
|
case "download":
|
||||||
|
file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id")))
|
||||||
|
if file:
|
||||||
|
return send_file(
|
||||||
|
file.path, as_attachment=True, download_name=file.filename
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash("File not found", category="error")
|
||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id")))
|
file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id")))
|
||||||
if any(
|
if any(
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from .util import is_logged_in, requires_admin
|
|
||||||
|
|
||||||
from flask import (
|
|
||||||
Blueprint,
|
|
||||||
flash,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
from tentacles.globals import ctx
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
BLUEPRINT = Blueprint("printer", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@BLUEPRINT.route("/printers")
|
|
||||||
@requires_admin
|
|
||||||
def printers():
|
|
||||||
return render_template("printers.html.j2")
|
|
||||||
|
|
||||||
|
|
||||||
@BLUEPRINT.route("/printers/add", methods=["get", "post"])
|
|
||||||
@requires_admin
|
|
||||||
def add_printer():
|
|
||||||
if not is_logged_in():
|
|
||||||
return redirect("/")
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
|
||||||
try:
|
|
||||||
assert request.form["name"]
|
|
||||||
assert request.form["url"]
|
|
||||||
assert request.form["api_key"]
|
|
||||||
ctx.db.try_create_printer(
|
|
||||||
name=request.form["name"],
|
|
||||||
url=request.form["url"],
|
|
||||||
api_key=request.form["api_key"],
|
|
||||||
sid=0, # Disconnected
|
|
||||||
)
|
|
||||||
flash("Printer created")
|
|
||||||
return redirect("/printers")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Failed to create printer")
|
|
||||||
flash(f"Unable to create printer", category="error")
|
|
||||||
|
|
||||||
return render_template("add_printer.html.j2")
|
|
||||||
|
|
||||||
|
|
||||||
@BLUEPRINT.route("/printers/delete")
|
|
||||||
@requires_admin
|
|
||||||
def delete_printer():
|
|
||||||
return render_template("delete_printer.html.j2")
|
|
|
@ -120,6 +120,18 @@ def post_register():
|
||||||
return render_template("register.html.j2")
|
return render_template("register.html.j2")
|
||||||
|
|
||||||
|
|
||||||
|
@BLUEPRINT.route("/user/verify", methods=["GET", "POST"])
|
||||||
|
def verify():
|
||||||
|
token = request.args.get("token", "")
|
||||||
|
row = ctx.db.try_verify_user(token=token)
|
||||||
|
if row:
|
||||||
|
flash(
|
||||||
|
f"Thanks for verifying your email {row.name}! You'll receive another email when your account is ready for use.",
|
||||||
|
category="success",
|
||||||
|
)
|
||||||
|
return redirect("/user/login")
|
||||||
|
|
||||||
|
|
||||||
@BLUEPRINT.route("/user/logout")
|
@BLUEPRINT.route("/user/logout")
|
||||||
def logout():
|
def logout():
|
||||||
# Invalidate the user's authorization
|
# Invalidate the user's authorization
|
||||||
|
|
|
@ -28,7 +28,7 @@ def qfn(name, f):
|
||||||
# Force lazy values for convenience
|
# Force lazy values for convenience
|
||||||
if isinstance(res, GeneratorType):
|
if isinstance(res, GeneratorType):
|
||||||
res = list(res)
|
res = list(res)
|
||||||
log.log(logging.DEBUG - 1, "%s (%r) -> %r", name, kwargs, res)
|
log.log(logging.TRACE, "%s (%r) -> %r", name, kwargs, res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
_helper.__name__ = f.__name__
|
_helper.__name__ = f.__name__
|
||||||
|
@ -158,15 +158,10 @@ class Db(Queries):
|
||||||
digest.update(password.encode("utf-8"))
|
digest.update(password.encode("utf-8"))
|
||||||
res = super().try_login(username=username, hash=digest.hexdigest())
|
res = super().try_login(username=username, hash=digest.hexdigest())
|
||||||
if not res:
|
if not res:
|
||||||
return
|
res = self.fetch_user_status(sid=res.status_id)
|
||||||
|
raise LoginError(res.name)
|
||||||
|
|
||||||
uid, status = res
|
return self.create_key(uid=res.id, name="web session", ttl=ttl)
|
||||||
if status > 0:
|
|
||||||
return self.create_key(uid=uid, name="web session", ttl=ttl)
|
|
||||||
|
|
||||||
else:
|
|
||||||
_, status = self.fetch_user_status(status)
|
|
||||||
raise LoginError(status)
|
|
||||||
|
|
||||||
def try_create_user(
|
def try_create_user(
|
||||||
self, *, username: str, email: str, password: str, gid: int = 1, sid: int = -3
|
self, *, username: str, email: str, password: str, gid: int = 1, sid: int = -3
|
||||||
|
|
|
@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS user_statuses (
|
||||||
, UNIQUE(name)
|
, UNIQUE(name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-4, 'passwdchng');
|
||||||
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-3, 'unverified');
|
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-3, 'unverified');
|
||||||
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-2, 'unapproved');
|
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-2, 'unapproved');
|
||||||
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-1, 'disabled');
|
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-1, 'disabled');
|
||||||
|
@ -30,6 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
, email TEXT
|
, email TEXT
|
||||||
, hash TEXT
|
, hash TEXT
|
||||||
, status_id INTEGER
|
, status_id INTEGER
|
||||||
|
, created_at TEXT DEFAULT (datetime('now'))
|
||||||
, verification_token TEXT DEFAULT (lower(hex(randomblob(32))))
|
, verification_token TEXT DEFAULT (lower(hex(randomblob(32))))
|
||||||
, verified_at TEXT
|
, verified_at TEXT
|
||||||
, approved_at TEXT
|
, approved_at TEXT
|
||||||
|
@ -89,16 +91,29 @@ CREATE TABLE IF NOT EXISTS files (
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
-- A job is a request for a copy of a file to be run off
|
-- A job is a request for a copy of a file to be run off
|
||||||
-- For simplicity, jobs also serve as scheduling records
|
-- For simplicity, jobs also serve as scheduling records
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS job_statuses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
, name TEXT
|
||||||
|
, UNIQUE(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO job_statuses (id, name) VALUES (2, 'success');
|
||||||
|
INSERT OR IGNORE INTO job_statuses (id, name) VALUES (1, 'running');
|
||||||
|
INSERT OR IGNORE INTO job_statuses (id, name) VALUES (0, 'queued');
|
||||||
|
INSERT OR IGNORE INTO job_statuses (id, name) VALUES (-1, 'cancelled');
|
||||||
|
INSERT OR IGNORE INTO job_statuses (id, name) VALUES (-2, 'failed');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
, user_id INTEGER NOT NULL
|
, user_id INTEGER NOT NULL
|
||||||
, file_id INTEGER NOT NULL
|
, file_id INTEGER NOT NULL
|
||||||
|
, printer_id INTEGER
|
||||||
, started_at TEXT
|
, started_at TEXT
|
||||||
, cancelled_at TEXT
|
, cancelled_at TEXT
|
||||||
, finished_at TEXT
|
, finished_at TEXT
|
||||||
, state TEXT
|
, status_id INTEGER DEFAULT (0)
|
||||||
, message TEXT
|
, message TEXT
|
||||||
, printer_id INTEGER
|
|
||||||
, FOREIGN KEY(user_id) REFERENCES users(id)
|
, FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
, FOREIGN KEY(file_id) REFERENCES files(id)
|
, FOREIGN KEY(file_id) REFERENCES files(id)
|
||||||
, FOREIGN KEY(printer_id) REFERENCES printer(id)
|
, FOREIGN KEY(printer_id) REFERENCES printer(id)
|
||||||
|
@ -141,8 +156,7 @@ WHERE
|
||||||
|
|
||||||
-- name: list-users
|
-- name: list-users
|
||||||
SELECT
|
SELECT
|
||||||
id
|
*
|
||||||
, email
|
|
||||||
FROM users
|
FROM users
|
||||||
;
|
;
|
||||||
|
|
||||||
|
@ -155,21 +169,60 @@ WHERE
|
||||||
AND verified_at IS NULL
|
AND verified_at IS NULL
|
||||||
;
|
;
|
||||||
|
|
||||||
-- name: verify-user!
|
-- name: try-verify-user^
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
verified_at = datetime('now')
|
verified_at = datetime('now')
|
||||||
|
, verification_token = lower(hex(randomblob(32)))
|
||||||
WHERE
|
WHERE
|
||||||
id = :uid
|
verification_token = :token
|
||||||
|
RETURNING
|
||||||
|
id
|
||||||
|
, name
|
||||||
;
|
;
|
||||||
|
|
||||||
|
-- name: enable-user^
|
||||||
-- name: set-user-status!
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
status_id = :sid
|
enabled_at = datetime('now')
|
||||||
WHERE
|
WHERE
|
||||||
id = :uid
|
id = :uid
|
||||||
|
RETURNING
|
||||||
|
id
|
||||||
|
, name
|
||||||
|
;
|
||||||
|
|
||||||
|
-- name: disable-user^
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
enabled_at = NULL
|
||||||
|
WHERE
|
||||||
|
id = :uid
|
||||||
|
RETURNING
|
||||||
|
id
|
||||||
|
, name
|
||||||
|
;
|
||||||
|
|
||||||
|
-- name: approve-user^
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
approved_at = datetime('now')
|
||||||
|
WHERE
|
||||||
|
id = :uid
|
||||||
|
RETURNING
|
||||||
|
id
|
||||||
|
, name
|
||||||
|
;
|
||||||
|
|
||||||
|
-- name: set-user-status^
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
status_id = (SELECT id FROM user_statuses WHERE id = :status OR name = :status)
|
||||||
|
WHERE
|
||||||
|
id = :uid
|
||||||
|
RETURNING
|
||||||
|
id
|
||||||
|
, name
|
||||||
;
|
;
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
@ -207,8 +260,12 @@ SELECT
|
||||||
, status_id
|
, status_id
|
||||||
FROM users
|
FROM users
|
||||||
WHERE
|
WHERE
|
||||||
(name = :username AND hash = :hash)
|
((name = :username AND hash = :hash)
|
||||||
OR (email = :username AND hash = :hash)
|
OR (email = :username AND hash = :hash))
|
||||||
|
AND ((verified_at IS NOT NULL
|
||||||
|
AND approved_at IS NOT NULL
|
||||||
|
AND enabled_at IS NOT NULL)
|
||||||
|
OR group_id = 0)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
;
|
;
|
||||||
|
|
||||||
|
@ -222,6 +279,17 @@ WHERE
|
||||||
user_id = :uid
|
user_id = :uid
|
||||||
;
|
;
|
||||||
|
|
||||||
|
-- name: list-nonweb-keys
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
, name
|
||||||
|
, expiration
|
||||||
|
FROM user_keys
|
||||||
|
WHERE
|
||||||
|
user_id = :uid
|
||||||
|
AND name NOT LIKE '%web session%'
|
||||||
|
;
|
||||||
|
|
||||||
-- name: fetch-key^
|
-- name: fetch-key^
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
|
@ -232,12 +300,15 @@ WHERE
|
||||||
|
|
||||||
-- name: try-key^
|
-- name: try-key^
|
||||||
SELECT
|
SELECT
|
||||||
id
|
k.id
|
||||||
, user_id
|
, user_id
|
||||||
FROM user_keys
|
FROM user_keys k
|
||||||
|
INNER JOIN users u
|
||||||
|
ON k.user_id = u.id
|
||||||
WHERE
|
WHERE
|
||||||
(expiration IS NULL OR unixepoch(expiration) > unixepoch('now'))
|
(expiration IS NULL OR unixepoch(expiration) > unixepoch('now'))
|
||||||
AND id = :kid
|
AND k.id = :kid
|
||||||
|
AND u.enabled_at IS NOT NULL -- and the user is not disabled!
|
||||||
;
|
;
|
||||||
|
|
||||||
-- name: refresh-key
|
-- name: refresh-key
|
||||||
|
@ -336,7 +407,9 @@ RETURNING
|
||||||
-- name: list-files
|
-- name: list-files
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
FROM files
|
, (SELECT COUNT(*) FROM jobs WHERE file_id = f.id AND status_id > 1) AS `print_successes`
|
||||||
|
, (SELECT COUNT(*) FROM jobs WHERE file_id = f.id AND status_id < 0) AS `print_failures`
|
||||||
|
FROM files f
|
||||||
WHERE
|
WHERE
|
||||||
user_id = :uid
|
user_id = :uid
|
||||||
;
|
;
|
||||||
|
@ -385,6 +458,7 @@ WHERE
|
||||||
-- name: list-jobs
|
-- name: list-jobs
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
|
, (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE
|
WHERE
|
||||||
(:uid IS NULL OR user_id = :uid)
|
(:uid IS NULL OR user_id = :uid)
|
||||||
|
@ -393,6 +467,7 @@ WHERE
|
||||||
-- name: list-jobs-by-file
|
-- name: list-jobs-by-file
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
|
, (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE
|
WHERE
|
||||||
file_id = :fid
|
file_id = :fid
|
||||||
|
@ -422,7 +497,8 @@ LIMIT 1
|
||||||
-- name: list-job-history
|
-- name: list-job-history
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
FROM jobs
|
, (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
|
||||||
|
FROM jobs j
|
||||||
WHERE
|
WHERE
|
||||||
finished_at IS NOT NULL
|
finished_at IS NOT NULL
|
||||||
AND (:uid IS NULL OR user_id = :uid)
|
AND (:uid IS NULL OR user_id = :uid)
|
||||||
|
@ -496,7 +572,7 @@ WHERE
|
||||||
UPDATE jobs
|
UPDATE jobs
|
||||||
SET
|
SET
|
||||||
finished_at = datetime('now')
|
finished_at = datetime('now')
|
||||||
, state = :state
|
, status_id = (SELECT id FROM job_statuses WHERE name = :state)
|
||||||
, message = :message
|
, message = :message
|
||||||
WHERE
|
WHERE
|
||||||
id = :jid
|
id = :jid
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "base.html.j2" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row twelve columns mb-1">
|
||||||
|
{% include "users_list.html.j2" %}
|
||||||
|
</div>
|
||||||
|
<div class="row twelve columns mb-1">
|
||||||
|
{% include "files_admin_list.html.j2" %}
|
||||||
|
</div>
|
||||||
|
<div class="row twelve columns mb-1">
|
||||||
|
{% include "printers_list.html.j2" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="{{ base_url }}/static/css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="container navbar">
|
||||||
|
<span class="logo">
|
||||||
|
<a class="row" href="/">
|
||||||
|
<img src="{{ base_url }}/static/tentacles.svg" alt="Tentacles">
|
||||||
|
<span class="name color-yellow">Tentacles</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container content">
|
||||||
|
<div class="row">
|
||||||
|
<p>
|
||||||
|
Hello {{ username }},
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your account on Tentacles has been approved! You may now <a href="{{ base_url }}login">log in</a> and start printing!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
</html>
|
|
@ -33,7 +33,7 @@
|
||||||
<li><a class="twelve columns button slide" href="/jobs">Jobs</a></li>
|
<li><a class="twelve columns button slide" href="/jobs">Jobs</a></li>
|
||||||
<li><a class="twelve columns button slide" href="/files">Files</a></li>
|
<li><a class="twelve columns button slide" href="/files">Files</a></li>
|
||||||
{% if ctx.is_admin %}
|
{% if ctx.is_admin %}
|
||||||
<li><a class="twelve columns button slide" href="/printers">Printers</a></li>
|
<li><a class="twelve columns button slide" href="/admin">Administration</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a class="twelve columns button slide" href="/user">Settings</a></li>
|
<li><a class="twelve columns button slide" href="/user">Settings</a></li>
|
||||||
<li><a class="twelve columns button slide" href="/user/logout">Log out</a></li>
|
<li><a class="twelve columns button slide" href="/user/logout">Log out</a></li>
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
{% import "macros.html.j2" as macros %}
|
||||||
|
<h2>Files</h2>
|
||||||
|
{% with files = ctx.db.list_files(uid=ctx.uid) %}
|
||||||
|
{% if files %}
|
||||||
|
{% for file in files %}
|
||||||
|
<div class="file row u-flex">
|
||||||
|
<div class="details six columns">
|
||||||
|
<span class="file-name">{{ file.filename }}</span>
|
||||||
|
<span class="file-owner">{{ ctx.db.fetch_user(uid=file.user_id).name }}</span>
|
||||||
|
<span class="file-sucesses">{{ file.print_successes }}</span> successes
|
||||||
|
<span class="file-failures">{{ file.print_failures }}</span> errors
|
||||||
|
</div>
|
||||||
|
<div class="controls u-flex u-ml-auto">
|
||||||
|
{{ macros.delete_file(file.id, endpoint="/admin/files") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
You don't have any files. Upload something!
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
|
@ -6,9 +6,12 @@
|
||||||
<div class="file row u-flex">
|
<div class="file row u-flex">
|
||||||
<div class="details six columns">
|
<div class="details six columns">
|
||||||
<span class="file-name">{{ file.filename }}</span>
|
<span class="file-name">{{ file.filename }}</span>
|
||||||
|
<span class="file-sucesses">{{ file.print_successes }}</span> successes
|
||||||
|
<span class="file-failures">{{ file.print_failures }}</span> errors
|
||||||
</div>
|
</div>
|
||||||
<div class="controls u-flex u-ml-auto">
|
<div class="controls u-flex u-ml-auto">
|
||||||
{{ macros.start_job(file.id) }}
|
{{ macros.start_job(file.id) }}
|
||||||
|
{{ macros.download_file(file.id) }}
|
||||||
{{ macros.delete_file(file.id) }}
|
{{ macros.delete_file(file.id) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -36,15 +36,58 @@
|
||||||
{{ 'queued' if (not job.finished_at and not job.printer_id and not job.cancelled_at) else
|
{{ 'queued' if (not job.finished_at and not job.printer_id and not job.cancelled_at) else
|
||||||
'running' if (not job.finished_at and job.printer_id and not job.cancelled_at) else
|
'running' if (not job.finished_at and job.printer_id and not job.cancelled_at) else
|
||||||
'cancelling' if (not job.finished_at and job.cancelled_at) else
|
'cancelling' if (not job.finished_at and job.cancelled_at) else
|
||||||
job.state }}
|
job.status }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{# #################################################################################################### #}
|
{# #################################################################################################### #}
|
||||||
{# File CRUD #}
|
{# File CRUD #}
|
||||||
{% macro delete_file(id) %}
|
{% macro delete_file(id, endpoint="/files") %}
|
||||||
<form class="inline" method="post" action="/files">
|
<form class="inline" method="post" action="{{ endpoint }}">
|
||||||
<input type="hidden" name="action" value="delete" />
|
<input type="hidden" name="action" value="delete" />
|
||||||
<input type="hidden" name="file_id" value="{{ id }}" />
|
<input type="hidden" name="file_id" value="{{ id }}" />
|
||||||
<input id="submit" type="submit" value="Delete"/>
|
<input id="submit" type="submit" value="Delete"/>
|
||||||
</form>
|
</form>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro download_file(id, endpoint="/files") %}
|
||||||
|
<form class="inline" method="post" action="{{ endpoint }}">
|
||||||
|
<input type="hidden" name="action" value="download" />
|
||||||
|
<input type="hidden" name="file_id" value="{{ id }}" />
|
||||||
|
<input id="submit" type="submit" value="Download"/>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# #################################################################################################### #}
|
||||||
|
{# User CRUD #}
|
||||||
|
|
||||||
|
{% macro approve_user(id) %}
|
||||||
|
<form class="inline" method="post" action="/admin/users">
|
||||||
|
<input type="hidden" name="action" value="approve" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ id }}" />
|
||||||
|
<input id="submit" type="submit" value="Approve"/>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro enable_user(id) %}
|
||||||
|
<form class="inline" method="post" action="/admin/users">
|
||||||
|
<input type="hidden" name="action" value="enable" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ id }}" />
|
||||||
|
<input id="submit" type="submit" value="Enable"/>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro lock_user(id) %}
|
||||||
|
<form class="inline" method="post" action="/admin/users">
|
||||||
|
<input type="hidden" name="action" value="lock" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ id }}" />
|
||||||
|
<input id="submit" type="submit" value="Lock"/>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro passwdchng_user(id) %}
|
||||||
|
<form class="inline" method="post" action="/admin/users">
|
||||||
|
<input type="hidden" name="action" value="passwdchng" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ id }}" />
|
||||||
|
<input id="submit" type="submit" value="Password change"/>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
|
@ -3,14 +3,14 @@
|
||||||
<h1>User settings</h1>
|
<h1>User settings</h1>
|
||||||
<div class="row twelve columns keys">
|
<div class="row twelve columns keys">
|
||||||
<h2>API keys</h2>
|
<h2>API keys</h2>
|
||||||
{% with keys = ctx.db.list_keys(ctx.uid) %}
|
{% with keys = ctx.db.list_nonweb_keys(ctx.uid) %}
|
||||||
{% if keys %}
|
{% if keys %}
|
||||||
{% for id, name, exp in keys %}
|
{% for id, name, exp in keys %}
|
||||||
<div class="row key u-flex">
|
<div class="row key u-flex">
|
||||||
<div class="details six columns">
|
<div class="details six columns">
|
||||||
<span class="key-name">{{ name or 'anonymous' }}</span>
|
<span class="key-name">{{ name or 'anonymous' }}</span>
|
||||||
<span class="key-key">{{ id[:10] }}...</span>
|
<span class="key-key">{{ id[:10] }}...</span>
|
||||||
<span class="key-expiration u-ml-auto">{{ 'Expires in ' if exp else ''}}{{ exp - datetime.now() if exp else 'Never expires' }}</span>
|
<span class="key-expiration u-ml-auto">{{ 'Expires in ' if exp else ''}}{{ datetime.fromisoformat(exp) - datetime.now() if exp else 'Never expires' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls u-flex u-ml-auto">
|
<div class="controls u-flex u-ml-auto">
|
||||||
<form class="inline" method="none" class="ml-auto">
|
<form class="inline" method="none" class="ml-auto">
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% import "macros.html.j2" as macros %}
|
||||||
|
<h2>Users</h2>
|
||||||
|
{% with users = ctx.db.list_users(uid=ctx.uid) %}
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="user row u-flex">
|
||||||
|
<div class="details six columns">
|
||||||
|
<span class="user-name">{{ user.name }}</span>
|
||||||
|
<span class="user-email">{{ user.email }}</span>
|
||||||
|
<span class="user-group">{{ user.group }}</span>
|
||||||
|
<span class="user-status">{{ user.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls u-flex u-ml-auto">
|
||||||
|
{% if not user.approved_at %}{{ macros.approve_user(user.id) }}{% endif %}
|
||||||
|
{% if not user.enabled_at %}
|
||||||
|
{{ macros.enable_user(user.id) }}
|
||||||
|
{% else %}
|
||||||
|
{{ macros.lock_user(user.id) }}
|
||||||
|
{% endif %}
|
||||||
|
{{ macros.passwdchng_user(user.id) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
|
@ -10,7 +10,6 @@ Mostly related to monitoring and managing Printer state.
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Event
|
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from urllib import parse as urlparse
|
from urllib import parse as urlparse
|
||||||
|
|
||||||
|
@ -42,8 +41,6 @@ class OctoRest(_OR):
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
SHUTDOWN = Event()
|
|
||||||
|
|
||||||
|
|
||||||
def poll_printers(app: App, db: Db) -> None:
|
def poll_printers(app: App, db: Db) -> None:
|
||||||
"""Poll printers for their status."""
|
"""Poll printers for their status."""
|
||||||
|
@ -56,13 +53,14 @@ def poll_printers(app: App, db: Db) -> None:
|
||||||
log.info(f"Printer {printer.id} {printer.status} -> {status}")
|
log.info(f"Printer {printer.id} {printer.status} -> {status}")
|
||||||
db.update_printer_status(pid=printer.id, status=status)
|
db.update_printer_status(pid=printer.id, status=status)
|
||||||
|
|
||||||
|
printer_job = {}
|
||||||
try:
|
try:
|
||||||
client = OctoRest(url=printer.url, apikey=printer.api_key)
|
client = OctoRest(url=printer.url, apikey=printer.api_key)
|
||||||
printer_job = client.job_info()
|
printer_job: dict = client.job_info()
|
||||||
try:
|
try:
|
||||||
printer_state = client.printer().get("state").get("flags", {})
|
printer_state: dict = client.printer().get("state").get("flags", {})
|
||||||
except HTTPError:
|
except HTTPError:
|
||||||
printer_state = {"disconnected": True}
|
printer_state: dict = {"disconnected": True}
|
||||||
|
|
||||||
if printer_state.get("error"):
|
if printer_state.get("error"):
|
||||||
# If there's a mapped job, we manually fail it so that
|
# If there's a mapped job, we manually fail it so that
|
||||||
|
@ -251,19 +249,7 @@ def send_emails(app, db: Db):
|
||||||
subject=message.subject,
|
subject=message.subject,
|
||||||
msg=message.body,
|
msg=message.body,
|
||||||
)
|
)
|
||||||
db.send_email(message.id)
|
db.send_email(eid=message.id)
|
||||||
|
|
||||||
|
|
||||||
def once(f):
|
|
||||||
val = uninitialized = object()
|
|
||||||
|
|
||||||
def _helper(*args, **kwargs):
|
|
||||||
nonlocal val
|
|
||||||
if val is uninitialized:
|
|
||||||
val = f(*args, **kwargs)
|
|
||||||
return val
|
|
||||||
|
|
||||||
return _helper
|
|
||||||
|
|
||||||
|
|
||||||
def toil(*fs):
|
def toil(*fs):
|
||||||
|
|
Loading…
Reference in a new issue