diff options
25 files changed, 741 insertions, 122 deletions
diff --git a/.travis.yml b/.travis.yml index 4e2516be..15d4e95e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ install: - pipenv sync --dev --three script: - pipenv run lint - - python gunicorn_config.py + - pipenv run python gunicorn_config.py - pipenv run test - pipenv run coveralls after_success: diff --git a/gunicorn_config.py b/gunicorn_config.py index c09bee6a..00e5ccf0 100644 --- a/gunicorn_config.py +++ b/gunicorn_config.py @@ -1,15 +1,18 @@ -import html import re +from pysite.migrations.runner import run_migrations + STRIP_REGEX = re.compile(r"<[^<]+?>") WIKI_TABLE = "wiki" -def when_ready(server=None): +def when_ready(server=None, output_func=None): """ server hook that only runs when the gunicorn master process loads """ if server: output = server.log.info + elif output_func: + output = output_func else: output = print @@ -26,16 +29,4 @@ def when_ready(server=None): tables = ", ".join([f"{table}" for table in created]) output(f"Created the following tables: {tables}") - # Init the tables that require initialization - initialized = db.init_tables() - if initialized: - tables = ", ".join([f"{table} ({count} items)" for table, count in initialized.items()]) - output(f"Initialized the following tables: {tables}") - - output("Adding plain-text version of any wiki articles that don't have one...") - - for article in db.pluck(WIKI_TABLE, "html", "text", "slug"): - if "text" not in article: - article["text"] = html.unescape(STRIP_REGEX.sub("", article["html"]).strip()) - - db.insert(WIKI_TABLE, article, conflict="update") + run_migrations(db, output=output) diff --git a/pysite/constants.py b/pysite/constants.py index 49882030..cb8dad62 100644 --- a/pysite/constants.py +++ b/pysite/constants.py @@ -28,6 +28,7 @@ HELPER_ROLE = "267630620367257601" CONTRIB_ROLE = "295488872404484098" ALL_STAFF_ROLES = (OWNER_ROLE, ADMIN_ROLE, MODERATOR_ROLE, DEVOPS_ROLE) +TABLE_MANAGER_ROLES = (OWNER_ROLE, ADMIN_ROLE, DEVOPS_ROLE) EDITOR_ROLES = ALL_STAFF_ROLES + (HELPER_ROLE, CONTRIB_ROLE) SERVER_ID = 267624335836053506 diff --git a/pysite/database.py b/pysite/database.py index b9ed5b89..2fb10598 100644 --- a/pysite/database.py +++ b/pysite/database.py @@ -1,6 +1,3 @@ -# coding=utf-8 -import html -import json import logging import os from typing import Any, Callable, Dict, Iterator, List, Optional, Union @@ -11,18 +8,7 @@ from rethinkdb.ast import RqlMethodQuery, Table, UserError from rethinkdb.net import DefaultConnection from werkzeug.exceptions import ServiceUnavailable -from pysite.constants import DEBUG_MODE - -ALL_TABLES = { - # table: primary_key - "hiphopify": "user_id", - "hiphopify_namelist": "name", - "oauth_data": "id", - "tags": "tag_name", - "users": "user_id", - "wiki": "slug", - "wiki_revisions": "id" -} +from pysite.tables import TABLES STRIP_REGEX = re.compile(r"<[^<]+?>") WIKI_TABLE = "wiki" @@ -47,83 +33,21 @@ class RethinkDB: except rethinkdb.RqlRuntimeError: self.log.debug(f"Database found: '{self.database}'") - if DEBUG_MODE: - # Create any table that doesn't exist - created = self.create_tables() - if created: - tables = ", ".join([f"{table}" for table in created]) - self.log.debug(f"Created the following tables: {tables}") - - # Init the tables that require initialization - initialized = self.init_tables() - if initialized: - tables = ", ".join([f"{table} ({count} items)" for table, count in initialized.items()]) - self.log.debug(f"Initialized the following tables: {tables}") - - # Upgrade wiki articles - for article in self.pluck(WIKI_TABLE, "html", "text", "slug"): - if "text" not in article: - article["text"] = html.unescape(STRIP_REGEX.sub("", article["html"]).strip()) - - self.insert(WIKI_TABLE, article, conflict="update") - def create_tables(self) -> List[str]: """ - Creates whichever tables exist in the ALL_TABLES + Creates whichever tables exist in the TABLES constant if they don't already exist in the database. :return: a list of the tables that were created. """ created = [] - for table, primary_key in ALL_TABLES.items(): - if self.create_table(table, primary_key): + for table, obj in TABLES.items(): + if self.create_table(table, obj.primary_key): created.append(table) return created - def init_tables(self) -> Dict[str, int]: - """ - If the table is empty, and there is a corresponding JSON file with data, - then we fill the table with the data in the JSON file. - - The JSON files are contained inside of pysite/database/table_init/ - :return: - """ - - self.log.debug("Initializing tables") - initialized = {} - - for table, primary_key in ALL_TABLES.items(): - - self.log.trace(f"Checking if {table} is empty.") - - # If the table is empty - if not self.pluck(table, primary_key): - - self.log.trace(f"{table} appears to be empty. Checking if there is a json file at {os.getcwd()}" - f"/pysite/database/table_init/{table}.json") - - # And a corresponding JSON file exists - if os.path.isfile(f"pysite/database/table_init/{table}.json"): - - # Load in all the data in that file. - with open(f"pysite/database/table_init/{table}.json") as json_file: - table_data = json.load(json_file) - - self.log.trace(f"Loading the json file into the table. " - f"The json file contains {len(table_data)} items.") - - for row in table_data: - self.insert( - table, - row - ) - - initialized[table] = len(table_data) - - return initialized - def get_connection(self, connect_database: bool = True) -> DefaultConnection: """ Grab a connection to the RethinkDB server, optionally without selecting a database @@ -267,12 +191,12 @@ class RethinkDB: :return: The RethinkDB table object for the table """ - if table_name not in ALL_TABLES: - self.log.warning(f"Table not declared in database.py: {table_name}") + if table_name not in TABLES: + self.log.warning(f"Table not declared in tables.py: {table_name}") return rethinkdb.table(table_name) - def run(self, query: RqlMethodQuery, *, new_connection: bool = False, + def run(self, query: Union[RqlMethodQuery, Table], *, new_connection: bool = False, connect_database: bool = True, coerce: type = None) -> Union[rethinkdb.Cursor, List, Dict, object]: """ Run a query using a table object obtained from a call to `query()` @@ -467,10 +391,16 @@ class RethinkDB: :return: A list of matching documents; may be empty if no matches were made """ - return self.run( # pragma: no cover - self.query(table_name).get_all(*keys, index=index), - coerce=list - ) + if keys: + return self.run( # pragma: no cover + self.query(table_name).get_all(*keys, index=index), + coerce=list + ) + else: + return self.run( + self.query(table_name), + coerce=list + ) def insert(self, table_name: str, *objects: Dict[str, Any], durability: str = "hard", @@ -480,7 +410,7 @@ class RethinkDB: [Dict[str, Any], Dict[str, Any]], # ...takes two dicts with string keys and any values... Dict[str, Any] # ...and returns a dict with string keys and any values ] - ] = "error") -> Union[List, Dict]: # flake8: noqa + ] = "error") -> Dict[str, Any]: # flake8: noqa """ Insert an object or a set of objects into a table @@ -492,17 +422,14 @@ class RethinkDB: you can also provide your own function in order to handle conflicts yourself. If you do this, the function should take two arguments (the old document and the new one), and return a single document to replace both. - :return: A list of changes if `return_changes` is True; a dict detailing the operations run otherwise + :return: A dict detailing the operations run """ query = self.query(table_name).insert( objects, durability=durability, return_changes=return_changes, conflict=conflict ) - if return_changes: - return self.run(query, coerce=list) - else: - return self.run(query, coerce=dict) + return self.run(query, coerce=dict) def map(self, table_name: str, func: Callable): """ diff --git a/pysite/migrations/__init__.py b/pysite/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pysite/migrations/__init__.py diff --git a/pysite/migrations/runner.py b/pysite/migrations/runner.py new file mode 100644 index 00000000..d498832f --- /dev/null +++ b/pysite/migrations/runner.py @@ -0,0 +1,95 @@ +import importlib +import json +import os +from typing import Callable + +from pysite.database import RethinkDB +from pysite.tables import TABLES + +TABLES_DIR = os.path.abspath("./pysite/migrations/tables") +VERSIONS_TABLE = "_versions" + + +def get_migrations(table_path, table): + """ + Take a table name and the path to its migration files, and return a dict containing versions and modules + corresponding with each migration. + + And, yes, migrations start at 1. + """ + migrations = {} + final_version = 0 + + for filename in sorted(os.listdir(table_path)): + if filename.startswith("v") and filename.endswith(".py"): + final_version = int(filename[1:-3]) + migrations[final_version] = f"pysite.migrations.tables.{table}.v{final_version}" + + return migrations, final_version + + +def run_migrations(db: RethinkDB, output: Callable[[str], None]=None): + for table, obj in TABLES.items(): # All _defined_ tables + table_path = os.path.join(TABLES_DIR, table) + + if not os.path.exists(table_path): # Check whether we actually have any migration data for this table at all + output(f"No migration data found for table: {table}") + continue + + with db.get_connection() as conn: # Make sure we have an active connection + try: + if not db.query(table).count().run(conn): # If there are no documents in the table... + # Table's empty, so we'll have to run migrations again anyway + db.delete(VERSIONS_TABLE, table) + + json_path = os.path.join(table_path, "initial_data.json") + + if os.path.exists(json_path): # We have initial data to insert, so let's do that + with open(json_path, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + db.insert(table, *data) # Table's empty, so... just do the thing + + output(f"Inserted initial data for table: {table}") + else: # There's no json data file for this table + output(f"No initial_data.json file for table: {table}") + output(json_path) + + # Translate migration files into modules and versions + migrations, final_version = get_migrations(table_path, table) + + if not migrations: # No migration files found + output(f"No structural migrations for table: {table}") + continue + + current_version = 0 + doc = db.get(VERSIONS_TABLE, table) + + if doc: # We've done a migration before, so continue from where we left off + current_version = doc["version"] + + if current_version == final_version: # Nothing to do, we're up to date + output(f"Table is already up to date: {table}") + continue + output(f"Table has never been migrated: {table}") + + while current_version < final_version: + current_version += 1 + + module = importlib.import_module(migrations[current_version]) + module.run(db, table, obj) + output(f"Table upgraded to version {current_version}/{final_version}: {table}") + + # Make sure the versions table is kept up to date, so we don't ever migrate twice + # We do this in the loop to save our progress, in case we fail during a migration + + db.insert( + VERSIONS_TABLE, + {"table": table, "version": current_version}, + conflict="replace", + durability="soft" + ) + except Exception: + output(f"Failed to migrate table: {table}") + raise + finally: + db.sync(VERSIONS_TABLE) diff --git a/pysite/migrations/tables/__init__.py b/pysite/migrations/tables/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pysite/migrations/tables/__init__.py diff --git a/pysite/migrations/tables/hiphopify_namelist/__init__.py b/pysite/migrations/tables/hiphopify_namelist/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pysite/migrations/tables/hiphopify_namelist/__init__.py diff --git a/pysite/database/table_init/hiphopify_namelist.json b/pysite/migrations/tables/hiphopify_namelist/initial_data.json index 6d90a4a2..6d90a4a2 100644 --- a/pysite/database/table_init/hiphopify_namelist.json +++ b/pysite/migrations/tables/hiphopify_namelist/initial_data.json diff --git a/pysite/migrations/tables/wiki/__init__.py b/pysite/migrations/tables/wiki/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pysite/migrations/tables/wiki/__init__.py diff --git a/pysite/migrations/tables/wiki/v1.py b/pysite/migrations/tables/wiki/v1.py new file mode 100644 index 00000000..a5282f28 --- /dev/null +++ b/pysite/migrations/tables/wiki/v1.py @@ -0,0 +1,7 @@ +def run(db, table, table_obj): + for document in db.pluck(table, table_obj.primary_key, "title"): + if not document.get("title"): + document["title"] = "No Title" + + db.insert(table, document, conflict="update", durability="soft") + db.sync(table) diff --git a/pysite/route_manager.py b/pysite/route_manager.py index 429a553e..f99a4736 100644 --- a/pysite/route_manager.py +++ b/pysite/route_manager.py @@ -6,11 +6,12 @@ import os from flask import Blueprint, Flask, _request_ctx_stack from flask_dance.contrib.discord import make_discord_blueprint from flask_sockets import Sockets +from gunicorn_config import when_ready from pysite.base_route import APIView, BaseView, ErrorView, RouteView from pysite.constants import ( - CSRF, DISCORD_OAUTH_AUTHORIZED, DISCORD_OAUTH_ID, DISCORD_OAUTH_REDIRECT, DISCORD_OAUTH_SCOPE, - DISCORD_OAUTH_SECRET, PREFERRED_URL_SCHEME) + CSRF, DEBUG_MODE, DISCORD_OAUTH_AUTHORIZED, DISCORD_OAUTH_ID, DISCORD_OAUTH_REDIRECT, + DISCORD_OAUTH_SCOPE, DISCORD_OAUTH_SECRET, PREFERRED_URL_SCHEME) from pysite.database import RethinkDB from pysite.oauth import OauthBackend from pysite.websockets import WS @@ -40,6 +41,10 @@ class RouteManager: # to edit self.app.config["WTF_CSRF_TIME_LIMIT"] = None + if DEBUG_MODE: + # Migrate the database, as we would in prod + when_ready(output_func=self.db.log.info) + self.app.before_request(self.db.before_request) self.app.teardown_request(self.db.teardown_request) diff --git a/pysite/tables.py b/pysite/tables.py new file mode 100644 index 00000000..4d9142fa --- /dev/null +++ b/pysite/tables.py @@ -0,0 +1,89 @@ +from typing import List, NamedTuple + + +class Table(NamedTuple): + primary_key: str + keys: List[str] + locked: bool = True + + +TABLES = { + "hiphopify": Table( # Users in hiphop prison + primary_key="user_id", + keys=sorted([ + "user_id", + "end_timestamp", + "forced_nick" + ]) + ), + + "hiphopify_namelist": Table( # Names and images of hiphop artists + primary_key="name", + keys=sorted([ + "name", + "image_url" + ]), + locked=False + ), + + "oauth_data": Table( # OAuth login information + primary_key="id", + keys=sorted([ + "id", + "access_token", + "expires_at", + "refresh_token", + "snowflake" + ]) + ), + + "tags": Table( # Tag names and values + primary_key="tag_name", + keys=sorted([ + "tag_name", + "tag_content" + ]), + locked=False + ), + + "users": Table( # Users from the Discord server + primary_key="user_id", + keys=sorted([ + "user_id", + "roles", + "username", + "discriminator", + "email" + ]) + ), + + "wiki": Table( # Wiki articles + primary_key="slug", + keys=sorted([ + "slug", + "headers", + "html", + "rst", + "text", + "title" + ]) + ), + + "wiki_revisions": Table( # Revisions of wiki articles + primary_key="id", keys=sorted([ + "id", + "date", + "post", + "slug", + "user" + ]) + ), + + "_versions": Table( # Table migration versions + primary_key="table", + keys=sorted([ + "table", + "version" + ]) + ) +} diff --git a/pysite/views/staff/index.py b/pysite/views/staff/index.py index 11447f87..fc05f1a7 100644 --- a/pysite/views/staff/index.py +++ b/pysite/views/staff/index.py @@ -3,14 +3,29 @@ from pprint import pformat from flask import current_app from pysite.base_route import RouteView -from pysite.constants import ALL_STAFF_ROLES +from pysite.constants import ALL_STAFF_ROLES, DEBUG_MODE, TABLE_MANAGER_ROLES from pysite.decorators import require_roles class StaffView(RouteView): path = "/" - name = "index" + name = "staff_index" @require_roles(*ALL_STAFF_ROLES) def get(self): - return self.render("staff/staff.html", app_config=pformat(current_app.config, indent=4, width=120)) + return self.render( + "staff/staff.html", manager=self.is_table_editor(), + app_config=pformat(current_app.config, indent=4, width=120) + ) + + def is_table_editor(self): + if DEBUG_MODE: + return True + + data = self.user_data + + for role in TABLE_MANAGER_ROLES: + if role in data.get("roles", []): + return True + + return False diff --git a/pysite/views/staff/tables/__init__.py b/pysite/views/staff/tables/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pysite/views/staff/tables/__init__.py diff --git a/pysite/views/staff/tables/edit.py b/pysite/views/staff/tables/edit.py new file mode 100644 index 00000000..b70bc20e --- /dev/null +++ b/pysite/views/staff/tables/edit.py @@ -0,0 +1,110 @@ +import json + +from flask import redirect, request, url_for +from werkzeug.exceptions import BadRequest, NotFound + +from pysite.base_route import RouteView +from pysite.constants import TABLE_MANAGER_ROLES +from pysite.decorators import csrf, require_roles +from pysite.mixins import DBMixin +from pysite.tables import TABLES + + +class TableEditView(RouteView, DBMixin): + path = "/tables/<table>/edit" + name = "tables.edit" + + @require_roles(*TABLE_MANAGER_ROLES) + def get(self, table): + obj = TABLES.get(table) + + if not obj: + # Unknown table + raise NotFound() + + if obj.locked: + return redirect(url_for("staff.tables.table", table=table, page=1), code=303) + + key = request.args.get("key") + + old_primary = None + + if key: + db_obj = self.db.get(table, key) + old_primary = key # Provide the current document's primary key, in case it's modified + + document = json.dumps( # Editor uses JSON + db_obj, + indent=4 + ) + else: + document = json.dumps( # Generate default document from key schema + {k: "" for k in obj.keys}, + indent=4 + ) + + return self.render( + "staff/tables/edit.html", table=table, primary_key=obj.primary_key, + document=document, old_primary=old_primary + ) + + @require_roles(*TABLE_MANAGER_ROLES) + @csrf + def post(self, table): + obj = TABLES.get(TABLES) + + if not obj: + # Unknown table + raise NotFound() + + if obj.locked: + raise BadRequest() + + data = request.form.get("json") + old_primary = request.form.get("old_primary") + + if not data: + # No data given (for some reason) + document = json.dumps( + {k: "" for k in obj.keys}, + indent=4 + ) + + return self.render( + "staff/tables/edit.html", table=table, primary_key=obj.primary_key, document=document, + message="Please provide some data to save", old_primary=old_primary + ) + + try: + data = json.loads(data) + except json.JSONDecodeError as e: + # Invalid JSON + return self.render( + "staff/tables/edit.html", table=table, primary_key=obj.primary_key, document=data, + message=f"Invalid JSON, please try again: {e}", old_primary=old_primary + ) + + if not data[obj.primary_key]: + # No primary key value provided + return self.render( + "staff/tables/edit.html", table=table, primary_key=obj.primary_key, document=data, + message=f"Please provide a value for the primary key: {obj.primary_key}", old_primary=old_primary + ) + + if old_primary is None: + self.db.insert( # This is a new object, so just insert it + table, data + ) + elif old_primary == data[obj.primary_key]: + self.db.insert( # This is an update without a primary key change, replace the whole document + table, data, conflict="replace" + ) + else: + self.db.delete( # This is a primary key change, so we need to remove the old object + table, old_primary + ) + self.db.insert( + table, data, + ) + + return redirect(url_for("staff.tables.table", table=table, page=1), code=303) diff --git a/pysite/views/staff/tables/index.py b/pysite/views/staff/tables/index.py new file mode 100644 index 00000000..0d84aeb4 --- /dev/null +++ b/pysite/views/staff/tables/index.py @@ -0,0 +1,13 @@ +from pysite.base_route import RouteView +from pysite.constants import TABLE_MANAGER_ROLES +from pysite.decorators import require_roles +from pysite.tables import TABLES + + +class TablesView(RouteView): + path = "/tables" + name = "tables.index" + + @require_roles(*TABLE_MANAGER_ROLES) + def get(self): + return self.render("staff/tables/index.html", tables=TABLES) diff --git a/pysite/views/staff/tables/table.py b/pysite/views/staff/tables/table.py new file mode 100644 index 00000000..f47d7793 --- /dev/null +++ b/pysite/views/staff/tables/table.py @@ -0,0 +1,63 @@ +from math import ceil + +from flask import request +from werkzeug.exceptions import BadRequest, NotFound + +from pysite.base_route import RouteView +from pysite.constants import TABLE_MANAGER_ROLES +from pysite.decorators import require_roles +from pysite.mixins import DBMixin +from pysite.tables import TABLES + + +class TableView(RouteView, DBMixin): + path = "/tables/<table>/<page>" + name = "tables.table" + + @require_roles(*TABLE_MANAGER_ROLES) + def get(self, table, page): + search = request.args.get("search") + search_key = request.args.get("search-key") + + pages = page + obj = TABLES.get(table) + + if not obj: + return NotFound() + + if search: + new_search = f"(?i){search}" # Case-insensitive search + search_key = search_key or obj.primary_key + + query = self.db.query(table).filter(lambda d: d[search_key].match(new_search)) + else: + query = self.db.query(table) + + if page != "all": + try: + page = int(page) + except ValueError: + # Not an integer + return BadRequest() + + count = self.db.run(query.count(), coerce=int) + pages = max(ceil(count / 10), 1) # Pages if we have 10 documents per page, always at least one + + if page < 1 or page > pages: + # If the page is too small or too big, well, that's an error + return BadRequest() + + documents = self.db.run( # Get only the documents for this page + query.skip((page - 1) * 10).limit(10), + coerce=list + ) + else: + documents = self.db.run(query, coerce=list) + + documents = [dict(sorted(d.items())) for d in documents] + + return self.render( + "staff/tables/table.html", + table=table, documents=documents, table_obj=obj, + page=page, pages=pages, search=search, search_key=search_key + ) diff --git a/pysite/views/staff/tables/table_bare.py b/pysite/views/staff/tables/table_bare.py new file mode 100644 index 00000000..abd6cb19 --- /dev/null +++ b/pysite/views/staff/tables/table_bare.py @@ -0,0 +1,30 @@ +from flask import redirect, request, url_for +from werkzeug.exceptions import NotFound + +from pysite.base_route import RouteView +from pysite.constants import TABLE_MANAGER_ROLES +from pysite.decorators import require_roles +from pysite.mixins import DBMixin +from pysite.tables import TABLES + + +class TableView(RouteView, DBMixin): + path = "/tables/<table>" + name = "tables.table_bare" + + @require_roles(*TABLE_MANAGER_ROLES) + def get(self, table): + if table not in TABLES: + raise NotFound() + + search = request.args.get("search") + + args = { + "table": table, + "page": 1 + } + + if search is not None: + args["search"] = search + + return redirect(url_for("staff.tables.table", **args)) diff --git a/pysite/views/wiki/special/__init__.py b/pysite/views/wiki/special/__init__.py index 9bad5790..e69de29b 100644 --- a/pysite/views/wiki/special/__init__.py +++ b/pysite/views/wiki/special/__init__.py @@ -1 +0,0 @@ -# coding=utf-8 diff --git a/static/style.css b/static/style.css index f99fd256..2b9b329c 100644 --- a/static/style.css +++ b/static/style.css @@ -109,6 +109,16 @@ footer div.uk-section div.uk-text-center { background: rgba(0, 0, 0, 0.22); } +.uk-button-dark { + background: rgba(0, 0, 0, 0.95); + border: 1px solid rgba(34, 34, 34, 0.93); + color: white; +} + +.uk-button-dark:hover { + background: rgba(0, 0, 0, 0.70); +} + /* Flash of Unstyled Content fixes */ .prevent_fouc { display: none; @@ -161,4 +171,12 @@ div.quote { .uk-article-meta { margin-left: 2px; +} + +select { + position: relative !important; + top: auto !important; + left: auto !important; + -webkit-appearance: unset !important; + opacity: 1 !important; }
\ No newline at end of file diff --git a/templates/staff/staff.html b/templates/staff/staff.html index 157bdf21..b22bfcec 100644 --- a/templates/staff/staff.html +++ b/templates/staff/staff.html @@ -3,12 +3,20 @@ {% block og_title %}Staff | Home{% endblock %} {% block og_description %}Landing page for the staff management area{% endblock %} {% block content %} - <div class="uk-container uk-section"> - <h1 class="uk-title uk-text-center"> - App config - </h1> - <pre> -{{ app_config | safe }} - </pre> + <div class="uk-container uk-container-small uk-section"> + <h1 class="uk-text-center"> + Management links + </h1> + {% if manager %} + <a class="uk-button uk-button-primary" href="{{ url_for("staff.tables.index") }}">Table Management</a> + {% else %} + <p>Nothing for you yet, I'm afraid.</p> + {% endif %} + <h1 class="uk-title uk-text-center"> + App config + </h1> + <pre> + {{ app_config | safe }} + </pre> </div> {% endblock %}
\ No newline at end of file diff --git a/templates/staff/tables/edit.html b/templates/staff/tables/edit.html new file mode 100644 index 00000000..7b027884 --- /dev/null +++ b/templates/staff/tables/edit.html @@ -0,0 +1,51 @@ +{% extends "main/base.html" %} +{% block title %}Staff | Home{% endblock %} +{% block og_title %}Staff | Home{% endblock %} +{% block og_description %}Landing page for the staff management area{% endblock %} +{% block extra_head %} +<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.3.3/ace.js" type="application/javascript"></script> +{% endblock %} +{% block content %} + <div class="uk-container uk-section uk-container-small"> + {% if message %} + <div class="uk-alert uk-alert-warning"> + {{ message }} + </div> + {% endif %} + <form uk-grid class="uk-grid-small" action="{{ url_for("staff.tables.edit", table=table) }}" method="post"> + <div class="uk-width-expand"> + <p>Primary key: <strong>"<span style="font-family: monospace">{{ primary_key }}</span>"</strong></p> + </div> + <div class="uk-width-auto"> + <a class="uk-button uk-button-default" href="{{ url_for("staff.tables.table", table=table, page=1) }}"><i class="uk-icon fa-fw fas fa-arrow-left"></i> Back</a> + <input class="uk-button uk-button-primary" type="submit" id="submit" value="Save" /> + </div> + <div class="uk-width-1-1"> + <div id="editor" class="uk-textarea" style="resize: vertical; min-height: 15rem;">{{ document }}</div> + <input type="hidden" name="json" id="json" /> + </div> + + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> + + {% if old_primary %} + <input type="hidden" name="old_primary" value="{{ old_primary }}"/> + {% endif %} + </form> + + <script type="application/javascript"> + "use strict"; + + let editor = ace.edit("editor"); + + editor.session.setMode("ace/mode/json"); + editor.session.setUseWrapMode(true); + + editor.setTheme("ace/theme/iplastic"); + editor.setShowPrintMargin(false); + + editor.on("input", function() { + document.getElementById("json").value = editor.getValue(); + }); + </script> + </div> +{% endblock %} diff --git a/templates/staff/tables/index.html b/templates/staff/tables/index.html new file mode 100644 index 00000000..079ed306 --- /dev/null +++ b/templates/staff/tables/index.html @@ -0,0 +1,32 @@ +{% extends "main/base.html" %} +{% block title %}Staff | Tables{% endblock %} +{% block og_title %}Staff | Tables{% endblock %} +{% block og_description %}Table management and editor{% endblock %} +{% block content %} + <div class="uk-container uk-section uk-container-small"> + <a class="uk-button uk-button-default" href="{{ url_for("staff.staff_index") }}"><i class="uk-icon fa-fw fas fa-arrow-left"></i> Back</a> + <h1 class="uk-title uk-text-center"> + Table manager + </h1> + <p> + Click one of the tables below to manage its data: + </p> + <ul> + {% for table, obj in tables.items() %} + {% if obj.locked %} + <li> + <a href="{{ url_for("staff.tables.table", table=table, page=1) }}" title="Table locked for editing"> + <i class="uk-icon fa-fw fas fa-lock"></i> {{ table }} + </a> + </li> + {% else %} + <li> + <a href="{{ url_for("staff.tables.table", table=table, page=1) }}"> + <i class="uk-icon fa-fw fas fa-pencil"></i> {{ table }} + </a> + </li> + {% endif %} + {% endfor %} + </ul> + </div> +{% endblock %}
\ No newline at end of file diff --git a/templates/staff/tables/table.html b/templates/staff/tables/table.html new file mode 100644 index 00000000..aa26818f --- /dev/null +++ b/templates/staff/tables/table.html @@ -0,0 +1,165 @@ +{% extends "main/base.html" %} +{% block title %}Staff | Home{% endblock %} +{% block og_title %}Staff | Home{% endblock %} +{% block og_description %}Landing page for the staff management area{% endblock %} +{% block content %} + <div class="uk-container uk-section uk-container-small"> + <a class="uk-button uk-button-default" href="{{ url_for("staff.tables.index") }}"><i class="uk-icon fa-fw fas fa-arrow-left"></i> Back</a> + + {% if page == "all" %} + <a class="uk-button uk-button-dark" href="{{ url_for("staff.tables.table", table=table, page=1) }}"><i class="uk-icon fa-fw fas fa-bars"></i> Page 1</a> + {% else %} + <a class="uk-button uk-button-dark" href="{{ url_for("staff.tables.table", table=table, page="all") }}"><i class="uk-icon fa-fw fas fa-bars"></i> All Data</a> + {% endif %} + + {% if not table_obj.locked %} + <a class="uk-button uk-button-primary" href="{{ url_for("staff.tables.edit", table=table) }}"><i class="uk-icon fa-fw fas fa-plus"></i> Add</a> + {% endif %} + + <h1 class="uk-title uk-text-center"> + <span style="font-family: monospace"> + {{ table }} + + {% if table_obj.locked %} + <i class="uk-icon fa-fw fas fa-lock" title="Table locked for editing"></i> + {% endif %} + </span> + </h1> + + <form action="{{ url_for("staff.tables.table", table=table, page="all") }}" method="get" class="uk-width-1-1"> + <div class="uk-form-custom uk-width-1-1 uk-flex"> + {% if search %} + <input class="uk-input uk-width-expand" name="search" type="text" placeholder="Search (RE2)" value="{{ search }}" /> + {% else %} + <input class="uk-input uk-width-expand" name="search" type="text" placeholder="Search (RE2)" /> + {% endif %} + + <div class="uk-width-auto uk-flex-auto"> + <select class="uk-select uk-width-1-1" name="search-key" title="Table Key"> + <option style="font-weight: bold;">{{ table_obj.primary_key }}</option> + {% for key in table_obj.keys %} + {% if key != table_obj.primary_key %} + {% if search_key == key %} + <option selected>{{ key }}</option> + {% else %} + <option>{{ key }}</option> + {% endif %} + {% endif %} + {% endfor %} + </select> + </div> + + <button class="uk-button uk-button-primary uk-width-auto" type="submit"><i class="uk-icon fas fa-search"></i></button> + <a class="uk-button uk-button-dark uk-width-auto" target="_blank" href="https://github.com/google/re2/wiki/Syntax"><i class="uk-icon fas fa-question-circle"></i></a> + </div> + </form> + + {% macro paginate() %} + {% if pages != "all" %} + <ul class="uk-pagination uk-flex-center" uk-margin> + {% if page > 1 %} + <li><a href="{{ url_for("staff.tables.table", table=table, page=page - 1) }}"><span uk-pagination-previous></span></a></li> + {% else %} + <li class="uk-disabled"><a><span uk-pagination-previous></span></a></li> + {% endif %} + + {% if page == 1 %} + <li class="uk-active"><a href="{{ url_for("staff.tables.table", table=table, page=1) }}">1</a></li> + {% else %} + <li><a href="{{ url_for("staff.tables.table", table=table, page=1) }}">1</a></li> + {% endif %} + + {% if page >= 5 %} + <li class="uk-disabled"><a>...</a></li> + {% endif %} + + {% set current_page = page - 2 %} + + {% for num in range(5) %} + {% if current_page + num > 1 and current_page + num < pages %} + {% if current_page + num == page %} + <li class="uk-active"><a href="{{ url_for("staff.tables.table", table=table, page=current_page + num) }}">{{ current_page + num }}</a></li> + {% else %} + <li><a href="{{ url_for("staff.tables.table", table=table, page=current_page + num) }}">{{ current_page + num }}</a></li> + {% endif %} + {% endif %} + {% set current_page = current_page - 1 %} + {% endfor %} + + {% if pages - page > 3 %} + <li class="uk-disabled"><a>...</a></li> + {% endif %} + + {% if pages != 1 %} + {% if page == pages %} + <li class="uk-active"><a href="{{ url_for("staff.tables.table", table=table, page=pages) }}">{{ pages }}</a></li> + {% else %} + <li><a href="{{ url_for("staff.tables.table", table=table, page=pages) }}">{{ pages }}</a></li> + {% endif %} + {% endif %} + + {% if page < pages %} + <li><a href="{{ url_for("staff.tables.table", table=table, page=page + 1) }}"><span uk-pagination-next></span></a></li> + {% else %} + <li class="uk-disabled"><a><span uk-pagination-next></span></a></li> + {% endif %} + </ul> + {% endif %} + {% endmacro %} + + {{ paginate() }} + + </div> + <div class="uk-container uk-section"> + {% if documents %} + <table class="uk-table uk-table-striped uk-overflow-auto"> + <thead> + <tr> + {% if not table_obj.locked %} + <th class="uk-table-shrink uk-text-center"> + <i class="uk-icon fa-fw fas fa-pencil"></i> + </th> + {% endif %} + + {% for key in table_obj.keys %} + <th title="{{ key }}"> + {% if key == table_obj.primary_key %} + <strong>{{ key }}</strong> + {% else %} + {{ key }} + {% endif %} + </th> + {% endfor %} + </tr> + </thead> + <tbody> + {% for doc in documents %} + <tr> + {% if not table_obj.locked %} + <td class="uk-table-shrink"> + <a href="{{ url_for("staff.tables.edit", table=table, key=doc[table_obj.primary_key]) }}"> + <i class="uk-icon fa-fw fas fa-pencil"></i> + </a> + </td> + {% endif %} + + {% for key in table_obj.keys %} + <td class="uk-text-truncate" style="font-family: monospace" title="{{ doc[key] }}"> + {% if key == table_obj.primary_key %} + <strong>{{ doc[key] }}</strong> + {% else %} + {{ doc[key] }} + {% endif %} + </td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <p class="uk-text-center">No documents found</p> + {% endif %} + + {{ paginate() }} + </div> +{% endblock %}
\ No newline at end of file |