Added support for article scenarios.

master
Pacman Ghost 4 years ago
parent f60986d478
commit 782bebadca
  1. 5
      .pylintrc
  2. 45
      alembic/versions/e77d4e8d37f3_added_the_scenario_tables.py
  3. 3
      asl_articles/__init__.py
  4. 50
      asl_articles/articles.py
  5. 49
      asl_articles/models.py
  6. 28
      asl_articles/scenarios.py
  7. 10
      asl_articles/tests/fixtures/article-scenarios.json
  8. 7
      asl_articles/tests/fixtures/roar-scenarios.json
  9. 4
      asl_articles/tests/test_articles.py
  10. 4
      asl_articles/tests/test_authors.py
  11. 131
      asl_articles/tests/test_import_roar_scenarios.py
  12. 102
      asl_articles/tests/test_scenarios.py
  13. 1
      asl_articles/tests/utils.py
  14. 173
      tools/import_roar_scenarios.py
  15. 42
      web/src/App.js
  16. 43
      web/src/ArticleSearchResult.js
  17. 30
      web/src/utils.js

@ -144,7 +144,8 @@ disable=print-statement,
wrong-import-position,
global-statement,
bad-continuation,
too-few-public-methods
too-few-public-methods,
no-else-return
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
@ -511,7 +512,7 @@ valid-metaclass-classmethod-first-arg=cls
[DESIGN]
# Maximum number of arguments for function / method.
max-args=5
max-args=8
# Maximum number of attributes for a class (see R0902).
max-attributes=7

@ -0,0 +1,45 @@
"""Added the 'scenario' tables.
Revision ID: e77d4e8d37f3
Revises: 23e928dda837
Create Date: 2019-12-20 09:46:35.304849
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e77d4e8d37f3'
down_revision = '23e928dda837'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('scenario',
sa.Column('scenario_id', sa.Integer(), nullable=False),
sa.Column('scenario_roar_id', sa.String(length=50), nullable=True),
sa.Column('scenario_display_id', sa.String(length=50), nullable=True),
sa.Column('scenario_name', sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint('scenario_id'),
sa.UniqueConstraint('scenario_roar_id', 'scenario_display_id', 'scenario_name', name='unq_id_name')
)
op.create_table('article_scenario',
sa.Column('article_scenario_id', sa.Integer(), nullable=False),
sa.Column('seq_no', sa.Integer(), nullable=False),
sa.Column('article_id', sa.Integer(), nullable=False),
sa.Column('scenario_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['article_id'], ['article.article_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['scenario_id'], ['scenario.scenario_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('article_scenario_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('article_scenario')
op.drop_table('scenario')
# ### end Alembic commands ###

@ -77,8 +77,9 @@ import asl_articles.publishers #pylint: disable=cyclic-import
import asl_articles.publications #pylint: disable=cyclic-import
import asl_articles.articles #pylint: disable=cyclic-import
import asl_articles.authors #pylint: disable=cyclic-import
import asl_articles.tags #pylint: disable=cyclic-import
import asl_articles.scenarios #pylint: disable=cyclic-import
import asl_articles.images #pylint: disable=cyclic-import
import asl_articles.tags #pylint: disable=cyclic-import
import asl_articles.utils #pylint: disable=cyclic-import
# initialize

@ -7,8 +7,9 @@ import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Article, Author, ArticleAuthor, ArticleImage
from asl_articles.models import Article, Author, ArticleAuthor, Scenario, ArticleScenario, ArticleImage
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.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
@ -34,6 +35,9 @@ def get_article_vals( article ):
authors = sorted( article.article_authors,
key = lambda a: a.seq_no
)
scenarios = sorted( article.article_scenarios,
key = lambda a: a.seq_no
)
return {
"article_id": article.article_id,
"article_title": article.article_title,
@ -41,6 +45,7 @@ def get_article_vals( article ):
"article_authors": [ a.author_id for a in authors ],
"article_snippet": article.article_snippet,
"article_url": article.article_url,
"article_scenarios": [ s.scenario_id for s in scenarios ],
"article_tags": decode_tags( article.article_tags ),
"pub_id": article.pub_id,
}
@ -66,6 +71,7 @@ def create_article():
db.session.flush()
new_article_id = article.article_id
_save_authors( article, updated )
_save_scenarios( article, updated )
_save_image( article )
db.session.commit()
_logger.debug( "- New ID: %d", new_article_id )
@ -74,6 +80,7 @@ def create_article():
extras = { "article_id": new_article_id }
if request.args.get( "list" ):
extras[ "authors" ] = do_get_authors()
extras[ "scenarios" ] = do_get_scenarios()
extras[ "tags" ] = do_get_tags()
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
@ -95,7 +102,8 @@ def _save_authors( article, updated_fields ):
author_id = author
else:
# this is a new author - create it
assert isinstance( author, str )
if not isinstance( author, str ):
raise RuntimeError( "Expected an author name: {}".format( author ) )
author = Author( author_name=author )
db.session.add( author )
db.session.flush()
@ -112,6 +120,42 @@ def _save_authors( article, updated_fields ):
# yup - let the caller know about them
updated_fields[ "article_authors"] = author_ids
def _save_scenarios( article, updated_fields ):
"""Save the article's scenarios."""
# delete the existing article-scenario rows
db.session.query( ArticleScenario ) \
.filter( ArticleScenario.article_id == article.article_id ) \
.delete()
# add the article-scenario rows
scenarios = request.json.get( "article_scenarios", [] )
scenario_ids = []
new_scenarios = False
for seq_no,scenario in enumerate( scenarios ):
if isinstance( scenario, int ):
# this is an existing scenario
scenario_id = scenario
else:
# this is a new scenario - create it
if not isinstance( scenario, list ):
raise RuntimeError( "Expected a scenario ID and name: {}".format( scenario ) )
new_scenario = Scenario( scenario_display_id=scenario[0], scenario_name=scenario[1] )
db.session.add( new_scenario )
db.session.flush()
scenario_id = new_scenario.scenario_id
new_scenarios = True
_logger.debug( "Created new scenario \"%s [%s]\": id=%d", scenario[1], scenario[0], scenario_id )
db.session.add(
ArticleScenario( seq_no=seq_no, article_id=article.article_id, scenario_id=scenario_id )
)
scenario_ids.append( scenario_id )
# check if we created any new scenarios
if new_scenarios:
# yup - let the caller know about them
updated_fields[ "article_scenarios"] = scenario_ids
def _save_image( article ):
"""Save the article's image."""
@ -155,6 +199,7 @@ def update_article():
abort( 404 )
apply_attrs( article, vals )
_save_authors( article, updated )
_save_scenarios( article, updated )
_save_image( article )
vals[ "time_updated" ] = datetime.datetime.now()
db.session.commit()
@ -163,6 +208,7 @@ def update_article():
extras = {}
if request.args.get( "list" ):
extras[ "authors" ] = do_get_authors()
extras[ "scenarios" ] = do_get_scenarios()
extras[ "tags" ] = do_get_tags()
return make_ok_response( updated=updated, extras=extras, warnings=warnings )

@ -2,6 +2,8 @@
# NOTE: Don't forget to keep the list of tables in init_db() in sync with the models defined here.
from sqlalchemy.schema import UniqueConstraint
from asl_articles import db
# ---------------------------------------------------------------------
@ -13,7 +15,7 @@ class Publisher( db.Model ):
publ_name = db.Column( db.String(100), nullable=False )
publ_description = db.Column( db.String(1000) )
publ_url = db.Column( db.String(500) )
# NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres
# NOTE: time_created should be non-nullable, but getting this to work on both SQLite and Postgres
# is more trouble than it's worth :-/
time_created = db.Column( db.TIMESTAMP(timezone=True) )
time_updated = db.Column( db.TIMESTAMP(timezone=True) )
@ -37,7 +39,7 @@ class Publication( db.Model ):
publ_id = db.Column( db.Integer,
db.ForeignKey( Publisher.__table__.c.publ_id, ondelete="CASCADE" )
)
# NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres
# NOTE: time_created should be non-nullable, but getting this to work on both SQLite and Postgres
# is more trouble than it's worth :-/
time_created = db.Column( db.TIMESTAMP(timezone=True) )
time_updated = db.Column( db.TIMESTAMP(timezone=True) )
@ -61,12 +63,13 @@ class Article( db.Model ):
pub_id = db.Column( db.Integer,
db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" )
)
# NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres
# NOTE: time_created should be non-nullable, but getting this to work on both SQLite and Postgres
# is more trouble than it's worth :-/
time_created = db.Column( db.TIMESTAMP(timezone=True) )
time_updated = db.Column( db.TIMESTAMP(timezone=True) )
#
article_authors = db.relationship( "ArticleAuthor", backref="parent_article", passive_deletes=True )
article_scenarios = db.relationship( "ArticleScenario", backref="parent_article", passive_deletes=True )
def __repr__( self ):
return "<Article:{}|{}>".format( self.article_id, self.article_title )
@ -151,6 +154,46 @@ class ArticleImage( db.Model ):
# ---------------------------------------------------------------------
class Scenario( db.Model ):
"""Define the Scenario model."""
scenario_id = db.Column( db.Integer, primary_key=True )
scenario_roar_id = db.Column( db.String(50) )
scenario_display_id = db.Column( db.String(50) )
scenario_name = db.Column( db.String(200), nullable=False )
#
article_scenarios = db.relationship( "ArticleScenario", backref="parent_scenario", passive_deletes=True )
#
# We would like to make rows unique by display ID and name, but there are some scenarios that have
# duplicate values for these e.g. "The T-Patchers [180]" was released in multiple publications.
__table_args__ = (
UniqueConstraint( "scenario_roar_id", "scenario_display_id", "scenario_name", name="unq_id_name" ),
)
def __repr__( self ):
return "<Scenario:{}|{}:{}>".format( self.scenario_id, self.scenario_display_id, self.scenario_name )
class ArticleScenario( db.Model ):
"""Define the link between Article's and Scenario's."""
article_scenario_id = db.Column( db.Integer, primary_key=True )
seq_no = db.Column( db.Integer, nullable=False )
article_id = db.Column( db.Integer,
db.ForeignKey( Article.__table__.c.article_id, ondelete="CASCADE" ),
nullable = False
)
scenario_id = db.Column( db.Integer,
db.ForeignKey( Scenario.__table__.c.scenario_id, ondelete="CASCADE" ),
nullable = False
)
def __repr__( self ):
return "<ArticleScenario:{}|{}:{},{}>".format( self.article_scenario_id,
self.seq_no, self.article_id, self.scenario_id
)
# ---------------------------------------------------------------------
def get_model_from_table_name( table_name ):
"""Return the model class for the specified table."""
pos = table_name.find( "_" )

@ -0,0 +1,28 @@
""" Handle scenario requests. """
from flask import jsonify
from asl_articles import app
from asl_articles.models import Scenario
# ---------------------------------------------------------------------
@app.route( "/scenarios" )
def get_scenarios():
"""Get all scenarios."""
return jsonify( do_get_scenarios() )
def do_get_scenarios():
"""Get all scenarios."""
return {
s.scenario_id: _get_scenario_vals( s )
for s in Scenario.query #pylint: disable=not-an-iterable
}
def _get_scenario_vals( scenario ):
"""Extract public fields from a scenario record."""
return {
"scenario_id": scenario.scenario_id,
"scenario_display_id": scenario.scenario_display_id,
"scenario_name": scenario.scenario_name
}

@ -0,0 +1,10 @@
{
"scenario": [
{ "scenario_display_id": "TEST 1", "scenario_name": "Test Scenario 1" },
{ "scenario_display_id": "TEST 2", "scenario_name": "Test Scenario 2" },
{ "scenario_display_id": "TEST 3", "scenario_name": "Test Scenario 3" },
{ "scenario_name": "No scenario ID" }
]
}

@ -0,0 +1,7 @@
{
"1": { "scenario_id": "1", "name": "Fighting Withdrawal" },
"99": { "scenario_id": "FOO BAR", "name": "test scenario" },
"129": { "scenario_id": "E", "name": "Hill 621" }
}

@ -376,8 +376,8 @@ def _edit_article( result, vals, toast_type="info", expected_error=None ):
elif key == "publication":
select = ReactSelect( find_child( ".publication .react-select", dlg ) )
select.select_by_name( val )
elif key == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
elif key in ["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" )

@ -10,8 +10,8 @@ from asl_articles.tests.test_articles import _create_article, _edit_article
# ---------------------------------------------------------------------
def test_authors( webdriver, flask_app, dbconn ):
"""Test author operations."""
def test_article_authors( webdriver, flask_app, dbconn ):
"""Test article author operations."""
# initialize
init_tests( webdriver, flask_app, dbconn )

@ -0,0 +1,131 @@
""" Test importing ROAR scenarios into our database. """
import sys
import os
import json
from asl_articles.models import Scenario
from asl_articles.tests.utils import init_db
sys.path.append( os.path.join( os.path.split(__file__)[0], "../../tools/" ) )
from import_roar_scenarios import import_roar_scenarios
# ---------------------------------------------------------------------
def test_import_roar_scenarios( dbconn ):
"""Test importing ROAR scenarios."""
# initialize
session = init_db( dbconn, None )
roar_fname = os.path.join( os.path.split(__file__)[0], "fixtures/roar-scenarios.json" )
roar_data = json.load( open( roar_fname, "r" ) )
# do the first import
_do_import( dbconn, session, roar_fname,
{ "nInserts": 3, "nUpdates": 0, "nDupes": 0 }, [
[ "1", "1", "Fighting Withdrawal" ],
[ "99", "FOO BAR", "test scenario" ],
[ "129", "E", "Hill 621" ]
] )
# repeat the import (nothing should happen)
_do_import( dbconn, session, roar_fname,
{ "nInserts": 0, "nUpdates": 0, "nDupes": 3 }, [
[ "1", "1", "Fighting Withdrawal" ],
[ "99", "FOO BAR", "test scenario" ],
[ "129", "E", "Hill 621" ]
] )
# simulate a scenario's details being updated in ROAR (we should update accordingly)
roar_data["1"]["scenario_id"] += "u"
roar_data["1"]["name"] += " (updated)"
_do_import( dbconn, session, roar_data,
{ "nInserts": 0, "nUpdates": 1, "nDupes": 2 }, [
[ "1", "1u", "Fighting Withdrawal (updated)" ],
[ "99", "FOO BAR", "test scenario" ],
[ "129", "E", "Hill 621" ],
],
[ "Data mismatch for ROAR ID 1:" ]
)
# add a new ROAR scenario (we should import the new scenario)
roar_data[ "42" ] = { "scenario_id": "NEW", "name": "new scenario" }
_do_import( dbconn, session, roar_data,
{ "nInserts": 1, "nUpdates": 0, "nDupes": 3 }, [
[ "1", "1u", "Fighting Withdrawal (updated)" ],
[ "99", "FOO BAR", "test scenario" ],
[ "129", "E", "Hill 621" ],
[ "42", "NEW", "new scenario" ]
] )
# delete all ROAR scenarios (nothing should happen)
_do_import( dbconn, session, {},
{ "nInserts": 0, "nUpdates": 0, "nDupes": 0 }, [
[ "1", "1u", "Fighting Withdrawal (updated)" ],
[ "99", "FOO BAR", "test scenario" ],
[ "129", "E", "Hill 621" ],
[ "42", "NEW", "new scenario" ]
] )
# ---------------------------------------------------------------------
def test_scenario_matching( dbconn ):
"""Test matching ROAR scenarios with scenarios in the database."""
# initialize
session = init_db( dbconn, None )
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
session.add( Scenario( scenario_display_id="1", scenario_name="Fighting Withdrawal" ) )
session.commit()
# do an import
# NOTE: The scenario we created above will be matched to the corresponding ROAR scenario,
# since the ROAR ID is not considered when matching scenarios.
_do_import( dbconn, session, roar_fname,
{ "nInserts": 2, "nUpdates": 0, "nDupes": 1 }, [
[ None, "1", "Fighting Withdrawal" ],
[ "99", "FOO BAR", "test scenario" ],
[ "129", "E", "Hill 621" ]
] )
# put a scenario in the database that only has a name
session.query( Scenario ).delete()
session.add( Scenario( scenario_name="Hill 621" ) )
session.commit()
# do an import
# NOTE: The scenario we created above will be matched to the corresponding ROAR scenario,
# and also updated with the scenario ID (as reported by ROAR).
_do_import( dbconn, session, roar_fname,
{ "nInserts": 2, "nUpdates": 1, "nDupes": 0 }, [
[ "1", "1", "Fighting Withdrawal" ],
[ "99", "FOO BAR", "test scenario" ],
[ None, "E", "Hill 621" ]
],
[ "Data mismatch for ROAR ID 129:" ]
)
# ---------------------------------------------------------------------
def _do_import( dbconn, session, roar_data, expected_stats, expected_scenarios, expected_warnings=None ):
"""Import the ROAR scenarios and check the results."""
# import the ROAR scenarios
stats, warnings = import_roar_scenarios( dbconn.url, roar_data )
# check that the import went as expected
assert stats == expected_stats
if expected_warnings:
assert warnings[0][0] == expected_warnings[0]
else:
assert not warnings
# check the scenarios in the database
rows = [
[ s.scenario_roar_id, s.scenario_display_id, s.scenario_name ]
for s in session.query( Scenario ) #pylint: disable=not-an-iterable
]
sort_rows = lambda rows: sorted( rows, key=lambda r: r[2] )
assert sort_rows( rows ) == sort_rows( expected_scenarios )

@ -0,0 +1,102 @@
""" Test article scenario operations. """
import urllib.request
import json
from asl_articles.tests.utils import init_tests, find_child, 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
# ---------------------------------------------------------------------
def test_article_scenarios( webdriver, flask_app, dbconn ):
"""Test article scenario operations."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="article-scenarios.json" )
all_scenarios = set( [
"Test Scenario 1 [TEST 1]", "Test Scenario 2 [TEST 2]", "Test Scenario 3 [TEST 3]",
"No scenario ID"
] )
# create some test articles
_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" ), {
"scenarios": [ "+Test Scenario 1 [TEST 1]" ]
} )
_check_scenarios( flask_app, all_scenarios, [
[ "Test Scenario 1 [TEST 1]" ],
[]
] )
# add scenarios to article #2
_edit_article( find_search_result( "article 2" ), {
"scenarios": [ "+Test Scenario 3 [TEST 3]", "+No scenario ID" ]
} )
_check_scenarios( flask_app, all_scenarios, [
[ "Test Scenario 1 [TEST 1]" ],
[ "Test Scenario 3 [TEST 3]", "No scenario ID" ]
] )
# add/remove scenarios to 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, [
[ "Test Scenario 1 [TEST 1]" ],
[ "No scenario ID", "Test Scenario 1 [TEST 1]" ]
] )
# add an unknown scenario to article #1
_edit_article( find_search_result( "article 1" ), {
"scenarios": [ "+new scenario [NEW]" ]
} )
_check_scenarios( flask_app, all_scenarios, [
[ "Test Scenario 1 [TEST 1]", "new scenario [NEW]" ],
[ "No scenario ID", "Test Scenario 1 [TEST 1]" ]
] )
# ---------------------------------------------------------------------
def _check_scenarios( flask_app, all_scenarios, expected ):
"""Check the scenarios of the test articles."""
# update the complete list of scenarios
# NOTE: Unlike tags, scenarios remain in the database even if no-one is referencing them,
# so we need to track them over the life of the entire series of tests.
for scenarios in expected:
all_scenarios.update( scenarios )
# check the scenarios in the UI
for article_no,scenarios in enumerate( expected ):
# check the scenarios for the next article
sr = find_search_result( "article {}".format( 1+article_no ) )
find_child( ".edit", sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
select = ReactSelect( find_child( ".scenarios .react-select", dlg ) )
assert select.get_multiselect_values() == scenarios
# check that the list of available scenarios is correct
assert select.get_multiselect_choices() == \
sorted( all_scenarios.difference( scenarios ), key=lambda s: s.lower() )
# close the dialog
find_child( "button.cancel", dlg ).click()
# check the scenarios in the database
url = flask_app.url_for( "get_scenarios" )
scenarios = json.load( urllib.request.urlopen( url ) )
assert set( _make_scenario_display_name(a) for a in scenarios.values() ) == all_scenarios
def _make_scenario_display_name( scenario ):
"""Generate the display name for a scenario."""
if scenario["scenario_display_id"]:
return "{} [{}]".format( scenario["scenario_name"], scenario["scenario_display_id"] )
else:
return scenario["scenario_name"]

@ -61,6 +61,7 @@ def init_db( dbconn, fixtures_fname ):
table_names = [ "publisher", "publication", "article" ]
table_names.extend( [ "author", "article_author" ] )
table_names.extend( [ "publisher_image", "publication_image", "article_image" ] )
table_names.extend( [ "scenario", "article_scenario" ] )
for table_name in table_names:
model = asl_articles.models.get_model_from_table_name( table_name )
session.query( model ).delete()

@ -0,0 +1,173 @@
#!/usr/bin/env python3
""" Import scenarios from ROAR into our database.
Download this file somewhere:
https://vasl-templates.org/services/roar/scenario-index.json
Then pass it in to this program.
This script should be run to initialize a new database with a list of known scenarios, but we also
have to consider the possibilty that it will be run on a database that has been in use for a while,
and has new scenarios that have been added by the user i.e. we need to try to match scenarios
that are already in the database with what's in ROAR.
"""
import sys
import os
import json
import sqlalchemy
from sqlalchemy import text
# ---------------------------------------------------------------------
def main():
"""Import ROAR scenarios into our database."""
# parse the command line arguments
if len(sys.argv) != 3:
print( "Usage: {} <dbconn> <scenario-index>".format( os.path.split(__file__)[0] ) )
print( " dbconn: database connection string e.g. \"sqlite:///~/asl-articles.db\"" )
print( " scenario-index: the ROAR scenario index file." )
sys.exit( 0 )
dbconn = sys.argv[1]
roar_fname = sys.argv[2]
# load the ROAR scenario data
stats, warnings = import_roar_scenarios( dbconn, roar_fname, progress=print )
# output any warnings
for warning in warnings:
print()
print( "\n".join( warning ) )
# output stats
row_count = lambda n: "1 row" if n == 1 else "{} rows".format(n)
print()
print( "New scenarios added: {}".format( row_count( stats["nInserts"] ) ) )
print( "Scenarios updated: {}".format( row_count( stats["nUpdates"] ) ) )
print( "Duplicates ignored: {}".format( row_count( stats["nDupes"] ) ) )
# ---------------------------------------------------------------------
def import_roar_scenarios( dbconn, roar_data, progress=None ):
"""Import scenarios from ROAR into our database."""
# initialize
stats = { "nInserts": 0, "nUpdates": 0, "nDupes": 0 }
warnings = []
def log_progress( msg, *args, **kwargs ):
if progress:
progress( msg.format( *args, **kwargs ) )
# load the ROAR scenarios
if isinstance( roar_data, str ):
log_progress( "Loading scenarios: {}", roar_data )
roar_data = json.load( open( roar_data, "r" ) )
else:
assert isinstance( roar_data, dict )
log_progress( "- Last updated: {}".format( roar_data.get("_lastUpdated_","(unknown)") ) )
scenarios = { k: v for k,v in roar_data.items() if k.isdigit() }
log_progress( "- Loaded {} scenarios.".format( len(scenarios) ) )
# update the database
# NOTE: We can never delete rows from the scenario table, since Article's reference them by ID.
engine = sqlalchemy.create_engine( dbconn )
conn = engine.connect()
with conn.begin():
for roar_id,scenario in scenarios.items():
# prepare the next scenario
vals = { "roar_id": roar_id, "display_id": scenario["scenario_id"], "name": scenario["name"] }
# check if we already have the scenario
row = find_existing_scenario( conn, roar_id, scenario )
if row:
# yup - check if the details are the same
if row["scenario_display_id"] == scenario["scenario_id"] and row["scenario_name"] == scenario["name"]:
# yup - nothing to do
stats[ "nDupes" ] += 1
continue
# nope - update the row
warnings.append( [
"Data mismatch for ROAR ID {}:".format( roar_id ),
"- Old details: {}: {}".format( row["scenario_display_id"], row["scenario_name"] ),
"- Updating to: {}: {}".format( scenario["scenario_id"], scenario["name"] )
] )
conn.execute( text( "UPDATE scenario"
" SET scenario_display_id = :display_id, scenario_name = :name"
" WHERE scenario_id = :scenario_id" ),
scenario_id=row["scenario_id"], **vals
)
stats[ "nUpdates" ] += 1
else:
# nope - insert a new row
conn.execute( text( "INSERT INTO scenario"
" ( scenario_roar_id, scenario_display_id, scenario_name )"
" VALUES ( :roar_id, :display_id, :name )" ),
**vals
)
stats[ "nInserts" ] += 1
conn.close()
return stats, warnings
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def find_existing_scenario( conn, roar_id, scenario ):
"""Check if the scenario is already in the database.
Identifying existing scenarios in the database is complicated by the fact that the user can add scenarios
from the webapp, and the only required field is the scenario name e.g. we have to handle things like:
- this script is run to load an empty database
- the user manually adds a new scenario, but only provides its name
- the scenario is added to ROAR (with a ROAR ID and scenario ID)
- this script is run again.
In this case, we want to match the two scenarios and update the existing row, not create a new one.
"""
def find_match( sql, **args ):
rows = list( conn.execute( text(sql), **args ) )
if rows:
assert len(rows) == 1
return rows[0]
else:
return None
# try to match by ROAR ID
row = find_match( "SELECT * FROM scenario WHERE scenario_roar_id = :roar_id",
roar_id = roar_id
)
if row:
return row
# try to match by scenario display ID and name
row = find_match( "SELECT * FROM scenario"
" WHERE (scenario_roar_id IS NULL OR scenario_roar_id = '' )"
" AND scenario_display_id = :scenario_id AND scenario_name = :name",
scenario_id=scenario["scenario_id"], name=scenario["name"]
)
if row:
return row
# try to match by scenario name
row = find_match( "SELECT * FROM scenario"
" WHERE (scenario_roar_id IS NULL OR scenario_roar_id = '' )"
" AND ( scenario_display_id IS NULL OR scenario_display_id = '' )"
" AND scenario_name = :name",
name=scenario["name"]
)
if row:
return row
return None
# ---------------------------------------------------------------------
if __name__ == "__main__":
main()

@ -27,7 +27,7 @@ export default class App extends React.Component
searchSeqNo: 0,
modalForm: null,
askDialog: null,
startupTasks: [ "caches.publishers", "caches.publications", "caches.authors", "caches.tags" ],
startupTasks: [ "caches.publishers", "caches.publications", "caches.authors", "caches.scenarios", "caches.tags" ],
} ;
// initialize
@ -93,37 +93,15 @@ export default class App extends React.Component
// than trying to manually keep our caches in sync. It's less efficient, but it won't happen too often, there won't be
// too many entries, and the database server is local.
this.caches = {} ;
axios.get( this.makeFlaskUrl( "/publishers" ) )
.then( resp => {
this.caches.publishers = resp.data ;
this._onStartupTask( "caches.publishers" ) ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the publishers: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.makeFlaskUrl( "/publications" ) )
.then( resp => {
this.caches.publications = resp.data ;
this._onStartupTask( "caches.publications" ) ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the publications: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.makeFlaskUrl( "/authors" ) )
.then( resp => {
this.caches.authors = resp.data ;
this._onStartupTask( "caches.authors" ) ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the authors: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.makeFlaskUrl( "/tags" ) )
.then( resp => {
this.caches.tags = resp.data ;
this._onStartupTask( "caches.tags" ) ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the tags: <div className="monospace"> {err.toString()} </div> </div> ) ;
["publishers","publications","authors","scenarios","tags"].forEach( (type) => {
axios.get( this.makeFlaskUrl( "/" + type ) )
.then( resp => {
this.caches[ type ] = resp.data ;
this._onStartupTask( "caches." + type ) ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the {type}: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} ) ;
}

@ -4,7 +4,7 @@ import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { ImageFileUploader } from "./FileUploader.js" ;
import { makeOptionalLink, unloadCreatableSelect, applyUpdatedVals } from "./utils.js" ;
import { makeScenarioDisplayName, parseScenarioDisplayName, makeOptionalLink, unloadCreatableSelect, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -40,6 +40,7 @@ export class ArticleSearchResult extends React.Component
.then( resp => {
// update the caches
gAppRef.caches.authors = resp.data.authors ;
gAppRef.caches.scenarios = resp.data.scenarios ;
gAppRef.caches.tags = resp.data.tags ;
// unload any updated values
applyUpdatedVals( newVals, newVals, resp.data.updated, refs ) ;
@ -65,6 +66,7 @@ export class ArticleSearchResult extends React.Component
.then( resp => {
// update the caches
gAppRef.caches.authors = resp.data.authors ;
gAppRef.caches.scenarios = resp.data.scenarios ;
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
@ -124,16 +126,27 @@ export class ArticleSearchResult extends React.Component
return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ;
} ) ;
// initialize the authors
let authors = [] ;
let allAuthors = [] ;
for ( let a of Object.entries(gAppRef.caches.authors) )
authors.push( { value: a[1].author_id, label: a[1].author_name } );
authors.sort( (lhs,rhs) => { return lhs.label.localeCompare( rhs.label ) ; } ) ;
allAuthors.push( { value: a[1].author_id, label: a[1].author_name } );
allAuthors.sort( (lhs,rhs) => { return lhs.label.localeCompare( rhs.label ) ; } ) ;
let currAuthors = [] ;
if ( vals.article_authors ) {
currAuthors = vals.article_authors.map( a => {
return { value: a, label: gAppRef.caches.authors[a].author_name }
} ) ;
}
// initialize the scenarios
let allScenarios = [] ;
for ( let s of Object.entries(gAppRef.caches.scenarios) )
allScenarios.push( { value: s[1].scenario_id, label: makeScenarioDisplayName(s[1]) } ) ;
allScenarios.sort( (lhs,rhs) => { return lhs.label.localeCompare( rhs.label ) ; } ) ;
let currScenarios = [] ;
if ( vals.article_scenarios ) {
currScenarios = vals.article_scenarios.map( s => {
return { value: s, label: makeScenarioDisplayName(gAppRef.caches.scenarios[s]) }
} ) ;
}
// initialize the tags
const tags = gAppRef.makeTagLists( vals.article_tags ) ;
// prepare the form content
@ -151,7 +164,7 @@ export class ArticleSearchResult extends React.Component
<input type="text" defaultValue={vals.article_subtitle} ref={(r) => refs.article_subtitle=r} />
</div>
<div className="row authors"> <label> Authors: </label>
<CreatableSelect className="react-select" classNamePrefix="react-select" options={authors} isMulti
<CreatableSelect className="react-select" classNamePrefix="react-select" options={allAuthors} isMulti
defaultValue = {currAuthors}
ref = { (r) => refs.article_authors=r }
/>
@ -168,6 +181,12 @@ export class ArticleSearchResult extends React.Component
ref = { (r) => refs.article_tags=r }
/>
</div>
<div className="row scenarios"> <label> Scenarios: </label>
<CreatableSelect className="react-select" classNamePrefix="react-select" options={allScenarios} isMulti
defaultValue = {currScenarios}
ref = { (r) => refs.article_scenarios=r }
/>
</div>
<div className="row snippet"> <label> Snippet: </label>
<textarea defaultValue={vals.article_snippet} ref={(r) => refs.article_snippet=r} />
</div>
@ -191,9 +210,17 @@ export class ArticleSearchResult extends React.Component
else
newVals.article_authors.push( v.value ) ; // nb: integer = existing author ID
} ) ;
}
else if ( r === "article_tags" ) {
let vals= unloadCreatableSelect( refs[r] ) ;
} else if ( r === "article_scenarios" ) {
let vals = unloadCreatableSelect( refs[r] ) ;
newVals.article_scenarios = [] ;
vals.forEach( v => {
if ( v.__isNew__ )
newVals.article_scenarios.push( parseScenarioDisplayName( v.label ) ) ; // nb: array = new scenario
else
newVals.article_scenarios.push( v.value ) ; // nb: integer = existing scenario ID
} ) ;
} else if ( r === "article_tags" ) {
let vals = unloadCreatableSelect( refs[r] ) ;
newVals[ r ] = vals.map( v => v.label ) ;
} else
newVals[ r ] = refs[r].value.trim() ;

@ -31,6 +31,36 @@ export function applyUpdatedVals( vals, newVals, updated, refs ) {
// --------------------------------------------------------------------
// 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 ;
else
return "???" ;
}
export function parseScenarioDisplayName( displayName ) {
// try to locate the scenario ID
displayName = displayName.trim() ;
let scenarioId=null, scenarioName=displayName ;
if ( displayName[ displayName.length-1 ] === "]" ) {
let pos = displayName.lastIndexOf( "[" ) ;
if ( pos !== -1 ) {
// found it - separate it from the scenario name
scenarioId = displayName.substr( pos+1, displayName.length-pos-2 ).trim() ;
scenarioName = displayName.substr( 0, pos ).trim() ;
}
}
return [ scenarioId, scenarioName ] ;
}
// --------------------------------------------------------------------
export function makeOptionalLink( caption, url ) {
let link = <span dangerouslySetInnerHTML={{ __html: caption }} /> ;
if ( url )

Loading…
Cancel
Save