diff --git a/projects/tentacles/BUILD b/projects/tentacles/BUILD index 66ccda3..88703b5 100644 --- a/projects/tentacles/BUILD +++ b/projects/tentacles/BUILD @@ -7,5 +7,8 @@ py_project( py_requirement("click"), py_requirement("flask"), py_requirement("jinja2"), - ] + ], + main_data = [ + "//projects/tentacles/src/python/tentacles/static/css", + ], ) diff --git a/projects/tentacles/README.md b/projects/tentacles/README.md index e223fff..1dc95fa 100644 --- a/projects/tentacles/README.md +++ b/projects/tentacles/README.md @@ -6,7 +6,7 @@ A simple queue system for OctoPrint, designed to receive jobs and forward them t Username+Password users -API keys mapped to users +API keys mapped to users (same thing as sessions actually) Users mapped to job priority @@ -34,7 +34,8 @@ Checking bed status on a printer -X POST \ -H 'Accept: application/json' \ -H 'Content-Type: application/json; charset=UTF-8' \ - -H 'Authorization: Bearer ...' \ + -H 'Authorization: Bearer ...No creds +' \ --data-raw '{"command":"check_bed","reference":"reference_2023-05-11T05:22:40.212Z.jpg"}' \ | jq . diff --git a/projects/tentacles/config.toml b/projects/tentacles/config.toml new file mode 100644 index 0000000..afb4f9b --- /dev/null +++ b/projects/tentacles/config.toml @@ -0,0 +1,2 @@ +[db] +uri = "tentacles.sqlite3" diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py index 6f447a5..e0ba654 100644 --- a/projects/tentacles/src/python/tentacles/__main__.py +++ b/projects/tentacles/src/python/tentacles/__main__.py @@ -4,8 +4,13 @@ """ +from pathlib import Path import click -import flask +from flask import Flask, request, session, current_app +import tomllib + +from tentacles.blueprints import ui, api +from tentacles.store import Store @click.group() @@ -13,11 +18,49 @@ def cli(): pass +def open_db(): + current_app.db = Store(current_app.config.get("db", {}).get("uri")) + current_app.db.connect() + + +def commit_db(resp): + current_app.db.commit() + return resp + + +def user_session(): + if (session_id := request.cookies.get("sid", "")) and ( + uid := current_app.db.try_key(session_id) + ): + request.sid = session_id + request.uid = uid + _, gid, name, _ = current_app.db.fetch_user(uid) + request.gid = gid + request.username = name + else: + request.sid = request.uid = request.gid = request.username = None + + @cli.command() -def serve(): - app = flask.Flask() - app.register_blueprint() - app.run() +@click.option("--hostname", "hostname", type=str, default="0.0.0.0") +@click.option("--port", "port", type=int, default=8080) +@click.option("--config", type=Path) +def serve(hostname: str, port: int, config: Path): + app = Flask(__name__) + if config: + with open(config, "rb") as fp: + app.config.update(tomllib.load(fp)) + + print(app.config) + + app.before_first_request(open_db) + app.before_request(user_session) + app.after_request(commit_db) + + app.register_blueprint(ui.BLUEPRINT) + app.register_blueprint(api.BLUEPRINT) + + app.run(host=hostname, port=port) if __name__ == "__main__": diff --git a/projects/tentacles/src/python/tentacles/blueprints.py b/projects/tentacles/src/python/tentacles/blueprints.py deleted file mode 100644 index e0eeab7..0000000 --- a/projects/tentacles/src/python/tentacles/blueprints.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 - -"""Blueprints for the Tentacles app.""" - -from flask import Blueprint - -BLUEPRINT = Blueprint(__name__, __qualname__, template_folder=__package__) diff --git a/projects/tentacles/src/python/tentacles/blueprints/api.py b/projects/tentacles/src/python/tentacles/blueprints/api.py new file mode 100644 index 0000000..4bab571 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/blueprints/api.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +"""API endpoints supporting the 'ui'.""" + +from importlib.resources import files +from sys import meta_path, prefix + +from flask import Blueprint, request, Request, redirect + +BLUEPRINT = Blueprint("api", __name__, url_prefix="/api") + + +#################################################################################################### +# Printers +# +# The trick here is handling multipart uploads. +@BLUEPRINT.route("/printer", methods=["POST"]) +def create_printer(): + pass + + +@BLUEPRINT.route("/printer", methods=["GET"]) +def list_printers(): + pass + + +@BLUEPRINT.route("/printer", methods=["DELETE"]) +def delete_printer(): + pass + + +#################################################################################################### +# Files +# +# The trick here is handling multipart uploads. +@BLUEPRINT.route("/file", methods=["POST"]) +def create_file(): + pass + + +@BLUEPRINT.route("/file", methods=["GET"]) +def get_files(): + pass + + +@BLUEPRINT.route("/file", methods=["DELETE"]) +def delete_file(): + pass + + +#################################################################################################### +# Jobs +@BLUEPRINT.route("/job", methods=["POST"]) +def create_job(): + pass + + +@BLUEPRINT.route("/job", methods=["GET"]) +def get_jobs(): + pass + + +@BLUEPRINT.route("/job", methods=["DELETE"]) +def delete_job(): + pass + + +#################################################################################################### +# API tokens +@BLUEPRINT.route("/token", methods=["GET"]) +def get_tokens(): + pass diff --git a/projects/tentacles/src/python/tentacles/blueprints/ui.py b/projects/tentacles/src/python/tentacles/blueprints/ui.py new file mode 100644 index 0000000..6cc1565 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/blueprints/ui.py @@ -0,0 +1,86 @@ +#!/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, +) + +BLUEPRINT = Blueprint("ui", __name__) + + +def is_logged_in(authorization): + return False + + +@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.authorization): + return redirect("/") + + elif request.method == "POST": + if sid := current_app.db.try_login( + request.form["username"], request.form["password"], timedelta(days=1) + ): + resp = redirect("/") + resp.set_cookie("sid", sid) + return resp + + else: + return render_template("login.html.j2", error="Incorrect username/password") + + else: + return render_template("login.html.j2") + + +@BLUEPRINT.route("/register", methods=["GET", "POST"]) +def register(): + if is_logged_in(request.authorization): + return redirect("/") + elif request.method == "POST": + try: + if uid := current_app.db.try_create_user( + request.form["username"], request.form["password"] + ): + return render_template( + "login.html.j2", message="Success, please log in" + ) + except: + pass + + return render_template( + "login.html.j2", error="Unable to register that username" + ) + + else: + return render_template("login.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/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql new file mode 100644 index 0000000..cc818aa --- /dev/null +++ b/projects/tentacles/src/python/tentacles/schema.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , name TEXT + , priority INTEGER CHECK(priority IS NOT NULL AND priority > 0) +); + +INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'root', 20); +INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'users', 10); +INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'guests', 0); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , group_id INTEGER + , name TEXT + , hash TEXT + , FOREIGN KEY(group_id) REFERENCES groups(id) + , UNIQUE(name) +); + +---------------------------------------------------------------------------------------------------- +-- Keys represent API keys and auth sessions +CREATE TABLE IF NOT EXISTS user_keys ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(48)))) + , user_id INTEGER + , expiration TEXT +); + +---------------------------------------------------------------------------------------------------- +-- Printers represent physical devices (really octoprint API targets) that jobs can go to +CREATE TABLE IF NOT EXISTs printer_statuses ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , name TEXT + , UNIQUE(name) +); + +INSERT OR IGNORE INTO printer_statuses (id, name) values (-1, 'error'); +INSERT OR IGNORE INTO printer_statuses (id, name) values (0, 'disconnected'); +INSERT OR IGNORE INTO printer_statuses (id, name) values (1, 'connected'); +INSERT OR IGNORE INTO printer_statuses (id, name) values (2, 'idle'); +INSERT OR IGNORE INTO printer_statuses (id, name) values (3, 'running'); + +CREATE TABLE IF NOT EXISTS printers ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , url TEXT + , api_key TEXT + , status_id INTEGER + , last_poll_date TEXT + , FOREIGN KEY(status_id) REFERENCES printer_statuses(id) +); + +---------------------------------------------------------------------------------------------------- +-- Files are printables +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , user_id INTEGER + , filename TEXT + , upload_date TEXT + , FOREIGN KEY(user_id) REFERENCES user(id) +); + +---------------------------------------------------------------------------------------------------- +-- 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 jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , user_id INTEGER NOT NULL + , file_id INTEGER NOT NULL + , priority INTEGER CHECK(priority IS NOT NULL AND 0 <= priority) + , started_at TEXT + , finished_at TEXT + , state 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) +); diff --git a/projects/tentacles/src/python/tentacles/static/__init__.py b/projects/tentacles/src/python/tentacles/static/__init__.py new file mode 100644 index 0000000..e5a0d9b --- /dev/null +++ b/projects/tentacles/src/python/tentacles/static/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python3 diff --git a/projects/tentacles/src/python/tentacles/static/css/BUILD b/projects/tentacles/src/python/tentacles/static/css/BUILD new file mode 100644 index 0000000..5733c94 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/static/css/BUILD @@ -0,0 +1,20 @@ +sass_library( + name = "lib", + srcs = glob(["**/_*.scss"]), +) + +sass_binary( + name = "style", + src = "style.scss", + deps = [ + ":lib" + ], +) + +filegroup( + name = "css", + srcs = [ + ":style", + ], + visibility = ["//projects/tentacles:__pkg__"], +) diff --git a/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss b/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss new file mode 100644 index 0000000..89a12b9 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss @@ -0,0 +1,9 @@ +$black: #171426; +$beige: #F4F8EE; +$red: #BB2D2E; +$orange: #CA4F1F; +$yellow: #EDB822; +$secondary_blue: #288BC2; +$secondary_green: #A5C426; +$secondary_light_grey: #CACBCA; +$secondary_dark_grey: #9A9A9A; diff --git a/projects/tentacles/src/python/tentacles/static/css/style.scss b/projects/tentacles/src/python/tentacles/static/css/style.scss new file mode 100644 index 0000000..3446f8b --- /dev/null +++ b/projects/tentacles/src/python/tentacles/static/css/style.scss @@ -0,0 +1,89 @@ +// @use 'tirefire' as tfi; + +$black: #171426; +$beige: #F4F8EE; +$red: #BB2D2E; +$orange: #CA4F1F; +$yellow: #EDB822; +$secondary_blue: #288BC2; +$secondary_green: #A5C426; +$secondary_light_grey: #CACBCA; +$secondary_dark_grey: #9A9A9A; + + +@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'); +} +@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'); +} +@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'); +} +@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'); +} + +html { + font-family: 'Aaux Next', sans-serif; + background-color: $beige; + color: $black; + display: flex; +} + +body { + width: 100vw; +} + +*, *::before, *::after { + box-sizing: inherit; + margin: 0; + padding: 0; +} + +nav { + width: 100%; + border-bottom: $orange 10px solid; + padding-left: 30px; + padding-right: 10px; +} + +.logo { + display: inline-block; +} + +.nav-links { + list-style: none; +} + +.nav-item a { + display: inline-block; + padding: 10px 15px; + text-decoration: none; + color: $secondary_green; +} + +.nav-item:hover { + background-color: white; +} + +.nav-item:hover a { + color: $secondary_blue; +} + +.logo img { + width: 175px; + vertical-align: middle; +} diff --git a/projects/tentacles/src/python/tentacles/static/font/AauxNextBlk.otf b/projects/tentacles/src/python/tentacles/static/font/AauxNextBlk.otf new file mode 100644 index 0000000..f2012a5 Binary files /dev/null and b/projects/tentacles/src/python/tentacles/static/font/AauxNextBlk.otf differ diff --git a/projects/tentacles/src/python/tentacles/static/font/aauxnextbdwebfont.otf b/projects/tentacles/src/python/tentacles/static/font/aauxnextbdwebfont.otf new file mode 100644 index 0000000..3be3d82 Binary files /dev/null and b/projects/tentacles/src/python/tentacles/static/font/aauxnextbdwebfont.otf differ diff --git a/projects/tentacles/src/python/tentacles/static/font/aauxnextltwebfont.otf b/projects/tentacles/src/python/tentacles/static/font/aauxnextltwebfont.otf new file mode 100644 index 0000000..619650d Binary files /dev/null and b/projects/tentacles/src/python/tentacles/static/font/aauxnextltwebfont.otf differ diff --git a/projects/tentacles/src/python/tentacles/static/font/aauxnextmdwebfont.otf b/projects/tentacles/src/python/tentacles/static/font/aauxnextmdwebfont.otf new file mode 100644 index 0000000..09d3356 Binary files /dev/null and b/projects/tentacles/src/python/tentacles/static/font/aauxnextmdwebfont.otf differ diff --git a/projects/tentacles/src/python/tentacles/store.py b/projects/tentacles/src/python/tentacles/store.py index a5f7a85..e95616d 100644 --- a/projects/tentacles/src/python/tentacles/store.py +++ b/projects/tentacles/src/python/tentacles/store.py @@ -5,80 +5,13 @@ from hashlib import sha3_256 from pathlib import Path import sqlite3 from textwrap import indent +from importlib.resources import files -from collections import namedtuple from typing import Optional -PRELUDE = """ -CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , name TEXT - , priority INTEGER CHECK(priority IS NOT NULL AND priority > 0) - DEFAULT 100 -); - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , group_id INTEGER - , name TEXT - , hash TEXT - , FOREIGN KEY(group_id) REFERENCES groups(id) - , UNIQUE(name) -); - -CREATE TABLE IF NOT EXISTS user_keys ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(48)))) - , user_id INTEGER - , expiration TEXT -); - -CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , user_id INTEGER - , filename TEXT - , upload_date TEXT - , FOREIGN KEY(user_id) REFERENCES user(id) -); - -CREATE TABLE IF NOT EXISTS jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , user_id INTEGER - , FOREIGN KEY(user_id) REFERENCES user(id) -); - -CREATE TABLE IF NOT EXISTs printer_statuses ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , name TEXT - , UNIQUE(name) -); - -INSERT OR IGNORE INTO printer_statuses (name) values ('disconnected'); -INSERT OR IGNORE INTO printer_statuses (name) values ('connected'); -INSERT OR IGNORE INTO printer_statuses (name) values ('idle'); -INSERT OR IGNORE INTO printer_statuses (name) values ('running'); -INSERT OR IGNORE INTO printer_statuses (name) values ('error'); - -CREATE TABLE IF NOT EXISTS printers ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , url TEXT - , api_key TEXT - , status_id INTEGER - , last_poll_date TEXT - , FOREIGN KEY(status_id) REFERENCES printer_statuses(id) -); - -CREATE TABLE IF NOT EXISTS runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT - , job_id INTEGER - , started_at TEXT - , finished_at TEXT - , state TEXT - , printer_id INTEGER - , FOREIGN KEY(job_id) REFERENCES job(id) - , FOREIGN KEY(printer_id) REFERENCES printer(id) -); -""" +with files(__package__).joinpath("schema.sql").open("r") as fp: + PRELUDE = fp.read() def requires_conn(f): @@ -90,6 +23,28 @@ def requires_conn(f): return _helper +def fmap(ctor): + """Fmap a constructor over the records returned from a query function.""" + + def _h1(f): + def _h2(*args, **kwargs): + res = f(*args, **kwargs) + if isinstance(res, list): + return [ctor(*it) for it in res] + elif isinstance(res, tuple): + return ctor(*res) + + return _h2 + + return _h1 + + +def one(it, *args, **kwargs): + assert not args + assert not kwargs + return it + + class Store(object): def __init__(self, path): self._path = path @@ -106,12 +61,17 @@ class Store(object): f"Unable to execute startup script:\n{indent(hunk, ' > ')}" ) from e + @requires_conn + def commit(self): + self._conn.commit() + def close(self): if self._conn: - self._conn.commit() + self.commit() self._conn.close() self._conn = None + @fmap(one) @requires_conn def try_create_user(self, username, password): """Attempt to create a new user.""" @@ -121,7 +81,19 @@ class Store(object): return self._conn.execute( "INSERT INTO users (name, hash) VALUES (?, ?) RETURNING (id)", [username, digest.hexdigest()], - ).fetchone()[0] + ).fetchone() + + @requires_conn + def fetch_user(self, uid: int): + return self._conn.execute("SELECT * FROM users WHERE id = ?", [uid]).fetchone() + + @fmap(one) + @requires_conn + def fetch_user_priority(self, uid: int): + return self._conn.execute( + "SELECT priority FROM groups g INNER JOIN users u ON g.id = u.group_id WHERE u.id = ?", + [uid], + ).fetchone() @requires_conn def list_users(self): @@ -130,12 +102,13 @@ class Store(object): ################################################################################ # Sessions / 'keys' + @fmap(one) @requires_conn def _create_session(self, uid: int, ttl: Optional[timedelta]): return self._conn.execute( "INSERT INTO user_keys (user_id, expiration) VALUES (?, ?) RETURNING (id)", [uid, (datetime.now() + ttl).isoformat() if ttl else None], - ).fetchone()[0] + ).fetchone() @requires_conn def try_login(self, username: str, password: str, ttl: timedelta) -> Optional[str]: @@ -177,16 +150,15 @@ class Store(object): "SELECT * FROM user_keys WHERE id = ?", [kid] ).fetchone() + @fmap(one) @requires_conn def try_key(self, kid: str): """Try to find the mapped user for a session.""" - res = self._conn.execute( + return self._conn.execute( "SELECT user_id FROM user_keys WHERE expiration IS NULL OR unixepoch(expiration) > unixepoch('now') and id = ?", [kid], ).fetchone() - if res: - return res[0] @requires_conn def refresh_key(self, kid: str, ttl: timedelta): @@ -227,39 +199,79 @@ class Store(object): # Files # # A record of local files on disk, and the users who own then. + @fmap(one) @requires_conn - def create_file(self, uid: int, path: Path): - pass + def create_file(self, uid: int, name: str, path: Path) -> int: + return self._conn.execute( + "INSERT INTO files (user_id, filename, upload_date) VALUES (?, ?, datetime('now')) RETURNING (id)", + [uid, name], + ).fetchone() @requires_conn def list_files(self, uid: int): - pass + return self._conn.execute( + "SELECT * FROM files WHERE user_id = ?", [uid] + ).fetchall() @requires_conn def delete_file(self, uid: int, fid: int): - pass + self._conn.execute("DELETE FROM files WHERE user_id = ? AND id = ?", [uid, fid]) ################################################################################ # Job # # A request by a user for a given file to be printed. + @fmap(one) @requires_conn - def create_job(self, uid: int): - pass + def create_job(self, uid: int, fid: int, relative_priority: int = 0): + """Create a job mapped to a user with a file to print and a priority. + + Note that the user may provide a sub-priority within their group queue. This allows users to create jobs with + higher priority than existing jobs as a means of controlling the queue order. + + May want a different (eg. more explicit) queue ordering mechanism here. Emulating the Netflix queue behavior of + being able to drag some jobs ahead of others? How to model that? + + """ + assert 0 <= relative_priority <= 9 + return self._conn.execute( + "INSERT INTO jobs (user_id, file_id, priority) VALUES (?, ?, ?) RETURNING (id)", + [uid, fid, self.fetch_user_priority(uid) + relative_priority], + ).fetchone() @requires_conn - def list_jobs(self, uid: int): - pass + def list_jobs(self, uid: Optional[int] = None): + """Enumerate jobs in priority order.""" + cond = f"AND user_id = {uid}" if uid else "" + return self._conn.execute( + f"SELECT * FROM jobs WHERE started_at IS NULL {cond} ORDER BY priority DESC", + [], + ).fetchall() + + @requires_conn + def fetch_job(self, uid: int, jid: int) -> Optional[tuple]: + return self._conn.execute( + "SELECT * FROM jobs WHERE user_id = ? AND id = ?", [uid, jid] + ).fetchone() + + @requires_conn + def alter_job(self, job: tuple): + fields = [ + "id", + "user_id", + "file_id", + "priority", + "started_at", + "finished_at", + "state", + "printer_id", + ] + assert len(job) == len(fields) + return self._conn.execute( + f"INSERT OR REPLACE INTO jobs ({', '.join(fields)}) VALUES ({', '.join(['?'] * len(fields))})", + job, + ) @requires_conn def delete_job(self, uid: int, jid: int): - pass - - ################################################################################ - # Run - # - # A record tha that a Job has been assigned to a Printer and is running. - # Could perhaps be eliminated in favor of rolling state into the Job. - @requires_conn - def list_runs(self, uid: int): - pass + self._conn.execute("DELETE FROM jobs WHERE user_id = ? and id = ?", [uid, jid]) diff --git a/projects/tentacles/src/python/tentacles/templates/__init__.py b/projects/tentacles/src/python/tentacles/templates/__init__.py new file mode 100644 index 0000000..e5a0d9b --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python3 diff --git a/projects/tentacles/src/python/tentacles/templates/base.html.j2 b/projects/tentacles/src/python/tentacles/templates/base.html.j2 new file mode 100644 index 0000000..421f1a4 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/base.html.j2 @@ -0,0 +1,42 @@ + + + + {% block head %} + + Tentacles{% block title %}{% endblock %} + {% endblock %} + + + + + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ +
+ {% endif %} + {% endwith %} +
+ {% block content %}Oops, an empty page :/{% endblock %} +
+ + + diff --git a/projects/tentacles/src/python/tentacles/templates/index.html.j2 b/projects/tentacles/src/python/tentacles/templates/index.html.j2 new file mode 100644 index 0000000..925abcb --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/index.html.j2 @@ -0,0 +1,4 @@ +{% extends "base.html.j2" %} +{% block content %} +

Hello, {% if request.uid %}{{ request.username }}{% else %}world{% endif %}!

+{% endblock %} diff --git a/projects/tentacles/src/python/tentacles/templates/login.html.j2 b/projects/tentacles/src/python/tentacles/templates/login.html.j2 new file mode 100644 index 0000000..a2f4995 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/login.html.j2 @@ -0,0 +1,9 @@ +{% extends "base.html.j2" %} +{% block content %} +

Log in

+
+

Username: +

Password: +

+

+{% endblock %} diff --git a/projects/tentacles/src/python/tentacles/templates/register.html.j2 b/projects/tentacles/src/python/tentacles/templates/register.html.j2 new file mode 100644 index 0000000..dd51e75 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/register.html.j2 @@ -0,0 +1,9 @@ +{% extends "base.html.j2" %} +{% block content %} +

Register

+
+

Username: +

Password: +

+

+{% endblock %}