From b6b0f87a8416ceef83394752617564ad56b9e9d7 Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Mon, 22 May 2023 22:21:53 -0600
Subject: [PATCH] More tentacles progress

---
 projects/tentacles/config.toml                |   7 +-
 .../src/python/tentacles/__main__.py          |  32 +++--
 .../python/tentacles/blueprints/printer_ui.py |  47 +++++++
 .../src/python/tentacles/blueprints/ui.py     |  95 --------------
 .../python/tentacles/blueprints/user_ui.py    | 123 ++++++++++++++++++
 .../src/python/tentacles/blueprints/util.py   |  29 +++++
 .../tentacles/src/python/tentacles/schema.sql |   1 +
 .../python/tentacles/static/css/style.scss    |  52 +++++++-
 .../tentacles/src/python/tentacles/store.py   |  82 +++++++++---
 .../tentacles/templates/add_printer.html.j2   |  31 +++++
 .../python/tentacles/templates/base.html.j2   |  25 ++--
 .../python/tentacles/templates/index.html.j2  |  47 ++++++-
 .../tentacles/templates/register.html.j2      |   2 +-
 projects/tentacles/test/python/conftest.py    |   8 +-
 14 files changed, 442 insertions(+), 139 deletions(-)
 create mode 100644 projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
 delete mode 100644 projects/tentacles/src/python/tentacles/blueprints/ui.py
 create mode 100644 projects/tentacles/src/python/tentacles/blueprints/user_ui.py
 create mode 100644 projects/tentacles/src/python/tentacles/blueprints/util.py
 create mode 100644 projects/tentacles/src/python/tentacles/templates/add_printer.html.j2

diff --git a/projects/tentacles/config.toml b/projects/tentacles/config.toml
index 5313bbb..cbd6019 100644
--- a/projects/tentacles/config.toml
+++ b/projects/tentacles/config.toml
@@ -1,4 +1,9 @@
 SECRET_KEY = "SgvzxsO5oPBGInmqsyyGQWAJXkS9"
 
 [db]
-uri = "tentacles.sqlite3"
+uri = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tentacles.sqlite3"
+
+[[users]]
+email = "root@tirefireind.us"
+group_id = 0
+status_id = 1
diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py
index 6a69e0c..926dc8a 100644
--- a/projects/tentacles/src/python/tentacles/__main__.py
+++ b/projects/tentacles/src/python/tentacles/__main__.py
@@ -6,10 +6,10 @@
 
 from pathlib import Path
 import click
-from flask import Flask, request, session, current_app, Authorization
+from flask import Flask, request, session, current_app
 import tomllib
 
-from tentacles.blueprints import ui, api
+from tentacles.blueprints import user_ui, printer_ui, api
 from tentacles.store import Store
 
 
@@ -19,22 +19,28 @@ def cli():
 
 
 def open_db():
-    current_app.db = Store(current_app.config.get("db", {}).get("uri"))
-    current_app.db.connect()
+    request.db = Store(current_app.config.get("db", {}).get("uri"))
+    request.db.connect()
 
 
 def commit_db(resp):
-    current_app.db.commit()
+    request.db.close()
     return resp
 
 
+def create_j2_request_global():
+    current_app.jinja_env.globals["request"] = request
+
+
 def user_session():
     if (session_id := request.cookies.get("sid", "")) and (
-        uid := current_app.db.try_key(session_id)
+        uid := request.db.try_key(session_id)
     ):
         request.sid = session_id
         request.uid = uid
-        _, gid, name, _ = current_app.db.fetch_user(uid)
+        _id, gid, name, _email, _hash, _status, _verification = request.db.fetch_user(
+            uid
+        )
         request.gid = gid
         request.username = name
         request.is_admin = gid == 0
@@ -58,11 +64,19 @@ def serve(hostname: str, port: int, config: Path):
 
     print(app.config)
 
-    app.before_first_request(open_db)
+    # Before first request
+    app.before_first_request(create_j2_request_global)
+
+    # Before request
+    app.before_request(open_db)
     app.before_request(user_session)
+
+    # After request
     app.after_request(commit_db)
 
-    app.register_blueprint(ui.BLUEPRINT)
+    # Blueprints
+    app.register_blueprint(user_ui.BLUEPRINT)
+    app.register_blueprint(printer_ui.BLUEPRINT)
     app.register_blueprint(api.BLUEPRINT)
 
     app.run(host=hostname, port=port)
diff --git a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
new file mode 100644
index 0000000..27e6da0
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+
+"""Blueprints for HTML serving 'ui'."""
+
+import logging
+from datetime import timedelta
+from importlib.resources import files
+
+from click import group
+
+from flask import (
+    Blueprint,
+    current_app,
+    request,
+    Request,
+    redirect,
+    render_template,
+    session,
+    url_for,
+    flash,
+)
+
+from .util import is_logged_in
+
+log = logging.getLogger(__name__)
+BLUEPRINT = Blueprint("printer", __name__)
+
+
+@BLUEPRINT.route("/printers")
+def printers():
+    return render_template("printers.html.j2")
+
+
+@BLUEPRINT.route("/printers/add", methods=["get", "post"])
+def add_printer():
+    if not is_logged_in(request):
+        return redirect("/")
+
+    elif request.method == "POST":
+        pass
+
+    return render_template("add_printer.html.j2")
+
+
+@BLUEPRINT.route("/printers/delete")
+def delete_printer():
+    return render_template("delete_printer.html.j2")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/ui.py b/projects/tentacles/src/python/tentacles/blueprints/ui.py
deleted file mode 100644
index 25d078b..0000000
--- a/projects/tentacles/src/python/tentacles/blueprints/ui.py
+++ /dev/null
@@ -1,95 +0,0 @@
-#!/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,
-    flash,
-)
-
-BLUEPRINT = Blueprint("ui", __name__)
-
-
-def is_logged_in(request: Request) -> bool:
-    return request.uid is not None
-
-
-@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):
-        return redirect("/")
-
-    elif request.method == "POST":
-        if sid := current_app.db.try_login(
-            username := request.form["username"],
-            request.form["password"],
-            timedelta(days=1),
-        ):
-            resp = redirect("/")
-            resp.set_cookie("sid", sid)
-            flash(f"Welcome, {username}", category="success")
-            return resp
-
-        else:
-            flash("Incorrect username/password", category="error")
-            return render_template("login.html.j2")
-
-    else:
-        return render_template("login.html.j2")
-
-
-@BLUEPRINT.route("/register", methods=["GET", "POST"])
-def register():
-    if is_logged_in(request):
-        return redirect("/")
-
-    elif request.method == "POST":
-        try:
-            if uid := current_app.db.try_create_user(
-                request.form["username"],
-                request.form["email"],
-                request.form["password"],
-            ):
-                flash(
-                    "Please check your email for a verification request",
-                    category="success",
-                )
-                return render_template("register.html.j2")
-        except:
-            pass
-
-        flash("Unable to register that username", category="error")
-        return render_template("register.html.j2")
-
-    else:
-        return render_template("register.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
diff --git a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
new file mode 100644
index 0000000..49633ee
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+"""Blueprints for HTML serving 'ui'."""
+
+import logging
+from datetime import timedelta
+from importlib.resources import files
+
+from click import group
+from flask import (
+    Blueprint,
+    current_app,
+    request,
+    Request,
+    redirect,
+    render_template,
+    session,
+    url_for,
+    flash,
+)
+
+from .util import salt, is_logged_in
+
+log = logging.getLogger(__name__)
+BLUEPRINT = Blueprint("user", __name__)
+
+
+@BLUEPRINT.route("/")
+def root():
+    return (
+        render_template(
+            "index.html.j2",
+        ),
+        200,
+    )
+
+
+@BLUEPRINT.route("/user/login", methods=["GET", "POST"])
+def login():
+    if is_logged_in(request):
+        return redirect("/")
+
+    elif request.method == "POST":
+        if sid := request.db.try_login(
+            username := request.form["username"],
+            salt(request.form["password"]),
+            timedelta(days=1),
+        ):
+            resp = redirect("/")
+            resp.set_cookie("sid", sid)
+            flash(f"Welcome, {username}", category="success")
+            return resp
+
+        else:
+            flash("Incorrect username/password", category="error")
+            return render_template("login.html.j2")
+
+    else:
+        return render_template("login.html.j2")
+
+
+@BLUEPRINT.route("/user/register", methods=["GET", "POST"])
+def register():
+    if is_logged_in(request):
+        return redirect("/")
+
+    elif request.method == "POST":
+        try:
+            username = request.form["username"]
+            email = request.form["email"]
+            group_id = None
+            status_id = None
+
+            for user_config in current_app.config.get("users", []):
+                if user_config["email"] == email:
+                    if "group_id" in user_config:
+                        group_id = user_config["group_id"]
+
+                    if "status_id" in user_config:
+                        status_id = user_config["status_id"]
+
+                    break
+
+            if res := request.db.try_create_user(
+                username, email, salt(request.form["password"]), group_id, status_id
+            ):
+                id, status = res
+                if status == -1:
+                    flash(
+                        "Please check your email for a verification request",
+                        category="success",
+                    )
+                return render_template("register.html.j2")
+
+        except Exception as e:
+            log.exception("Error encountered while registering a user...")
+
+        flash("Unable to register that username", category="error")
+        return render_template("register.html.j2")
+
+    else:
+        return render_template("register.html.j2")
+
+
+@BLUEPRINT.route("/user/logout")
+def logout():
+    # Invalidate the user's authorization
+    request.db.delete_key(request.sid)
+    resp = redirect("/")
+    resp.set_cookie("sid", "")
+    return resp
+
+
+@BLUEPRINT.route("/user", methods=["GET", "POST"])
+def settings():
+    if is_logged_in(request):
+        return redirect("/")
+
+    elif request.method == "POST":
+        pass
+
+    else:
+        return render_template("user.html.j2")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/util.py b/projects/tentacles/src/python/tentacles/blueprints/util.py
new file mode 100644
index 0000000..25b2725
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/util.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+
+import logging
+from datetime import timedelta
+from importlib.resources import files
+
+from click import group
+
+from flask import (
+    Blueprint,
+    current_app,
+    request,
+    Request,
+    redirect,
+    render_template,
+    session,
+    url_for,
+    flash,
+)
+
+log = logging.getLogger(__name__)
+
+
+def is_logged_in(request: Request) -> bool:
+    return request.uid is not None
+
+
+def salt(password: str) -> str:
+    return "$SALT$" + current_app.config["SECRET_KEY"] + password
diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql
index d077e9f..3d7aa3a 100644
--- a/projects/tentacles/src/python/tentacles/schema.sql
+++ b/projects/tentacles/src/python/tentacles/schema.sql
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users (
  , FOREIGN KEY(group_id) REFERENCES groups(id)
  , FOREIGN KEY(status_id) REFERENCES user_statuses(id)
  , UNIQUE(name)
+ , UNIQUE(email)
 );
 
 ----------------------------------------------------------------------------------------------------
diff --git a/projects/tentacles/src/python/tentacles/static/css/style.scss b/projects/tentacles/src/python/tentacles/static/css/style.scss
index 0fea28a..70af198 100644
--- a/projects/tentacles/src/python/tentacles/static/css/style.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/style.scss
@@ -10,31 +10,35 @@ $secondary_blue: #288BC2;
 $secondary_green: #A5C426;
 $secondary_light_grey: #CACBCA;
 $secondary_dark_grey: #9A9A9A;
+$secondary_red: red;
 $clear: rgba(255, 255, 255, 255);
 
 @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');
+  src: local('Aaux Next'), url('/static/font/AauxNextBlk.otf') format('otf');
 }
+
 @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');
+  src: local('Aaux Next'), url('/static/font/aauxnextbdwebfont.otf') format('otf');
 }
+
 @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');
+  src: local('Aaux Next'), url('/static/font/aauxnextltwebfont.otf') format('otf');
 }
+
 @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');
+  src: local('Aaux Next'), url('/static/font/aauxnextmdwebfont.otf') format('otf');
 }
 
 @import url(https://fonts.googleapis.com/css?family=Raleway);
@@ -55,6 +59,20 @@ html, body {
   height: 100%;
   width: 100%;
   min-width: 400px;
+  display: flex;
+  flex-grow: 1;
+  flex-direction: column;
+}
+
+.content, .footer {
+    padding-left: 10%;
+    padding-right: 10%;
+}
+
+.content {
+  .flash, .panel {
+    margin-bottom: 40px;
+  }
 }
 
 a {
@@ -228,3 +246,29 @@ $navbar_padding: 10px;
   padding-left: 10%;
   padding-right: 10%;
 }
+
+.footer {
+  margin-top: auto;
+  width: 100%;
+}
+
+.flashes {
+  .flash {
+    border: 10px solid $secondary_blue;
+    border-radius: 20px;
+    min-height: 40px;
+    p {
+      font-size: 20px;
+      margin-top: 10px;
+      margin-left: 10px;
+    }
+  }
+
+  .success {
+    border-color: $secondary_green;
+  }
+
+  .error {
+    border-color: $secondary_red;
+  }
+}
diff --git a/projects/tentacles/src/python/tentacles/store.py b/projects/tentacles/src/python/tentacles/store.py
index 17b0b41..197fcd6 100644
--- a/projects/tentacles/src/python/tentacles/store.py
+++ b/projects/tentacles/src/python/tentacles/store.py
@@ -1,12 +1,12 @@
 #!/usr/bin/env python3
 
-from datetime import timedelta, datetime
+from collections import namedtuple
+from datetime import datetime, timedelta
 from hashlib import sha3_256
+from importlib.resources import files
 from pathlib import Path
 import sqlite3
 from textwrap import indent
-from importlib.resources import files
-
 from typing import Optional
 
 
@@ -45,14 +45,28 @@ def one(it, *args, **kwargs):
     return it
 
 
+class StoreError(Exception):
+    pass
+
+
+class LoginError(StoreError):
+    pass
+
+
 class Store(object):
     def __init__(self, path):
         self._path = path
         self._conn: sqlite3.Connection = None
 
+    def _factory(self, cursor, row):
+        fields = [column[0] for column in cursor.description]
+        cls = namedtuple("Row", fields)
+        return cls._make(row)
+
     def connect(self):
         if not self._conn:
             self._conn = sqlite3.connect(self._path, isolation_level="IMMEDIATE")
+            self._conn.row_factory = self._factory
             for hunk in PRELUDE.split("\n\n"):
                 try:
                     self._conn.executescript(hunk).fetchall()
@@ -71,16 +85,28 @@ class Store(object):
             self._conn.close()
             self._conn = None
 
-    @fmap(one)
+    ################################################################################
+    # Users
+
     @requires_conn
-    def try_create_user(self, username, email, password):
-        """Attempt to create a new user."""
+    def try_create_user(self, username, email, password, group_id=10, status_id=-2):
+        """Attempt to create a new user.
+
+        :param username: The name of the user to be created.
+        :param email: The email of the user to be created.
+        :param password: The (hopefully salted!) plain text password for the user. Will be hashed before storage.
+        :param group_id: The numeric ID of a group to assign the user to. Default 10 AKA normal user.
+        :param status_id: The numeric ID of the status to assign the user to. Default -2 AKA email verification required.
+
+        """
 
         digest = sha3_256()
         digest.update(password.encode("utf-8"))
+        digest = digest.hexdigest()
+        print(f"{username}: {digest!r}")
         return self._conn.execute(
-            "INSERT INTO users (name, email, hash) VALUES (?, ?, ?) RETURNING (id)",
-            [username, email, digest.hexdigest()],
+            "INSERT INTO users (name, email, hash, group_id, status_id) VALUES (?, ?, ?, ?, ?) RETURNING id, status_id",
+            [username, email, digest, group_id, status_id],
         ).fetchone()
 
     @requires_conn
@@ -99,6 +125,15 @@ class Store(object):
     def list_users(self):
         return self._conn.execute("SELECT id, name FROM users").fetchall()
 
+    @fmap(one)
+    @requires_conn
+    def fetch_user_status(self, user_status_id: int):
+        """Fetch a user status by ID"""
+
+        return self._conn.execute(
+            "SELECT id, name FROM user_statuses WHERE id = ?", [user_status_id]
+        ).fetchone()
+
     ################################################################################
     # Sessions / 'keys'
 
@@ -120,14 +155,22 @@ class Store(object):
 
         digest = sha3_256()
         digest.update(password.encode("utf-8"))
+        digest = digest.hexdigest()
+        print(f"{username}: {digest!r}")
         res = self._conn.execute(
-            "SELECT id FROM users WHERE name=? AND hash=? LIMIT 1",
-            [username, digest.hexdigest()],
+            "SELECT id, status_id FROM users WHERE (name=?1 AND hash=?2) OR (email=?1 AND hash=?2) LIMIT 1",
+            [username, digest],
         ).fetchone()
         if not res:
             return
-        uid = res[0]
-        return self._create_session(uid, ttl)
+
+        uid, status = res
+        if status > 0:
+            return self._create_session(uid, ttl)
+
+        else:
+            _, status = self.fetch_user_status(status)
+            raise LoginError(status)
 
     @requires_conn
     def create_key(self, kid: str, ttl) -> Optional[str]:
@@ -183,16 +226,23 @@ class Store(object):
     # Printers
     #
     # Printers represent connections to OctoPrint instances controlling physical machines.
+
+    @fmap(one)
     @requires_conn
-    def create_printer(self):
-        pass
+    def try_create_printer(self, url, api_key):
+        self._conn.execute(
+            "INSERT INTO printers (url, api_key, status_id) VALUES (?, ?, 0) RETURNING id",
+            [url, api_key],
+        ).fetchone()
 
     @requires_conn
     def list_printers(self):
-        pass
+        return self._conn.execute(
+            "SELECT id, url, last_poll_date, s.name as status FROM printers p INNER JOIN printer_stauses s ON p.status_id = s.id"
+        ).fetchall()
 
     @requires_conn
-    def update_printer_status(self):
+    def update_printer_status(self, printer_id, status_id):
         pass
 
     ################################################################################
diff --git a/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2 b/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
new file mode 100644
index 0000000..479d7ba
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
@@ -0,0 +1,31 @@
+{% extends "base.html.j2" %}
+{% block content %}
+<h1>Add printer</h1>
+<form method="post" id="form">
+  <p>Hostname: <input type="text" name="hostname" /></p>
+  <p>API key: <input type="text" name="key" /></p>
+  <p><input id="test" type="button" value="Test" enabled="false" /></p>
+  <p><input id="submit" type="submit" value="Add" onclick="maybeSubmit();" /></p>
+  <input type="hidden" name="tested" value="false" />
+</form>
+
+<script type="text/javascript">
+  document.getElementById("input").disabled = True;
+
+  function testSettings() {
+    var formData = new FormData(document.getElementById("form"))
+    var req = new XMLHttpRequest();
+    req.open("POST", "/printer/test");
+    req.send(formData);
+
+  };
+
+  function maybeSubmit() {
+    if (document.getElementById("tested").value == "true") {
+      document.getElementById("form").submit();
+    } else {
+      console.error("Form values have not been tested!");
+    }
+  };
+</script>
+{% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/base.html.j2 b/projects/tentacles/src/python/tentacles/templates/base.html.j2
index 1f77969..d5a5524 100644
--- a/projects/tentacles/src/python/tentacles/templates/base.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/base.html.j2
@@ -24,28 +24,31 @@
 
     <ul class="menu">
       {% if not request.uid %}
-      <li><a href="/login">Log in</a></li>
-      <li><a href="/register">Register</a></li>
+      <li><a href="/user/login">Log in</a></li>
+      <li><a href="/user/register">Register</a></li>
       {% else %}
       {% if request.is_admin %}
-      <li><a href="/printers">Settings</a></li>
+      <li><a href="/printers">Printers</a></li>
       {% endif %}
-      <li><a href="/settings">Settings</a></li>
-      <li><a href="/logout">Log out</a></li>
+      <li><a href="/user">Settings</a></li>
+      <li><a href="/user/logout">Log out</a></li>
       {% endif %}
     </ul>
   </nav>
   </div>
-  {% with messages = get_flashed_messages(with_categories=True) %}
-  {% if messages %}
+  <div class="content">
+    {% with messages = get_flashed_messages(with_categories=True) %}
+    {% if messages %}
     <div class="flashes">
       {% for category, message in messages %}
-        <div class="flash-{{ category }}">{{ message }}</div>
+        <div class="flash {{ category }}">
+          <center><p>{{ message }}</p></center>
+        </div>
       {% endfor %}
     </div>
-  {% endif %}
-  {% endwith %}
-  <div class="content">
+    {% endif %}
+    {% endwith %}
+
     {% block content %}Oops, an empty page :/{% endblock %}
   </div>
   <div class="footer">
diff --git a/projects/tentacles/src/python/tentacles/templates/index.html.j2 b/projects/tentacles/src/python/tentacles/templates/index.html.j2
index 925abcb..265a51d 100644
--- a/projects/tentacles/src/python/tentacles/templates/index.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/index.html.j2
@@ -1,4 +1,49 @@
 {% extends "base.html.j2" %}
 {% block content %}
-<p>Hello, {% if request.uid %}{{ request.username }}{% else %}world{% endif %}!</p>
+  <div class="panel printers">
+    <h2>Printers</h2>
+    {% with printers = request.db.list_printers() %}
+    {% if printers %}
+    <ul>
+      {% for printer in printers %}
+      <li></li>
+      {% endfor %}
+    </ul>
+    {% else %}
+      No printers available. {% if request.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
+    {% endif %}
+    {% endwith %}
+  </div>
+
+  <div class="panel queue">
+    <h2>Queue</h2>
+    {% with jobs = request.db.list_jobs(uid=request.uid) %}
+    {% if jobs %}
+    <ul>
+      {% for job in jobs %}
+      <li></li>
+      {% endfor %}
+    </ul>
+  {% else %}
+    No pending tasks. {% if request.uid %}Start something!{% endif %}
+  {% endif %}
+  {% endwith %}
+  </div>
+
+  {% if request.uid %}
+  <div class="panel files">
+    <h2>Files</h2>
+    {% with files = request.db.list_files(uid=request.uid) %}
+    {% if files %}
+    <ul>
+      {% for file in files %}
+      <li></li>
+      {% endfor %}
+    </ul>
+    {% else %}
+      You don't have any files. Upload something!
+    {% endif %}
+    {% endwith %}
+  </div>
+  {% endif %}
 {% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/register.html.j2 b/projects/tentacles/src/python/tentacles/templates/register.html.j2
index 7652c71..a02a1db 100644
--- a/projects/tentacles/src/python/tentacles/templates/register.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/register.html.j2
@@ -5,6 +5,6 @@
   <p>Username: <input type="text" name="username">
   <p>Email address: <input type="text" name="email">
   <p>Password: <input type="password" name="password">
-  <p><input type="submit" value=Register>
+  <p><input type="submit" value="Register">
 </form>
 {% endblock %}
diff --git a/projects/tentacles/test/python/conftest.py b/projects/tentacles/test/python/conftest.py
index ace7acd..19ed4c6 100644
--- a/projects/tentacles/test/python/conftest.py
+++ b/projects/tentacles/test/python/conftest.py
@@ -26,7 +26,13 @@ def password_testy():
 
 @pytest.fixture
 def uid_testy(store, username_testy, password_testy):
-    return store.try_create_user(username_testy, password_testy)
+    uid, status = store.try_create_user(
+        username_testy,
+        username_testy,
+        password_testy,
+        status_id=1,
+    )
+    return uid
 
 
 @pytest.fixture