Skip to content

Commit

Permalink
#1 - use server-side processing for runs table, update deps, random m…
Browse files Browse the repository at this point in the history
…ypy/flake8 fixes

Signed-off-by: Lance Drane <[email protected]>
Lance-Drane committed Apr 23, 2024
1 parent 4b689e3 commit 607b2ac
Showing 8 changed files with 506 additions and 145 deletions.
61 changes: 53 additions & 8 deletions ipsportal/api.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,75 @@
import time
from json import JSONDecodeError
from typing import Tuple, Dict, Any, Optional, List, Union
from flask import Blueprint, jsonify, request, Response, current_app
from flask import Blueprint, json, jsonify, request, Response, current_app
import logging
import pymongo
import pymongo.errors
import requests
import hashlib
from ipsportal.db import (get_runs, get_events, get_run, next_runid,
get_trace, add_run, update_run, get_portal_runid,
get_parent_portal_runid)
from ipsportal.datatables import get_datatables_results
from ipsportal.db import (
get_runs,
get_runs_total,
get_events,
get_run,
next_runid,
get_trace,
add_run,
update_run,
get_portal_runid,
get_parent_portal_runid,
)
from ipsportal.trace import send_trace
from ipsportal.util import ALLOWED_PROPS_RUN

logger = logging.getLogger(__name__)

bp = Blueprint('api', __name__)


@bp.route("/api/runs-datatables")
def runs_datatables() -> Tuple[Response, int]:
try:
arguments: Dict[str, Any] = json.loads(request.args.get('data', '{}'))
except JSONDecodeError:
return jsonify(('data', '"data" query parameter must be JSON-parseable')), 400

try:
datatables_ok, datatables_value = get_datatables_results(
arguments,
allowed_props=ALLOWED_PROPS_RUN,
data_query_fn=get_runs,
count_query_fn=get_runs_total,
base_filter={"parent_portal_runid": None},
)
except pymongo.errors.PyMongoError as e:
logger.error('Pymongo error', e)
return jsonify('Interal Service Error'), 500
if not datatables_ok:
logger.warning('DataTables value invalid', datatables_value)
return jsonify(datatables_value), 400
return jsonify(datatables_value), 200


# TODO - legacy, consider removing once tests are reworked.
@bp.route("/api/runs")
def runs() -> Tuple[Response, int]:
return jsonify(get_runs(filter={'parent_portal_runid': None})), 200
try:
return jsonify(get_runs(db_filter={'parent_portal_runid': None}, limit=100)), 200
except pymongo.errors.PyMongoError as e:
logger.error('Pymongo error', e)
return jsonify('Interal Service Error'), 500


@bp.route("/api/run/<string:portal_runid>/children")
def child_runs(portal_runid: str) -> Tuple[Response, int]:
return jsonify(get_runs(filter={'parent_portal_runid': portal_runid})), 200
return jsonify(get_runs(db_filter={'parent_portal_runid': portal_runid})), 200


@bp.route("/api/run/<int:runid>/children")
def child_runs_runid(runid: int) -> Tuple[Response, int]:
return jsonify(get_runs(filter={'parent_portal_runid': get_portal_runid(runid)})), 200
return jsonify(get_runs(db_filter={'parent_portal_runid': get_portal_runid(runid)})), 200


@bp.route("/api/run/<int:runid>/events")
@@ -81,7 +126,7 @@ def trace(portal_runid: str) -> Tuple[Response, int]:
@bp.route("/", methods=['POST'])
@bp.route("/api/event", methods=['POST'])
def event() -> Tuple[Response, int]:
event_list: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = request.get_json() # type: ignore[attr-defined]
event_list: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = request.get_json()

if event_list is None:
current_app.logger.error("Missing data")
69 changes: 56 additions & 13 deletions ipsportal/data_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from __future__ import annotations

import logging
from flask import Blueprint, jsonify, request, Response
from typing import Tuple
from typing import Any, Tuple
from . db import db

logger = logging.getLogger(__name__)

bp = Blueprint('data', __name__)


@@ -17,39 +22,77 @@ def data_runs() -> Tuple[Response, int]:

@bp.route('/api/data/<string:portal_runid>')
def data(portal_runid: str) -> Tuple[Response, int]:
return jsonify(db.data.find_one({"portal_runid": portal_runid}, projection={'_id': False})), 200
result = db.data.find_one({"portal_runid": portal_runid}, projection={'_id': False})
if result:
return jsonify(result), 200
return jsonify('Not Found'), 404


@bp.route('/api/data/<string:portal_runid>/tags')
def tags(portal_runid: str) -> Tuple[Response, int]:
return jsonify(db.data.find_one({"portal_runid": portal_runid},
projection={'_id': False, 'tags': True})['tags']), 200
result = db.data.find_one(
{"portal_runid": portal_runid},
projection={'_id': False, 'tags': True}
)
if result:
result = result.get('tags')
if result:
return jsonify(result), 200
return jsonify('Not Found'), 404


@bp.route('/api/data/<string:portal_runid>/<string:tag>')
def tagx(portal_runid: str, tag: str) -> Tuple[Response, int]:
return jsonify(db.data.find_one({"portal_runid": portal_runid},
projection={'_id': False, f'data.{tag}': True})['data'][tag]), 200
result: dict[str, Any] | None = db.data.find_one(
{"portal_runid": portal_runid},
projection={'_id': False, f'data.{tag}': True}
)
if result:
result = result.get('data')
if result:
result = result.get(tag)
if result:
return jsonify(result), 200
return jsonify('Not Found'), 404


@bp.route('/api/data/<string:portal_runid>/<string:tag>/parameters')
def parameters(portal_runid: str, tag: str) -> Tuple[Response, int]:
return jsonify(list(db.data.find_one({"portal_runid": portal_runid},
projection={'_id': False, f'data.{tag}': True})['data'][tag].keys())), 200
result: dict[str, Any] | None = db.data.find_one(
{"portal_runid": portal_runid},
projection={'_id': False, f'data.{tag}': True}
)
if result:
result = result.get('data')
if result:
result = result.get(tag)
if result:
return jsonify(list(result.keys())), 200
return jsonify('Not Found'), 404


@bp.route('/api/data/<string:portal_runid>/<string:timestamp>/<string:parameter>')
def parameter(portal_runid: str, timestamp: str, parameter: str) -> Tuple[Response, int]:
return jsonify(db.data.find_one(
result: dict[str, Any] | None = db.data.find_one(
{"portal_runid": portal_runid},
projection={'_id': False,
f'data.{timestamp}.{parameter}': True})['data'][timestamp][parameter]), 200
f'data.{timestamp}.{parameter}': True})

if result:
result = result.get('data')
if result:
result = result.get(timestamp)
if result:
result = result.get(parameter)
if result:
return jsonify(result), 200
return jsonify('Not Found'), 404


@bp.route('/api/data', methods=['POST'])
def add() -> Tuple[Response, int]:
for data in request.get_json(): # type: ignore[attr-defined]
print(data)
for data in request.get_json():
logger.info(data)

db.data.update_one({"portal_runid": data['portal_runid']},
{
@@ -65,5 +108,5 @@ def add() -> Tuple[Response, int]:
@bp.route('/api/data/query', methods=['POST'])
def query() -> Tuple[Response, int]:
return jsonify(sorted(x['portal_runid']
for x in db.data.find(request.get_json(), # type: ignore[attr-defined]
for x in db.data.find(request.get_json(),
projection={'_id': False, "portal_runid": True}))), 200
224 changes: 224 additions & 0 deletions ipsportal/datatables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
from __future__ import annotations

from copy import deepcopy
from typing import Any, Callable, Literal


class SortParamException(Exception):
def __init__(self, property: str, message: str, *args: object) -> None:
super().__init__(*args)
self.property = property
self.message = message


def _parse_sort_arguments(
request: dict[str, Any], allowed_props: list[str]
) -> dict[str, int]:
"""Parse sort arguments from DataTables
Params:
request: DataTables server-side processing request argument
allowed_props: list of allowed properties.
If the request tries to get a property not in this list, raise SortParamException
Returns:
dictionary of property to sort direction.
The dictionary should have the property names as keys,
and the values should either be "1" (sort ASC) or "-1" (sort DESC).
Raises:
SortParamException - if any params are not properly formatted. Should never be raised if
DataTables queries this API, but may be raised if the API is used directly elsewhere.
"""
sort_dict: dict[str, int] = {}
columns: list[Any] = request.get("columns", None)
if not isinstance(columns, list):
raise SortParamException("columns", "must be an array")
for idx, sort_args in enumerate(request.get("order", [])):
col_idx = 0
err = False
try:
col_idx = sort_args["column"]
if col_idx >= len(allowed_props):
err = True
except KeyError:
err = True
if err:
raise SortParamException(f"order[{idx}]", "invalid column reference")
try:
column = columns[col_idx]
except IndexError:
raise SortParamException(
f'order[{idx}]["column"]', "invalid column reference"
)

try:
sort_dir = sort_args["dir"]
if sort_dir == "asc":
sort_code = 1
elif sort_dir == "desc":
sort_code = -1
else:
raise SortParamException(
f'order[{idx}]["dir"]', 'must be either "asc" or "desc"'
)
except KeyError:
raise SortParamException(
f'order[{idx}]["dir"]', 'must be either "asc" or "desc"'
)

if not isinstance(column, dict):
raise SortParamException(
f"columns[{col_idx}]", "must be a valid DataTables column object"
)

if column.get("orderable", False):
err = False
try:
dataname = column["data"]
# if "columns" were defined client-side
if isinstance(dataname, str):
if dataname in allowed_props:
sort_dict[dataname] = sort_code
else:
raise SortParamException(
f"columns[{col_idx}][data]", "must be a valid property name"
)
# if "columns" were not defined client-side
elif isinstance(dataname, int):
try:
sort_dict[allowed_props[dataname]] = sort_code
except IndexError:
raise SortParamException(
f"columns[{col_idx}][data]",
"must be a valid property index",
)
else:
raise SortParamException(
f"columns[{col_idx}][data]",
"must be a valid property name or property index",
)
except KeyError:
raise SortParamException(
f"columns[{col_idx}]", 'missing "data" property'
)

if sort_dict:
# enforce sort consistency in case all other properties are equal
sort_dict["_id"] = 1

return sort_dict


def _add_search_terms(
search_terms: list[str], allowed_props: list[str]
) -> dict[str, list[dict[str, Any]]]:
"""Parse global search option from DataTables
This simply adds an ignore-case regex search option for every column.
Params:
- search_terms: non-empty list of strings
- allowed_props: specific columns we want to search by
Returns:
value to update the $match filter with
"""
return {
"$and": [
{
"$or": [
{column: {"$regex": search_term, "$options": "i"}}
for column in allowed_props
]
}
for search_term in search_terms
]
}


# potentially useful reference: https://github.com/pjosols/mongo-datatables
# (NOTE: this library does NOT attempt to validate the input, so don't use it directly)
def get_datatables_results(
request: dict[str, Any],
allowed_props: list[str],
data_query_fn: Callable[[dict[str, Any], int, int, dict[str, int]], list[Any]],
count_query_fn: Callable[[dict[str, Any]], int],
base_filter: dict[str, Any] | None = None,
) -> tuple[Literal[False], list[tuple[str, str]]] | tuple[Literal[True], dict[str, Any]]:
"""
This function is intended to be a generic mechanism when interacting with a DataTables request.
The DataTables API specification is available at: https://datatables.net/manual/server-side
Params:
request: the parameters that DataTables provides.
Note that it is assumed that this has already been parsed into a dictionary,
this makes no assumptions about what web framework you're using.
HOWEVER - the function will perform validation on the request object from here.
(TODO - maybe use Pydantic or Msgspec to validate the data structure?)
allowed_props: list of permitted properties to query for. DataTables sends in arbitrary
values for its columns, but we need to enforce this on the server-side and never assume
that client-side data is valid.
data_query_fn: Callback function which takes in a filter, skip amount, length amount,
and sort orders, and returns the actual data from Mongo.
count_query_fn: Callback function which takes in a filter,
and returns the number of documents from the query
base_filter: Optional parameter, this is a starting $match MongoDB query
that can be appended to if the frontend sends search instructions.
(note that the parameter will not be modified, we instead make a copy of it internally)
Returns:
always a two-tuple value:
- OnError: False, followed by a list of tuples. The first entry is the property path string,
the second entry is the error string.
- OnSuccess: True, followed by the DataTables response (as a dictionary - not serialized yet)
"""
if not isinstance(request, dict):
return False, (
("<BASE>", "query parameter must be a DataTables JSON object string")
)

errors: list[tuple[str, str]] = []

draw = request.get("draw", 0)
if not isinstance(draw, int) or draw < 0:
errors.append(("draw", "must be a non-negative integer"))

start = request.get("start", 0)
if not isinstance(start, int) or start < 0:
errors.append(("start", "must be a non-negative integer"))

length = request.get("length", 20)
if not isinstance(length, int) or length < 1:
errors.append(("length", "must be a positive integer"))

try:
sort_args = _parse_sort_arguments(request, allowed_props)
except SortParamException as e:
sort_args = {}
errors.append((e.property, e.message))

if errors:
return False, errors

# TODO: currently only checking global search, but DataTables API allows for per-column search
where_filter = deepcopy(base_filter) if base_filter else {}
search = request.get("search")
if isinstance(search, dict):
search_value = search.get("value")
if isinstance(search_value, str):
# TODO should probably sanitize this search result
search_values = search_value.split()
if search_values:
where_filter.update(_add_search_terms(search_values, allowed_props))

return (
True,
{
"draw": draw,
"recordsTotal": count_query_fn(base_filter or {}),
"recordsFiltered": count_query_fn(where_filter),
"data": data_query_fn(where_filter, start, length, sort_args),
},
)
80 changes: 57 additions & 23 deletions ipsportal/db.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
from typing import List, Dict, Any, Optional
from pymongo import MongoClient, ASCENDING, DESCENDING
from pymongo.database import Database
from werkzeug.local import LocalProxy
from flask import g, Flask
import os


def get_db() -> Any:
def get_db() -> Database[Dict[str, Any]]:
if 'db' not in g:
client = MongoClient(host=os.environ.get('MONGO_HOST', 'localhost'),
client: MongoClient[Dict[str, Any]] = MongoClient(
host=os.environ.get('MONGO_HOST', 'localhost'),
port=int(os.environ.get('MONGO_PORT', 27017)),
username=os.environ.get('MONGO_USERNAME'),
password=os.environ.get('MONGO_PASSWORD'))
client.portal.runs.create_index([('runid', DESCENDING)], unique=True)
client.portal.runs.create_index([('portal_runid', ASCENDING)], unique=True)
g.db = client.portal

return g.db
return g.db # type: ignore[no-any-return]


def close_db(e: Optional[BaseException] = None) -> None:
@@ -30,13 +32,21 @@ def init_app(app: Flask) -> None:


# Use LocalProxy to read the global db instance with just `db`
db: LocalProxy = LocalProxy(get_db)
db: Database[Dict[str, Any]] = LocalProxy(get_db) # type: ignore[assignment]


def get_runs(filter: Dict[str, Any]) -> List[Dict[str, Any]]:
return list(db.runs.aggregate(
[
{"$match": filter},
def get_runs_total(db_filter: Dict[str, Any]) -> int:
return db.runs.count_documents(db_filter)


def get_runs(
db_filter: Dict[str, Any],
skip: Optional[int] = None,
limit: Optional[int] = None,
sort: Optional[Dict[str, int]] = None,
) -> List[Dict[str, Any]]:
aggregation_pipeline: List[Dict[str, Any]] = [
{"$match": db_filter},
{"$addFields": {
"timeout": {"$cond":
[
@@ -74,25 +84,34 @@ def get_runs(filter: Dict[str, Any]) -> List[Dict[str, Any]]:
"walltime": True,
"vizurl": True
}}
]))
]
if sort:
aggregation_pipeline.append({'$sort': sort})
if skip:
aggregation_pipeline.append({'$skip': skip})
if limit:
aggregation_pipeline.append({'$limit': limit})

# if the query is taking longer than 30 seconds (probably already too long), kill it to prevent a DDOS
return list(db.runs.aggregate(aggregation_pipeline, maxTimeMS=30_000))


def get_events(filter: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
runs = db.runs.find_one(filter, projection={'_id': False, 'events': True})
def get_events(db_filter: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
runs = db.runs.find_one(db_filter, projection={'_id': False, 'events': True})
if runs is None:
return None
return runs['events'] # type: ignore[no-any-return]


def get_run(filter: Dict[str, Any]) -> Optional[Dict[str, Any]]:
runs = get_runs(filter)
def get_run(db_filter: Dict[str, Any]) -> Optional[Dict[str, Any]]:
runs = get_runs(db_filter, limit=1)
if runs:
return runs[0]
return None


def get_trace(filter: Dict[str, Any]) -> List[Dict[str, Any]]:
runs = db.runs.find(filter,
def get_trace(db_filter: Dict[str, Any]) -> List[Dict[str, Any]]:
runs = db.runs.find(filter=db_filter,
projection={'_id': False, 'portal_runid': True, 'traces': True})
traces = []
for run in runs:
@@ -112,23 +131,38 @@ def add_run(run: Dict[str, Any]) -> Any:
return db.runs.insert_one(run)


def update_run(filter: Dict[str, Any], update: Dict[str, Any]) -> Any:
return db.runs.update_one(filter, update)
def update_run(db_filter: Dict[str, Any], update: Dict[str, Any]) -> Any:
return db.runs.update_one(db_filter, update)


def get_runid(portal_runid: str) -> Any:
return db.runs.find_one(filter={'portal_runid': portal_runid},
projection={'runid': True, '_id': False}).get('runid')
result = db.runs.find_one(
filter={'portal_runid': portal_runid},
projection={'runid': True, '_id': False}
)
if result:
return result.get('runid')
return None


def get_portal_runid(runid: int) -> Any:
return db.runs.find_one(filter={'runid': runid},
projection={'portal_runid': True, '_id': False}).get('portal_runid')
result = db.runs.find_one(
filter={'runid': runid},
projection={'portal_runid': True, '_id': False}
)
if result:
return result.get('portal_runid')
return None


def get_parent_portal_runid(portal_runid: str) -> Any:
return db.runs.find_one(filter={'portal_runid': portal_runid},
projection={'parent_portal_runid': True, '_id': False}).get('parent_portal_runid')
result = db.runs.find_one(
filter={'portal_runid': portal_runid},
projection={'parent_portal_runid': True, '_id': False}
)
if result:
return result.get('parent_portal_runid')
return None


def next_runid() -> int:
143 changes: 67 additions & 76 deletions ipsportal/static/runs-table.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,68 @@
$(document).ready( function () {
$('#runs-table').DataTable( {
ajax: {
url: '/api/runs',
dataSrc: ''
},
deferRender: true,
responsive: true,
order: [[ 0, 'desc' ]],
columns: [
{data: 'runid',
render: function(data, type, row, meta){
if(type === 'display'){
data = `<a href="/${data}">${data}</a>`;
}
return data;},
responsivePriority: 1},
{data: 'state',
defaultContent: '',
responsivePriority: 8},
{data: 'rcomment',
defaultContent: '',
responsivePriority: 4},
{data: 'simname',
defaultContent: '',
responsivePriority: 2},
{data: 'host',
defaultContent: ''},
{data: 'user',
defaultContent: '',
responsivePriority: 5},
{data: 'startat',
defaultContent: '',
responsivePriority: 6},
{data: 'stopat',
defaultContent: '',
responsivePriority: 7},
{data: 'walltime',
defaultContent: '',
responsivePriority: 3}
],
buttons: {
buttons: [
{
text: 'Reload',
className: 'btn btn-primary',
action: function ( e, dt, node, config ) {
dt.ajax.reload();
}
}
],
dom: {
button: {
className: 'btn'
}
}
},
processing: true,
dom:
"<'row'<'col-sm-12 col-md-6'B><'col-sm-12 col-md-6'f>>" +
"<'row'<'col-sm-12'tr>>" +
"<'row'<'col-sm-12 col-md-5'l><'col-sm-12 col-md-3'i><'col-sm-12 col-md-4'p>>",
createdRow: function(row, data, dataIndex){
if( data['state'] == 'Completed'){
if (data['ok']) {
$(row).addClass('table-success');
} else {
$(row).addClass('table-danger');
}
}
else if (data['state'] == 'Timeout') {
$(row).addClass('table-warning');
}
$(document).ready(function () {
$("#runs-table").DataTable({
ajax: {
url: "/api/runs-datatables",
// require query parameter for "data" to be JSON-encoded string
data: (d) => ({ data: JSON.stringify(d) }),
},
serverSide: true,
processing: true,
// uncomment this if we find that search takes too long
// search: {
// return: true,
// },
responsive: true,
order: [[0, "desc"]],
columns: [
{
data: "runid",
render: function (data, type, row, meta) {
if (type === "display") {
data = `<a href="/${data}">${data}</a>`;
}
return data;
},
responsivePriority: 1,
},
{ data: "state", defaultContent: "", responsivePriority: 8 },
{ data: "rcomment", defaultContent: "", responsivePriority: 4 },
{ data: "simname", defaultContent: "", responsivePriority: 2 },
{ data: "host", defaultContent: "" },
{ data: "user", defaultContent: "", responsivePriority: 5 },
{ data: "startat", defaultContent: "", responsivePriority: 6 },
{ data: "stopat", defaultContent: "", responsivePriority: 7 },
{ data: "walltime", defaultContent: "", responsivePriority: 3 },
],
buttons: {
buttons: [
{
text: "Reload",
className: "btn btn-primary",
action: function (e, dt, node, config) {
dt.ajax.reload();
},
},
],
dom: {
button: {
className: "btn",
},
},
},
dom:
"<'row'<'col-sm-12 col-md-6'B><'col-sm-12 col-md-6'f>>" +
"<'row'<'col-sm-12'tr>>" +
"<'row'<'col-sm-12 col-md-5'l><'col-sm-12 col-md-3'i><'col-sm-12 col-md-4'p>>",
createdRow: function (row, data, dataIndex) {
if (data["state"] == "Completed") {
if (data["ok"]) {
$(row).addClass("table-success");
} else {
$(row).addClass("table-danger");
}
}
);
} );
} else if (data["state"] == "Timeout") {
$(row).addClass("table-warning");
}
},
});
});
47 changes: 26 additions & 21 deletions ipsportal/templates/base.html
Original file line number Diff line number Diff line change
@@ -1,39 +1,44 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/v/bs5/jq-3.6.0/dt-1.12.1/b-2.2.3/r-2.3.0/datatables.min.css"/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.0.5/b-3.0.2/r-3.0.2/datatables.min.css" integrity="sha384-weUIDgnB1MxpCon++brXK2lxeXAwjWqmkpbur3C8scS+ghoeb+cCjYG1T8RrZX3W" crossorigin="anonymous">

<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.1.3/js/bootstrap.bundle.min.js" integrity="sha256-9SEPo+fwJFpMUet/KACSwO+Z/dKMReF9q4zFhU/fT9M=" crossorigin="anonymous"></script>
<script type="text/javascript" src="https://cdn.datatables.net/v/bs5/jq-3.6.0/dt-1.12.1/b-2.2.3/r-2.3.0/datatables.min.js" integrity="sha256-vlTf/vLdvJS3Kn9quSfyTf+zGawqN0jCuN6ziTEbWso=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.0.5/b-3.0.2/r-3.0.2/datatables.min.js" integrity="sha384-3rjNyGE1FfJ85xdeik+xKUXOqr/7SvLVeHofViooMbhywDuChe3dioclA9/wsEn1" crossorigin="anonymous"></script>
<title>IPS Portal - {% block title %}{% endblock %}</title>
{% block head %}{% endblock %}
</head>

<body>

<nav class="navbar navbar-expand navbar-light bg-light mb-4">
<div class="container-fluid">
<div class="navbar-brand">IPS Portal</div>
<div class="navbar" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://ips-framework.readthedocs.io/en/latest/user_guides/portal_guides.html">Documentation</a>
</li>
</ul>
</div>
<div class="navbar" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://ips-framework.readthedocs.io/en/latest/user_guides/portal_guides.html"
>Documentation</a
>
</li>
</ul>
</div>
</div>
</nav>

<main>
<section style="max-width:1600px; width: 90%; margin: 0 auto;">
{% block content %}{% endblock %}
</section>
<section style="max-width: 1600px; width: 90%; margin: 0 auto">
{% block content %}{% endblock %}
</section>
</main>

</body>
</html>
18 changes: 18 additions & 0 deletions ipsportal/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from __future__ import annotations

ALLOWED_PROPS_RUN = [
'runid',
'state',
'rcomment',
'simname',
'host',
'user',
'startat',
'stopat',
'walltime',
]
"""
Properties we allow sort queries on for the runs table.
THIS MUST MATCH THE CLIENT SIDE CONFIGURATION.
"""
9 changes: 5 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -8,17 +8,18 @@ include_package_data = True
packages = find:
python_requires = >=3.8
install_requires =
flask==2.2.2
pymongo==4.3.3
flask==2.3.3
pymongo==4.6.3
gunicorn==20.1
plotly==5.11
requests==2.28.1
requests==2.31.0

[options.extras_require]
dev = pytest; coverage; flake8==4.0.1; mypy; types-requests; types-Werkzeug; mongo-types
dev = pytest; coverage; flake8; mypy; types-requests; types-Werkzeug; mongo-types

[flake8]
max-line-length = 120
extend-exclude = .venv

[coverage:report]
include = ipsportal/*.py

0 comments on commit 607b2ac

Please sign in to comment.