diff --git a/alembic/versions/85abe5bcbac0_added_the_article_table.py b/alembic/versions/85abe5bcbac0_added_the_article_table.py new file mode 100644 index 0000000..1a31f3d --- /dev/null +++ b/alembic/versions/85abe5bcbac0_added_the_article_table.py @@ -0,0 +1,39 @@ +"""Added the 'article' table. + +Revision ID: 85abe5bcbac0 +Revises: 6054f3f09626 +Create Date: 2019-12-08 03:28:43.202699 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '85abe5bcbac0' +down_revision = '6054f3f09626' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('article', + sa.Column('article_id', sa.Integer(), nullable=False), + sa.Column('article_title', sa.String(length=200), nullable=False), + sa.Column('article_subtitle', sa.String(length=200), nullable=True), + sa.Column('article_snippet', sa.String(length=5000), nullable=True), + sa.Column('article_url', sa.String(length=500), nullable=True), + sa.Column('pub_id', sa.Integer(), nullable=True), + sa.Column('time_created', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('time_updated', sa.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['pub_id'], ['publication.pub_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('article_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('article') + # ### end Alembic commands ### diff --git a/asl_articles/__init__.py b/asl_articles/__init__.py index 470a717..7d49b4a 100644 --- a/asl_articles/__init__.py +++ b/asl_articles/__init__.py @@ -75,6 +75,7 @@ import asl_articles.main #pylint: disable=cyclic-import import asl_articles.search #pylint: disable=cyclic-import import asl_articles.publishers #pylint: disable=cyclic-import import asl_articles.publications #pylint: disable=cyclic-import +import asl_articles.articles #pylint: disable=cyclic-import import asl_articles.utils #pylint: disable=cyclic-import # initialize diff --git a/asl_articles/articles.py b/asl_articles/articles.py new file mode 100644 index 0000000..9d3499f --- /dev/null +++ b/asl_articles/articles.py @@ -0,0 +1,87 @@ +""" Handle article requests. """ + +import datetime +import logging + +from flask import request, jsonify, abort + +from asl_articles import app, db +from asl_articles.models import Article +from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs + +_logger = logging.getLogger( "db" ) + +_FIELD_NAMES = [ "article_title", "article_subtitle", "article_snippet", "article_url", "pub_id" ] + +# --------------------------------------------------------------------- + +@app.route( "/article/" ) +def get_article( article_id ): + """Get an article.""" + _logger.debug( "Get article: id=%s", article_id ) + article = Article.query.get( article_id ) + if not article: + abort( 404 ) + _logger.debug( "- %s", article ) + return jsonify( get_article_vals( article ) ) + +def get_article_vals( article ): + """Extract public fields from an Article record.""" + return { + "article_id": article.article_id, + "article_title": article.article_title, + "article_subtitle": article.article_subtitle, + "article_snippet": article.article_snippet, + "article_url": article.article_url, + "pub_id": article.pub_id, + } + +# --------------------------------------------------------------------- + +@app.route( "/article/create", methods=["POST"] ) +def create_article(): + """Create an article.""" + vals = get_request_args( request.json, _FIELD_NAMES, + log = ( _logger, "Create article:" ) + ) + cleaned = clean_request_args( vals, _FIELD_NAMES, _logger ) + vals[ "time_created" ] = datetime.datetime.now() + article = Article( **vals ) + db.session.add( article ) #pylint: disable=no-member + db.session.commit() #pylint: disable=no-member + _logger.debug( "- New ID: %d", article.article_id ) + return make_ok_response( cleaned=cleaned, + extras = { "article_id": article.article_id } + ) + +# --------------------------------------------------------------------- + +@app.route( "/article/update", methods=["POST"] ) +def update_article(): + """Update an article.""" + article_id = request.json[ "article_id" ] + vals = get_request_args( request.json, _FIELD_NAMES, + log = ( _logger, "Update article: id={}".format( article_id ) ) + ) + cleaned = clean_request_args( vals, _FIELD_NAMES, _logger ) + vals[ "time_updated" ] = datetime.datetime.now() + article = Article.query.get( article_id ) + if not article: + abort( 404 ) + apply_attrs( article, vals ) + db.session.commit() #pylint: disable=no-member + return make_ok_response( cleaned=cleaned ) + +# --------------------------------------------------------------------- + +@app.route( "/article/delete/" ) +def delete_article( article_id ): + """Delete an article.""" + _logger.debug( "Delete article: id=%s", article_id ) + article = Article.query.get( article_id ) + if not article: + abort( 404 ) + _logger.debug( "- %s", article ) + db.session.delete( article ) #pylint: disable=no-member + db.session.commit() #pylint: disable=no-member + return make_ok_response( extras={} ) diff --git a/asl_articles/models.py b/asl_articles/models.py index 20d0ac3..36637cf 100644 --- a/asl_articles/models.py +++ b/asl_articles/models.py @@ -41,6 +41,30 @@ class Publication( db.Model ): # is more trouble than it's worth :-/ time_created = db.Column( db.TIMESTAMP(timezone=True), nullable=True ) time_updated = db.Column( db.TIMESTAMP(timezone=True), nullable=True ) + # + children = db.relationship( "Article", backref="parent", passive_deletes=True ) def __repr__( self ): return "".format( self.pub_id, self.pub_name ) + +# --------------------------------------------------------------------- + +class Article( db.Model ): + """Define the Article model.""" + + article_id = db.Column( db.Integer, primary_key=True ) + article_title = db.Column( db.String(200), nullable=False ) + article_subtitle = db.Column( db.String(200), nullable=True ) + article_snippet = db.Column( db.String(5000), nullable=True ) + article_url = db.Column( db.String(500), nullable=True ) + pub_id = db.Column( db.Integer, + db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" ), + nullable=True + ) + # NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres + # is more trouble than it's worth :-/ + time_created = db.Column( db.TIMESTAMP(timezone=True), nullable=True ) + time_updated = db.Column( db.TIMESTAMP(timezone=True), nullable=True ) + + def __repr__( self ): + return "".format( self.article_id, self.article_title ) diff --git a/asl_articles/publications.py b/asl_articles/publications.py index 701a54d..0a521ee 100644 --- a/asl_articles/publications.py +++ b/asl_articles/publications.py @@ -6,7 +6,7 @@ import logging from flask import request, jsonify, abort from asl_articles import app, db -from asl_articles.models import Publication +from asl_articles.models import Publication, Article from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs _logger = logging.getLogger( "db" ) @@ -24,7 +24,7 @@ def do_get_publications(): """Get all publications.""" # NOTE: The front-end maintains a cache of the publications, so as a convenience, # we return the current list as part of the response to a create/update/delete operation. - results = list( Publication.query ) + results = Publication.query.all() return { r.pub_id: get_publication_vals(r) for r in results } # --------------------------------------------------------------------- @@ -36,8 +36,12 @@ def get_publication( pub_id ): pub = Publication.query.get( pub_id ) if not pub: abort( 404 ) - _logger.debug( "- %s", pub ) - return jsonify( get_publication_vals( pub ) ) + vals = get_publication_vals( pub ) + # include the number of associated articles + query = Article.query.filter_by( pub_id = pub_id ) + vals[ "nArticles" ] = query.count() + _logger.debug( "- %s ; #articles=%d", pub, vals["nArticles"] ) + return jsonify( vals ) def get_publication_vals( pub ): """Extract public fields from a Publication record.""" @@ -95,14 +99,24 @@ def update_publication(): @app.route( "/publication/delete/" ) def delete_publication( pub_id ): """Delete a publication.""" + _logger.debug( "Delete publication: id=%s", pub_id ) + + # get the publication pub = Publication.query.get( pub_id ) if not pub: abort( 404 ) _logger.debug( "- %s", pub ) + + # figure out which associated articles will be deleted + query = db.session.query( Article.article_id ).filter_by( pub_id = pub_id ) #pylint: disable=no-member + deleted_articles = [ r[0] for r in query ] + + # delete the publication db.session.delete( pub ) #pylint: disable=no-member db.session.commit() #pylint: disable=no-member - extras = {} + + extras = { "deleteArticles": deleted_articles } if request.args.get( "list" ): extras[ "publications" ] = do_get_publications() return make_ok_response( extras=extras ) diff --git a/asl_articles/publishers.py b/asl_articles/publishers.py index 71e50c0..878f256 100644 --- a/asl_articles/publishers.py +++ b/asl_articles/publishers.py @@ -6,7 +6,7 @@ import logging from flask import request, jsonify, abort from asl_articles import app, db -from asl_articles.models import Publisher, Publication +from asl_articles.models import Publisher, Publication, Article from asl_articles.publications import do_get_publications from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs @@ -25,7 +25,7 @@ def _do_get_publishers(): """Get all publishers.""" # NOTE: The front-end maintains a cache of the publishers, so as a convenience, # we return the current list as part of the response to a create/update/delete operation. - results = list( Publisher.query ) + results = Publisher.query.all() return { r.publ_id: get_publisher_vals(r) for r in results } # --------------------------------------------------------------------- @@ -40,9 +40,15 @@ def get_publisher( publ_id ): abort( 404 ) vals = get_publisher_vals( publ ) # include the number of associated publications - query = Publication.query.filter_by( publ_id = publ.publ_id ) + query = Publication.query.filter_by( publ_id = publ_id ) vals[ "nPublications" ] = query.count() - _logger.debug( "- %s ; #publications=%d", publ, vals["nPublications"] ) + # include the number of associated articles + query = db.session.query #pylint: disable=no-member + query = query( Article, Publication ) \ + .filter( Publication.publ_id == publ_id ) \ + .filter( Article.pub_id == Publication.pub_id ) + vals[ "nArticles" ] = query.count() + _logger.debug( "- %s ; #publications=%d ; #articles=%d", publ, vals["nPublications"], vals["nArticles"] ) return jsonify( vals ) def get_publisher_vals( publ ): @@ -109,14 +115,21 @@ def delete_publisher( publ_id ): _logger.debug( "- %s", publ ) # figure out which associated publications will be deleted - query = db.session.query( Publication.pub_id ).filter_by( publ_id = publ.publ_id ) #pylint: disable=no-member + query = db.session.query( Publication.pub_id ).filter_by( publ_id = publ_id ) #pylint: disable=no-member deleted_pubs = [ r[0] for r in query ] + # figure out which associated articles will be deleted + query = db.session.query #pylint: disable=no-member + query = query( Article.article_id ).join( Publication ) \ + .filter( Publication.publ_id == publ_id ) \ + .filter( Article.pub_id == Publication.pub_id ) + deleted_articles = [ r[0] for r in query ] + # delete the publisher db.session.delete( publ ) #pylint: disable=no-member db.session.commit() #pylint: disable=no-member - extras = { "deletedPublications": deleted_pubs } + extras = { "deletedPublications": deleted_pubs, "deletedArticles": deleted_articles } if request.args.get( "list" ): extras[ "publishers" ] = _do_get_publishers() extras[ "publications" ] = do_get_publications() diff --git a/asl_articles/search.py b/asl_articles/search.py index 92f4a01..cea047f 100644 --- a/asl_articles/search.py +++ b/asl_articles/search.py @@ -5,9 +5,10 @@ import logging from flask import request, jsonify from asl_articles import app -from asl_articles.models import Publisher, Publication +from asl_articles.models import Publisher, Publication, Article from asl_articles.publishers import get_publisher_vals from asl_articles.publications import get_publication_vals +from asl_articles.articles import get_article_vals _logger = logging.getLogger( "search" ) @@ -29,7 +30,7 @@ def search(): Publisher.publ_name.ilike( "%{}%".format( query_string ) ) ) query = query.order_by( Publisher.publ_name.asc() ) - publishers = list( query ) + publishers = query.all() _logger.debug( "- Found: %s", " ; ".join( str(p) for p in publishers ) ) for publ in publishers: publ = get_publisher_vals( publ ) @@ -43,11 +44,25 @@ def search(): Publication.pub_name.ilike( "%{}%".format( query_string ) ) ) query = query.order_by( Publication.pub_name.asc() ) - publications = list( query ) + 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 ) ) + ) + 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 ) + return jsonify( results ) diff --git a/asl_articles/tests/fixtures/articles.json b/asl_articles/tests/fixtures/articles.json new file mode 100644 index 0000000..bfd8517 --- /dev/null +++ b/asl_articles/tests/fixtures/articles.json @@ -0,0 +1,12 @@ +{ + +"article": [ + { "article_title": "Smoke Gets In Your Eyes", "article_subtitle": "All You Wanted To Know About SMOKE", + "article_snippet": "SMOKE comes in all shapes, colors, and sizes." + }, + { "article_title": "What To Do If You Have A Tin Can", + "article_snippet": "My Panthers... My beautiful Panthers!" + } +] + +} diff --git a/asl_articles/tests/fixtures/cascading-deletes-1.json b/asl_articles/tests/fixtures/cascading-deletes-1.json new file mode 100644 index 0000000..53c3a37 --- /dev/null +++ b/asl_articles/tests/fixtures/cascading-deletes-1.json @@ -0,0 +1,41 @@ +{ + +"publisher": [ + { "publ_id": 1, "publ_name": "#pubs=0, #articles=0" }, + { "publ_id": 2, "publ_name": "#pubs=1, #articles=0" }, + { "publ_id": 3, "publ_name": "#pubs=1, #articles=1" }, + { "publ_id": 4, "publ_name": "#pubs=1, #articles=2" }, + { "publ_id": 5, "publ_name": "#pubs=2, #articles=0" }, + { "publ_id": 6, "publ_name": "#pubs=2, #articles=1" }, + { "publ_id": 7, "publ_name": "#pubs=2, #articles=2" }, + { "publ_id": 8, "publ_name": "#pubs=2, #articles=4" } +], + +"publication": [ + { "pub_id": 20, "pub_name": "publication 2", "publ_id": "2" }, + { "pub_id": 30, "pub_name": "publication 3", "publ_id": "3" }, + { "pub_id": 40, "pub_name": "publication 4", "publ_id": "4" }, + { "pub_id": 50, "pub_name": "publication 5a", "publ_id": "5" }, + { "pub_id": 51, "pub_name": "publication 5b", "publ_id": "5" }, + { "pub_id": 60, "pub_name": "publication 6a", "publ_id": "6" }, + { "pub_id": 61, "pub_name": "publication 6b", "publ_id": "6" }, + { "pub_id": 70, "pub_name": "publication 7a", "publ_id": "7" }, + { "pub_id": 71, "pub_name": "publication 7b", "publ_id": "7" }, + { "pub_id": 80, "pub_name": "publication 8a", "publ_id": "8" }, + { "pub_id": 81, "pub_name": "publication 8b", "publ_id": "8" } +], + +"article": [ + { "article_title": "article 3", "pub_id": 30 }, + { "article_title": "article 4a", "pub_id": 40 }, + { "article_title": "article 4b", "pub_id": 40 }, + { "article_title": "article 6a", "pub_id": 60 }, + { "article_title": "article 7a", "pub_id": 70 }, + { "article_title": "article 7b", "pub_id": 71 }, + { "article_title": "article 8a.1", "pub_id": 80 }, + { "article_title": "article 8a.2", "pub_id": 80 }, + { "article_title": "article 8b.1", "pub_id": 81 }, + { "article_title": "article 8b.2", "pub_id": 81 } +] + +} diff --git a/asl_articles/tests/fixtures/cascading-deletes-2.json b/asl_articles/tests/fixtures/cascading-deletes-2.json new file mode 100644 index 0000000..4d77532 --- /dev/null +++ b/asl_articles/tests/fixtures/cascading-deletes-2.json @@ -0,0 +1,15 @@ +{ + +"publication": [ + { "pub_id": 1, "pub_name": "Cascading Deletes 1" }, + { "pub_id": 2, "pub_name": "Cascading Deletes 2" }, + { "pub_id": 3, "pub_name": "Cascading Deletes 3" } +], + +"article": [ + { "article_title": "article 2", "pub_id": "2" }, + { "article_title": "article 3a", "pub_id": "3" }, + { "article_title": "article 3b", "pub_id": "3" } +] + +} diff --git a/asl_articles/tests/fixtures/cascading-deletes.json b/asl_articles/tests/fixtures/cascading-deletes.json deleted file mode 100644 index 3314b62..0000000 --- a/asl_articles/tests/fixtures/cascading-deletes.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - -"publisher": [ - { "publ_id": 1, "publ_name": "Cascading Deletes 0" }, - { "publ_id": 2, "publ_name": "Cascading Deletes 1" }, - { "publ_id": 3, "publ_name": "Cascading Deletes 2" } -], - -"publication": [ - { "pub_name": "publication 1", "publ_id": "2" }, - { "pub_name": "publication 2a", "publ_id": "3" }, - { "pub_name": "publication 2b", "publ_id": "3" } -] - -} diff --git a/asl_articles/tests/test_articles.py b/asl_articles/tests/test_articles.py new file mode 100644 index 0000000..b9e0be5 --- /dev/null +++ b/asl_articles/tests/test_articles.py @@ -0,0 +1,255 @@ +""" Test article operations. """ + +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, \ + set_toast_marker, check_toast, check_ask_dialog, check_error_msg + +# --------------------------------------------------------------------- + +def test_edit_article( webdriver, flask_app, dbconn ): + """Test editing articles.""" + + # initialize + init_tests( webdriver, flask_app ) + init_db( dbconn, "articles.json" ) + + # edit "What To Do If You Have A Tin Can" + results = do_search( "tin can" ) + assert len(results) == 1 + result = results[0] + _edit_article( result, { + "title": " Updated title ", + "subtitle": " Updated subtitle ", + "snippet": " Updated snippet. ", + "url": " http://updated-article.com ", + } ) + + # check that the search result was updated in the UI + results = find_children( "#search-results .search-result" ) + result = results[0] + _check_result( result, [ "Updated title", "Updated subtitle", "Updated snippet.", "http://updated-article.com/" ] ) + + # try to remove all fields from the article (should fail) + _edit_article( result, + { "title": "", "subtitle": "", "snippet": "", "url": "" }, + expected_error = "Please specify the article's title." + ) + + # enter something for the name + dlg = find_child( "#modal-form" ) + set_elem_text( find_child( ".title input", dlg ), "Tin Cans Rock!" ) + find_child( "button.ok", dlg ).click() + + # check that the search result was updated in the UI + results = find_children( "#search-results .search-result" ) + result = results[0] + assert find_child( ".title a", result ) is None + assert find_child( ".title", result ).text == "Tin Cans Rock!" + assert find_child( ".snippet", result ).text == "" + + # check that the search result was updated in the database + results = do_search( "tin can" ) + _check_result( results[0], [ "Tin Cans Rock!", None, "", None ] ) + +# --------------------------------------------------------------------- + +def test_create_article( webdriver, flask_app, dbconn ): + """Test creating new articles.""" + + # initialize + init_tests( webdriver, flask_app ) + init_db( dbconn, "basic.json" ) + + # try creating a article with no name (should fail) + _create_article( {}, toast_type=None ) + check_error_msg( "Please specify the article's title." ) + + # enter a name and other details + dlg = find_child( "#modal-form" ) # nb: the form is still on-screen + set_elem_text( find_child( ".title input", dlg ), "New article" ) + set_elem_text( find_child( ".subtitle input", dlg ), "New subtitle" ) + set_elem_text( find_child( ".snippet textarea", dlg ), "New snippet." ) + set_elem_text( find_child( ".url input", dlg ), "http://new-snippet.com" ) + set_toast_marker( "info" ) + find_child( "button.ok", dlg ).click() + wait_for( 2, + lambda: check_toast( "info", "created OK", contains=True ) + ) + + # check that the new article appears in the UI + def check_new_article( result ): + _check_result( result, [ + "New article", "New subtitle", "New snippet.", "http://new-snippet.com/" + ] ) + results = find_children( "#search-results .search-result" ) + check_new_article( results[0] ) + + # check that the new article has been saved in the database + results = do_search( "new" ) + assert len( results ) == 1 + check_new_article( results[0] ) + +# --------------------------------------------------------------------- + +def test_delete_article( webdriver, flask_app, dbconn ): + """Test deleting articles.""" + + # initialize + init_tests( webdriver, flask_app ) + init_db( dbconn, "articles.json" ) + + # start to delete article "Smoke Gets In Your Eyes", but cancel the operation + results = do_search( "smoke" ) + assert len(results) == 1 + result = results[0] + find_child( ".delete", result ).click() + check_ask_dialog( ( "Delete this article?", "Smoke Gets In Your Eyes" ), "cancel" ) + + # check that search results are unchanged on-screen + results2 = find_children( "#search-results .search-result" ) + assert results2 == results + + # check that the search results are unchanged in the database + results3 = do_search( "smoke" ) + assert results3 == results + + # delete the article "Smoke Gets In Your Eyes" + result = results3[0] + find_child( ".delete", result ).click() + set_toast_marker( "info" ) + check_ask_dialog( ( "Delete this article?", "Smoke Gets In Your Eyes" ), "ok" ) + wait_for( 2, + lambda: check_toast( "info", "The article was deleted." ) + ) + + # check that search result was removed on-screen + results = find_children( "#search-results .search-result" ) + assert get_result_names( results ) == [] + + # check that the search result was deleted from the database + results = do_search( "smoke" ) + assert get_result_names( results ) == [] + +# --------------------------------------------------------------------- + +def test_unicode( webdriver, flask_app, dbconn ): + """Test Unicode content.""" + + # initialize + init_tests( webdriver, flask_app ) + init_db( dbconn, "basic.json" ) + + # create a article with Unicode content + _create_article( { + "title": "japan = \u65e5\u672c", + "subtitle": "s.korea = \ud55c\uad6d", + "snippet": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1", + "url": "http://\ud55c\uad6d.com" + } ) + + # check that the new article is showing the Unicode content correctly + results = do_search( "japan" ) + assert len( results ) == 1 + _check_result( results[0], [ + "japan = \u65e5\u672c", + "s.korea = \ud55c\uad6d", + "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1", + "http://xn--3e0b707e.com/" + ] ) + +# --------------------------------------------------------------------- + +def test_clean_html( webdriver, flask_app, dbconn ): + """Test cleaning HTML content.""" + + # initialize + init_tests( webdriver, flask_app ) + init_db( dbconn, "basic.json" ) + + # create a article with HTML content + _create_article( { + "title": "title: bold xxx italic", + "subtitle": "italicized subtitle", + "snippet": "bad stuff here: " + }, toast_type="warning" ) + + # check that the HTML was cleaned + results = wait_for( 2, + lambda: find_children( "#search-results .search-result" ) + ) + assert len( results ) == 1 + result = results[0] + _check_result( result, [ "title: bold xxx italic", "italicized subtitle", "bad stuff here:", None ] ) + assert find_child( ".title span" ).get_attribute( "innerHTML" ) \ + == "title: bold xxx italic" + assert find_child( ".subtitle" ).get_attribute( "innerHTML" ) \ + == "italicized subtitle" + assert check_toast( "warning", "Some values had HTML removed.", contains=True ) + + # update the article with new HTML content + _edit_article( result, { + "title": "
updated
" + }, toast_type="warning" ) + def check_result(): + results = find_children( "#search-results .search-result" ) + assert len( results ) == 1 + result = results[0] + return find_child( ".title span", result ).text == "updated" + wait_for( 2, check_result ) + assert check_toast( "warning", "Some values had HTML removed.", contains=True ) + +# --------------------------------------------------------------------- + +def _create_article( vals, toast_type="info" ): + """Create a new article.""" + # initialize + if toast_type: + set_toast_marker( toast_type ) + # create the new article + find_child( "#menu .new-article" ).click() + dlg = wait_for_elem( 2, "#modal-form" ) + for k,v in vals.items(): + sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" ) + set_elem_text( find_child( sel, dlg ), v ) + find_child( "button.ok", dlg ).click() + if toast_type: + # check that the new article was created successfully + wait_for( 2, + lambda: check_toast( toast_type, "created OK", contains=True ) + ) + +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() + dlg = wait_for_elem( 2, "#modal-form" ) + for k,v in vals.items(): + sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" ) + set_elem_text( find_child( sel, dlg ), v ) + set_toast_marker( toast_type ) + find_child( "button.ok", dlg ).click() + if expected_error: + # we were expecting an error, confirm the error message + check_error_msg( expected_error ) + else: + # we were expecting the update to work, confirm this + wait_for( 2, + lambda: check_toast( toast_type, "updated OK", contains=True ) + ) + +# --------------------------------------------------------------------- + +def _check_result( result, expected ): + """Check a result.""" + assert find_child( ".title span", result ).text == expected[0] + elem = find_child( ".subtitle", result ) + if elem: + assert elem.text == expected[1] + else: + assert expected[1] is None + assert find_child( ".snippet", result ).text == expected[2] + elem = find_child( ".title a", result ) + if elem: + assert elem.get_attribute( "href" ) == expected[3] + else: + assert expected[3] is None diff --git a/asl_articles/tests/test_publications.py b/asl_articles/tests/test_publications.py index 086ffa5..e5571ed 100644 --- a/asl_articles/tests/test_publications.py +++ b/asl_articles/tests/test_publications.py @@ -104,7 +104,7 @@ def test_delete_publication( webdriver, flask_app, dbconn ): result = results[1] assert find_child( ".name", result ).text == "ASL Journal (2)" find_child( ".delete", result ).click() - check_ask_dialog( ( "Do you want to delete", "ASL Journal (2)" ), "cancel" ) + check_ask_dialog( ( "Delete this publication?", "ASL Journal (2)" ), "cancel" ) # check that search results are unchanged on-screen results2 = find_children( "#search-results .search-result" ) @@ -119,7 +119,7 @@ def test_delete_publication( webdriver, flask_app, dbconn ): assert find_child( ".name", result ).text == "ASL Journal (2)" find_child( ".delete", result ).click() set_toast_marker( "info" ) - check_ask_dialog( ( "Do you want to delete", "ASL Journal (2)" ), "ok" ) + check_ask_dialog( ( "Delete this publication?", "ASL Journal (2)" ), "ok" ) wait_for( 2, lambda: check_toast( "info", "The publication was deleted." ) ) @@ -134,6 +134,48 @@ def test_delete_publication( webdriver, flask_app, dbconn ): # --------------------------------------------------------------------- +def test_cascading_deletes( webdriver, flask_app, dbconn ): + """Test cascading deletes.""" + + # initialize + init_tests( webdriver, flask_app ) + + def do_test( pub_name, expected_warning, expected_articles ): + + # initialize + init_db( dbconn, "cascading-deletes-2.json" ) + results = do_search( "" ) + + # 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() + check_ask_dialog( ( "Delete this publication?", pub_name, expected_warning ), "ok" ) + + # check that deleted associated articles were removed from the UI + def check_articles( results ): + results = [ find_child(".name",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 that associated articles were removed from the database + results = do_search( "article" ) + check_articles( results ) + + # do the tests + do_test( "Cascading Deletes 1", + "No articles will be deleted", ["article 2","article 3a","article 3b"] + ) + do_test( "Cascading Deletes 2", + "1 associated article will also be deleted", ["article 3a","article 3b"] + ) + do_test( "Cascading Deletes 3", + "2 associated articles will also be deleted", ["article 2"] + ) + +# --------------------------------------------------------------------- + def test_unicode( webdriver, flask_app, dbconn ): """Test Unicode content.""" diff --git a/asl_articles/tests/test_publishers.py b/asl_articles/tests/test_publishers.py index 87ffb40..1afbb9d 100644 --- a/asl_articles/tests/test_publishers.py +++ b/asl_articles/tests/test_publishers.py @@ -102,7 +102,7 @@ def test_delete_publisher( webdriver, flask_app, dbconn ): result = results[1] assert find_child( ".name", result ).text == "Le Franc Tireur" find_child( ".delete", result ).click() - check_ask_dialog( ( "Do you want to delete", "Le Franc Tireur" ), "cancel" ) + check_ask_dialog( ( "Delete this publisher?", "Le Franc Tireur" ), "cancel" ) # check that search results are unchanged on-screen results2 = find_children( "#search-results .search-result" ) @@ -117,7 +117,7 @@ def test_delete_publisher( webdriver, flask_app, dbconn ): assert find_child( ".name", result ).text == "Le Franc Tireur" find_child( ".delete", result ).click() set_toast_marker( "info" ) - check_ask_dialog( ( "Do you want to delete", "Le Franc Tireur" ), "ok" ) + check_ask_dialog( ( "Delete this publisher?", "Le Franc Tireur" ), "ok" ) wait_for( 2, lambda: check_toast( "info", "The publisher was deleted." ) ) @@ -138,38 +138,76 @@ def test_cascading_deletes( webdriver, flask_app, dbconn ): # initialize init_tests( webdriver, flask_app ) - def do_test( publ_name, expected_warning, expected_pubs ): + def check_results( 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",r).text for r in results ] + results = [ r for r in results if r.startswith( sr_type ) ] + assert results == expected + + def do_test( publ_name, expected_warning, expected_deletions ): # initialize - init_db( dbconn, "cascading-deletes.json" ) + init_db( dbconn, "cascading-deletes-1.json" ) results = do_search( "" ) # 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() - check_ask_dialog( ( "Do you want to delete", publ_name, expected_warning ), "ok" ) - - # check that deleted associated publications were removed from the UI - def check_publications( results ): - results = [ find_child(".name",r).text for r in results ] - pubs = [ r for r in results if r.startswith( "publication" ) ] - assert pubs == expected_pubs - check_publications( find_children( "#search-results .search-result" ) ) + check_ask_dialog( ( "Delete this publisher?", publ_name, expected_warning ), "ok" ) - # check that associated publications were removed from the database - results = do_search( "publication" ) - check_publications( results ) + # check that deleted associated publications/articles were removed from the UI + results = find_children( "#search-results .search-result" ) + def check_publications(): + check_results( results, + "publication", [ "2", "3", "4", "5a", "5b", "6a", "6b", "7a", "7b", "8a", "8b" ], + expected_deletions + ) + def check_articles(): + check_results( results, + "article", [ "3", "4a", "4b", "6a", "7a", "7b", "8a.1", "8a.2", "8b.1", "8b.2" ], + expected_deletions + ) + check_publications() + check_articles() + + # check that associated publications/articles were removed from the database + results = do_search( "" ) + check_publications() + check_articles() # do the tests - do_test( "Cascading Deletes 0", - "No publications will be deleted", ["publication 1","publication 2a","publication 2b"] + do_test( "#pubs=0, #articles=0", + "No publications nor articles will be deleted", [] + ) + do_test( "#pubs=1, #articles=0", + "1 publication will also be deleted", + [ "publication 2" ] + ) + do_test( "#pubs=1, #articles=1", + "1 publication and 1 article will also be deleted", + [ "publication 3", "article 3" ] + ) + do_test( "#pubs=1, #articles=2", + "1 publication and 2 articles will also be deleted", + [ "publication 4", "article 4a", "article 4b" ] + ) + do_test( "#pubs=2, #articles=0", + "2 publications will also be deleted", + [ "publication 5a", "publication 5b" ] + ) + do_test( "#pubs=2, #articles=1", + "2 publications and 1 article will also be deleted", + [ "publication 6a", "publication 6b", "article 6a" ] ) - do_test( "Cascading Deletes 1", - "1 associated publication will also be deleted", ["publication 2a","publication 2b"] + do_test( "#pubs=2, #articles=2", + "2 publications and 2 articles will also be deleted", + [ "publication 7a", "publication 7b", "article 7a", "article 7b" ] ) - do_test( "Cascading Deletes 2", - "2 associated publications will also be deleted", ["publication 1"] + do_test( "#pubs=2, #articles=4", + "2 publications and 4 articles will also be deleted", + [ "publication 8a", "publication 8b", "article 8a.1", "article 8a.2", "article 8b.1", "article 8b.2" ] ) # --------------------------------------------------------------------- diff --git a/asl_articles/tests/utils.py b/asl_articles/tests/utils.py index 965d73a..293e586 100644 --- a/asl_articles/tests/utils.py +++ b/asl_articles/tests/utils.py @@ -86,7 +86,7 @@ def init_db( engine, fname ): data = json.load( open( fname, "r" ) ) # load the test data into the database - for table_name in ["publisher","publication"]: + for table_name in ["publisher","publication","article"]: model = getattr( asl_articles.models, table_name.capitalize() ) session.query( model ).delete() if table_name in data: diff --git a/web/src/App.js b/web/src/App.js index 71b0707..76a4996 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -6,6 +6,7 @@ import SearchForm from "./SearchForm" ; import { SearchResults } from "./SearchResults" ; import { PublisherSearchResult } from "./PublisherSearchResult" ; import { PublicationSearchResult } from "./PublicationSearchResult" ; +import { ArticleSearchResult } from "./ArticleSearchResult" ; import ModalForm from "./ModalForm"; import AskDialog from "./AskDialog" ; import "./App.css" ; @@ -49,6 +50,9 @@ export default class App extends React.Component [ { e.preventDefault() ; PublicationSearchResult.onNewPublication( this._onNewPublication.bind(this) ) ; } } >New publication] + [ { e.preventDefault() ; ArticleSearchResult.onNewArticle( this._onNewArticle.bind(this) ) ; } } + >New article] @@ -106,6 +110,7 @@ export default class App extends React.Component _onNewPublisher( publ_id, vals ) { this._addNewSearchResult( vals, "publisher", "publ_id", publ_id ) ; } _onNewPublication( pub_id, vals ) { this._addNewSearchResult( vals, "publication", "pub_id", pub_id ) ; } + _onNewArticle( article_id, vals ) { this._addNewSearchResult( vals, "article", "article_id", article_id ) ; } _addNewSearchResult( vals, srType, idName, idVal ) { // add the new search result to the start of the search results // NOTE: This isn't really the right thing to do, since the new object might not actually be diff --git a/web/src/ArticleSearchResult.js b/web/src/ArticleSearchResult.js new file mode 100644 index 0000000..5465b94 --- /dev/null +++ b/web/src/ArticleSearchResult.js @@ -0,0 +1,152 @@ +import React from "react" ; +import ReactDOMServer from "react-dom/server" ; +import Select from "react-select" ; +import { gAppRef } from "./index.js" ; +import { makeOptionalLink } from "./utils.js" ; + +const axios = require( "axios" ) ; + +// -------------------------------------------------------------------- + +export class ArticleSearchResult extends React.Component +{ + + render() { + // 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 (
+
{ makeOptionalLink( this.props.data.article_title, this.props.data.article_url ) } + Edit this article. + Delete this article. + { this.props.data.article_subtitle &&
} +
+
+
) ; + } + + static onNewArticle( notify ) { + ArticleSearchResult._doEditArticle( {}, (newVals,refs) => { + axios.post( gAppRef.state.flaskBaseUrl + "/article/create", newVals ) + .then( resp => { + // unload any cleaned values + for ( let r in refs ) { + if ( resp.data.cleaned && resp.data.cleaned[r] ) + newVals[ r ] = resp.data.cleaned[ r ] ; + } + // update the UI with the new details + notify( resp.data.article_id, newVals ) ; + if ( resp.data.warning ) + gAppRef.showWarningToast(
The new article was created OK.

{resp.data.warning}

) ; + else + gAppRef.showInfoToast(
The new article was created OK.
) ; + gAppRef.closeModalForm() ; + } ) + .catch( err => { + gAppRef.showErrorMsg(
Couldn't create the article:
{err.toString()}
) ; + } ) ; + } ) ; + } + + onEditArticle() { + ArticleSearchResult._doEditArticle( this.props.data, (newVals,refs) => { + // send the updated details to the server + newVals.article_id = this.props.data.article_id ; + axios.post( gAppRef.state.flaskBaseUrl + "/article/update", newVals ) + .then( resp => { + // update the UI with the new details + for ( let r in refs ) + this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ; + this.forceUpdate() ; + if ( resp.data.warning ) + gAppRef.showWarningToast(
The article was updated OK.

{resp.data.warning}

) ; + else + gAppRef.showInfoToast(
The article was updated OK.
) ; + gAppRef.closeModalForm() ; + } ) + .catch( err => { + gAppRef.showErrorMsg(
Couldn't update the article:
{err.toString()}
) ; + } ) ; + } ); + } + + static _doEditArticle( vals, notify ) { + let refs = {} ; + let publications = [ { value: null, label: (none) } ] ; + let currPub = 0 ; + for ( let p of Object.entries(gAppRef.caches.publications) ) { + publications.push( { + value: p[1].pub_id, + label: + } ) ; + if ( p[1].pub_id === vals.pub_id ) + currPub = publications.length - 1 ; + } + publications.sort( (lhs,rhs) => { + return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ; + } ) ; + const content =
+
+ refs.article_title=r} /> +
+
+ refs.article_subtitle=r} /> +
+
+