Get a user approval flow working

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

View file

@ -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,8 +39,7 @@ 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()
_ctx.reset(token) _ctx.reset(token)
@ -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()

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

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

View file

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

View file

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

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="/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>

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

View file

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

View file

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

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