Added the search engine.

master
Pacman Ghost 4 years ago
parent ae2e5a61db
commit b84d0bc7da
  1. 12
      asl_articles/__init__.py
  2. 17
      asl_articles/articles.py
  3. 2
      asl_articles/models.py
  4. 18
      asl_articles/publications.py
  5. 13
      asl_articles/publishers.py
  6. 399
      asl_articles/search.py
  7. 3
      asl_articles/tags.py
  8. 0
      asl_articles/tests/fixtures/publishers.json
  9. 96
      asl_articles/tests/fixtures/search.json
  10. 45
      asl_articles/tests/test_articles.py
  11. 14
      asl_articles/tests/test_authors.py
  12. 39
      asl_articles/tests/test_basic.py
  13. 6
      asl_articles/tests/test_import_roar_scenarios.py
  14. 94
      asl_articles/tests/test_publications.py
  15. 98
      asl_articles/tests/test_publishers.py
  16. 14
      asl_articles/tests/test_scenarios.py
  17. 376
      asl_articles/tests/test_search.py
  18. 18
      asl_articles/tests/test_tags.py
  19. 50
      asl_articles/tests/utils.py
  20. 27
      asl_articles/utils.py
  21. 16
      web/src/App.js
  22. 61
      web/src/ArticleSearchResult.js
  23. 38
      web/src/PublicationSearchResult.js
  24. 9
      web/src/PublisherSearchResult.js
  25. 4
      web/src/SearchForm.js
  26. 2
      web/src/SearchResults.css
  27. 4
      web/src/SearchResults.js
  28. 45
      web/src/utils.js

@ -14,6 +14,15 @@ from asl_articles.utils import to_bool
# ---------------------------------------------------------------------
def _on_startup():
"""Do startup initialization."""
# initialize the search index
_logger = logging.getLogger( "startup" )
asl_articles.search.init_search( db.session, _logger )
# ---------------------------------------------------------------------
def _load_config( cfg, fname, section ):
"""Load config settings from a file."""
if not os.path.isfile( fname ):
@ -84,3 +93,6 @@ import asl_articles.utils #pylint: disable=cyclic-import
# initialize
asl_articles.utils.load_html_whitelists( app )
# register startup initialization
app.before_first_request( _on_startup )

@ -11,6 +11,7 @@ from asl_articles.models import Article, Author, ArticleAuthor, Scenario, Articl
from asl_articles.authors import do_get_authors
from asl_articles.scenarios import do_get_scenarios
from asl_articles.tags import do_get_tags
from asl_articles import search
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
@ -30,7 +31,7 @@ def get_article( article_id ):
_logger.debug( "- %s", article )
return jsonify( get_article_vals( article ) )
def get_article_vals( article ):
def get_article_vals( article, add_type=False ):
"""Extract public fields from an Article record."""
authors = sorted( article.article_authors,
key = lambda a: a.seq_no
@ -38,7 +39,7 @@ def get_article_vals( article ):
scenarios = sorted( article.article_scenarios,
key = lambda a: a.seq_no
)
return {
vals = {
"article_id": article.article_id,
"article_title": article.article_title,
"article_subtitle": article.article_subtitle,
@ -50,6 +51,9 @@ def get_article_vals( article ):
"article_tags": decode_tags( article.article_tags ),
"pub_id": article.pub_id,
}
if add_type:
vals[ "type" ] = "article"
return vals
# ---------------------------------------------------------------------
@ -61,9 +65,10 @@ def create_article():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create article:" )
)
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# NOTE: Tags are stored in the database using \n as a separator, so we need to encode *after* cleaning them.
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
# create the new article
vals[ "time_created" ] = datetime.datetime.now()
@ -76,6 +81,7 @@ def create_article():
_save_image( article, updated )
db.session.commit()
_logger.debug( "- New ID: %d", new_article_id )
search.add_or_update_article( None, article )
# generate the response
extras = { "article_id": new_article_id }
@ -192,9 +198,10 @@ def update_article():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update article: id={}".format( article_id ) )
)
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# NOTE: Tags are stored in the database using \n as a separator, so we need to encode *after* cleaning them.
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
# update the article
article = Article.query.get( article_id )
@ -206,6 +213,7 @@ def update_article():
_save_image( article, updated )
vals[ "time_updated" ] = datetime.datetime.now()
db.session.commit()
search.add_or_update_article( None, article )
# generate the response
extras = {}
@ -231,6 +239,7 @@ def delete_article( article_id ):
# delete the article
db.session.delete( article )
db.session.commit()
search.delete_articles( [ article ] )
# generate the response
extras = {}

@ -1,6 +1,6 @@
""" Define the database models. """
# NOTE: Don't forget to keep the list of tables in init_db() in sync with the models defined here.
# NOTE: Don't forget to keep the list of tables in init_tests() in sync with the models defined here.
from sqlalchemy.orm import deferred
from sqlalchemy.schema import UniqueConstraint

@ -9,6 +9,7 @@ from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publication, PublicationImage, Article
from asl_articles.tags import do_get_tags
from asl_articles import search
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
@ -46,9 +47,9 @@ def get_publication( pub_id ):
_logger.debug( "- %s ; #articles=%d", pub, vals["nArticles"] )
return jsonify( vals )
def get_publication_vals( pub ):
def get_publication_vals( pub, add_type=False ):
"""Extract public fields from a Publication record."""
return {
vals = {
"pub_id": pub.pub_id,
"pub_name": pub.pub_name,
"pub_edition": pub.pub_edition,
@ -58,6 +59,9 @@ def get_publication_vals( pub ):
"pub_tags": decode_tags( pub.pub_tags ),
"publ_id": pub.publ_id,
}
if add_type:
vals[ "type" ] = "publication"
return vals
# ---------------------------------------------------------------------
@ -69,9 +73,10 @@ def create_publication():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publication:" )
)
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# NOTE: Tags are stored in the database using \n as a separator, so we need to encode *after* cleaning them.
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
# create the new publication
vals[ "time_created" ] = datetime.datetime.now()
@ -80,6 +85,7 @@ def create_publication():
_save_image( pub, updated )
db.session.commit()
_logger.debug( "- New ID: %d", pub.pub_id )
search.add_or_update_publication( None, pub )
# generate the response
extras = { "pub_id": pub.pub_id }
@ -123,9 +129,10 @@ def update_publication():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publication: id={}".format( pub_id ) )
)
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# NOTE: Tags are stored in the database using \n as a separator, so we need to encode *after* cleaning them.
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
# update the publication
pub = Publication.query.get( pub_id )
@ -135,6 +142,7 @@ def update_publication():
_save_image( pub, updated )
vals[ "time_updated" ] = datetime.datetime.now()
db.session.commit()
search.add_or_update_publication( None, pub )
# generate the response
extras = {}
@ -164,6 +172,8 @@ def delete_publication( pub_id ):
# delete the publication
db.session.delete( pub )
db.session.commit()
search.delete_publications( [ pub ] )
search.delete_articles( deleted_articles )
# generate the response
extras = { "deleteArticles": deleted_articles }

@ -9,6 +9,7 @@ from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publisher, PublisherImage, Publication, Article
from asl_articles.publications import do_get_publications
from asl_articles import search
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
_logger = logging.getLogger( "db" )
@ -51,15 +52,18 @@ def get_publisher( publ_id ):
_logger.debug( "- %s ; #publications=%d ; #articles=%d", publ, vals["nPublications"], vals["nArticles"] )
return jsonify( vals )
def get_publisher_vals( publ ):
def get_publisher_vals( publ, add_type=False ):
"""Extract public fields from a Publisher record."""
return {
vals = {
"publ_id": publ.publ_id,
"publ_name": publ.publ_name,
"publ_description": publ.publ_description,
"publ_url": publ.publ_url,
"publ_image_id": publ.publ_id if publ.publ_image else None,
}
if add_type:
vals[ "type" ] = "publisher"
return vals
# ---------------------------------------------------------------------
@ -81,6 +85,7 @@ def create_publisher():
_save_image( publ, updated )
db.session.commit()
_logger.debug( "- New ID: %d", publ.publ_id )
search.add_or_update_publisher( None, publ )
# generate the response
extras = { "publ_id": publ.publ_id }
@ -134,6 +139,7 @@ def update_publisher():
apply_attrs( publ, vals )
vals[ "time_updated" ] = datetime.datetime.now()
db.session.commit()
search.add_or_update_publisher( None, publ )
# generate the response
extras = {}
@ -168,6 +174,9 @@ def delete_publisher( publ_id ):
# delete the publisher
db.session.delete( publ )
db.session.commit()
search.delete_publishers( [ publ ] )
search.delete_publications( deleted_pubs )
search.delete_articles( deleted_articles )
extras = { "deletedPublications": deleted_pubs, "deletedArticles": deleted_articles }
if request.args.get( "list" ):

@ -1,68 +1,377 @@
""" Handle search requests. """
import os
import sqlite3
import tempfile
import re
import logging
from flask import request, jsonify
from asl_articles import app
from asl_articles.models import Publisher, Publication, Article
from asl_articles import app, db
from asl_articles.models import Publisher, Publication, Article, Author, Scenario, get_model_from_table_name
from asl_articles.publishers import get_publisher_vals
from asl_articles.publications import get_publication_vals
from asl_articles.articles import get_article_vals
from asl_articles.utils import clean_html, decode_tags, to_bool
_search_index_path = None
_logger = logging.getLogger( "search" )
_SQLITE_FTS_SPECIAL_CHARS = "+-#':/."
_PASSTHROUGH_REGEXES = set( [
re.compile( r"\bAND\b" ),
re.compile( r"\bOR\b" ),
re.compile( r"\bNOT\b" ),
re.compile( r"\((?![Rr]\))" ),
] )
# NOTE: The following are special search terms used by the test suite.
SEARCH_ALL = "<!all!>"
SEARCH_ALL_PUBLISHERS = "<!publishers!>"
SEARCH_ALL_PUBLICATIONS = "<!publications!>"
SEARCH_ALL_ARTICLES = "<!articles!>"
BEGIN_HILITE = '<span class="hilite">'
END_HILITE = "</span>"
# ---------------------------------------------------------------------
class SearchDbConn:
"""Context manager to handle SQLite transactions."""
def __init__( self ):
self.conn = sqlite3.connect( _search_index_path )
def __enter__( self ):
return self
def __exit__( self, exc_type, exc_value, traceback ):
if exc_type is None:
self.conn.commit()
else:
self.conn.rollback()
self.conn.close()
# ---------------------------------------------------------------------
def _get_authors( article ):
"""Return the searchable authors for an article."""
author_ids = [ a.author_id for a in article.article_authors ]
query = db.session.query( Author ).filter( Author.author_id.in_( author_ids ) )
return "\n".join( a.author_name for a in query )
def _get_scenarios( article ):
"""Return the searchable scenarios for an article."""
scenario_ids = [ s.scenario_id for s in article.article_scenarios ]
query = db.session.query( Scenario ).filter( Scenario.scenario_id.in_( scenario_ids ) )
return "\n".join(
"{}\t{}".format( s.scenario_display_id, s.scenario_name ) if s.scenario_display_id else s.scenario_name
for s in query
)
def _get_tags( tags ):
"""Return the searchable tags for an article or publication."""
if not tags:
return None
tags = decode_tags( tags )
return "\n".join( tags )
# map search index columns to ORM fields
_FIELD_MAPPINGS = {
"publisher": { "name": "publ_name", "description": "publ_description" },
"publication": { "name": "pub_name", "description": "pub_description",
"tags": lambda pub: _get_tags( pub.pub_tags )
},
"article": { "name": "article_title", "name2": "article_subtitle", "description": "article_snippet",
"authors": _get_authors, "scenarios": _get_scenarios,
"tags": lambda article: _get_tags( article.article_tags )
}
}
# ---------------------------------------------------------------------
@app.route( "/search", methods=["POST"] )
def search():
"""Run a search query."""
"""Run a search."""
try:
return _do_search()
except Exception as exc: #pylint: disable=broad-except
msg = str( exc )
if isinstance( exc, sqlite3.OperationalError ):
if msg.startswith( "fts5: " ):
msg = msg[5:]
if not msg:
msg = str( type(exc) )
return jsonify( { "error": msg } )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _do_search(): #pylint: disable=too-many-locals,too-many-statements,too-many-branches
"""Run a search."""
# initialize
# parse the request parameters
query_string = request.json.get( "query" ).strip()
_logger.debug( "SEARCH: [%s]", query_string )
if not query_string:
raise RuntimeError( "Missing query string." )
no_hilite = to_bool( request.json.get( "no_hilite" ) )
_logger.info( "SEARCH REQUEST: %s", query_string )
# check for special query terms (for testing porpoises)
results = []
def find_special_term( term ):
nonlocal query_string
pos = query_string.find( term )
if pos >= 0:
query_string = query_string[:pos] + query_string[pos+len(term):]
return True
return False
special_terms = {
SEARCH_ALL_PUBLISHERS:
lambda: [ get_publisher_vals(p,True) for p in Publisher.query ], #pylint: disable=not-an-iterable
SEARCH_ALL_PUBLICATIONS:
lambda: [ get_publication_vals(p,True) for p in Publication.query ], #pylint: disable=not-an-iterable
SEARCH_ALL_ARTICLES:
lambda: [ get_article_vals(a,True) for a in Article.query ] #pylint: disable=not-an-iterable
}
if find_special_term( SEARCH_ALL ):
for term,func in special_terms.items():
results.extend( func() )
else:
for term,func in special_terms.items():
if find_special_term( term ):
results.extend( func() )
query_string = query_string.strip()
if not query_string:
return jsonify( results )
# return all publishers
query = Publisher.query
if query_string:
query = query.filter(
Publisher.publ_name.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Publisher.publ_name.asc() )
publishers = query.all()
_logger.debug( "- Found: %s", " ; ".join( str(p) for p in publishers ) )
for publ in publishers:
publ = get_publisher_vals( publ )
publ["type"] = "publisher"
results.append( publ )
# return all publications
query = Publication.query
if query_string:
query = query.filter(
Publication.pub_name.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Publication.pub_name.asc() )
publications = query.all()
_logger.debug( "- Found: %s", " ; ".join( str(p) for p in publications ) )
for pub in publications:
pub = get_publication_vals( pub )
pub[ "type" ] = "publication"
results.append( pub )
# return all articles
query = Article.query
if query_string:
query = query.filter(
Article.article_title.ilike( "%{}%".format( query_string ) )
# prepare the query
fts_query_string = _make_fts_query_string( query_string )
_logger.debug( "FTS query string: %s", fts_query_string )
# NOTE: We would like to cache the connection, but SQLite connections can only be used
# in the same thread they were created in.
with SearchDbConn() as dbconn:
# run the search
hilites = [ "", "" ] if no_hilite else [ BEGIN_HILITE, END_HILITE ]
def highlight( n ):
return "highlight( searchable, {}, '{}', '{}' )".format(
n, hilites[0], hilites[1]
)
sql = "SELECT owner,rank,{}, {}, {}, {}, {}, {} FROM searchable" \
" WHERE searchable MATCH ?" \
" ORDER BY rank".format(
highlight(1), highlight(2), highlight(3), highlight(4), highlight(5), highlight(6)
)
curs = dbconn.conn.execute( sql,
( "{name name2 description authors scenarios tags}: " + fts_query_string, )
)
query = query.order_by( Article.article_title.asc() )
articles = query.all()
_logger.debug( "- Found: %s", " ; ".join( str(a) for a in articles ) )
for article in articles:
article = get_article_vals( article )
article[ "type" ] = "article"
results.append( article )
# get the results
for row in curs:
# get the next result
owner_type, owner_id = row[0].split( ":" )
model = get_model_from_table_name( owner_type )
obj = model.query.get( owner_id )
_logger.debug( "- {} ({:.3f})".format( obj, row[1] ) )
# prepare the result for the front-end
result = globals()[ "get_{}_vals".format( owner_type ) ]( obj )
result[ "type" ] = owner_type
# return highlighted versions of the content to the caller
fields = _FIELD_MAPPINGS[ owner_type ]
for col_no,col_name in enumerate(["name","name2","description"]):
field = fields.get( col_name )
if not field:
continue
if row[2+col_no] and BEGIN_HILITE in row[2+col_no]:
# NOTE: We have to return both the highlighted and non-highlighted versions, since the front-end
# will show the highlighted version in the search results, but the non-highlighted version elsewhere
# e.g. an article's title in the titlebar of its edit dialog.
result[ field+"!" ] = row[ 2+col_no ]
if row[5] and BEGIN_HILITE in row[5]:
result[ "authors!" ] = row[5].split( "\n" )
if row[6] and BEGIN_HILITE in row[6]:
result[ "scenarios!" ] = [ s.split("\t") for s in row[6].split("\n") ]
if row[7] and BEGIN_HILITE in row[7]:
result[ "tags!" ] = row[7].split( "\n" )
# add the result to the list
results.append( result )
return jsonify( results )
def _make_fts_query_string( query_string ):
"""Generate the SQLite query string."""
# check if this looks like a raw FTS query
if any( regex.search(query_string) for regex in _PASSTHROUGH_REGEXES ):
return query_string
# split the query string (taking into account quoted phrases)
words = query_string.split()
i = 0
while True:
if i >= len(words):
break
if i > 0 and words[i-1].startswith('"'):
words[i-1] += " {}".format( words[i] )
del words[i]
if words[i-1].startswith('"') and words[i-1].endswith('"'):
words[i-1] = words[i-1][1:-1]
continue
i += 1
# clean up quoted phrases
words = [ w[1:] if w.startswith('"') else w for w in words ]
words = [ w[:-1] if w.endswith('"') else w for w in words ]
words = [ w.strip() for w in words ]
words = [ w for w in words if w ]
# quote any phrases that need it
def has_special_char( word ):
return any( ch in word for ch in _SQLITE_FTS_SPECIAL_CHARS+" " )
def quote_word( word ):
return '"{}"'.format(word) if has_special_char(word) else word
words = [ quote_word(w) for w in words ]
# escape any special characters
words = [ w.replace("'","''") for w in words ]
return " AND ".join( words )
# ---------------------------------------------------------------------
def init_search( session, logger ):
"""Initialize the search engine."""
# initialize the database
global _search_index_path
_search_index_path = app.config.get( "SEARCH_INDEX_PATH" )
if not _search_index_path:
# FUDGE! We should be able to create a shared, in-memory database using this:
# file::memory:?mode=memory&cache=shared
# but it doesn't seem to work (on Linux) and ends up creating a file with this name :-/
# We manually create a temp file, which has to have the same name each time, so that we don't
# keep creating a new database each time we start up. Sigh...
_search_index_path = os.path.join( tempfile.gettempdir(), "asl-articles.searchdb" )
if os.path.isfile( _search_index_path ):
os.unlink( _search_index_path )
logger.info( "Creating search index: %s", _search_index_path )
with SearchDbConn() as dbconn:
# NOTE: We would like to make "owner" the primary key, but FTS doesn't support primary keys
# (nor UNIQUE constraints), so we have to manage this manually :-(
dbconn.conn.execute(
"CREATE VIRTUAL TABLE searchable USING fts5"
" ( owner, name, name2, description, authors, scenarios, tags, tokenize='porter unicode61' )"
)
# load the searchable content
logger.debug( "Loading the search index..." )
logger.debug( "- Loading publishers." )
for publ in session.query( Publisher ):
add_or_update_publisher( dbconn, publ )
logger.debug( "- Loading publications." )
for pub in session.query( Publication ):
add_or_update_publication( dbconn, pub )
logger.debug( "- Loading articles." )
for article in session.query( Article ):
add_or_update_article( dbconn, article )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def add_or_update_publisher( dbconn, publ ):
"""Add/update a publisher in the search index."""
_do_add_or_update_searchable( dbconn, "publisher",
_make_publisher_key(publ), publ
)
def add_or_update_publication( dbconn, pub ):
"""Add/update a publication in the search index."""
_do_add_or_update_searchable( dbconn, "publication",
_make_publication_key(pub.pub_id), pub
)
def add_or_update_article( dbconn, article ):
"""Add/update an article in the search index."""
_do_add_or_update_searchable( dbconn, "article",
_make_article_key(article.article_id), article
)
def _do_add_or_update_searchable( dbconn, owner_type, owner, obj ):
"""Add or update a record in the search index."""
# prepare the fields
fields = _FIELD_MAPPINGS[ owner_type ]
vals = {
f: getattr( obj,fields[f] ) if isinstance( fields[f], str ) else fields[f]( obj )
for f in fields
}
vals = {
k: clean_html( v, allow_tags=[], safe_attrs=[] )
for k,v in vals.items()
}
def do_add_or_update( dbconn ):
dbconn.conn.execute( "INSERT INTO searchable"
" ( owner, name, name2, description, authors, scenarios, tags )"
" VALUES (?,?,?,?,?,?,?)", (
owner,
vals.get("name"), vals.get("name2"), vals.get("description"),
vals.get("authors"), vals.get("scenarios"), vals.get("tags")
) )
# update the database
if dbconn:
# NOTE: If we are passed a connection to use, we assume we are starting up and are doing
# the initial build of the search index, and therefore don't need to check for an existing row.
# The caller is responsible for committing the transaction.
do_add_or_update( dbconn )
else:
with SearchDbConn() as dbconn2:
# NOTE: Because we can't have a UNIQUE constraint on "owner", we can't use UPSERT nor INSERT OR UPDATE,
# so we have to delete any existing row manually, then insert :-/
_logger.debug( "Updating searchable: %s", owner )
_logger.debug( "- %s", " ; ".join( "{}=\"{}\"".format( k, repr(v) ) for k,v in vals.items() if v ) )
dbconn2.conn.execute( "DELETE FROM searchable WHERE owner = ?", (owner,) )
do_add_or_update( dbconn2 )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def delete_publishers( publs ):
"""Remove publishers from the search index."""
with SearchDbConn() as dbconn:
for publ in publs:
_do_delete_searchable( dbconn, _make_publisher_key( publ ) )
def delete_publications( pubs ):
"""Remove publications from the search index."""
with SearchDbConn() as dbconn:
for pub in pubs:
_do_delete_searchable( dbconn, _make_publication_key( pub ) )
def delete_articles( articles ):
"""Remove articles from the search index."""
with SearchDbConn() as dbconn:
for article in articles:
_do_delete_searchable( dbconn, _make_article_key( article ) )
def _do_delete_searchable( dbconn, owner ):
"""Remove an entry from the search index."""
dbconn.conn.execute( "DELETE FROM searchable WHERE owner = ?", (owner,) )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _make_publisher_key( publ ):
"""Generate the owner key for a Publisher."""
return "publisher:{}".format( publ.publ_id if isinstance(publ,Publisher) else publ )
def _make_publication_key( pub ):
"""Generate the owner key for a Publication."""
return "publication:{}".format( pub.pub_id if isinstance(pub,Publication) else pub )
def _make_article_key( article ):
"""Generate the owner key for an Article."""
return "article:{}".format( article.article_id if isinstance(article,Article) else article )

@ -6,6 +6,7 @@ from flask import jsonify
from asl_articles import app, db
from asl_articles.models import Publication, Article
from asl_articles.utils import decode_tags
# ---------------------------------------------------------------------
@ -25,7 +26,7 @@ def do_get_tags():
for row in query:
if not row[0]:
continue
for tag in row[0].split( ";" ):
for tag in decode_tags( row[0] ):
tags[ tag ] = tags[ tag ] + 1
count_tags( db.session.query( Publication.pub_tags ) )
count_tags( db.session.query( Article.article_tags ) )

@ -0,0 +1,96 @@
{
"publisher": [
{ "publ_id": 1,
"publ_name": "Multi-Man Publishing",
"publ_description": "Designers and producers of Advanced Squad Leader and other fine wargames."
},
{ "publ_id": 2,
"publ_name": "View From The Trenches",
"publ_description": "Britain's Premier ASL Journal",
"publ_url": "http://vftt.co.uk"
}
],
"publication": [
{ "pub_id": 10,
"pub_name": "ASL Journal",
"pub_edition": 4,
"pub_tags": "aslj",
"publ_id": 1
},
{ "pub_id": 11,
"pub_name": "ASL Journal",
"pub_edition": 5,
"pub_tags": "aslj",
"publ_id": 1
},
{ "pub_id": 12,
"pub_name": "View From The Trenches",
"pub_edition": 100,
"pub_description": "Fantastic 100th issue!",
"pub_tags": "vftt",
"publ_id": 2
}
],
"article": [
{ "article_id": 500,
"article_title": "Hit 'Em High, Or Hit 'Em Low",
"article_subtitle": "Some things about light mortars you might like to know",
"article_snippet": "Light mortars in ASL can be game winners depending on what they can shoot at, how low you roll and how often you get rate.",
"article_tags": "aslj\nmortars",
"pub_id": 10
},
{ "article_id": 501,
"article_title": "'Bolts From Above",
"article_snippet": "Infantry often found itself battling the elements as well as the enemy. ASL has made provisions for the inclusion of inclement weather conditions, such as rain and snow.",
"article_tags": "aslj\nweather",
"pub_id": 10
},
{ "article_id": 510,
"article_title": "The Jungle Isn't Neutral",
"article_subtitle": "Up close and personal in the PTO",
"article_snippet": "British Lieutenant Colonel F. Spencer Chapman wrote a memoir of jungle fighting in Malaysia titled \"The Jungle Is Neutral.\"",
"article_tags": "aslj\nPTO",
"pub_id": 11
},
{ "article_id": 511,
"article_title": "Hunting DUKWs and Buffalos",
"article_subtitle": "Scenario Analysis: HS17 \"Water Foul\"",
"article_snippet": "This scenario features a late-war Canadian assault on a German-occupied flooded town - an unusual tactical challenge in ASL.",
"article_tags": "aslj",
"pub_id": 11
},
{ "article_id": 520,
"article_title": "Jagdpanzer 38(t) Hetzer",
"article_snippet": "In the 1930s the Germans conducted a number of military exercises which showed that close support from light field guns was helpful for infantry operations.",
"pub_id": 12
}
],
"article_author": [
{ "seq_no": 1, "article_id": 500, "author_id": 1000 },
{ "seq_no": 1, "article_id": 510, "author_id": 1001 },
{ "seq_no": 1, "article_id": 511, "author_id": 1002 },
{ "seq_no": 1, "article_id": 520, "author_id": 1003 }
],
"author": [
{ "author_id": 1000, "author_name": "Simon Spinetti" },
{ "author_id": 1001, "author_name": "Mark Pitcavage" },
{ "author_id": 1002, "author_name": "Oliver Giancola" },
{ "author_id": 1003, "author_name": "Michael Davies" }
],
"article_scenario": [
{ "seq_no": 1, "article_id": 511, "scenario_id": 2000 },
{ "seq_no": 1, "article_id": 511, "scenario_id": 2001 }
],
"scenario": [
{ "scenario_id": 2000, "scenario_display_id": "HS17", "scenario_name": "Water Foul" },
{ "scenario_id": 2001, "scenario_name": "No Scenario ID" }
]
}

@ -20,10 +20,10 @@ def test_edit_article( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn, fixtures="articles.json" )
# edit "What To Do If You Have A Tin Can"
results = do_search( "tin can" )
results = do_search( '"tin can"' )
assert len(results) == 1
result = results[0]
_edit_article( result, {
edit_article( result, {
"title": " Updated title ",
"subtitle": " Updated subtitle ",
"snippet": " Updated snippet. ",
@ -39,7 +39,7 @@ def test_edit_article( webdriver, flask_app, dbconn ):
)
# try to remove all fields from the article (should fail)
_edit_article( result,
edit_article( result,
{ "title": "", "subtitle": "", "snippet": "", "tags": ["-abc","-xyz"], "url": "" },
expected_error = "Please specify the article's title."
)
@ -58,7 +58,7 @@ def test_edit_article( webdriver, flask_app, dbconn ):
assert find_children( ".tag", result ) == []
# check that the search result was updated in the database
results = do_search( "tin can" )
results = do_search( '"tin can"' )
_check_result( results[0], [ "Tin Cans Rock!", None, "", [], None ] )
# ---------------------------------------------------------------------
@ -70,7 +70,7 @@ def test_create_article( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# try creating a article with no name (should fail)
_create_article( {}, toast_type=None )
create_article( {}, toast_type=None )
check_error_msg( "Please specify the article's title." )
# enter a name and other details
@ -190,7 +190,7 @@ def test_images( webdriver, flask_app, dbconn ): #pylint: disable=too-many-state
find_child( ".cancel", dlg ).click()
# create an article with no image
_create_article( { "title": "Test Article" } )
create_article( { "title": "Test Article" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
article_sr = results[0]
@ -199,16 +199,16 @@ def test_images( webdriver, flask_app, dbconn ): #pylint: disable=too-many-state
# add an image to the article
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )
_edit_article( article_sr, { "image": fname } )
edit_article( article_sr, { "image": fname } )
check_image( fname )
# change the article's image
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" )
_edit_article( article_sr, { "image": fname } )
edit_article( article_sr, { "image": fname } )
check_image( fname )
# remove the article's image
_edit_article( article_sr, { "image": None } )
edit_article( article_sr, { "image": None } )
check_image( None )
# try to upload an image that's too large
@ -250,7 +250,7 @@ def test_parent_publisher( webdriver, flask_app, dbconn ):
assert article["pub_id"] is None
# check that the parent publication was updated in the UI
results = do_search( "My Article" )
results = do_search( '"My Article"' )
assert len(results) == 1
article_sr = results[0]
elem = find_child( ".title .publication", article_sr )
@ -260,18 +260,18 @@ def test_parent_publisher( webdriver, flask_app, dbconn ):
assert elem is None
# create an article with no parent publication
_create_article( { "title": "My Article" } )
create_article( { "title": "My Article" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
article_sr = results[0]
check_results( None )
# change the article to have a publication
_edit_article( article_sr, { "publication": "ASL Journal" } )
edit_article( article_sr, { "publication": "ASL Journal" } )
check_results( (1, "ASL Journal") )
# change the article back to having no publication
_edit_article( article_sr, { "publication": "(none)" } )
edit_article( article_sr, { "publication": "(none)" } )
check_results( None )
# ---------------------------------------------------------------------
@ -283,7 +283,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a article with Unicode content
_create_article( {
create_article( {
"title": "japan = \u65e5\u672c",
"subtitle": "s.korea = \ud55c\uad6d",
"snippet": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
@ -311,7 +311,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a article with HTML content
_create_article( {
create_article( {
"title": "title: <span style='boo!'> <b>bold</b> <xxx>xxx</xxx> <i>italic</i>",
"subtitle": "<i>italicized subtitle</i>",
"snippet": "bad stuff here: <script>HCF</script>"
@ -331,7 +331,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# update the article with new HTML content
_edit_article( result, {
edit_article( result, {
"title": "<div style='...'>updated</div>"
}, toast_type="warning" )
def check_result():
@ -344,7 +344,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def _create_article( vals, toast_type="info" ):
def create_article( vals, toast_type="info" ):
"""Create a new article."""
# initialize
if toast_type:
@ -353,8 +353,8 @@ def _create_article( vals, toast_type="info" ):
find_child( "#menu .new-article" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for key,val in vals.items():
if key == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
if key in ["authors","scenarios","tags"]:
select = ReactSelect( find_child( ".{} .react-select".format(key), dlg ) )
select.update_multiselect_values( *val )
else:
sel = ".{} {}".format( key , "textarea" if key == "snippet" else "input" )
@ -366,7 +366,7 @@ def _create_article( vals, toast_type="info" ):
lambda: check_toast( toast_type, "created OK", contains=True )
)
def _edit_article( result, vals, toast_type="info", expected_error=None ):
def edit_article( result, vals, toast_type="info", expected_error=None ):
"""Edit a article's details."""
# update the specified article's details
find_child( ".edit", result ).click()
@ -381,13 +381,10 @@ def _edit_article( result, vals, toast_type="info", expected_error=None ):
)
else:
find_child( ".remove-image", dlg ).click()
elif key == "authors":
select = ReactSelect( find_child( ".authors .react-select", dlg ) )
select.update_multiselect_values( *val )
elif key == "publication":
select = ReactSelect( find_child( ".publication .react-select", dlg ) )
select.select_by_name( val )
elif key in ["scenarios","tags"]:
elif key in ["authors","scenarios","tags"]:
select = ReactSelect( find_child( ".{} .react-select".format(key), dlg ) )
select.update_multiselect_values( *val )
else:

@ -6,7 +6,7 @@ import json
from asl_articles.tests.utils import init_tests, find_child, find_children, wait_for_elem, find_search_result
from asl_articles.tests.react_select import ReactSelect
from asl_articles.tests.test_articles import _create_article, _edit_article
from asl_articles.tests.test_articles import create_article, edit_article
# ---------------------------------------------------------------------
@ -17,25 +17,25 @@ def test_article_authors( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create some test articles
_create_article( { "title": "article 1" } )
_create_article( { "title": "article 2" } )
create_article( { "title": "article 1" } )
create_article( { "title": "article 2" } )
all_authors = set()
_check_authors( flask_app, all_authors, [ [], [] ] )
# add an author to article #1
_edit_article( find_search_result( "article 1" ), {
edit_article( find_search_result( "article 1" ), {
"authors": [ "+andrew" ]
} )
_check_authors( flask_app, all_authors, [ ["andrew"], [] ] )
# add authors to article #2
_edit_article( find_search_result( "article 2" ), {
edit_article( find_search_result( "article 2" ), {
"authors": [ "+bob", "+charlie" ]
} )
_check_authors( flask_app, all_authors, [ ["andrew"], ["bob","charlie"] ] )
# add/remove authors to article #2
_edit_article( find_search_result( "article 2" ), {
edit_article( find_search_result( "article 2" ), {
"authors": [ "+dan", "-charlie", "+andrew" ]
} )
_check_authors( flask_app, all_authors, [ ["andrew"], ["bob","dan","andrew"] ] )
@ -43,7 +43,7 @@ def test_article_authors( webdriver, flask_app, dbconn ):
# add new/existing authors to article #1
# NOTE: The main thing we're checking here is that despite new and existing authors
# being added to the article, their order is preserved.
_edit_article( find_search_result( "article 1" ), {
edit_article( find_search_result( "article 1" ), {
"authors": [ "+bob", "+new1", "+charlie", "+new2" ]
} )
_check_authors( flask_app, all_authors, [

@ -1,39 +0,0 @@
""" Basic tests. """
from asl_articles.tests.utils import init_tests, do_search, find_child
# ---------------------------------------------------------------------
def test_basic( webdriver, flask_app, dbconn ):
"""Basic tests."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="basic.json" )
# make sure the home page loaded correctly
elem = find_child( "#search-form .caption" )
assert elem.text == "Search for:"
# run some test searches
def do_test( query, expected ):
results = do_search( query )
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", "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/" )
] )

@ -5,7 +5,7 @@ import os
import json
from asl_articles.models import Scenario
from asl_articles.tests.utils import init_db
from asl_articles.tests.utils import init_tests
sys.path.append( os.path.join( os.path.split(__file__)[0], "../../tools/" ) )
from import_roar_scenarios import import_roar_scenarios
@ -16,7 +16,7 @@ def test_import_roar_scenarios( dbconn ):
"""Test importing ROAR scenarios."""
# initialize
session = init_db( dbconn, None )
session = init_tests( None, None, dbconn )
roar_fname = os.path.join( os.path.split(__file__)[0], "fixtures/roar-scenarios.json" )
roar_data = json.load( open( roar_fname, "r" ) )
@ -73,7 +73,7 @@ def test_scenario_matching( dbconn ):
"""Test matching ROAR scenarios with scenarios in the database."""
# initialize
session = init_db( dbconn, None )
session = init_tests( None, None, dbconn )
roar_fname = os.path.join( os.path.split(__file__)[0], "fixtures/roar-scenarios.json" )
# put a scenario in the database that has no ROAR ID

@ -6,8 +6,11 @@ import urllib.error
import json
import base64
from asl_articles.tests.utils import init_tests, init_db, do_search, get_result_names, \
wait_for, wait_for_elem, find_child, find_children, set_elem_text, \
from selenium.common.exceptions import StaleElementReferenceException
from asl_articles.search import SEARCH_ALL
from asl_articles.tests.utils import init_tests, load_fixtures, do_search, get_result_names, \
wait_for, wait_for_elem, find_child, find_children, find_search_result, set_elem_text, \
set_toast_marker, check_toast, send_upload_data, check_ask_dialog, check_error_msg
from asl_articles.tests.react_select import ReactSelect
@ -20,10 +23,10 @@ def test_edit_publication( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn, fixtures="publications.json" )
# edit "ASL Journal #2"
results = do_search( "asl journal" )
results = do_search( '"asl journal"' )
assert len(results) == 2
result = results[1]
_edit_publication( result, {
edit_publication( result, {
"name": " ASL Journal (updated) ",
"edition": " 2a ",
"description": " Updated ASLJ description. ",
@ -39,7 +42,7 @@ def test_edit_publication( webdriver, flask_app, dbconn ):
)
# try to remove all fields from "ASL Journal #2" (should fail)
_edit_publication( result,
edit_publication( result,
{ "name": "", "edition": "", "description": "", "tags":["-abc","-xyz"], "url": "" },
expected_error = "Please specify the publication's name."
)
@ -58,7 +61,7 @@ def test_edit_publication( webdriver, flask_app, dbconn ):
assert find_children( ".tag", result ) == []
# check that the search result was updated in the database
results = do_search( "ASL Journal" )
results = do_search( '"ASL Journal"' )
assert get_result_names( results ) == [ "ASL Journal (1)", "Updated ASL Journal" ]
# ---------------------------------------------------------------------
@ -70,7 +73,7 @@ def test_create_publication( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# try creating a publication with no name (should fail)
_create_publication( {}, toast_type=None )
create_publication( {}, toast_type=None )
check_error_msg( "Please specify the publication's name." )
# enter a name and other details
@ -109,11 +112,10 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn, fixtures="publications.json" )
# start to delete publication "ASL Journal #1", but cancel the operation
results = do_search( "ASL Journal" )
results = do_search( '"ASL Journal"' )
assert len(results) == 2
result = results[1]
assert find_child( ".name", result ).text == "ASL Journal (2)"
find_child( ".delete", result ).click()
sr = find_search_result( "ASL Journal (2)", results )
find_child( ".delete", sr ).click()
check_ask_dialog( ( "Delete this publication?", "ASL Journal (2)" ), "cancel" )
# check that search results are unchanged on-screen
@ -121,13 +123,12 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
assert results2 == results
# check that the search results are unchanged in the database
results3 = do_search( "ASL Journal" )
results3 = do_search( '"ASL Journal"' )
assert results3 == results
# delete the publication "ASL Journal 2"
result = results3[1]
assert find_child( ".name", result ).text == "ASL Journal (2)"
find_child( ".delete", result ).click()
sr = find_search_result( "ASL Journal (2)", results3 )
find_child( ".delete", sr ).click()
set_toast_marker( "info" )
check_ask_dialog( ( "Delete this publication?", "ASL Journal (2)" ), "ok" )
wait_for( 2,
@ -139,7 +140,7 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
assert get_result_names( results ) == [ "ASL Journal (1)" ]
# check that the search result was deleted from the database
results = do_search( "ASL Journal" )
results = do_search( '"ASL Journal"' )
assert get_result_names( results ) == [ "ASL Journal (1)" ]
# ---------------------------------------------------------------------
@ -192,7 +193,7 @@ def test_images( webdriver, flask_app, dbconn ): #pylint: disable=too-many-state
find_child( ".cancel", dlg ).click()
# create an publication with no image
_create_publication( {"name": "Test Publication" } )
create_publication( {"name": "Test Publication" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
pub_sr = results[0]
@ -201,16 +202,16 @@ def test_images( webdriver, flask_app, dbconn ): #pylint: disable=too-many-state
# add an image to the publication
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )
_edit_publication( pub_sr, { "image": fname } )
edit_publication( pub_sr, { "image": fname } )
check_image( fname )
# change the publication's image
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" )
_edit_publication( pub_sr, { "image": fname } )
edit_publication( pub_sr, { "image": fname } )
check_image( fname )
# remove the publication's image
_edit_publication( pub_sr, { "image": None } )
edit_publication( pub_sr, { "image": None } )
check_image( None )
# try to upload an image that's too large
@ -252,7 +253,7 @@ def test_parent_publisher( webdriver, flask_app, dbconn ):
assert pub["publ_id"] is None
# check that the parent publisher was updated in the UI
results = do_search( "MMP News" )
results = do_search( '"MMP News"' )
assert len(results) == 1
pub_sr = results[0]
elem = find_child( ".name .publisher", pub_sr )
@ -262,18 +263,18 @@ def test_parent_publisher( webdriver, flask_app, dbconn ):
assert elem is None
# create a publication with no parent publisher
_create_publication( { "name": "MMP News" } )
create_publication( { "name": "MMP News" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
pub_sr = results[0]
check_results( None )
# change the publication to have a publisher
_edit_publication( pub_sr, { "publisher": "Multiman Publishing" } )
edit_publication( pub_sr, { "publisher": "Multiman Publishing" } )
check_results( (1, "Multiman Publishing") )
# change the publication back to having no publisher
_edit_publication( pub_sr, { "publisher": "(none)" } )
edit_publication( pub_sr, { "publisher": "(none)" } )
check_results( None )
# ---------------------------------------------------------------------
@ -282,30 +283,41 @@ def test_cascading_deletes( webdriver, flask_app, dbconn ):
"""Test cascading deletes."""
# initialize
init_tests( webdriver, flask_app, None )
session = init_tests( webdriver, flask_app, dbconn )
def do_test( pub_name, expected_warning, expected_articles ):
# initialize
init_db( dbconn, "cascading-deletes-2.json" )
results = do_search( "" )
load_fixtures( session, "cascading-deletes-2.json" )
results = do_search( SEARCH_ALL )
# delete the specified publication
results = [ r for r in results if find_child( ".name", r ).text == pub_name ]
assert len( results ) == 1
find_child( ".delete", results[0] ).click()
sr = find_search_result( pub_name, results )
find_child( ".delete", sr ).click()
check_ask_dialog( ( "Delete this publication?", pub_name, expected_warning ), "ok" )
def check_results():
results = wait_for( 2, lambda: get_results( len(expected_articles) ) )
assert set( results ) == set( expected_articles )
def get_results( expected_len ):
# NOTE: The UI will remove anything that has been deleted, so we need to
# give it a bit of time to finish doing this.
results = find_children( "#search-results .search-result" )
try:
results = [ find_child( ".name span", r ).text for r in results ]
except StaleElementReferenceException:
return None
results = [ r for r in results if r.startswith( "article" ) ]
if len(results) == expected_len:
return results
return None
# check that deleted associated articles were removed from the UI
def check_articles( results ):
results = [ find_child( ".name span", r ).text for r in results ]
articles = [ r for r in results if r.startswith( "article" ) ]
assert articles == expected_articles
check_articles( find_children( "#search-results .search-result" ) )
check_results()
# check that associated articles were removed from the database
results = do_search( "article" )
check_articles( results )
check_results()
# do the tests
do_test( "Cascading Deletes 1",
@ -327,7 +339,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a publication with Unicode content
_create_publication( {
create_publication( {
"name": "japan = \u65e5\u672c",
"edition": "\u263a",
"tags": [ "+\u0e51", "+\u0e52", "+\u0e53" ],
@ -354,7 +366,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a publication with HTML content
_create_publication( {
create_publication( {
"name": "name: <span style='boo!'> <b>bold</b> <xxx>xxx</xxx> <i>italic</i>",
"edition": "<i>2</i>",
"description": "bad stuff here: <script>HCF</script>"
@ -372,7 +384,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# update the publication with new HTML content
_edit_publication( result, {
edit_publication( result, {
"name": "<div style='...'>updated</div>"
}, toast_type="warning" )
def check_result():
@ -385,7 +397,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def _create_publication( vals, toast_type="info" ):
def create_publication( vals, toast_type="info" ):
"""Create a new publication."""
# initialize
if toast_type:
@ -407,7 +419,7 @@ def _create_publication( vals, toast_type="info" ):
lambda: check_toast( toast_type, "created OK", contains=True )
)
def _edit_publication( result, vals, toast_type="info", expected_error=None ):
def edit_publication( result, vals, toast_type="info", expected_error=None ):
"""Edit a publication's details."""
# update the specified publication's details
find_child( ".edit", result ).click()

@ -5,8 +5,11 @@ import urllib.request
import urllib.error
import base64
from asl_articles.tests.utils import init_tests, init_db, do_search, get_result_names, \
wait_for, wait_for_elem, find_child, find_children, set_elem_text, \
from selenium.common.exceptions import StaleElementReferenceException
from asl_articles.search import SEARCH_ALL, SEARCH_ALL_PUBLISHERS
from asl_articles.tests.utils import init_tests, load_fixtures, do_search, get_result_names, \
wait_for, wait_for_elem, find_child, find_children, find_search_result, set_elem_text, \
set_toast_marker, check_toast, send_upload_data, check_ask_dialog, check_error_msg
# ---------------------------------------------------------------------
@ -15,13 +18,13 @@ def test_edit_publisher( webdriver, flask_app, dbconn ):
"""Test editing publishers."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="basic.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="publishers.json" )
# edit "Avalon Hill"
results = do_search( "" )
results = do_search( SEARCH_ALL_PUBLISHERS )
result = results[0]
assert find_child( ".name", result ).text == "Avalon Hill"
_edit_publisher( result, {
edit_publisher( result, {
"name": " Avalon Hill (updated) ",
"description": " Updated AH description. ",
"url": " http://ah-updated.com "
@ -33,7 +36,7 @@ def test_edit_publisher( webdriver, flask_app, dbconn ):
_check_result( result, [ "Avalon Hill (updated)", "Updated AH description.", "http://ah-updated.com/" ] )
# try to remove all fields from "Avalon Hill" (should fail)
_edit_publisher( result,
edit_publisher( result,
{ "name": "", "description": "", "url": "" },
expected_error = "Please specify the publisher's name."
)
@ -51,8 +54,9 @@ def test_edit_publisher( webdriver, flask_app, dbconn ):
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" ]
results = do_search( SEARCH_ALL_PUBLISHERS )
assert set( get_result_names( results ) ) == \
set([ "Le Franc Tireur", "Multiman Publishing", "Updated Avalon Hill" ])
# ---------------------------------------------------------------------
@ -63,7 +67,7 @@ def test_create_publisher( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# try creating a publisher with no name (should fail)
_create_publisher( {}, toast_type=None )
create_publisher( {}, toast_type=None )
check_error_msg( "Please specify the publisher's name." )
# enter a name and other details
@ -94,13 +98,13 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
"""Test deleting publishers."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="basic.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="publishers.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()
results = do_search( SEARCH_ALL_PUBLISHERS )
sr = find_search_result( "Le Franc Tireur", results )
assert find_child( ".name", sr ).text == "Le Franc Tireur"
find_child( ".delete", sr ).click()
check_ask_dialog( ( "Delete this publisher?", "Le Franc Tireur" ), "cancel" )
# check that search results are unchanged on-screen
@ -108,13 +112,12 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
assert results2 == results
# check that the search results are unchanged in the database
results3 = do_search( "" )
results3 = do_search( SEARCH_ALL_PUBLISHERS )
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()
sr = find_search_result( "Le Franc Tireur", results3 )
find_child( ".delete", sr ).click()
set_toast_marker( "info" )
check_ask_dialog( ( "Delete this publisher?", "Le Franc Tireur" ), "ok" )
wait_for( 2,
@ -123,11 +126,11 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
# check that search result was removed on-screen
results = find_children( "#search-results .search-result" )
assert get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
assert set( get_result_names( results ) ) == set([ "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" ]
results = do_search( SEARCH_ALL_PUBLISHERS )
assert set( get_result_names( results ) ) == set([ "Avalon Hill", "Multiman Publishing" ])
# ---------------------------------------------------------------------
@ -179,7 +182,7 @@ def test_images( webdriver, flask_app, dbconn ): #pylint: disable=too-many-state
find_child( ".cancel", dlg ).click()
# create an publisher with no image
_create_publisher( { "name": "Test Publisher" } )
create_publisher( { "name": "Test Publisher" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
publ_sr = results[0]
@ -188,16 +191,16 @@ def test_images( webdriver, flask_app, dbconn ): #pylint: disable=too-many-state
# add an image to the publisher
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )
_edit_publisher( publ_sr, { "image": fname } )
edit_publisher( publ_sr, { "image": fname } )
check_image( fname )
# change the publisher's image
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" )
_edit_publisher( publ_sr, { "image": fname } )
edit_publisher( publ_sr, { "image": fname } )
check_image( fname )
# remove the publisher's image
_edit_publisher( publ_sr, { "image": None } )
edit_publisher( publ_sr, { "image": None } )
check_image( None )
# try to upload an image that's too large
@ -216,36 +219,45 @@ def test_cascading_deletes( webdriver, flask_app, dbconn ):
"""Test cascading deletes."""
# initialize
init_tests( webdriver, flask_app, None )
session = init_tests( webdriver, flask_app, dbconn )
def check_results( results, sr_type, expected, expected_deletions ):
def check_results( sr_type, expected, expected_deletions ):
expected = [ "{} {}".format( sr_type, p ) for p in expected ]
expected = [ e for e in expected if e not in expected_deletions ]
results = [ find_child( ".name span", r ).text for r in results ]
results = wait_for( 2, lambda: get_results( sr_type, len(expected) ) )
assert set( results ) == set( expected )
def get_results( sr_type, expected_len ):
# NOTE: The UI will remove anything that has been deleted, so we need to
# give it a bit of time to finish doing this.
results = find_children( "#search-results .search-result" )
try:
results = [ find_child( ".name span", r ).text for r in results ]
except StaleElementReferenceException:
return None
results = [ r for r in results if r.startswith( sr_type ) ]
assert results == expected
if len(results) == expected_len:
return results
return None
def do_test( publ_name, expected_warning, expected_deletions ):
# initialize
init_db( dbconn, "cascading-deletes-1.json" )
results = do_search( "" )
load_fixtures( session, "cascading-deletes-1.json" )
results = do_search( SEARCH_ALL )
# delete the specified publisher
results = [ r for r in results if find_child( ".name", r ).text == publ_name ]
assert len( results ) == 1
find_child( ".delete", results[0] ).click()
sr = find_search_result( publ_name, results )
find_child( ".delete", sr ).click()
check_ask_dialog( ( "Delete this publisher?", publ_name, expected_warning ), "ok" )
# check that deleted associated publications/articles were removed from the UI
results = find_children( "#search-results .search-result" )
def check_publications():
check_results( results,
check_results(
"publication", [ "2", "3", "4", "5a", "5b", "6a", "6b", "7a", "7b", "8a", "8b" ],
expected_deletions
)
def check_articles():
check_results( results,
check_results(
"article", [ "3", "4a", "4b", "6a", "7a", "7b", "8a.1", "8a.2", "8b.1", "8b.2" ],
expected_deletions
)
@ -253,7 +265,7 @@ def test_cascading_deletes( webdriver, flask_app, dbconn ):
check_articles()
# check that associated publications/articles were removed from the database
results = do_search( "" )
results = do_search( SEARCH_ALL )
check_publications()
check_articles()
@ -299,7 +311,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a publisher with Unicode content
_create_publisher( {
create_publisher( {
"name": "japan = \u65e5\u672c",
"url": "http://\ud55c\uad6d.com",
"description": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1"
@ -323,7 +335,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a publisher with HTML content
_create_publisher( {
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" )
@ -340,7 +352,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# update the publisher with new HTML content
_edit_publisher( result, {
edit_publisher( result, {
"name": "<div style='...'>updated</div>"
}, toast_type="warning" )
def check_result():
@ -353,7 +365,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def _create_publisher( vals, toast_type="info" ):
def create_publisher( vals, toast_type="info" ):
"""Create a new publisher."""
# initialize
if toast_type:
@ -371,7 +383,7 @@ def _create_publisher( vals, toast_type="info" ):
lambda: check_toast( toast_type, "created OK", contains=True )
)
def _edit_publisher( result, vals, toast_type="info", expected_error=None ):
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()

@ -6,7 +6,7 @@ import json
from asl_articles.tests.utils import init_tests, find_child, find_children, wait_for_elem, find_search_result
from asl_articles.tests.react_select import ReactSelect
from asl_articles.tests.test_articles import _create_article, _edit_article
from asl_articles.tests.test_articles import create_article, edit_article
# ---------------------------------------------------------------------
@ -21,12 +21,12 @@ def test_article_scenarios( webdriver, flask_app, dbconn ):
] )
# create some test articles
_create_article( { "title": "article 1" } )
_create_article( { "title": "article 2" } )
create_article( { "title": "article 1" } )
create_article( { "title": "article 2" } )
_check_scenarios( flask_app, all_scenarios, [ [], [] ] )
# add a scenario to article #1
_edit_article( find_search_result( "article 1" ), {
edit_article( find_search_result( "article 1" ), {
"scenarios": [ "+Test Scenario 1 [TEST 1]" ]
} )
_check_scenarios( flask_app, all_scenarios, [
@ -35,7 +35,7 @@ def test_article_scenarios( webdriver, flask_app, dbconn ):
] )
# add scenarios to article #2
_edit_article( find_search_result( "article 2" ), {
edit_article( find_search_result( "article 2" ), {
"scenarios": [ "+Test Scenario 3 [TEST 3]", "+No scenario ID" ]
} )
_check_scenarios( flask_app, all_scenarios, [
@ -44,7 +44,7 @@ def test_article_scenarios( webdriver, flask_app, dbconn ):
] )
# add/remove scenarios to article #2
_edit_article( find_search_result( "article 2" ), {
edit_article( find_search_result( "article 2" ), {
"scenarios": [ "+Test Scenario 1 [TEST 1]", "-Test Scenario 3 [TEST 3]" ]
} )
_check_scenarios( flask_app, all_scenarios, [
@ -53,7 +53,7 @@ def test_article_scenarios( webdriver, flask_app, dbconn ):
] )
# add an unknown scenario to article #1
_edit_article( find_search_result( "article 1" ), {
edit_article( find_search_result( "article 1" ), {
"scenarios": [ "+new scenario [NEW]" ]
} )
_check_scenarios( flask_app, all_scenarios, [

@ -0,0 +1,376 @@
""" Test search operations. """
from asl_articles.search import SearchDbConn, _make_fts_query_string
from asl_articles.tests.test_publishers import create_publisher, edit_publisher
from asl_articles.tests.test_publications import create_publication, edit_publication
from asl_articles.tests.test_articles import create_article, edit_article
from asl_articles.tests.utils import init_tests, wait_for_elem, find_child, find_children, check_ask_dialog, \
do_search, get_result_names, find_search_result
# ---------------------------------------------------------------------
def test_search_publishers( webdriver, flask_app, dbconn ):
"""Test searching publishers."""
# initialize
init_tests( webdriver, flask_app, dbconn )
# test searching publisher names/descriptions
_do_test_searches( ["hill","original"], [] )
create_publisher( {
"name": "Avalon Hill", "description": "The original ASL vendor."
} )
_do_test_searches( ["hill","original"], ["Avalon Hill"] )
# edit the publisher
sr = find_search_result( "Avalon Hill" )
edit_publisher( sr, {
"name": "Avalon Mountain", "description": "The first ASL vendor."
} )
_do_test_searches( ["hill","original"], [] )
_do_test_searches( ["mountain","first"], ["Avalon Mountain"] )
# delete the publisher
sr = find_search_result( "Avalon Mountain" )
find_child( ".delete", sr ).click()
check_ask_dialog( "Delete this publisher?", "ok" )
_do_test_searches( ["hill","original","mountain","first"], [] )
# ---------------------------------------------------------------------
def test_search_publications( webdriver, flask_app, dbconn ):
"""Test searching publications."""
# initialize
init_tests( webdriver, flask_app, dbconn )
# test searching publication names/descriptions
_do_test_searches( ["journal","good"], [] )
create_publication( {
"name": "ASL Journal", "description": "A pretty good magazine."
} )
_do_test_searches( ["journal","good"], ["ASL Journal"] )
# edit the publication
sr = find_search_result( "ASL Journal" )
edit_publication( sr, {
"name": "ASL Magazine", "description": "Not a bad magazine."
} )
_do_test_searches( ["journal","good"], [] )
_do_test_searches( ["magazine","bad"], ["ASL Magazine"] )
# delete the publication
sr = find_search_result( "ASL Magazine" )
find_child( ".delete", sr ).click()
check_ask_dialog( "Delete this publication?", "ok" )
_do_test_searches( ["journal","good","magazine","bad"], [] )
# ---------------------------------------------------------------------
def test_search_articles( webdriver, flask_app, dbconn ):
"""Test searching articles."""
# initialize
init_tests( webdriver, flask_app, dbconn )
# test searching article titles/subtitles/snippets
_do_test_searches( ["low","some","game"], [] )
create_article( {
"title": "Hit 'Em High, Or Hit 'Em Low",
"subtitle": "Some things about light mortars you might like to know",
"snippet": "Light mortars in ASL can be game winners."
} )
_do_test_searches( ["low","some","game"], ["Hit 'Em High, Or Hit 'Em Low"] )
# edit the article
sr = find_search_result( "Hit 'Em High, Or Hit 'Em Low" )
edit_article( sr, {
"title": "Hit 'Em Hard",
"subtitle": "Where it hurts!",
"snippet": "Always the best way to do things."
} )
_do_test_searches( ["low","some","game"], [] )
_do_test_searches( ["hard","hurt","best"], ["Hit 'Em Hard"] )
# delete the article
sr = find_search_result( "Hit 'Em Hard" )
find_child( ".delete", sr ).click()
check_ask_dialog( "Delete this article?", "ok" )
_do_test_searches( ["hard","hurt","best"], [] )
# ---------------------------------------------------------------------
def test_search_authors( webdriver, flask_app, dbconn ):
"""Test searching for authors."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
# search for some authors
_do_test_search( "pitcavage", ["The Jungle Isn't Neutral"] )
_do_test_search( "davie", ["Jagdpanzer 38(t) Hetzer"] )
_do_test_search( "pit* dav*", [] ) # nb: implied AND
_do_test_search( "pit* OR dav*", ["The Jungle Isn't Neutral","Jagdpanzer 38(t) Hetzer"] )
# ---------------------------------------------------------------------
def test_search_scenarios( webdriver, flask_app, dbconn ):
"""Test searching for scenarios."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
# search for some scenarios
_do_test_search( "foul", ["Hunting DUKWs and Buffalos"] )
_do_test_search( "hs17", ["Hunting DUKWs and Buffalos"] )
# ---------------------------------------------------------------------
def test_search_tags( webdriver, flask_app, dbconn ):
"""Test searching for tags."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
# search for some publication tags
_do_test_search( "vftt", ["View From The Trenches (100)"] )
# search for some article tags
_do_test_search( "pto", ["The Jungle Isn't Neutral"] )
_do_test_search( "aslj", [
"ASL Journal (4)", "ASL Journal (5)",
"'Bolts From Above", "The Jungle Isn't Neutral", "Hunting DUKWs and Buffalos", "Hit 'Em High, Or Hit 'Em Low"
] )
# ---------------------------------------------------------------------
def test_empty_search( webdriver, flask_app, dbconn ):
"""Test handling of an empty search string."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
# search for an empty string
form = find_child( "#search-form" )
find_child( ".query", form ).send_keys( " " )
find_child( "button[type='submit']", form ).click()
dlg = wait_for_elem( 2, "#ask" )
assert find_child( ".MuiDialogContent-root", dlg ).text == "Please enter something to search for."
# ---------------------------------------------------------------------
def test_multiple_search_results( webdriver, flask_app, dbconn ):
"""Test more complicated search queries."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
# do a search
_do_test_search( "asl", [
"View From The Trenches",
"ASL Journal (4)", "ASL Journal (5)",
"Hunting DUKWs and Buffalos", "'Bolts From Above", "Hit 'Em High, Or Hit 'Em Low"
] )
# do some searches
_do_test_search( "infantry", [
"'Bolts From Above", "Jagdpanzer 38(t) Hetzer"
] )
_do_test_search( "infantry OR mortar", [
"'Bolts From Above", "Jagdpanzer 38(t) Hetzer",
"Hit 'Em High, Or Hit 'Em Low"
] )
_do_test_search( "infantry AND mortar", [] )
# ---------------------------------------------------------------------
def test_highlighting( webdriver, flask_app, dbconn ):
"""Test highlighting search matches."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json", no_sr_hilite=0 )
def find_highlighted( elems ):
results = []
for e in elems if isinstance(elems,list) else [elems]:
results.extend( c.text for c in find_children( ".hilite", e ) )
return results
# test highlighting in publisher search results
results = _do_test_search( "view britain", ["View From The Trenches"] )
sr = results[0]
assert find_highlighted( find_child( ".name span", sr ) ) == [ "View" ]
assert find_highlighted( find_child( ".description", sr ) ) == [ "Britain" ]
def check_publication_highlights( query, expected, name, description, tags ):
results = _do_test_search( query, [expected] )
assert len(results) == 1
sr = results[0]
assert find_highlighted( find_child( ".name span", sr ) ) == name
assert find_highlighted( find_child( ".description", sr ) ) == description
assert find_highlighted( find_children( ".tag", sr ) ) == tags
# test highlighting in publication search results
check_publication_highlights( "view fantastic",
"View From The Trenches (100)",
["View"], ["Fantastic"], []
)
check_publication_highlights( "vftt",
"View From The Trenches (100)",
[], [], ["vftt"]
)
def check_article_highlights( query, expected, title, subtitle, snippet, authors, scenarios, tags ):
results = _do_test_search( query, [expected] )
assert len(results) == 1
sr = results[0]
assert find_highlighted( find_child( ".title span", sr ) ) == title
assert find_highlighted( find_child( ".subtitle", sr ) ) == subtitle
assert find_highlighted( find_child( ".snippet", sr ) ) == snippet
assert find_highlighted( find_children( ".author", sr ) ) == authors
assert find_highlighted( find_children( ".scenario", sr ) ) == scenarios
assert find_highlighted( find_children( ".tag", sr ) ) == tags
# test highlighting in article search results
check_article_highlights( "hit light mortar",
"Hit 'Em High, Or Hit 'Em Low",
["Hit","Hit"], ["light","mortars"], ["Light","mortars"], [], [], ["mortars"]
)
# repeat the article search using a quoted phrase
check_article_highlights( '"light mortar"',
"Hit 'Em High, Or Hit 'Em Low",
[], ["light mortars"], ["Light mortars"], [], [], []
)
# test highlighting in article authors
check_article_highlights( "pitcav*",
"The Jungle Isn't Neutral",
[], [], [], ["Pitcavage"], [], []
)
# test highlighting in article scenario names
check_article_highlights( "foul",
"Hunting DUKWs and Buffalos",
[], ["Foul"], [], [], ["Foul"], []
)
# test highlighting in article scenario ID's
check_article_highlights( "hs17",
"Hunting DUKWs and Buffalos",
[], ["HS17"], [], [], ["HS17"], []
)
# test highlighting in article tags
check_article_highlights( "pto",
"The Jungle Isn't Neutral",
[], ["PTO"], [], [], [], ["PTO"]
)
# ---------------------------------------------------------------------
def test_html_stripping( webdriver, flask_app, dbconn ):
"""Test HTML stripping of searchable content."""
# initialize
init_tests( webdriver, flask_app, dbconn )
# create objects with HTML content
create_publisher( {
"name": "A <b>bold</b> publisher",
"description": "This is some <b>bold text</b>, this is <i>italic</i>."
} )
create_publication( {
"name": "A <b>bold</b> publication",
"edition": "75<u>L</u>",
"description": "This is some <b>bold text</b>, this is <i>italic</i>.",
"tags": [ "+<b>bold</b>", "+<i>italic</i>" ]
} )
create_article( {
"title": "An <i>italic</i> article",
"subtitle": "A <b>bold</b> subtitle",
"authors": [ "+Joe <u>Underlined</u>" ],
"tags": [ "+<b>bold</b>", "+<i>italic</i>" ],
"scenarios": [ "+<b>bold</b> [B1]", "+<i>italic</i> [I1]" ],
"snippet": "This is some <b>bold text</b>, this is <i>italic</i>."
} )
# check if the search index contains any HTML
def is_html_clean( val ):
return "<" not in val and ">" not in val if val else True
with SearchDbConn() as dbconn2:
curs = dbconn2.conn.execute( "SELECT * FROM searchable" )
for row in curs:
assert all( is_html_clean(v) for v in row )
# ---------------------------------------------------------------------
def test_make_fts_query_string():
"""Test generating FTS query strings."""
def do_test( query, expected ):
assert _make_fts_query_string(query) == expected
# test some query strings
do_test( "", "" )
do_test( "hello", "hello" )
do_test( " hello, world! ", "hello, AND world!" )
do_test(
"foo 1+2 A-T K# bar",
'foo AND "1+2" AND "A-T" AND "K#" AND bar'
)
do_test(
"a'b a''b",
"\"a''b\" AND \"a''''b\""
)
do_test(
'foo "set dc" bar',
'foo AND "set dc" AND bar'
)
# test some quoted phrases
do_test( '""', '' )
do_test( ' " " ', '' )
do_test(
'"hello world"',
'"hello world"'
)
do_test(
' foo "hello world" bar ',
'foo AND "hello world" AND bar'
)
do_test(
' foo " xyz " bar ',
'foo AND xyz AND bar'
)
do_test(
' foo " xyz 123 " bar ',
'foo AND "xyz 123" AND bar'
)
# test some incorrectly quoted phrases
do_test( '"', '' )
do_test( ' " " " ', '' )
do_test( ' a "b c d e', 'a AND "b c d e"' )
do_test( ' a b" c d e ', 'a AND b AND c AND d AND e' )
# test pass-through
do_test( "AND", "AND" )
do_test( "OR", "OR" )
do_test( "NOT", "NOT" )
do_test( "foo OR bar", "foo OR bar" )
do_test( "(a OR b)", "(a OR b)" )
# ---------------------------------------------------------------------
def _do_test_search( query, expected ):
"""Run a search and check the results."""
results = do_search( query )
assert set( get_result_names( results ) ) == set( expected )
return results
def _do_test_searches( queries, expected ):
"""Run searches and check the results."""
for query in queries:
_do_test_search( query, expected )

@ -7,8 +7,8 @@ from asl_articles.tests.utils import init_tests, wait_for_elem, find_child, find
find_search_result, get_result_names
from asl_articles.tests.react_select import ReactSelect
from asl_articles.tests.test_publications import _create_publication, _edit_publication
from asl_articles.tests.test_articles import _create_article, _edit_article
from asl_articles.tests.test_publications import create_publication, edit_publication
from asl_articles.tests.test_articles import create_article, edit_article
# ---------------------------------------------------------------------
@ -19,15 +19,15 @@ def test_tags( webdriver, flask_app, dbconn ):
init_tests( webdriver, flask_app, dbconn )
# create a test publication and article
_create_publication( { "name": "publication 1" } )
_create_article( { "title": "article 1" } )
create_publication( { "name": "publication 1" } )
create_article( { "title": "article 1" } )
_check_tags( flask_app, {
"publication 1": [],
"article 1": []
} )
# add some tags to the publication
_edit_publication( find_search_result( "publication 1" ), {
edit_publication( find_search_result( "publication 1" ), {
"tags": [ "+aaa", "+bbb" ]
} )
_check_tags( flask_app, {
@ -36,7 +36,7 @@ def test_tags( webdriver, flask_app, dbconn ):
} )
# add some tags to the article
_edit_article( find_search_result( "article 1" ), {
edit_article( find_search_result( "article 1" ), {
"tags": [ "+bbb", "+ccc" ]
} )
_check_tags( flask_app, {
@ -45,7 +45,7 @@ def test_tags( webdriver, flask_app, dbconn ):
} )
# remove some tags from the publication
_edit_article( find_search_result( "publication 1" ), {
edit_article( find_search_result( "publication 1" ), {
"tags": [ "-bbb" ]
} )
_check_tags( flask_app, {
@ -54,7 +54,7 @@ def test_tags( webdriver, flask_app, dbconn ):
} )
# remove some tags from the article
_edit_article( find_search_result( "article 1" ), {
edit_article( find_search_result( "article 1" ), {
"tags": [ "-ccc", "-bbb" ]
} )
_check_tags( flask_app, {
@ -63,7 +63,7 @@ def test_tags( webdriver, flask_app, dbconn ):
} )
# add duplicate tags to the publication
_edit_article( find_search_result( "publication 1" ), {
edit_article( find_search_result( "publication 1" ), {
"tags": [ "+bbb", "+aaa", "+eee" ]
} )
_check_tags( flask_app, {

@ -3,6 +3,7 @@
import os
import json
import uuid
import logging
import sqlalchemy
import sqlalchemy.orm
@ -14,6 +15,7 @@ from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException
from asl_articles import search
import asl_articles.models
_webdriver = None
@ -32,32 +34,38 @@ def init_tests( webdriver, flask_app, dbconn, **kwargs ):
# initialize the database
fixtures = kwargs.pop( "fixtures", None )
if dbconn:
init_db( dbconn, fixtures )
Session = sqlalchemy.orm.sessionmaker( bind=dbconn )
session = Session()
load_fixtures( session, fixtures )
else:
assert fixtures is None
session = None
# never highlight search results unless explicitly enabled
if "no_sr_hilite" not in kwargs:
kwargs[ "no_sr_hilite" ] = 1
# load the home page
webdriver.get( webdriver.make_url( "/", **kwargs ) )
wait_for_elem( 2, "#search-form" )
if webdriver:
webdriver.get( webdriver.make_url( "/", **kwargs ) )
wait_for_elem( 2, "#search-form" )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
return session
def init_db( dbconn, fixtures_fname ):
"""Load the database with test data."""
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# create a database session
Session = sqlalchemy.orm.sessionmaker( bind=dbconn )
session = Session()
def load_fixtures( session, fname ):
"""Load fixtures into the database."""
# load the test data
if fixtures_fname:
# load the fixtures
if fname:
dname = os.path.join( os.path.split(__file__)[0], "fixtures/" )
fname = os.path.join( dname, fixtures_fname )
fname = os.path.join( dname, fname )
data = json.load( open( fname, "r" ) )
else:
data = {}
# load the test data into the database
# save the fixture data in the database
table_names = [ "publisher", "publication", "article" ]
table_names.extend( [ "author", "article_author" ] )
table_names.extend( [ "publisher_image", "publication_image", "article_image" ] )
@ -69,7 +77,8 @@ def init_db( dbconn, fixtures_fname ):
session.bulk_insert_mappings( model, data[table_name] )
session.commit()
return session
# rebuild the search index
search.init_search( session, logging.getLogger("search") )
# ---------------------------------------------------------------------
@ -105,16 +114,17 @@ def do_search( query ):
def get_result_names( results ):
"""Get the names from a list of search results."""
return [
find_child( ".name", r ).text
find_child( ".name span", r ).text
for r in results
]
def find_search_result( name ):
def find_search_result( name, results=None ):
"""Find a search result."""
elems = find_children( "#search-results .search-result" )
elems = [ e for e in elems if find_child( ".name span", e ).text == name ]
assert len(elems) == 1
return elems[0]
if not results:
results = find_children( "#search-results .search-result" )
results = [ r for r in results if find_child( ".name span", r ).text == name ]
assert len(results) == 1
return results[0]
# ---------------------------------------------------------------------

@ -63,21 +63,31 @@ def make_ok_response( extras=None, updated=None, warnings=None ):
# ---------------------------------------------------------------------
def clean_html( val ):
def clean_html( val, allow_tags=None, safe_attrs=None ):
"""Sanitize HTML using a whitelist."""
# check if we need to do anything
if val is None:
return None
val = val.strip()
if not val:
return val
# strip the HTML
args = {}
if _html_whitelists["tags"]:
args[ "allow_tags" ] = _html_whitelists["tags"]
if allow_tags is None:
allow_tags = _html_whitelists.get( "tags" )
elif allow_tags == []:
allow_tags = [ "" ] # nb: this is how we remove everything :-/
if allow_tags:
args[ "allow_tags" ] = allow_tags
args[ "remove_unknown_tags" ] = None
if _html_whitelists["attrs"]:
args[ "safe_attrs" ] = _html_whitelists["attrs"]
if safe_attrs is None:
safe_attrs = _html_whitelists.get( "attrs" )
elif safe_attrs == []:
safe_attrs = [ "" ] # nb: this is how we remove everything :-/
if safe_attrs:
args[ "safe_attrs" ] = safe_attrs
cleaner = lxml.html.clean.Cleaner( **args )
buf = cleaner.clean_html( val )
@ -114,18 +124,15 @@ def load_html_whitelists( app ):
def encode_tags( tags ):
"""Encode tags prior to storing them in the database."""
# FUDGE! We store tags as a single string in the database, using ; as a separator, which means
# that we can't have a semicolon in a tag itself :-/, so we replace them with a comma.
if not tags:
return None
tags = [ t.replace( ";", "," ) for t in tags ]
return ";".join( t.lower() for t in tags )
return "\n".join( t.lower() for t in tags )
def decode_tags( tags ):
"""Decode tags after loading them from the database."""
if not tags:
return None
return tags.split( ";" )
return tags.split( "\n" )
# ---------------------------------------------------------------------

@ -33,6 +33,7 @@ export default class App extends React.Component
// initialize
this.args = queryString.parse( window.location.search ) ;
this._storeMsgs = this.isTestMode() && this.args.store_msgs ;
this._disableSearchResultHighlighting = this.isTestMode() && this.args.no_sr_hilite ;
this._fakeUploads = this.isTestMode() && this.args.fake_uploads ;
// figure out the base URL of the Flask backend server
@ -63,7 +64,7 @@ export default class App extends React.Component
onClick={ (e) => { e.preventDefault() ; ArticleSearchResult.onNewArticle( this._onNewArticle.bind(this) ) ; } }
>New article</a>]
</div>
<SearchForm onSearch={this.onSearch.bind(this)} />
<SearchForm onSearch={this.onSearch.bind(this)} ref="searchForm" />
<SearchResults seqNo={this.state.searchSeqNo} searchResults={this.state.searchResults} />
</div> ) ;
}
@ -107,11 +108,19 @@ export default class App extends React.Component
onSearch( query ) {
// run the search
query = query.trim() ;
if ( query.length === 0 ) {
this.focusQueryString() ;
this.showErrorMsg( "Please enter something to search for." )
return ;
}
axios.post( this.makeFlaskUrl( "/search" ), {
query: query
query: query,
no_hilite: this._disableSearchResultHighlighting,
} )
.then( resp => {
this.setState( { searchResults: resp.data, searchSeqNo: this.state.searchSeqNo+1 } ) ;
this.focusQueryString() ;
} )
.catch( err => {
this.showErrorToast( <div> The search query failed: <div className="monospace"> {err.toString()} </div> </div> ) ;
@ -154,6 +163,7 @@ export default class App extends React.Component
closeModalForm() {
this.setState( { modalForm: null } ) ;
setTimeout( () => { this.focusQueryString() ; }, 100 ) ;
}
showInfoToast( msg ) { this._doShowToast( "info", msg, 5*1000 ) ; }
@ -268,6 +278,8 @@ export default class App extends React.Component
this.setState( { startupTasks: this.state.startupTasks } ) ;
}
focusQueryString() { this.refs.searchForm.focusQueryString() ; }
isTestMode() { return process.env.REACT_APP_TEST_MODE ; }
isFakeUploads() { return this._fakeUploads ; }
setTestAttribute( obj, attrName, attrVal ) {

@ -1,7 +1,7 @@
import React from "react" ;
import { ArticleSearchResult2 } from "./ArticleSearchResult2.js" ;
import { gAppRef } from "./index.js" ;
import { makeScenarioDisplayName, applyUpdatedVals, makeOptionalLink, makeCommaList } from "./utils.js" ;
import { makeScenarioDisplayName, applyUpdatedVals, removeSpecialFields, makeOptionalLink, makeCommaList } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -11,17 +11,53 @@ export class ArticleSearchResult extends React.Component
{
render() {
const display_title = this.props.data[ "article_title!" ] || this.props.data.article_title ;
const display_subtitle = this.props.data[ "article_subtitle!" ] || this.props.data.article_subtitle ;
const display_snippet = this.props.data[ "article_snippet!" ] || this.props.data.article_snippet ;
const pub = gAppRef.caches.publications[ this.props.data.pub_id ] ;
const image_url = gAppRef.makeFlaskImageUrl( "article", this.props.data.article_image_id, true ) ;
const authors = makeCommaList( this.props.data.article_authors,
(a) => <span className="author" key={a}> {gAppRef.caches.authors[a].author_name} </span>
) ;
const scenarios = makeCommaList( this.props.data.article_scenarios,
(s) => <span className="scenario" key={s}> { makeScenarioDisplayName( gAppRef.caches.scenarios[s] ) } </span>
) ;
let authors ;
if ( this.props.data[ "authors!" ] ) {
// the backend has provided us with a list of author names (possibly highlighted) - use them directly
authors = makeCommaList( this.props.data["authors!"],
(a) => <span className="author" key={a} dangerouslySetInnerHTML={{__html: a}} />
) ;
} else {
// we only have a list of author ID's (the normal case) - figure out what the corresponding names are
authors = makeCommaList( this.props.data.article_authors,
(a) => <span className="author" key={a} dangerouslySetInnerHTML={{__html: gAppRef.caches.authors[a].author_name}} />
) ;
}
let scenarios ;
if ( this.props.data[ "scenarios!" ] ) {
// the backend has provided us with a list of scenarios (possibly highlighted) - use them directly
let scenarios2 = [];
this.props.data["scenarios!"].forEach( s => scenarios2.push( makeScenarioDisplayName(s) ) ) ;
scenarios = makeCommaList( scenarios2,
(s) => <span className="scenario" key={s} dangerouslySetInnerHTML={{__html: s}} />
) ;
} else {
// we only have a list of scenario ID's (the normal case) - figure out what the corresponding names are
scenarios = makeCommaList( this.props.data.article_scenarios,
(s) => <span className="scenario" key={s} dangerouslySetInnerHTML={{__html: makeScenarioDisplayName(gAppRef.caches.scenarios[s])}} />
) ;
}
let tags = [] ;
if ( this.props.data.article_tags )
this.props.data.article_tags.map( t => tags.push( <div key={t} className="tag"> {t} </div> ) ) ;
if ( this.props.data[ "tags!" ] ) {
// the backend has provided us with a list of tags (possibly highlighted) - use them directly
// NOTE: We don't normally show HTML in tags, but in this case we need to, in order to be able to highlight
// matching search terms. This will have the side-effect of rendering any HTML that may be in the tag,
// but we can live with that.
this.props.data[ "tags!" ].map(
t => tags.push( <div key={t} className="tag" dangerouslySetInnerHTML={{__html: t}} /> )
) ;
} else {
if ( this.props.data.article_tags ) {
this.props.data.article_tags.map(
t => tags.push( <div key={t} className="tag"> {t} </div> )
) ;
}
}
// NOTE: The "title" field is also given the CSS class "name" so that the normal CSS will apply to it.
// Some tests also look for a generic ".name" class name when checking search results.
return ( <div className="search-result article"
@ -29,14 +65,14 @@ export class ArticleSearchResult extends React.Component
>
<div className="title name">
{ image_url && <img src={image_url} className="image" alt="Article." /> }
{ makeOptionalLink( this.props.data.article_title, this.props.data.article_url ) }
{ makeOptionalLink( display_title, this.props.data.article_url ) }
{ pub && <span className="publication"> ({pub.pub_name}) </span> }
<img src="/images/edit.png" className="edit" onClick={this.onEditArticle.bind(this)} alt="Edit this article." />
<img src="/images/delete.png" className="delete" onClick={this.onDeleteArticle.bind(this)} alt="Delete this article." />
{ this.props.data.article_subtitle && <div className="subtitle" dangerouslySetInnerHTML={{ __html: this.props.data.article_subtitle }} /> }
{ display_subtitle && <div className="subtitle" dangerouslySetInnerHTML={{ __html: display_subtitle }} /> }
{ authors.length > 0 && <div className="authors"> By {authors} </div> }
</div>
<div className="snippet" dangerouslySetInnerHTML={{__html: this.props.data.article_snippet}} />
<div className="snippet" dangerouslySetInnerHTML={{__html: display_snippet}} />
{ scenarios.length > 0 && <div className="scenarios"> Scenarios: {scenarios} </div> }
{ tags.length > 0 && <div className="tags"> Tags: {tags} </div> }
</div> ) ;
@ -78,6 +114,7 @@ export class ArticleSearchResult extends React.Component
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
removeSpecialFields( this.props.data ) ;
this.forceUpdate() ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The article was updated OK.", resp.data.warnings ) ;

@ -1,7 +1,7 @@
import React from "react" ;
import { PublicationSearchResult2 } from "./PublicationSearchResult2.js" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, pluralString, applyUpdatedVals } from "./utils.js" ;
import { makeOptionalLink, pluralString, applyUpdatedVals, removeSpecialFields } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -11,22 +11,36 @@ export class PublicationSearchResult extends React.Component
{
render() {
const display_description = this.props.data[ "pub_description!" ] || this.props.data.pub_description ;
const publ = gAppRef.caches.publishers[ this.props.data.publ_id ] ;
const image_url = gAppRef.makeFlaskImageUrl( "publication", this.props.data.pub_image_id, true ) ;
let tags = [] ;
if ( this.props.data.pub_tags )
this.props.data.pub_tags.map( t => tags.push( <div key={t} className="tag"> {t} </div> ) ) ;
if ( this.props.data[ "tags!" ] ) {
// the backend has provided us with a list of tags (possibly highlighted) - use them directly
// NOTE: We don't normally show HTML in tags, but in this case we need to, in order to be able to highlight
// matching search terms. This will have the side-effect of rendering any HTML that may be in the tag,
// but we can live with that.
this.props.data[ "tags!" ].map(
t => tags.push( <div key={t} className="tag" dangerouslySetInnerHTML={{__html: t}} /> )
) ;
} else {
if ( this.props.data.pub_tags ) {
this.props.data.pub_tags.map(
t => tags.push( <div key={t} className="tag"> {t} </div> )
) ;
}
}
return ( <div className="search-result publication"
ref = { r => gAppRef.setTestAttribute( r, "pub_id", this.props.data.pub_id ) }
>
<div className="name">
{ image_url && <img src={image_url} className="image" alt="Publication." /> }
{ makeOptionalLink( this._makeDisplayName(), this.props.data.pub_url ) }
{ makeOptionalLink( this._makeDisplayName(true), this.props.data.pub_url ) }
{ publ && <span className="publisher"> ({publ.publ_name}) </span> }
<img src="/images/edit.png" className="edit" onClick={this.onEditPublication.bind(this)} alt="Edit this publication." />
<img src="/images/delete.png" className="delete" onClick={this.onDeletePublication.bind(this)} alt="Delete this publication." />
</div>
<div className="description" dangerouslySetInnerHTML={{__html: this.props.data.pub_description}} />
<div className="description" dangerouslySetInnerHTML={{__html: display_description}} />
{ tags.length > 0 && <div className="tags"> <label>Tags:</label> {tags} </div> }
</div> ) ;
}
@ -65,6 +79,7 @@ export class PublicationSearchResult extends React.Component
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
removeSpecialFields( this.props.data ) ;
this.forceUpdate() ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The publication was updated OK.", resp.data.warnings ) ;
@ -95,7 +110,7 @@ export class PublicationSearchResult extends React.Component
}
const content = ( <div>
Delete this publication?
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML = {{ __html: this._makeDisplayName() }} />
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML = {{ __html: this._makeDisplayName(false) }} />
{warning}
</div> ) ;
gAppRef.ask( content, "ask", {
@ -133,11 +148,16 @@ export class PublicationSearchResult extends React.Component
} ) ;
}
_makeDisplayName() {
_makeDisplayName( allowAlternateContent ) {
let pub_name = null ;
if ( allowAlternateContent && this.props.data["pub_name!"] )
pub_name = this.props.data[ "pub_name!" ] ;
if ( ! pub_name )
pub_name = this.props.data.pub_name ;
if ( this.props.data.pub_edition )
return this.props.data.pub_name + " (" + this.props.data.pub_edition + ")" ;
return pub_name + " (" + this.props.data.pub_edition + ")" ;
else
return this.props.data.pub_name ;
return pub_name ;
}
}

@ -1,7 +1,7 @@
import React from "react" ;
import { PublisherSearchResult2 } from "./PublisherSearchResult2.js"
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, pluralString, applyUpdatedVals } from "./utils.js" ;
import { makeOptionalLink, pluralString, applyUpdatedVals, removeSpecialFields } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -11,17 +11,19 @@ export class PublisherSearchResult extends React.Component
{
render() {
const display_name = this.props.data[ "publ_name!" ] || this.props.data.publ_name ;
const display_description = this.props.data[ "publ_description!" ] || this.props.data.publ_description ;
const image_url = gAppRef.makeFlaskImageUrl( "publisher", this.props.data.publ_image_id, true ) ;
return ( <div className="search-result publisher"
ref = { r => gAppRef.setTestAttribute( r, "publ_id", this.props.data.publ_id ) }
>
<div className="name">
{ image_url && <img src={image_url} className="image" alt="Publisher." /> }
{ makeOptionalLink( this.props.data.publ_name, this.props.data.publ_url ) }
{ makeOptionalLink( display_name, this.props.data.publ_url ) }
<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 className="description" dangerouslySetInnerHTML={{__html: display_description}} />
</div> ) ;
}
@ -57,6 +59,7 @@ export class PublisherSearchResult extends React.Component
gAppRef.caches.publishers = resp.data.publishers ;
// update the UI with the new details
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
removeSpecialFields( this.props.data ) ;
this.forceUpdate() ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The publisher was updated OK.", resp.data.warnings ) ;

@ -20,6 +20,8 @@ export default class SearchForm extends React.Component
<input type="text" className="query"
value = {this.state.queryString}
onChange = { e => this.setState( { queryString: e.target.value } ) }
ref = "queryString"
autoFocus
/>
<button type="submit"> Go </button>
</form>
@ -31,4 +33,6 @@ export default class SearchForm extends React.Component
this.props.onSearch( this.state.queryString ) ;
}
focusQueryString() { this.refs.queryString.focus() ; }
}

@ -25,3 +25,5 @@
.search-result .tags { margin-top: 0.25em ; font-size: 80% ; font-style: italic ; color: #666 ; }
.search-result .tags .tag { display: inline ; margin-right: 0.25em ; padding: 0 2px ; border: 1px solid #ccc ; background: #f0f0f0 ; }
.search-result .hilite { padding: 0 2px ; background: #f0e0b0 ; }

@ -12,7 +12,9 @@ export class SearchResults extends React.Component
render() {
let results ;
if ( ! this.props.searchResults || this.props.searchResults.length === 0 )
if ( this.props.searchResults && this.props.searchResults.error !== undefined )
results = "ERROR: " + this.props.searchResults.error ;
else if ( ! this.props.searchResults || this.props.searchResults.length === 0 )
results = (this.props.seqNo === 0) ? null : <div className="no-results"> No results. </div> ;
else {
results = [] ;

@ -33,17 +33,40 @@ export function applyUpdatedVals( vals, newVals, updated, refs ) {
vals[ key ] = updated[ key ] ;
}
export function removeSpecialFields( vals ) {
// NOTE: This removes special fields sent to us by the backend containing content that has search terms highlighted.
// We only really need to remove author names for articles, since the backend sends us these (possibly highlighted)
// as well as the ID's, but they could be incorrect after the user has edited an article. However, for consistency,
// we remove all these special fields for everything.
let keysToDelete = [] ;
for ( let key in vals ) {
if ( key[ key.length-1 ] === "!" )
keysToDelete.push( key ) ;
}
keysToDelete.forEach( k => delete vals[k] ) ;
}
// --------------------------------------------------------------------
// NOTE: The format of a scenario display name is "SCENARIO NAME [SCENARIO ID]".
export function makeScenarioDisplayName( scenario ) {
if ( scenario.scenario_name && scenario.scenario_display_id )
return scenario.scenario_name + " [" + scenario.scenario_display_id + "]" ;
else if ( scenario.scenario_name )
return scenario.scenario_name ;
else if ( scenario.scenario_display_id )
return scenario.scenario_display_id ;
let scenario_display_id, scenario_name
if ( Array.isArray( scenario ) ) {
// we've been given a scenario ID/name
scenario_display_id = scenario[0] ;
scenario_name = scenario[1] ;
} else {
// we've been given a scenario object (dict)
scenario_display_id = scenario.scenario_display_id ;
scenario_name = scenario.scenario_name ;
}
if ( scenario_name && scenario_display_id )
return scenario_name + " [" + scenario_display_id + "]" ;
else if ( scenario_name )
return scenario_name ;
else if ( scenario_display_id )
return scenario_display_id ;
else
return "???" ;
}
@ -74,10 +97,12 @@ export function makeOptionalLink( caption, url ) {
export function makeCommaList( vals, extract ) {
let result = [] ;
for ( let i=0 ; i < vals.length ; ++i ) {
result.push( extract( vals[i] ) ) ;
if ( i < vals.length-1 )
result.push( ", " ) ;
if ( vals ) {
for ( let i=0 ; i < vals.length ; ++i ) {
result.push( extract( vals[i] ) ) ;
if ( i < vals.length-1 )
result.push( ", " ) ;
}
}
return result ;
}

Loading…
Cancel
Save