Added support for publishers.

master
Pacman Ghost 4 years ago
parent fcf8668dbc
commit 5272bf9934
  1. 4
      Dockerfile
  2. 9
      alembic/README
  3. 6
      alembic/env.py
  4. 34
      alembic/versions/39196521adc5_updated_the_publisher_table.py
  5. 5
      asl_articles/__init__.py
  6. 5
      asl_articles/config/app.cfg
  7. 6
      asl_articles/config/logging.yaml.example
  8. 6
      asl_articles/models.py
  9. 88
      asl_articles/publishers.py
  10. 7
      asl_articles/search.py
  11. 6
      asl_articles/tests/fixtures/basic.json
  12. 26
      asl_articles/tests/test_basic.py
  13. 252
      asl_articles/tests/test_publishers.py
  14. 105
      asl_articles/tests/utils.py
  15. 74
      asl_articles/utils.py
  16. 5
      conftest.py
  17. 1
      requirements.txt
  18. 361
      web/package-lock.json
  19. 7
      web/package.json
  20. BIN
      web/public/images/delete.png
  21. BIN
      web/public/images/edit.png
  22. BIN
      web/public/images/error.png
  23. BIN
      web/public/images/info.png
  24. BIN
      web/public/images/warning.png
  25. 3
      web/public/index.html
  26. 12
      web/src/App.css
  27. 133
      web/src/App.js
  28. 1
      web/src/AskDialog.css
  29. 37
      web/src/AskDialog.js
  30. 0
      web/src/ModalForm.css
  31. 49
      web/src/ModalForm.js
  32. 9
      web/src/SearchResults.css
  33. 150
      web/src/SearchResults.js
  34. 2
      web/src/index.js
  35. 3
      web/src/utils.js

@ -9,7 +9,8 @@ FROM python:alpine3.7 AS base
# install the requirements
FROM base AS build
RUN mkdir /install
RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev
# NOTE: psycopg2 needs postgresql-dev and build tools, lxml needs libxslt
RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev && apk add libxslt-dev
WORKDIR /install
COPY requirements.txt /tmp/
RUN pip install --upgrade pip
@ -21,6 +22,7 @@ FROM base
COPY --from=build /install /usr/local
RUN apk --no-cache add libpq
RUN pip install --upgrade pip
RUN apk add libxslt
# install the application
WORKDIR /app

@ -1 +1,8 @@
Generic single-database configuration.
Generic single-database configuration.
To auto-generate a migration from the SQLAlchemy models:
alembic revision --autogenerate -m "..."
https://alembic.sqlalchemy.org/en/latest/autogenerate.html
To create/update the database:
alembic upgrade head

@ -15,9 +15,9 @@ fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
from asl_articles import db
from asl_articles.models import Publisher
target_metadata = Publisher.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:

@ -0,0 +1,34 @@
"""Updated the 'publisher' table.
Revision ID: 39196521adc5
Revises: 07de6afd4745
Create Date: 2019-11-23 05:22:15.997811
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '39196521adc5'
down_revision = '07de6afd4745'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('publisher', sa.Column('publ_description', sa.String(length=1000), nullable=True))
op.add_column('publisher', sa.Column('publ_url', sa.String(length=500), nullable=True))
op.add_column('publisher', sa.Column('time_created', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column('publisher', sa.Column('time_updated', sa.TIMESTAMP(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('publisher', 'time_updated')
op.drop_column('publisher', 'time_created')
op.drop_column('publisher', 'publ_url')
op.drop_column('publisher', 'publ_description')
# ### end Alembic commands ###

@ -73,3 +73,8 @@ db = SQLAlchemy( app )
import asl_articles.globvars #pylint: disable=cyclic-import
import asl_articles.main #pylint: disable=cyclic-import
import asl_articles.search #pylint: disable=cyclic-import
import asl_articles.publishers #pylint: disable=cyclic-import
import asl_articles.utils #pylint: disable=cyclic-import
# initialize
asl_articles.utils.load_html_whitelists( app )

@ -1 +1,6 @@
[System]
; Allowed HTML tags and attributes. If not specified, the lxml defaults will be used:
; https://github.com/lxml/lxml/blob/master/src/lxml/html/defs.py
;HTML_ATTR_WHITELIST =
;HTML_TAG_WHITELIST =

@ -20,6 +20,12 @@ loggers:
werkzeug:
level: "WARNING"
handlers: [ "console", "file" ]
startup:
level: "WARNING"
handlers: [ "console", "file" ]
search:
level: "WARNING"
handlers: [ "console", "file" ]
db:
level: "WARNING"
handlers: [ "console", "file" ]

@ -11,6 +11,12 @@ class Publisher( db.Model ):
publ_id = db.Column( db.Integer, primary_key=True )
publ_name = db.Column( db.String(100), nullable=False )
publ_url = db.Column( db.String(500), nullable=True )
publ_description = db.Column( db.String(1000), nullable=True )
# NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres
# is more trouble than it's worth :-/
time_created = db.Column( db.TIMESTAMP(timezone=True), nullable=True )
time_updated = db.Column( db.TIMESTAMP(timezone=True), nullable=True )
def __repr__( self ):
return "<Publisher:{}|{}>".format( self.publ_id, self.publ_name )

@ -0,0 +1,88 @@
""" Handle publisher requests. """
import datetime
import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publisher
from asl_articles.utils import get_request_args, apply_attrs, clean_html
_logger = logging.getLogger( "db" )
_FIELD_NAMES = [ "publ_name", "publ_url", "publ_description" ]
# ---------------------------------------------------------------------
@app.route( "/publishers/create", methods=["POST"] )
def create_publisher():
"""Create a publisher."""
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publisher:" )
)
cleaned = _clean_vals( vals )
vals[ "time_created" ] = datetime.datetime.now()
publ = Publisher( **vals )
db.session.add( publ ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
_logger.debug( "- New ID: %d", publ.publ_id )
return _make_ok_response( cleaned, { "publ_id": publ.publ_id } )
# ---------------------------------------------------------------------
@app.route( "/publishers/update", methods=["POST"] )
def update_publisher():
"""Update a publisher."""
publ_id = request.json[ "publ_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publisher: id={}".format( publ_id ) )
)
cleaned = _clean_vals( vals )
vals[ "time_updated" ] = datetime.datetime.now()
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
apply_attrs( publ, vals )
db.session.commit() #pylint: disable=no-member
return _make_ok_response( cleaned )
# ---------------------------------------------------------------------
@app.route( "/publishers/delete/<publ_id>" )
def delete_publisher( publ_id ):
"""Delete a publisher."""
_logger.debug( "Delete publisher: %s", publ_id )
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
_logger.debug( "- %s", publ )
db.session.delete( publ ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
return _make_ok_response( None )
# ---------------------------------------------------------------------
def _make_ok_response( cleaned, extras=None ):
"""Generate a Flask 'success' response."""
# generate the basic response
resp = { "status": "OK" }
if extras:
resp.update( extras )
# check if any values were cleaned
if cleaned:
# yup - return the updated values to the caller
resp[ "warning" ] = "Some values had HTML removed."
resp[ "cleaned" ] = cleaned
return jsonify( resp )
def _clean_vals( vals ):
"""Clean incoming data."""
cleaned = {}
for f in _FIELD_NAMES:
val2 = clean_html( vals[f] )
if val2 != vals[f]:
_logger.debug( "Cleaned HTML: %s => %s", f, val2 )
vals[f] = val2
cleaned[f] = val2
return cleaned

@ -21,11 +21,14 @@ def search():
query = query.filter(
Publisher.publ_name.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Publisher.publ_name.asc() )
publishers = list( query )
_logger.debug( "- Found: %s", " ; ".join( str(p) for p in publishers ) )
publishers = [ {
"type": "publ",
"id": p.publ_id,
"publ_name": p.publ_name
"publ_id": p.publ_id,
"publ_name": p.publ_name,
"publ_description": p.publ_description,
"publ_url": p.publ_url,
} for p in publishers ]
return jsonify( publishers )

@ -1,9 +1,9 @@
{
"publisher": [
{ "publ_name": "Avalon Hill" },
{ "publ_name": "Multiman Publishing" },
{ "publ_name": "Le Franc Tireur" }
{ "publ_name": "Avalon Hill", "publ_description": "AH description", "publ_url": "http://ah.com" },
{ "publ_name": "Multiman Publishing", "publ_url": "http://mmp.com" },
{ "publ_name": "Le Franc Tireur", "publ_description": "The French guys." }
]
}

@ -1,7 +1,5 @@
""" Basic tests. """
import json
from asl_articles.tests.utils import init_tests, init_db, do_search, find_child
# ---------------------------------------------------------------------
@ -20,9 +18,23 @@ def test_basic( webdriver, flask_app, dbconn ):
# run some test searches
def do_test( query, expected ):
results = do_search( query )
results = [ json.loads(r.text) for r in results ]
assert set( r["publ_name"] for r in results ) == set( expected )
do_test( "publish", ["Multiman Publishing"] )
def get_href( r ):
elem = find_child( ".name a", r )
return elem.get_attribute( "href" ) if elem else ""
results = [ (
find_child( ".name", r ).text,
find_child( ".description", r ).text,
get_href( r )
) for r in results ]
assert results == expected
do_test( "publish", [ ("Multiman Publishing","","http://mmp.com/") ] )
do_test( "foo", [] )
do_test( " ", [ "Avalon Hill", "Multiman Publishing", "Le Franc Tireur" ] )
do_test( " H ", [ "Avalon Hill", "Multiman Publishing" ] )
do_test( " ", [
( "Avalon Hill", "AH description" , "http://ah.com/" ),
( "Le Franc Tireur", "The French guys.", "" ),
( "Multiman Publishing", "", "http://mmp.com/" )
] )
do_test( " H ", [
( "Avalon Hill", "AH description" , "http://ah.com/" ),
( "Multiman Publishing", "", "http://mmp.com/" )
] )

@ -0,0 +1,252 @@
""" Test publisher operations. """
from asl_articles.tests.utils import init_tests, init_db, do_search, \
wait_for, wait_for_elem, find_child, find_children, set_elem_text, \
set_toast_marker, check_toast, check_ask_dialog, check_error_msg
# ---------------------------------------------------------------------
def test_edit_publisher( webdriver, flask_app, dbconn ):
"""Test editing publishers."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# edit "Avalon Hill"
results = do_search( "" )
result = results[0]
assert find_child( ".name", result ).text == "Avalon Hill"
_edit_publisher( result, {
"name": " Avalon Hill (updated) ",
"url": " http://ah-updated.com ",
"description": " Updated AH description. "
} )
# check that the search result was updated in the UI
results = find_children( "#search-results .search-result" )
result = results[0]
_check_result( result, [ "Avalon Hill (updated)", "http://ah-updated.com/", "Updated AH description." ] )
# try to remove all fields from "Avalon Hill" (should fail)
_edit_publisher( result,
{ "name": "", "url": "", "description": "" },
expected_error = "Please specify the publisher's name."
)
# enter something for the name
dlg = find_child( "#modal-form" )
set_elem_text( find_child( ".name input", dlg ), "Updated Avalon Hill" )
find_child( "button.ok", dlg ).click()
# check that the search result was updated in the UI
results = find_children( "#search-results .search-result" )
result = results[0]
assert find_child( ".name a", result ) is None
assert find_child( ".name", result ).text == "Updated Avalon Hill"
assert find_child( ".description", result ).text == ""
# check that the search result was updated in the database
results = do_search( "" )
assert _get_result_names( results ) == [ "Le Franc Tireur", "Multiman Publishing", "Updated Avalon Hill" ]
# ---------------------------------------------------------------------
def test_create_publisher( webdriver, flask_app, dbconn ):
"""Test creating new publishers."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# load up some search results
do_search( "" )
# try creating a publisher with no name (should fail)
_create_publisher( {}, toast_type=None )
check_error_msg( "Please specify the publisher's name." )
# enter a name and other details
dlg = find_child( "#modal-form" ) # nb: the form is still on-screen
set_elem_text( find_child( ".name input", dlg ), "New publisher" )
set_elem_text( find_child( ".url input", dlg ), "http://new-publisher.com" )
set_elem_text( find_child( ".description textarea", dlg ), "New publisher description." )
set_toast_marker( "info" )
find_child( "button.ok", dlg ).click()
wait_for( 2,
lambda: check_toast( "info", "created OK", contains=True )
)
# check that the new publisher appears in the UI
def check_new_publisher( result ):
_check_result( result, [ "New publisher", "http://new-publisher.com/", "New publisher description." ] )
results = find_children( "#search-results .search-result" )
check_new_publisher( results[0] )
# check that the new publisher has been saved in the database
results = do_search( "new" )
assert len( results ) == 1
check_new_publisher( results[0] )
# ---------------------------------------------------------------------
def test_delete_publisher( webdriver, flask_app, dbconn ):
"""Test deleting publishers."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# start to delete publisher "Le Franc Tireur", but cancel the operation
results = do_search( "" )
result = results[1]
assert find_child( ".name", result ).text == "Le Franc Tireur"
find_child( ".delete", result ).click()
check_ask_dialog( ( "Do you want to delete", "Le Franc Tireur" ), "cancel" )
# check that search results are unchanged on-screen
results2 = find_children( "#search-results .search-result" )
assert results2 == results
# check that the search results are unchanged in the database
results3 = do_search( "" )
assert results3 == results
# delete the publisher "Le Franc Tireur"
result = results3[1]
assert find_child( ".name", result ).text == "Le Franc Tireur"
find_child( ".delete", result ).click()
set_toast_marker( "info" )
check_ask_dialog( ( "Do you want to delete", "Le Franc Tireur" ), "ok" )
wait_for( 2,
lambda: check_toast( "info", "The publisher was deleted." )
)
# check that search result was removed on-screen
results = find_children( "#search-results .search-result" )
assert _get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
# check that the search result was deleted from the database
results = do_search( "" )
assert _get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
# ---------------------------------------------------------------------
def test_unicode( webdriver, flask_app, dbconn ):
"""Test Unicode content."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# create a publisher with Unicode content
_create_publisher( {
"name": "japan = \u65e5\u672c",
"url": "http://\ud55c\uad6d.com",
"description": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1"
} )
# check that the new publisher is showing the Unicode content correctly
results = do_search( "japan" )
assert len( results ) == 1
_check_result( results[0], [
"japan = \u65e5\u672c",
"http://xn--3e0b707e.com/",
"greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1"
] )
# ---------------------------------------------------------------------
def test_clean_html( webdriver, flask_app, dbconn ):
"""Test cleaning HTML content."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# create a publisher with HTML content
_create_publisher( {
"name": "name: <span style='boo!'> <b>bold</b> <xxx>xxx</xxx> <i>italic</i>",
"description": "bad stuff here: <script>HCF</script>"
}, toast_type="warning" )
# check that the HTML was cleaned
results = wait_for( 2,
lambda: find_children( "#search-results .search-result" )
)
assert len( results ) == 1
result = results[0]
_check_result( result, [ "name: bold xxx italic", None, "bad stuff here:" ] )
assert find_child( ".name span" ).get_attribute( "innerHTML" ) \
== "name: <span> <b>bold</b> xxx <i>italic</i></span>"
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# update the publisher with new HTML content
_edit_publisher( result, {
"name": "<div style='...'>updated</div>"
}, toast_type="warning" )
def check_result():
results = find_children( "#search-results .search-result" )
assert len( results ) == 1
result = results[0]
return find_child( ".name", result ).text == "updated"
wait_for( 2, check_result )
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# ---------------------------------------------------------------------
def _create_publisher( vals, toast_type="info" ):
"""Create a new publisher."""
# initialize
if toast_type:
set_toast_marker( toast_type )
# create the new publisher
find_child( "#menu .new-publisher" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new publisher was created successfully
wait_for( 2,
lambda: check_toast( toast_type, "created OK", contains=True )
)
def _edit_publisher( result, vals, toast_type="info", expected_error=None ):
"""Edit a publisher's details."""
# update the specified publisher's details
find_child( ".edit", result ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
set_toast_marker( toast_type )
find_child( "button.ok", dlg ).click()
if expected_error:
# we were expecting an error, confirm the error message
check_error_msg( expected_error )
else:
# we were expecting the update to work, confirm this
wait_for( 2,
lambda: check_toast( toast_type, "updated OK", contains=True )
)
# ---------------------------------------------------------------------
def _get_result_names( results ):
"""Get the names from a list of search results."""
return [
find_child( ".name", r ).text
for r in results
]
def _check_result( result, expected ):
"""Check a result."""
assert find_child( ".name", result ).text == expected[0]
elem = find_child( ".name a", result )
if elem:
assert elem.get_attribute( "href" ) == expected[1]
else:
assert expected[1] is None
assert find_child( ".description", result ).text == expected[2]

@ -2,6 +2,7 @@
import os
import json
import uuid
import sqlalchemy
import sqlalchemy.orm
@ -86,11 +87,11 @@ def wait_for( timeout, func ):
lambda wd: func()
)
def wait_for_elem( timeout, elem_id, visible=True ):
def wait_for_elem( timeout, sel, visible=True ):
"""Wait for an element to appear in the DOM."""
func = EC.visibility_of_element_located if visible else EC.presence_of_element_located
return WebDriverWait( _webdriver, timeout, 0.1 ).until(
func( ( By.CSS_SELECTOR, elem_id ) )
func( ( By.CSS_SELECTOR, sel ) )
)
# ---------------------------------------------------------------------
@ -108,3 +109,103 @@ def find_children( sel, parent=None ):
return (parent if parent else _webdriver).find_elements_by_css_selector( sel )
except NoSuchElementException:
return None
# ---------------------------------------------------------------------
def get_stored_msg( msg_id ):
"""Get a message stored for us by the front-end."""
elem = find_child( _make_stored_msg_elem_id( msg_id ), _webdriver )
assert elem.tag_name == "textarea"
return elem.get_attribute( "value" )
def set_stored_msg( msg_id, val ):
"""Set a message for the front-end."""
elem = find_child( _make_stored_msg_elem_id( msg_id ), _webdriver )
assert elem.tag_name == "textarea"
_webdriver.execute_script( "arguments[0].value = arguments[1]", elem, val )
def set_stored_msg_marker( msg_id ):
"""Store something in the message buffer (so we can tell if the front-end changes it)."""
marker = "marker:{}:{}".format( msg_id, uuid.uuid4() )
set_stored_msg( msg_id, marker )
return marker
def _make_stored_msg_elem_id( msg_id ):
return "#_stored_msg-{}_".format( msg_id )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
_TOAST_TYPES = [ "info", "warning", "error" ]
def get_toast( toast_type ):
"""Get a toast message stored for us by the front-end."""
buf = get_stored_msg( _make_toast_stored_msg_id( toast_type ) )
if buf.startswith( "<div>" ) and buf.endswith( "</div>" ):
buf = buf[5:-6].strip()
return buf
def set_toast_marker( toast_type, clear_others=True ):
"""Store marker text in the toast message buffer."""
marker = None
for t in _TOAST_TYPES:
msg_id = _make_toast_stored_msg_id( t )
if t == toast_type:
# set the specified stored message marker
marker = set_stored_msg_marker( msg_id )
else:
if clear_others:
# clear all other stored messages
set_stored_msg( msg_id, "" )
assert marker
return marker
def check_toast( toast_type, expected, contains=False, check_others=True ):
"""Check the contents of a stored toast message."""
rc = None
for t in _TOAST_TYPES:
if t == toast_type:
# check the specified toast message
rc = check_string( get_toast(t), expected, contains=contains )
else:
# check that all other toast messages have not been set
if check_others:
assert get_toast( t ) == ""
assert rc is not None
return rc
def _make_toast_stored_msg_id( toast_type ):
return "{}_toast".format( toast_type )
# ---------------------------------------------------------------------
def set_elem_text( elem, val ):
"""Set the text for an element."""
elem.clear()
elem.send_keys( val )
def check_ask_dialog( expected, click_on ):
"""Check that the ASK dialog is being shown, and its contents."""
# check the ASK dialog
elem = wait_for_elem( 2, "#ask .MuiPaper-root" )
buf = elem.get_attribute( "innerHTML" )
for e in [expected] if isinstance(expected,str) else expected:
assert e in buf
# dismiss the dialog
if click_on:
find_child( "button.{}".format( click_on ), elem ).click()
wait_for( 2, lambda: find_child( "#ask" ) is None )
def check_error_msg( expected ):
"""Check that an error dialog is being shown, and its contents."""
check_ask_dialog( expected, None )
# check that the error icon is shown
elem = find_child( "#ask img.icon" )
assert elem.get_attribute( "src" ).endswith( "/error.png" )
find_child( "#ask .MuiDialogActions-root button.ok" ).click()
wait_for( 2, lambda: find_child( "#ask" ) is None )
def check_string( val, expected, contains=False ):
"""Compare a value with its expected value."""
if contains:
return expected in val
return val == expected

@ -1,5 +1,79 @@
""" Helper utilities. """
import re
import logging
import lxml.html.clean
_html_whitelists = None
_startup_logger = logging.getLogger( "startup" )
# ---------------------------------------------------------------------
def get_request_args( vals, keys, log=None ):
"""Unload the arguments from a Flask request."""
vals = { k: vals.get( k ) for k in keys }
vals = {
k: v.strip() if isinstance(v,str) else v
for k,v in vals.items()
}
if log:
log[0].debug( "%s", log[1] )
for k in keys:
log[0].debug( "- %s = %s", k, str(vals[k]) )
return vals
def apply_attrs( obj, vals ):
"""Update an object's attributes."""
for k,v in vals.items():
setattr( obj, k, v )
# ---------------------------------------------------------------------
def clean_html( val ):
"""Sanitize HTML using a whitelist."""
# strip the HTML
val = val.strip()
if not val:
return val
args = {}
if _html_whitelists["tags"]:
args[ "allow_tags" ] = _html_whitelists["tags"]
args[ "remove_unknown_tags" ] = None
if _html_whitelists["attrs"]:
args[ "safe_attrs" ] = _html_whitelists["attrs"]
cleaner = lxml.html.clean.Cleaner( **args )
buf = cleaner.clean_html( val )
# clean up the results
buf = re.sub( r"\s+", " ", buf )
buf = re.sub( r"^\s+", "", buf, re.MULTILINE )
buf = re.sub( r"\s+$", "", buf, re.MULTILINE )
if buf.startswith( "<p>" ) and buf.endswith( "</p>" ):
buf = buf[3:-4]
if buf.startswith( "<div>" ) and buf.endswith( "</div>" ):
buf = buf[5:-6]
if buf.startswith( "<span>" ) and buf.endswith( "</span>" ):
buf = buf[6:-7]
return buf.strip()
def load_html_whitelists( app ):
"""Load the HTML whitelists."""
global _html_whitelists
assert _html_whitelists is None
def parse_whitelist( key ):
whitelist = app.config.get( key, "" )
whitelist = whitelist.replace( ",", " " )
whitelist = [ s.strip() for s in whitelist.split(" ") ]
whitelist = [ s for s in whitelist if s ]
_startup_logger.debug( "Configured %s: %s", key, whitelist )
return whitelist
_html_whitelists = {
"tags": parse_whitelist( "HTML_TAG_WHITELIST" ),
"attrs": parse_whitelist( "HTML_ATTR_WHITELIST" )
}
# ---------------------------------------------------------------------
def to_bool( val ):

@ -158,8 +158,11 @@ def webdriver( request ):
def make_web_url( url ):
"""Generate a URL for the React frontend."""
url = "{}/{}".format( web_url, url )
kwargs = {}
kwargs[ "_flask"] = flask_url
kwargs[ "store_msgs"] = 1 # stop notification messages from building up and obscuring clicks
url += "&" if "?" in url else "?"
url += urllib.parse.urlencode( { "_flask": flask_url } )
url += urllib.parse.urlencode( kwargs )
return url
driver.make_url = make_web_url

@ -5,3 +5,4 @@ flask-sqlalchemy==2.4.1
psycopg2-binary==2.8.4
alembic==1.3.1
pyyaml==5.1.2
lxml==4.4.2

361
web/package-lock.json generated

@ -940,6 +940,11 @@
"resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-9.0.1.tgz",
"integrity": "sha512-6It2EVfGskxZCQhuykrfnALg7oVeiI6KclWSmGDqB0AiInVrTGB9Jp9i4/Ad21u9Jde/voVQz6eFX/eSg/UsPA=="
},
"@emotion/hash": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.3.tgz",
"integrity": "sha512-14ZVlsB9akwvydAdaEnVnvqu6J2P6ySv39hYyl/aoB6w/V+bXX0tay8cF6paqbgZsN2n5Xh15uF4pE+GvE+itw=="
},
"@hapi/address": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.2.tgz",
@ -1165,6 +1170,77 @@
"@types/yargs": "^13.0.0"
}
},
"@material-ui/core": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.7.0.tgz",
"integrity": "sha512-mwLehUo0Q9ZxjuWo7J1uy1/Grh3nRxlOAaWJ3EtKeJP2HwqlSy8bWrcvRQYlapaYIPXa5jN8zWbTwi8Pk30VQg==",
"requires": {
"@babel/runtime": "^7.4.4",
"@material-ui/styles": "^4.6.0",
"@material-ui/system": "^4.5.2",
"@material-ui/types": "^4.1.1",
"@material-ui/utils": "^4.5.2",
"@types/react-transition-group": "^4.2.0",
"clsx": "^1.0.2",
"convert-css-length": "^2.0.1",
"hoist-non-react-statics": "^3.2.1",
"normalize-scroll-left": "^0.2.0",
"popper.js": "^1.14.1",
"prop-types": "^15.7.2",
"react-transition-group": "^4.3.0"
}
},
"@material-ui/styles": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.6.0.tgz",
"integrity": "sha512-lqqh4UEMdIYcU1Yth4pQyMTah02uAkg3NOT3MirN9FUexdL8pNA6zCHigEgDSfwmvnXyxHhxTkphfy0DRfnt9w==",
"requires": {
"@babel/runtime": "^7.4.4",
"@emotion/hash": "^0.7.1",
"@material-ui/types": "^4.1.1",
"@material-ui/utils": "^4.5.2",
"clsx": "^1.0.2",
"csstype": "^2.5.2",
"hoist-non-react-statics": "^3.2.1",
"jss": "^10.0.0",
"jss-plugin-camel-case": "^10.0.0",
"jss-plugin-default-unit": "^10.0.0",
"jss-plugin-global": "^10.0.0",
"jss-plugin-nested": "^10.0.0",
"jss-plugin-props-sort": "^10.0.0",
"jss-plugin-rule-value-function": "^10.0.0",
"jss-plugin-vendor-prefixer": "^10.0.0",
"prop-types": "^15.7.2"
}
},
"@material-ui/system": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.5.2.tgz",
"integrity": "sha512-h9RWvdM9XKlHHqwiuhyvWdobptQkHli+m2jJFs7i1AI/hmGsIc4reDmS7fInhETgt/Txx7uiAIznfRNIIVHmQw==",
"requires": {
"@babel/runtime": "^7.4.4",
"@material-ui/utils": "^4.5.2",
"prop-types": "^15.7.2"
}
},
"@material-ui/types": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-4.1.1.tgz",
"integrity": "sha512-AN+GZNXytX9yxGi0JOfxHrRTbhFybjUJ05rnsBVjcB+16e466Z0Xe5IxawuOayVZgTBNDxmPKo5j4V6OnMtaSQ==",
"requires": {
"@types/react": "*"
}
},
"@material-ui/utils": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.5.2.tgz",
"integrity": "sha512-zhbNfHd1gLa8At6RPDG7uMZubHxbY+LtM6IkSfeWi6Lo4Ax80l62YaN1QmUpO1IvGCkn/j62tQX3yObiQZrJsQ==",
"requires": {
"@babel/runtime": "^7.4.4",
"prop-types": "^15.7.2",
"react-is": "^16.8.6"
}
},
"@mrmlnc/readdir-enhanced": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@ -1357,11 +1433,33 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz",
"integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A=="
},
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
},
"@types/q": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz",
"integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw=="
},
"@types/react": {
"version": "16.9.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.13.tgz",
"integrity": "sha512-LikzRslbiufJYHyzbHSW0GrAiff8QYLMBFeZmSxzCYGXKxi8m/1PHX+rsVOwhr7mJNq+VIu2Dhf7U6mjFERK6w==",
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
}
},
"@types/react-transition-group": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.3.tgz",
"integrity": "sha512-Hk8jiuT7iLOHrcjKP/ZVSyCNXK73wJAUz60xm0mVhiRujrdiI++j4duLiL282VGxwAgxetHQFfqA29LgEeSkFA==",
"requires": {
"@types/react": "*"
}
},
"@types/stack-utils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
@ -2777,7 +2875,8 @@
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true
"bundled": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -3142,7 +3241,8 @@
},
"safe-buffer": {
"version": "5.1.2",
"bundled": true
"bundled": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -3190,6 +3290,7 @@
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -3228,11 +3329,13 @@
},
"wrappy": {
"version": "1.0.2",
"bundled": true
"bundled": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true
"bundled": true,
"optional": true
}
}
},
@ -3310,6 +3413,11 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"clean-css": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz",
@ -3382,6 +3490,11 @@
"shallow-clone": "^0.1.2"
}
},
"clsx": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.0.4.tgz",
"integrity": "sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg=="
},
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -3593,6 +3706,11 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
"integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
},
"convert-css-length": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/convert-css-length/-/convert-css-length-2.0.1.tgz",
"integrity": "sha512-iGpbcvhLPRKUbBc0Quxx7w/bV14AC3ItuBEGMahA5WTYqB8lq9jH0kTXFheCBASsYnqeMFZhiTruNxr1N59Axg=="
},
"convert-source-map": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz",
@ -3866,6 +3984,25 @@
"resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.1.tgz",
"integrity": "sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY="
},
"css-vendor": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.7.tgz",
"integrity": "sha512-VS9Rjt79+p7M0WkPqcAza4Yq1ZHrsHrwf7hPL/bjQB+c1lwmAI+1FXxYTYt818D/50fFVflw0XKleiBN5RITkg==",
"requires": {
"@babel/runtime": "^7.6.2",
"is-in-browser": "^1.0.2"
},
"dependencies": {
"@babel/runtime": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.4.tgz",
"integrity": "sha512-r24eVUUr0QqNZa+qrImUk8fn5SPhHq+IfYvIoIMg0do3GdK9sMdiLKP3GYVVaxpPKORgm8KRKaNTEhAjgIpLMw==",
"requires": {
"regenerator-runtime": "^0.13.2"
}
}
}
},
"css-what": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-3.2.1.tgz",
@ -3973,6 +4110,11 @@
"cssom": "0.3.x"
}
},
"csstype": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
},
"cyclist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
@ -4271,6 +4413,25 @@
"utila": "~0.4"
}
},
"dom-helpers": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.3.tgz",
"integrity": "sha512-nZD1OtwfWGRBWlpANxacBEZrEuLa16o1nh7YopFWeoF68Zt8GGEmzHu6Xv4F3XaFIC+YXtTLrzgqKxFgLEe4jw==",
"requires": {
"@babel/runtime": "^7.6.3",
"csstype": "^2.6.7"
},
"dependencies": {
"@babel/runtime": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.4.tgz",
"integrity": "sha512-r24eVUUr0QqNZa+qrImUk8fn5SPhHq+IfYvIoIMg0do3GdK9sMdiLKP3GYVVaxpPKORgm8KRKaNTEhAjgIpLMw==",
"requires": {
"regenerator-runtime": "^0.13.2"
}
}
}
},
"dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
@ -6023,6 +6184,14 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
"integrity": "sha512-wbg3bpgA/ZqWrZuMOeJi8+SKMhr7X9TesL/rXMjTzh0p0JUBo3II8DHboYbuIXWRlttrUFxwcu/5kygrCw8fJw==",
"requires": {
"react-is": "^16.7.0"
}
},
"hosted-git-info": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.5.tgz",
@ -6210,6 +6379,11 @@
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM="
},
"hyphenate-style-name": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz",
"integrity": "sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ=="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -6509,6 +6683,11 @@
"is-extglob": "^2.1.1"
}
},
"is-in-browser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
"integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU="
},
"is-number": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
@ -6941,7 +7120,8 @@
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true
"bundled": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -6959,11 +7139,13 @@
},
"balanced-match": {
"version": "1.0.0",
"bundled": true
"bundled": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -6976,15 +7158,18 @@
},
"code-point-at": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -7087,7 +7272,8 @@
},
"inherits": {
"version": "2.0.3",
"bundled": true
"bundled": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -7097,6 +7283,7 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -7109,17 +7296,20 @@
"minimatch": {
"version": "3.0.4",
"bundled": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true
"bundled": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -7136,6 +7326,7 @@
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -7208,7 +7399,8 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -7218,6 +7410,7 @@
"once": {
"version": "1.4.0",
"bundled": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -7293,7 +7486,8 @@
},
"safe-buffer": {
"version": "5.1.2",
"bundled": true
"bundled": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -7323,6 +7517,7 @@
"string-width": {
"version": "1.0.2",
"bundled": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -7340,6 +7535,7 @@
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -7378,11 +7574,13 @@
},
"wrappy": {
"version": "1.0.2",
"bundled": true
"bundled": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true
"bundled": true,
"optional": true
}
}
}
@ -7819,6 +8017,83 @@
"verror": "1.10.0"
}
},
"jss": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.0.tgz",
"integrity": "sha512-TPpDFsiBjuERiL+dFDq8QCdiF9oDasPcNqCKLGCo/qED3fNYOQ8PX2lZhknyTiAt3tZrfOFbb0lbQ9lTjPZxsQ==",
"requires": {
"@babel/runtime": "^7.3.1",
"csstype": "^2.6.5",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-camel-case": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.0.0.tgz",
"integrity": "sha512-yALDL00+pPR4FJh+k07A8FeDvfoPPuXU48HLy63enAubcVd3DnS+2rgqPXglHDGixIDVkCSXecl/l5GAMjzIbA==",
"requires": {
"@babel/runtime": "^7.3.1",
"hyphenate-style-name": "^1.0.3",
"jss": "10.0.0"
}
},
"jss-plugin-default-unit": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.0.0.tgz",
"integrity": "sha512-sURozIOdCtGg9ap18erQ+ijndAfEGtTaetxfU3H4qwC18Bi+fdvjlY/ahKbuu0ASs7R/+WKCP7UaRZOjUDMcdQ==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0"
}
},
"jss-plugin-global": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.0.0.tgz",
"integrity": "sha512-80ofWKSQUo62bxLtRoTNe0kFPtHgUbAJeOeR36WEGgWIBEsXLyXOnD5KNnjPqG4heuEkz9eSLccjYST50JnI7Q==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0"
}
},
"jss-plugin-nested": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.0.0.tgz",
"integrity": "sha512-waxxwl/po1hN3azTyixKnr8ReEqUv5WK7WsO+5AWB0bFndML5Yqnt8ARZ90HEg8/P6WlqE/AB2413TkCRZE8bA==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0",
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-props-sort": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.0.0.tgz",
"integrity": "sha512-41mf22CImjwNdtOG3r+cdC8+RhwNm616sjHx5YlqTwtSJLyLFinbQC/a4PIFk8xqf1qpFH1kEAIw+yx9HaqZ3g==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0"
}
},
"jss-plugin-rule-value-function": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.0.0.tgz",
"integrity": "sha512-Jw+BZ8JIw1f12V0SERqGlBT1JEPWax3vuZpMym54NAXpPb7R1LYHiCTIlaJUyqvIfEy3kiHMtgI+r2whGgRIxQ==",
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.0.0"
}
},
"jss-plugin-vendor-prefixer": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.0.0.tgz",
"integrity": "sha512-qslqvL0MUbWuzXJWdUxpj6mdNUX8jr4FFTo3aZnAT65nmzWL7g8oTr9ZxmTXXgdp7ANhS1QWE7036/Q2isFBpw==",
"requires": {
"@babel/runtime": "^7.3.1",
"css-vendor": "^2.0.6",
"jss": "10.0.0"
}
},
"jsx-ast-utils": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz",
@ -7988,6 +8263,16 @@
"resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz",
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0="
},
"lodash.clone": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz",
"integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y="
},
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -8614,6 +8899,11 @@
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI="
},
"normalize-scroll-left": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/normalize-scroll-left/-/normalize-scroll-left-0.2.0.tgz",
"integrity": "sha512-t5oCENZJl8TGusJKoCJm7+asaSsPuNmK6+iEjrZ5TyBj2f02brCRsd4c83hwtu+e5d4LCSBZ0uoDlMjBo+A8yA=="
},
"normalize-url": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz",
@ -9209,6 +9499,11 @@
"ts-pnp": "^1.1.2"
}
},
"popper.js": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz",
"integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw=="
},
"portfinder": {
"version": "1.0.25",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.25.tgz",
@ -10481,6 +10776,15 @@
"scheduler": "^0.17.0"
}
},
"react-draggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.1.0.tgz",
"integrity": "sha512-Or/qe70cfymshqoC8Lsp0ukTzijJObehb7Vfl7tb5JRxoV+b6PDkOGoqYaWBzZ59k9dH/bwraLGsnlW78/3vrA==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.6.0"
}
},
"react-error-overlay": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.3.tgz",
@ -10552,6 +10856,28 @@
"workbox-webpack-plugin": "4.3.1"
}
},
"react-toastify": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-5.4.1.tgz",
"integrity": "sha512-24EwkWrj47Id/HGjYfdcntaZpAQ3J5NX31SnGRD66hM/KvPKVJzPiDBPZ+/RZ3SvNkbNWfHpPKFWzenJjC26hg==",
"requires": {
"@babel/runtime": "^7.4.2",
"classnames": "^2.2.6",
"prop-types": "^15.7.2",
"react-transition-group": "^4"
}
},
"react-transition-group": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz",
"integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==",
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
},
"read-pkg": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
@ -12198,6 +12524,11 @@
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
},
"tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

@ -3,10 +3,15 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.7.0",
"axios": "^0.19.0",
"lodash.clone": "^4.5.0",
"lodash.clonedeep": "^4.5.0",
"react": "^16.11.0",
"react-dom": "^16.11.0",
"react-scripts": "3.2.0"
"react-draggable": "^4.1.0",
"react-scripts": "3.2.0",
"react-toastify": "^5.4.1"
},
"scripts": {
"start": "react-scripts start",

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@ -4,7 +4,8 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<link rel="manifest" href="/manifest.json" />
</head>

@ -0,0 +1,12 @@
#ask img.icon { width: 2em ; float: left ; margin: 0 1em 1em 0 ; }
.MuiButton-label { text-transform: none ; }
.Toastify p { margin-top: 0.25em ; }
.Toastify__toast--info { background: #20b040 ; }
.Toastify__toast--warn { background: #f0c010 ; }
.Toastify__toast--error { background: #e04060 ; }
#menu { margin-bottom: 5px ; padding: 2px 5px ; background: #b0e0f0 ; border: 1px dotted #80d0f0 ; font-size: 75% ; }
#menu a { text-decoration: none ; color: #000 ; }
.monospace { margin-top: 0.5em ; font-family: monospace ; font-style: italic ; font-size: 80% ; }

@ -1,6 +1,11 @@
import React from "react" ;
import ReactDOMServer from "react-dom/server" ;
import { ToastContainer, toast } from "react-toastify" ;
import "react-toastify/dist/ReactToastify.min.css" ;
import SearchForm from "./SearchForm" ;
import SearchResults from "./SearchResults" ;
import { SearchResults, SearchResult } from "./SearchResults" ;
import ModalForm from "./ModalForm";
import AskDialog from "./AskDialog" ;
import "./App.css" ;
const axios = require( "axios" ) ;
@ -17,39 +22,141 @@ export default class App extends React.Component
// it's own Flask server, but talks to an existing React server, so we need some way
// for pytest to change which Flask server the React frontend code should tak to.
const args = queryString.parse( window.location.search ) ;
let baseUrl = process.env.REACT_APP_TEST_MODE ? args._flask : null ;
if ( ! baseUrl )
baseUrl = process.env.REACT_APP_FLASK_URL ;
let flaskBaseUrl = process.env.REACT_APP_TEST_MODE ? args._flask : null ;
if ( ! flaskBaseUrl )
flaskBaseUrl = process.env.REACT_APP_FLASK_URL ;
super( props ) ;
this.state = {
baseUrl: baseUrl,
searchResults: [],
seqNo: 0,
searchSeqNo: 0,
modalForm: null,
askDialog: null,
// test settings follow
flaskBaseUrl: flaskBaseUrl,
storeMsgs: process.env.REACT_APP_TEST_MODE && args.store_msgs,
} ;
}
render() {
return ( <div>
<div id="menu">
[<a href="/" className="new-publisher"
onClick={ (e) => { e.preventDefault() ; SearchResult.onNewPublisher( this._onNewPublisher.bind(this) ) ; } }
>New publisher</a>]
</div>
<SearchForm onSearch={this.onSearch.bind(this)} />
<SearchResults seqNo={this.state.seqNo} searchResults={this.state.searchResults} />
<SearchResults seqNo={this.state.searchSeqNo} searchResults={this.state.searchResults} />
{ this.state.modalForm !== null &&
<ModalForm show={true} title={this.state.modalForm.title} content={this.state.modalForm.content} buttons={this.state.modalForm.buttons} />
}
{ this.state.askDialog !== null &&
<AskDialog show={true} content={this.state.askDialog.content} buttons={this.state.askDialog.buttons} />
}
<ToastContainer position="bottom-right" hideProgressBar={true} />
{ this.state.storeMsgs && <div>
<textarea id="_stored_msg-info_toast_" ref="_stored_msg-info_toast_" defaultValue="" hidden={true} />
<textarea id="_stored_msg-warning_toast_" ref="_stored_msg-warning_toast_" defaultValue="" hidden={true} />
<textarea id="_stored_msg-error_toast_" ref="_stored_msg-error_toast_" defaultValue="" hidden={true} />
</div> }
</div> ) ;
}
onSearch( query ) {
// run the search
console.log( "SEARCH: " + query ) ;
axios.post( this.state.baseUrl + "/search", {
axios.post( this.state.flaskBaseUrl + "/search", {
query: query
} )
.then( resp => {
console.log( "RESPONSE:", resp.data ) ;
this.setState( { searchResults: resp.data, seqNo: this.state.seqNo+1 } ) ;
this.setState( { searchResults: resp.data, searchSeqNo: this.state.searchSeqNo+1 } ) ;
} )
.catch( err => {
console.log( "ERROR:", err ) ;
this.setState( { searchResults: null, seqNo: this.state.seqNo+1 } ) ;
this.showErrorToast( <div> The search query failed: <div className="monospace"> {err.toString()} </div> </div> ) ;
this.setState( { searchResults: null, searchSeqNo: this.state.searchSeqNo+1 } ) ;
} ) ;
}
_onNewPublisher( publ_id, vals ) {
// add the new publisher to the start of the search results
// NOTE: This isn't really the right thing to do, since the new publisher might not actually be
// a result for the current search, but it's nice to give the user some visual feedback.
vals.publ_id = publ_id ;
let newSearchResults = [ vals ] ;
newSearchResults.push( ...this.state.searchResults ) ;
this.setState( { searchResults: newSearchResults } ) ;
}
showModalForm( title, content, buttons ) {
// prepare the buttons
let buttons2 = [] ;
for ( let b in buttons ) {
let notify = buttons[ b ] ;
buttons2[ b ] = () => {
// a button was clicked - notify the caller
if ( notify )
notify() ;
// NOTE: We don't automatically dismiss the dialog here, since the form might not want to close
// e.g. if it had problems updating something on the server. The form must dismiss the dialog manually.
} ;
}
// show the dialog
this.setState( {
modalForm: { title: title, content: content, buttons: buttons2 },
} ) ;
}
closeModalForm() {
this.setState( { modalForm: null } ) ;
}
showInfoToast( msg ) { this._doShowToast( "info", msg, 5*1000 ) ; }
showWarningToast( msg ) { this._doShowToast( "warning", msg, 15*1000 ) ; }
showErrorToast( msg ) { this._doShowToast( "error", msg, false ) ; }
_doShowToast( type, msg, autoClose ) {
if ( this.state.storeMsgs ) {
// save the message for the test suite to retrieve (nb: we also don't show the toast itself
// since these build up when tests are running at high speed, and obscure elements that
// we want to click on :-/
this.refs[ "_stored_msg-" + type + "_toast_" ].value = ReactDOMServer.renderToStaticMarkup( msg ) ;
return ;
}
toast( msg, { type: type, autoClose: autoClose } ) ;
}
showErrorMsg( content ) {
// show the error message in a modal dialog
this.ask(
<div> <img className="icon" src="/images/error.png" alt="Error." /> {content} </div>,
{ "OK": null }
) ;
}
ask( content, buttons ) {
// prepare the buttons
let buttons2 = [] ;
for ( let b in buttons ) {
let notify = buttons[ b ] ;
buttons2[ b ] = () => {
// a button was clicked - notify the caller
if ( notify )
notify() ;
// dismiss the dialog
this.setState( { askDialog: null } ) ;
} ;
}
// show the dialog
this.setState( { askDialog: { content: content, buttons: buttons2 } } ) ;
}
logInternalError( msg, detail ) {
// log an internal error
this.showErrorToast( <div>
INTERNAL ERROR <div>{msg}</div>
{detail && <div className="monospace">{detail}</div>}
</div> ) ;
console.log( "INTERNAL ERROR: " + msg ) ;
if ( detail )
console.log( detail ) ;
}
}

@ -0,0 +1,37 @@
import React from "react" ;
import Dialog from "@material-ui/core/Dialog" ;
import DialogContent from "@material-ui/core/DialogContent" ;
import DialogActions from "@material-ui/core/DialogActions" ;
import Button from "@material-ui/core/Button" ;
import "./AskDialog.css" ;
import { slugify } from "./utils" ;
// --------------------------------------------------------------------
export default class AskDialog extends React.Component
{
render() {
let buttons = [] ;
for ( let btn in this.props.buttons ) {
buttons.push(
<Button key={btn} color="primary" className={slugify(btn)}
onClick = { this.props.buttons[btn] }
> {btn} </Button>
) ;
}
return ( <Dialog id="ask" open={true} onClose={this.onClose.bind(this)} disableBackdropClick>
<DialogContent> {this.props.content} </DialogContent>
<DialogActions> {buttons} </DialogActions>
</Dialog> ) ;
}
onClose() {
// figure out which button to auto-click
const keys = Object.keys( this.props.buttons ) ;
let notify = (keys.length === 1) ? this.props.buttons[ keys[0] ] : this.props.buttons.Cancel ;
if ( notify )
notify() ;
}
}

@ -0,0 +1,49 @@
import React from "react" ;
import Draggable from "react-draggable" ;
import Dialog from "@material-ui/core/Dialog" ;
import DialogTitle from "@material-ui/core/DialogTitle" ;
import DialogContent from "@material-ui/core/DialogContent" ;
import DialogActions from "@material-ui/core/DialogActions" ;
import Paper from "@material-ui/core/Paper" ;
import Button from "@material-ui/core/Button" ;
import "./ModalForm.css" ;
import { slugify } from "./utils" ;
// --------------------------------------------------------------------
export default class ModalForm extends React.Component
{
render() {
let buttons = [] ;
for ( let btn in this.props.buttons ) {
buttons.push(
<Button key={btn} color="primary" className={slugify(btn)}
onClick = { this.props.buttons[btn] }
> {btn} </Button>
) ;
}
return ( <Dialog id="modal-form" PaperComponent={PaperComponent} open={true} onClose={this.onClose.bind(this)} disableBackdropClick>
<DialogTitle id="draggable-dialog-title" style={{cursor: "move"}}> {this.props.title} </DialogTitle>
<DialogContent dividers> {this.props.content} </DialogContent>
<DialogActions> {buttons} </DialogActions>
</Dialog> ) ;
}
onClose() {
// close the dialog
if ( this.props.buttons.Cancel )
this.props.buttons.Cancel() ;
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
function PaperComponent( props ) {
return (
<Draggable cancel={"[class*='MuiDialogContent-root']"}>
<Paper {...props} />
</Draggable>
) ;
}

@ -2,6 +2,11 @@
.search-result {
margin: 0.25em 0 ; padding: 0.1em 0.2em ;
border: 1px solid #eee ; background: #f8f8f8 ;
font-family: monospace ; font-size: 80% ;
font-size: 90% ;
}
.search-result .name { border: 1px solid #c0c0c0 ; background: #f0f0f0 ; padding: 2px 5px ; }
.search-result .name a { font-weight: bold ; text-decoration: none ; }
.search-result .name img.edit { margin-left: 0.5em ; height: 0.8em ; cursor: pointer ; }
.search-result .name img.delete { float: right ; margin: 0.2em 0 0 0.5em ; height: 0.8em ; cursor: pointer ; }
.search-result .description { font-size: 80% ; padding: 2px 5px ; }

@ -1,31 +1,167 @@
import React from "react" ;
import "./SearchResults.css" ;
import { gAppRef } from "./index.js" ;
const axios = require( "axios" ) ;
// --------------------------------------------------------------------
export default class SearchResults extends React.Component
export class SearchResults extends React.Component
{
render() {
if ( ! this.props.searchResults || this.props.searchResults.length === 0 )
return null ;
const elems = this.props.searchResults.map(
sr => <SearchResult key={sr.id} data={sr} />
sr => <SearchResult key={sr.publ_id} publ_id={sr.publ_id} data={sr}
onDelete = { this.onDeleteSearchResult.bind( this ) }
/>
) ;
return ( <div id="search-results" seqno={this.props.seqNo}>
{elems}
</div> ) ;
}
onDeleteSearchResult( id ) {
for ( let i=0 ; i < this.props.searchResults.length ; ++i ) {
const sr = this.props.searchResults[ i ] ;
if ( sr.publ_id === id ) {
this.props.searchResults.splice( i, 1 ) ;
this.forceUpdate() ;
return ;
}
}
gAppRef.logInternalError( "Tried to delete an unknown search result", "id="+id ) ;
}
}
// --------------------------------------------------------------------
class SearchResult extends React.Component
export class SearchResult extends React.Component
{
render() {
return (
<div className="search-result">
{ JSON.stringify( this.props.data ) }
function make_name( data ) {
if ( data.publ_url )
return ( <a href={data.publ_url} target="_blank" rel="noopener noreferrer">
{data.publ_name}
</a>
) ;
else
return <span dangerouslySetInnerHTML={{__html: data.publ_name}} /> ;
}
return ( <div className="search-result">
<div className="name">
{ make_name( this.props.data ) }
<img src="/images/edit.png" className="edit" onClick={this.onEditPublisher.bind(this)} alt="Edit this publisher." />
<img src="/images/delete.png" className="delete" onClick={this.onDeletePublisher.bind(this)} alt="Delete this publisher." />
</div>
) ;
<div className="description" dangerouslySetInnerHTML={{__html: this.props.data.publ_description}} />
</div> ) ;
}
onEditPublisher() {
SearchResult._doEditPublisher( this.props.data, (newVals,refs) => {
// send the updated details to the server
newVals.publ_id = this.props.publ_id ;
axios.post( gAppRef.state.flaskBaseUrl + "/publishers/update", newVals )
.then( resp => {
// update the UI with the new details
for ( var r in refs )
this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was updated OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publisher was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't update the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} );
}
static _doEditPublisher( vals, notify ) {
let refs = {} ;
const content = <div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.publ_name} ref={(r) => refs.publ_name=r} />
</div>
<div className="row url"> <label> Web: </label>
<input type="text" defaultValue={vals.publ_url} ref={(r) => refs.publ_url=r} />
</div>
<div className="row description"> <label> Description: </label>
<textarea defaultValue={vals.publ_description} ref={(r) => refs.publ_description=r} />
</div>
</div> ;
const buttons = {
OK: () => {
// unload the new values
let newVals = {} ;
for ( var r in refs )
newVals[ r ] = refs[r].value.trim() ;
if ( newVals.publ_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publisher's name. </div>) ;
return ;
}
// notify the caller about the new details
notify( newVals, refs ) ;
},
Cancel: () => { gAppRef.closeModalForm() ; },
} ;
const isNew = Object.keys( vals ).length === 0 ;
gAppRef.showModalForm( isNew?"New publisher":"Edit publisher", content, buttons ) ;
}
static onNewPublisher( notify ) {
SearchResult._doEditPublisher( {}, (newVals,refs) => {
axios.post( gAppRef.state.flaskBaseUrl + "/publishers/create", newVals )
.then( resp => {
// unload any cleaned values
for ( var r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
newVals[ r ] = resp.data.cleaned[ r ] ;
}
// update the UI with the new details
notify( resp.data.publ_id, newVals ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The new publisher was created OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The new publisher was created OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't create the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} ) ;
}
onDeletePublisher() {
// confirm the operation
const content = ( <div>
Do you want to delete this publisher?
<div style={{margin:"0.5em 0 0 2em",fontStyle:"italic"}} dangerouslySetInnerHTML={{__html: this.props.data.publ_name}} />
</div> ) ;
gAppRef.ask( content, {
"OK": () => {
// delete the publisher on the server
axios.get( gAppRef.state.flaskBaseUrl + "/publishers/delete/" + this.props.data.publ_id )
.then( resp => {
// update the UI
this.props.onDelete( this.props.data.publ_id ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was deleted. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publisher was deleted. </div> ) ;
} )
.catch( err => {
gAppRef.showErrorToast( <div> Couldn't delete the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
},
"Cancel": null,
} ) ;
}
}

@ -5,7 +5,7 @@ import "./index.css" ;
// --------------------------------------------------------------------
ReactDOM.render(
export let gAppRef = ReactDOM.render(
<App />,
document.getElementById( "app" )
) ;

@ -0,0 +1,3 @@
export function slugify( val ) {
return val.toLowerCase().replace( " ", "-" ) ;
}
Loading…
Cancel
Save