Get a user approval flow working

This commit is contained in:
Reid 'arrdem' McKenzie 2023-06-03 17:44:34 -06:00
parent 42ad82d96e
commit d6d9a348b1
16 changed files with 396 additions and 116 deletions

View file

@ -11,10 +11,10 @@ import cherrypy
import click
from flask import Flask, request
from tentacles.blueprints import (
admin_ui,
api,
file_ui,
job_ui,
printer_ui,
user_ui,
)
from tentacles.db import Db
@ -39,7 +39,6 @@ def custom_ctx(app, wsgi_app):
store = db_factory(app)
token = _ctx.set(Ctx(store))
try:
with store.savepoint():
return wsgi_app(environ, start_response)
finally:
store.close()
@ -88,11 +87,11 @@ def make_app():
app.before_request(user_session)
# 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(admin_ui.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
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("--config", type=Path)
def serve(hostname: str, port: int, config: Path, trace: bool):
logging.addLevelName(logging.DEBUG - 5, "TRACE")
logging.TRACE = logging.DEBUG - 5
logging.basicConfig(
format="%(asctime)s %(threadName)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
@ -113,7 +114,7 @@ def serve(hostname: str, port: int, config: Path, trace: bool):
logging.getLogger("tentacles").setLevel(logging.DEBUG)
if trace:
logging.getLogger("tentacles.db").setLevel(logging.DEBUG - 1)
logging.getLogger("tentacles.db").setLevel(logging.TRACE)
app = make_app()

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

View file

@ -12,6 +12,7 @@ from flask import (
redirect,
render_template,
request,
send_file,
)
from tentacles.globals import ctx
@ -41,6 +42,15 @@ def manipulate_files():
flash(resp.get("error"), category="error")
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":
file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id")))
if any(

View file

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

View file

@ -120,6 +120,18 @@ def post_register():
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")
def logout():
# Invalidate the user's authorization

View file

@ -28,7 +28,7 @@ def qfn(name, f):
# Force lazy values for convenience
if isinstance(res, GeneratorType):
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
_helper.__name__ = f.__name__
@ -158,15 +158,10 @@ class Db(Queries):
digest.update(password.encode("utf-8"))
res = super().try_login(username=username, hash=digest.hexdigest())
if not res:
return
res = self.fetch_user_status(sid=res.status_id)
raise LoginError(res.name)
uid, status = res
if status > 0:
return self.create_key(uid=uid, name="web session", ttl=ttl)
else:
_, status = self.fetch_user_status(status)
raise LoginError(status)
return self.create_key(uid=res.id, name="web session", ttl=ttl)
def try_create_user(
self, *, username: str, email: str, password: str, gid: int = 1, sid: int = -3

View file

@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS user_statuses (
, 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 (-2, 'unapproved');
INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-1, 'disabled');
@ -30,6 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
, email TEXT
, hash TEXT
, status_id INTEGER
, created_at TEXT DEFAULT (datetime('now'))
, verification_token TEXT DEFAULT (lower(hex(randomblob(32))))
, verified_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
-- 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT
, user_id INTEGER NOT NULL
, file_id INTEGER NOT NULL
, printer_id INTEGER
, started_at TEXT
, cancelled_at TEXT
, finished_at TEXT
, state TEXT
, status_id INTEGER DEFAULT (0)
, message 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)
@ -141,8 +156,7 @@ WHERE
-- name: list-users
SELECT
id
, email
*
FROM users
;
@ -155,21 +169,60 @@ WHERE
AND verified_at IS NULL
;
-- name: verify-user!
-- name: try-verify-user^
UPDATE users
SET
verified_at = datetime('now')
, verification_token = lower(hex(randomblob(32)))
WHERE
id = :uid
verification_token = :token
RETURNING
id
, name
;
-- name: set-user-status!
-- name: enable-user^
UPDATE users
SET
status_id = :sid
enabled_at = datetime('now')
WHERE
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
FROM users
WHERE
(name = :username AND hash = :hash)
OR (email = :username AND hash = :hash)
((name = :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
;
@ -222,6 +279,17 @@ WHERE
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^
SELECT
*
@ -232,12 +300,15 @@ WHERE
-- name: try-key^
SELECT
id
k.id
, user_id
FROM user_keys
FROM user_keys k
INNER JOIN users u
ON k.user_id = u.id
WHERE
(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
@ -336,7 +407,9 @@ RETURNING
-- name: list-files
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
user_id = :uid
;
@ -385,6 +458,7 @@ WHERE
-- name: list-jobs
SELECT
*
, (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
FROM jobs
WHERE
(:uid IS NULL OR user_id = :uid)
@ -393,6 +467,7 @@ WHERE
-- name: list-jobs-by-file
SELECT
*
, (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
FROM jobs
WHERE
file_id = :fid
@ -422,7 +497,8 @@ LIMIT 1
-- name: list-job-history
SELECT
*
FROM jobs
, (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
FROM jobs j
WHERE
finished_at IS NOT NULL
AND (:uid IS NULL OR user_id = :uid)
@ -496,7 +572,7 @@ WHERE
UPDATE jobs
SET
finished_at = datetime('now')
, state = :state
, status_id = (SELECT id FROM job_statuses WHERE name = :state)
, message = :message
WHERE
id = :jid

View file

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

View file

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

View file

@ -33,7 +33,7 @@
<li><a class="twelve columns button slide" href="/jobs">Jobs</a></li>
<li><a class="twelve columns button slide" href="/files">Files</a></li>
{% 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 %}
<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>

View file

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

View file

@ -6,9 +6,12 @@
<div class="file row u-flex">
<div class="details six columns">
<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 class="controls u-flex u-ml-auto">
{{ macros.start_job(file.id) }}
{{ macros.download_file(file.id) }}
{{ macros.delete_file(file.id) }}
</div>
</div>

View file

@ -36,15 +36,58 @@
{{ '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
'cancelling' if (not job.finished_at and job.cancelled_at) else
job.state }}
job.status }}
{% endmacro %}
{# #################################################################################################### #}
{# File CRUD #}
{% macro delete_file(id) %}
<form class="inline" method="post" action="/files">
{% macro delete_file(id, endpoint="/files") %}
<form class="inline" method="post" action="{{ endpoint }}">
<input type="hidden" name="action" value="delete" />
<input type="hidden" name="file_id" value="{{ id }}" />
<input id="submit" type="submit" value="Delete"/>
</form>
{% 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 %}

View file

@ -3,14 +3,14 @@
<h1>User settings</h1>
<div class="row twelve columns keys">
<h2>API keys</h2>
{% with keys = ctx.db.list_keys(ctx.uid) %}
{% with keys = ctx.db.list_nonweb_keys(ctx.uid) %}
{% if keys %}
{% for id, name, exp in keys %}
<div class="row key u-flex">
<div class="details six columns">
<span class="key-name">{{ name or 'anonymous' }}</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 class="controls u-flex u-ml-auto">
<form class="inline" method="none" class="ml-auto">

View file

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

View file

@ -10,7 +10,6 @@ Mostly related to monitoring and managing Printer state.
from contextlib import closing
import logging
from pathlib import Path
from threading import Event
from typing import Callable
from urllib import parse as urlparse
@ -42,8 +41,6 @@ class OctoRest(_OR):
log = logging.getLogger(__name__)
SHUTDOWN = Event()
def poll_printers(app: App, db: Db) -> None:
"""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}")
db.update_printer_status(pid=printer.id, status=status)
printer_job = {}
try:
client = OctoRest(url=printer.url, apikey=printer.api_key)
printer_job = client.job_info()
printer_job: dict = client.job_info()
try:
printer_state = client.printer().get("state").get("flags", {})
printer_state: dict = client.printer().get("state").get("flags", {})
except HTTPError:
printer_state = {"disconnected": True}
printer_state: dict = {"disconnected": True}
if printer_state.get("error"):
# 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,
msg=message.body,
)
db.send_email(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
db.send_email(eid=message.id)
def toil(*fs):