aboutsummaryrefslogtreecommitdiffstats
path: root/pysite
diff options
context:
space:
mode:
authorGravatar Gareth Coles <[email protected]>2018-05-14 20:42:41 +0100
committerGravatar GitHub <[email protected]>2018-05-14 20:42:41 +0100
commitb7fe5de12be5c9f02adeedba45befee751ea68be (patch)
tree740be4b7dff4a8e70616e1663375ef1940604d53 /pysite
parentSwitch from using abort to using werkzeug exception (diff)
Migration runner and migrations (#69)
* Migration runner and migrations * Remove demo wiki data * [Staff] Table management pages * Fix weird travis build omission * Address review and comments by @Volcyy * [Tables] Fix pagination * Move table definitions to new file with nameduple * Linting * Address lemon's review comments * Address @Volcyy's review * Address lemon's review * Update search placeholder * Search by key now available
Diffstat (limited to 'pysite')
-rw-r--r--pysite/constants.py1
-rw-r--r--pysite/database.py113
-rw-r--r--pysite/migrations/__init__.py0
-rw-r--r--pysite/migrations/runner.py95
-rw-r--r--pysite/migrations/tables/__init__.py0
-rw-r--r--pysite/migrations/tables/hiphopify_namelist/__init__.py0
-rw-r--r--pysite/migrations/tables/hiphopify_namelist/initial_data.json (renamed from pysite/database/table_init/hiphopify_namelist.json)0
-rw-r--r--pysite/migrations/tables/wiki/__init__.py0
-rw-r--r--pysite/migrations/tables/wiki/v1.py7
-rw-r--r--pysite/route_manager.py9
-rw-r--r--pysite/tables.py89
-rw-r--r--pysite/views/staff/index.py21
-rw-r--r--pysite/views/staff/tables/__init__.py0
-rw-r--r--pysite/views/staff/tables/edit.py110
-rw-r--r--pysite/views/staff/tables/index.py13
-rw-r--r--pysite/views/staff/tables/table.py63
-rw-r--r--pysite/views/staff/tables/table_bare.py30
-rw-r--r--pysite/views/wiki/special/__init__.py1
18 files changed, 453 insertions, 99 deletions
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