From 01bcd4fa95b8baafd5a7247ec1d9d3e70926c13a Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Sat, 3 Jun 2023 22:19:26 -0600
Subject: [PATCH] feat: Printer edit flow; dashboard webcams

---
 .../src/python/tentacles/__main__.py          |  8 +++-
 .../python/tentacles/blueprints/admin_ui.py   | 20 ++++++++
 projects/tentacles/src/python/tentacles/db.py | 17 ++++++-
 .../tentacles/src/python/tentacles/schema.sql | 42 ++++++++++++++++-
 .../tentacles/static/css/_skeleton.scss       |  2 +
 .../python/tentacles/static/css/style.scss    | 21 +++++++++
 .../tentacles/templates/edit_printer.html.j2  | 36 +++++++++++++++
 .../python/tentacles/templates/index.html.j2  |  4 ++
 .../tentacles/templates/printers_list.html.j2 | 46 +++++++++----------
 .../tentacles/templates/streams.html.j2       | 12 +++++
 10 files changed, 182 insertions(+), 26 deletions(-)
 create mode 100644 projects/tentacles/src/python/tentacles/templates/edit_printer.html.j2
 create mode 100644 projects/tentacles/src/python/tentacles/templates/streams.html.j2

diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py
index 46ec85b..c7fb82c 100644
--- a/projects/tentacles/src/python/tentacles/__main__.py
+++ b/projects/tentacles/src/python/tentacles/__main__.py
@@ -116,14 +116,21 @@ def serve(hostname: str, port: int, config: Path, trace: bool):
     if config:
         with open(config, "rb") as fp:
             app.config.update(tomllib.load(fp))
+
     print(app.config)
 
+    # Run migrations once at startup rather than when connecting
+    with closing(db_factory(app)) as db:
+        db.migrate()
+
+    # Configuring cherrypy is kinda awful
     cherrypy.server.unsubscribe()
     server = cherrypy._cpserver.Server()
     cherrypy.config.update(
         {
             "environment": "production",
             "engine.autoreload.on": False,
+            "log.screen.on": True,
         }
     )
     cherrypy.tree.graft(app, "/")
@@ -135,7 +142,6 @@ def serve(hostname: str, port: int, config: Path, trace: bool):
     server.subscribe()
 
     # Spawn the worker thread(s)
-
     Worker(cherrypy.engine, app, db_factory, poll_printers, frequency=5).start()
     Worker(cherrypy.engine, app, db_factory, assign_jobs, frequency=5).start()
     Worker(cherrypy.engine, app, db_factory, push_jobs, frequency=5).start()
diff --git a/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py b/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
index ad55e5b..e0068d1 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
@@ -103,6 +103,26 @@ def handle_add_printer():
     return render_template("printers.html.j2")
 
 
+@BLUEPRINT.route("/admin/printers/edit", methods=["GET"])
+@requires_admin
+def get_edit_printers():
+    pid = int(request.args.get("id", "-1"))
+    if row := ctx.db.fetch_printer(pid=pid):
+        return render_template("edit_printer.html.j2", printer=row)
+    else:
+        flash("No such printer", category="error")
+        return redirect("/admin"), 404
+
+
+@BLUEPRINT.route("/admin/printers/edit", methods=["POST"])
+@requires_admin
+def handle_edit_printers():
+    args = request.form.copy()
+    args["id"] = int(args["id"])
+    ctx.db.edit_printer(**request.form)
+    return redirect("/admin")
+
+
 @BLUEPRINT.route("/admin/files", methods=["POST"])
 @requires_admin
 def manipulate_files():
diff --git a/projects/tentacles/src/python/tentacles/db.py b/projects/tentacles/src/python/tentacles/db.py
index d19ad1e..6126c51 100644
--- a/projects/tentacles/src/python/tentacles/db.py
+++ b/projects/tentacles/src/python/tentacles/db.py
@@ -82,7 +82,22 @@ class Db(Queries):
             self._conn.row_factory = self._factory
             self._conn.isolation_level = None  # Disable automagical transactions
             self._cursor = self._conn.cursor()
-            self.create_tables()
+
+    def migrate(self):
+        self.migration_0000_create_migrations()
+        existing_migrations = {it.name for it in self.list_migrations()}
+        for query in sorted(
+            (
+                q
+                for q in _queries.available_queries
+                if q.startswith("migration_") and q not in existing_migrations
+            )
+        ):
+            log.warn("Applying migration %s", query)
+            getattr(self, query)()
+            digest = sha3_256()
+            digest.update(getattr(_queries, query).sql.encode())
+            self.record_migration(name=query, fingerprint=digest.hexdigest())
 
     def begin(self):
         self._conn.execute("BEGIN")
diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql
index 6093a96..1e36258 100644
--- a/projects/tentacles/src/python/tentacles/schema.sql
+++ b/projects/tentacles/src/python/tentacles/schema.sql
@@ -1,4 +1,31 @@
--- name: create_tables#
+-- name: migration-0000-create_migrations
+CREATE TABLE IF NOT EXISTS migrations (
+   id INTEGER PRIMARY KEY AUTOINCREMENT
+ , name TEXT
+ , fingerprint TEXT
+ , executed_at TEXT DEFAULT (datetime('now'))
+ , UNIQUE(name)
+);
+
+-- name: list-migrations
+SELECT
+   *
+FROM migrations
+ORDER BY
+   datetime(executed_at) ASC
+;
+
+-- name: record-migration!
+INSERT INTO migrations (
+   name
+ , fingerprint
+)
+VALUES (
+   :name
+ , :fingerprint
+);
+
+-- name: migration-0001-create_tables#
 -- Initialize the core db tables. Arguably migration 0.
 ----------------------------------------------------------------------------------------------------
 -- User structures
@@ -347,6 +374,7 @@ SELECT
    p.id
  , p.name
  , p.url
+ , p.stream_url
  , p.api_key
  , p.last_poll_date
  , s.name as status
@@ -360,6 +388,7 @@ SELECT
    p.id
  , p.name
  , p.url
+ , p.stream_url
  , p.api_key
  , p.last_poll_date
  , s.name as status
@@ -388,6 +417,17 @@ WHERE
    id = :pid
 ;
 
+-- name: edit-printer
+UPDATE printers
+SET
+   name = :name
+ , url = :url
+ , stream_url = :stream_url
+ , api_key = :api_key
+WHERE
+   id = :id
+;
+
 ----------------------------------------------------------------------------------------------------
 -- Files
 ----------------------------------------------------------------------------------------------------
diff --git a/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss b/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss
index f28bf6c..2b7ca2e 100644
--- a/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss
@@ -59,6 +59,8 @@
   .columns:first-child {
     margin-left: 0; }
 
+  .equal.columns {}
+
   .one.column,
   .one.columns                    { width: 4.66666666667%; }
   .two.columns                    { width: 13.3333333333%; }
diff --git a/projects/tentacles/src/python/tentacles/static/css/style.scss b/projects/tentacles/src/python/tentacles/static/css/style.scss
index 526b184..0ea459b 100644
--- a/projects/tentacles/src/python/tentacles/static/css/style.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/style.scss
@@ -54,6 +54,10 @@ label {
   margin-bottom: 20px;
 }
 
+.u-flex1 {
+  flex: 1;
+}
+
 @media (max-width: 760px) {
   .file, .printer, .key, .job {
     flex-direction: column;
@@ -65,3 +69,20 @@ label {
     }
   }
 }
+
+.row.webcams {
+	display: flex;
+	justify-content: space-between;
+
+  img {
+    object-fit: contain;
+  }
+
+  .webcam {
+    padding-right: 10px;
+  }
+
+  .webcam:last-child {
+    padding-right: 0px;
+  }
+}
diff --git a/projects/tentacles/src/python/tentacles/templates/edit_printer.html.j2 b/projects/tentacles/src/python/tentacles/templates/edit_printer.html.j2
new file mode 100644
index 0000000..1f3b8f1
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/edit_printer.html.j2
@@ -0,0 +1,36 @@
+{% extends "base.html.j2" %}
+{% block content %}
+<h1>Edit printer</h1>
+<div class="row">
+  <form method="post">
+    <div class="row">
+      <div class="twelve columns">
+        <label for="name">Printer name</label>
+        <input type="text" name="name" value="{{ printer.name }}" />
+      </div>
+    </div>
+    <div class="row">
+      <div class="twelve columns">
+        <label for="url">Printer base URL</label>
+        <input type="text" name="url" value="{{ printer.url }}" />
+      </div>
+    </div>
+    <div class="row">
+      <div class="twelve columns">
+        <label for="url">Printer stream URL</label>
+        <input type="text" name="stream_url" value="{{ printer.stream_url or ''}}" />
+      </div>
+    </div>
+    <div class="row">
+      <div class="twelve columns">
+        <label for="api_key">API key</label>
+        <input type="text" name="api_key" value="{{ printer.api_key }}" />
+      </div>
+    </div>
+    <div class="row">
+      <input type="hidden" name="id" value="{{ printer.id }}" />
+      <input id="submit" type="submit" value="Submit" />
+    </div>
+  </form>
+</div>
+{% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/index.html.j2 b/projects/tentacles/src/python/tentacles/templates/index.html.j2
index 392412e..fefaa1c 100644
--- a/projects/tentacles/src/python/tentacles/templates/index.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/index.html.j2
@@ -1,5 +1,9 @@
 {% extends "base.html.j2" %}
 {% block content %}
+<div class="row twelve columns mb-2">
+  {% include "streams.html.j2" %}
+</div>
+
 <div class="row twelve columns mb-2">
   {% include "jobs_list.html.j2" %}
 </div>
diff --git a/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2
index 5eeaed0..ebf7206 100644
--- a/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2
@@ -1,30 +1,30 @@
 <div class="printers">
   <h2>Printers</h2>
   {% with printers = ctx.db.list_printers() %}
-    {% if printers %}
-      {% for printer in printers %}
-        {% with id, name, url, _api_key, last_poll, status = printer %}
-          <div class="printer row u-flex">
-            <div class="details six columns">
-              <span class="printer-name">{{name}}</span>
-              <span class="printer-url"><code>{{url}}</code></span>
-              <span class="printer-status">{{status}}</span>
-              <span class="printer-date">{{last_poll}}</span>
-            </div>
-            {# FIXME: How should these action buttons work? #}
-            <div class="controls u-flex u-ml-auto">
-              <a class="button" href="/printers/test?id={{id}}">Test</a>
-              <a class="button" href="/printers/edit?id={{id}}">Edit</a>
-              <a class="button" href="/printers/delete?id={{id}}">Remove</a>
-            </div>
-          </div>
-        {% endwith %}
-      {% endfor %}
+  {% if printers %}
+  {% for printer in printers %}
+    <div class="printer row u-flex">
+      <div class="details six columns">
+        <span class="printer-name">{{printer.name}}</span>
+        <span class="printer-url"><code>{{printer.url}}</code></span>
+        <span class="printer-status">{{printer.status}}</span>
+        <span class="printer-date">{{printer.last_poll_date}}</span>
+      </div>
+      {# FIXME: How should these action buttons work? #}
+      <div class="controls u-flex u-ml-auto">
       {% if ctx.is_admin %}
-        <a class="button" href="/admin/printers">Add a printer</a>
+        <a class="button" href="/admin/printers/test?id={{printer.id}}">Test</a>
+        <a class="button" href="/admin/printers/edit?id={{printer.id}}">Edit</a>
+        <a class="button" href="/admin/printers/delete?id={{printer.id}}">Remove</a>
       {% endif %}
-    {% else %}
-      No printers available. {% if ctx.is_admin %}<a href="/admin/printers">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
-    {% endif %}
+      </div>
+    </div>
+  {% endfor %}
+  {% if ctx.is_admin %}
+    <a class="button" href="/admin/printers">Add a printer</a>
+  {% endif %}
+  {% else %}
+    No printers available. {% if ctx.is_admin %}<a href="/admin/printers">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
+  {% endif %}
   {% endwith %}
 </div>
diff --git a/projects/tentacles/src/python/tentacles/templates/streams.html.j2 b/projects/tentacles/src/python/tentacles/templates/streams.html.j2
new file mode 100644
index 0000000..16d8713
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/streams.html.j2
@@ -0,0 +1,12 @@
+{% import "macros.html.j2" as macros %}
+<h2>Webcams</h2>
+{% with printers = ctx.db.list_printers() %}
+<div class="webcams row">
+  {% for printer in printers if printer.stream_url %}
+    <div class="u-flex1 webcam" style="max-width: calc(100% / {{printers|length}})">
+      <label>{{ printer.name }}</label>
+      <img id="printer_{{printer.id}}_stream" src="{{ printer.stream_url }}" style="max-width: 100%;" />
+    </div>
+  {% endfor %}
+</div>
+{% endwith %}