diff --git a/alembic/versions/a33edb7272a2_allow_articles_to_be_associated_with_a_.py b/alembic/versions/a33edb7272a2_allow_articles_to_be_associated_with_a_.py new file mode 100644 index 0000000..59b9787 --- /dev/null +++ b/alembic/versions/a33edb7272a2_allow_articles_to_be_associated_with_a_.py @@ -0,0 +1,40 @@ +"""Allow articles to be associated with a publisher. + +Revision ID: a33edb7272a2 +Revises: 21ec84874208 +Create Date: 2021-10-22 20:10:50.440849 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a33edb7272a2' +down_revision = '21ec84874208' +branch_labels = None +depends_on = None + + +from alembic import context +is_sqlite = context.config.get_main_option( "sqlalchemy.url" ).startswith( "sqlite://" ) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('article', sa.Column('publ_id', sa.Integer(), nullable=True)) + if is_sqlite: + op.execute( "PRAGMA foreign_keys = off" ) # nb: stop cascading deletes + with op.batch_alter_table('article') as batch_op: + batch_op.create_foreign_key('fk_article_publisher', 'publisher', ['publ_id'], ['publ_id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + if is_sqlite: + op.execute( "PRAGMA foreign_keys = off" ) # nb: stop cascading deletes + with op.batch_alter_table('article') as batch_op: + batch_op.drop_constraint('fk_article_publisher', type_='foreignkey') + op.drop_column('article', 'publ_id') + # ### end Alembic commands ### diff --git a/asl_articles/articles.py b/asl_articles/articles.py index 16b9656..d86b74d 100644 --- a/asl_articles/articles.py +++ b/asl_articles/articles.py @@ -21,7 +21,7 @@ from asl_articles.utils import get_request_args, clean_request_args, clean_tags, _logger = logging.getLogger( "db" ) _FIELD_NAMES = [ "*article_title", "article_subtitle", "article_snippet", "article_pageno", - "article_url", "article_tags", "pub_id" + "article_url", "article_tags", "pub_id", "publ_id" ] # --------------------------------------------------------------------- @@ -57,6 +57,7 @@ def get_article_vals( article, add_type=False ): "article_tags": decode_tags( article.article_tags ), "article_rating": article.article_rating, "pub_id": article.pub_id, + "publ_id": article.publ_id, } if add_type: vals[ "type" ] = "article" diff --git a/asl_articles/models.py b/asl_articles/models.py index d4ef159..2268725 100644 --- a/asl_articles/models.py +++ b/asl_articles/models.py @@ -23,6 +23,7 @@ class Publisher( db.Model ): # publ_image = db.relationship( "PublisherImage", backref="parent_publ", passive_deletes=True ) publications = db.relationship( "Publication", backref="parent_publ", passive_deletes=True ) + articles = db.relationship( "Article", backref="parent_publ", passive_deletes=True ) def __repr__( self ): return "".format( self.publ_id, self.publ_name ) @@ -71,6 +72,9 @@ class Article( db.Model ): pub_id = db.Column( db.Integer, db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" ) ) + 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 both SQLite and Postgres # is more trouble than it's worth :-/ time_created = db.Column( db.TIMESTAMP(timezone=True) ) diff --git a/asl_articles/publishers.py b/asl_articles/publishers.py index da065e2..a57f6b2 100644 --- a/asl_articles/publishers.py +++ b/asl_articles/publishers.py @@ -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.articles import get_article_vals, get_article_sort_key from asl_articles import search from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs @@ -28,7 +29,7 @@ def _do_get_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 = Publisher.query.all() - return { r.publ_id: get_publisher_vals(r) for r in results } + return { r.publ_id: get_publisher_vals(r,False) for r in results } # --------------------------------------------------------------------- @@ -40,7 +41,8 @@ def get_publisher( publ_id ): publ = Publisher.query.get( publ_id ) if not publ: abort( 404 ) - vals = get_publisher_vals( publ ) + include_articles = request.args.get( "include_articles" ) + vals = get_publisher_vals( publ, include_articles ) # include the number of associated publications query = Publication.query.filter_by( publ_id = publ_id ) vals[ "nPublications" ] = query.count() @@ -48,11 +50,13 @@ def get_publisher( publ_id ): query = db.session.query( Article, Publication ) \ .filter( Publication.publ_id == publ_id ) \ .filter( Article.pub_id == Publication.pub_id ) - vals[ "nArticles" ] = query.count() + nArticles = query.count() + nArticles2 = Article.query.filter_by( publ_id = publ_id ).count() + vals[ "nArticles" ] = nArticles + nArticles2 _logger.debug( "- %s ; #publications=%d ; #articles=%d", publ, vals["nPublications"], vals["nArticles"] ) return jsonify( vals ) -def get_publisher_vals( publ, add_type=False ): +def get_publisher_vals( publ, include_articles, add_type=False ): """Extract public fields from a Publisher record.""" vals = { "publ_id": publ.publ_id, @@ -61,6 +65,9 @@ def get_publisher_vals( publ, add_type=False ): "publ_url": publ.publ_url, "publ_image_id": publ.publ_id if publ.publ_image else None, } + if include_articles: + articles = sorted( publ.articles, key=get_article_sort_key ) + vals[ "articles" ] = [ get_article_vals( a, False ) for a in articles ] if add_type: vals[ "type" ] = "publisher" return vals diff --git a/asl_articles/search.py b/asl_articles/search.py index dfdb4e4..f0f7b7b 100644 --- a/asl_articles/search.py +++ b/asl_articles/search.py @@ -30,7 +30,7 @@ _SQLITE_FTS_SPECIAL_CHARS = "+-#':/.@$" # NOTE: The column order defined here is important, since we have to access row results by column index. _SEARCHABLE_COL_NAMES = [ "name", "name2", "description", "authors", "scenarios", "tags" ] -_get_publisher_vals = lambda p: get_publisher_vals( p, True ) +_get_publisher_vals = lambda p: get_publisher_vals( p, True, True ) _get_publication_vals = lambda p: get_publication_vals( p, True, True ) _get_article_vals = lambda a: get_article_vals( a, True ) @@ -120,7 +120,7 @@ def search(): def search_publishers(): """Return all publishers.""" publs = sorted( Publisher.query.all(), key=lambda p: p.publ_name.lower() ) - results = [ get_publisher_vals( p, True ) for p in publs ] + results = [ get_publisher_vals( p, True, True ) for p in publs ] return jsonify( results ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -131,7 +131,7 @@ def search_publisher( publ_id ): publ = Publisher.query.get( publ_id ) if not publ: return jsonify( [] ) - results = [ get_publisher_vals( publ, True ) ] + results = [ get_publisher_vals( publ, True, True ) ] pubs = sorted( publ.publications, key=get_publication_sort_key, reverse=True ) for pub in pubs: results.append( get_publication_vals( pub, True, True ) ) @@ -168,6 +168,10 @@ def search_article( article_id ): pub = Publication.query.get( article["pub_id"] ) if pub: results.append( get_publication_vals( pub, True, True ) ) + if article["publ_id"]: + publ = Publisher.query.get( article["publ_id"] ) + if publ: + results.append( get_publisher_vals( publ, True, True ) ) return jsonify( results ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/asl_articles/tests/fixtures/publisher-articles.json b/asl_articles/tests/fixtures/publisher-articles.json new file mode 100644 index 0000000..9d21329 --- /dev/null +++ b/asl_articles/tests/fixtures/publisher-articles.json @@ -0,0 +1,17 @@ +{ + +"publisher": [ + { "publ_id": 1, "publ_name": "Avalon Hill" }, + { "publ_id": 2, "publ_name": "Multiman Publishing" }, + { "publ_id": 3, "publ_name": "Le Franc Tireur" } +], + +"publication": [ + { "pub_id": 20, "pub_name": "MMP News", "publ_id": 2 } +], + +"article": [ + { "article_id": 200, "article_title": "MMP Today", "pub_id": 20 } +] + +} diff --git a/asl_articles/tests/test_articles.py b/asl_articles/tests/test_articles.py index 3485eb2..07e57ab 100644 --- a/asl_articles/tests/test_articles.py +++ b/asl_articles/tests/test_articles.py @@ -5,6 +5,7 @@ import urllib.request import urllib.error import json import base64 +import re from asl_articles.search import SEARCH_ALL_ARTICLES from asl_articles.tests.utils import init_tests, select_main_menu_option, select_sr_menu_option, \ @@ -387,6 +388,102 @@ def test_parent_publisher( webdriver, flask_app, dbconn ): # --------------------------------------------------------------------- +def test_publisher_articles( webdriver, flask_app, dbconn ): #pylint: disable=too-many-statements + """Test articles that are associated with a publisher (not publication).""" + + # initialize + init_tests( webdriver, flask_app, dbconn, fixtures="publisher-articles.json" ) + + def check_parent_in_sr( sr, pub, publ ): + """Check the article's parent publication/publisher in a search result.""" + if pub: + elem = wait_for( 2, lambda: find_child( ".header a.publication", sr ) ) + assert elem.is_displayed() + assert elem.text == pub + assert re.search( r"^http://.+?/publication/\d+", elem.get_attribute( "href" ) ) + elif publ: + elem = wait_for( 2, lambda: find_child( ".header a.publisher", sr ) ) + assert elem.is_displayed() + assert elem.text == publ + assert re.search( r"^http://.+?/publisher/\d+", elem.get_attribute( "href" ) ) + else: + assert False, "At least one publication/publisher must be specified." + + def check_parent_in_dlg( dlg, pub, publ ): + """Check the article's parent publication/publication in the edit dialog.""" + if pub: + select = find_child( ".row.publication .react-select", dlg ) + assert select.is_displayed() + assert select.text == pub + elif publ: + select = find_child( ".row.publisher .react-select", dlg ) + assert select.is_displayed() + assert select.text == publ + else: + assert False, "At least one publication/publisher must be specified." + + # create an article associated with LFT + create_article( { + "title": "test article", + "publisher": "Le Franc Tireur" + } ) + results = wait_for( 2, get_search_results ) + assert len(results) == 1 + sr = results[0] + check_parent_in_sr( sr, None, "Le Franc Tireur" ) + + # open the article's dialog + select_sr_menu_option( sr, "edit" ) + dlg = wait_for_elem( 2, "#article-form" ) + check_parent_in_dlg( dlg, None, "Le Franc Tireur" ) + + # change the article to be associated with an MMP publication + find_child( ".row.publisher label.parent-mode" ).click() + select = wait_for_elem( 2, ".row.publication .react-select" ) + ReactSelect( select ).select_by_name( "MMP News" ) + find_child( "button.ok", dlg ).click() + results = wait_for( 2, get_search_results ) + assert len(results) == 1 + sr = results[0] + check_parent_in_sr( sr, "MMP News", None ) + + # open the article's dialog + select_sr_menu_option( sr, "edit" ) + dlg = wait_for_elem( 2, "#article-form" ) + check_parent_in_dlg( dlg, "MMP News", None ) + + # change the article to be associated with MMP (publisher) + find_child( ".row.publication label.parent-mode" ).click() + select = wait_for_elem( 2, ".row.publisher .react-select" ) + ReactSelect( select ).select_by_name( "Multiman Publishing" ) + find_child( "button.ok", dlg ).click() + results = wait_for( 2, get_search_results ) + assert len(results) == 1 + sr = results[0] + check_parent_in_sr( sr, None, "Multiman Publishing" ) + + # show the MMP publisher + results = do_search( "multiman" ) + assert len(results) == 1 + sr = results[0] + collapsibles = find_children( ".collapsible", sr ) + assert len(collapsibles) == 2 + items = find_children( "li a", collapsibles[1] ) + assert len(items) == 1 + item = items[0] + assert item.text == "test article" + assert re.search( r"^http://.+?/article/\d+", item.get_attribute( "href" ) ) + + # delete the MMP publisher + # NOTE: There are 2 MMP articles, the one that is in the "MMP News" publication, + # and the test article we created above that is associated with the publisher. + select_sr_menu_option( sr, "delete" ) + check_ask_dialog( ( "Delete this publisher?", "2 articles will also be deleted" ), "ok" ) + query = dbconn.execute( "SELECT count(*) FROM article" ) + assert query.scalar() == 0 + +# --------------------------------------------------------------------- + def test_unicode( webdriver, flask_app, dbconn ): """Test Unicode content.""" @@ -612,8 +709,14 @@ def _update_values( dlg, vals ): change_image( dlg, val ) else: remove_image( dlg ) - elif key == "publication": - select = ReactSelect( find_child( ".row.publication .react-select", dlg ) ) + elif key in ("publication", "publisher"): + row = find_child( ".row.{}".format( key ), dlg ) + select = ReactSelect( find_child( ".react-select", row ) ) + if not select.select.is_displayed(): + key2 = "publisher" if key == "publication" else "publication" + row2 = find_child( ".row.{}".format( key2 ), dlg ) + find_child( "label.parent-mode", row2 ).click() + wait_for( 2, select.select.is_displayed ) select.select_by_name( val ) elif key in ["authors","scenarios","tags"]: select = ReactSelect( find_child( ".row.{} .react-select".format(key), dlg ) ) diff --git a/web/src/ArticleSearchResult.css b/web/src/ArticleSearchResult.css index 5fc84ec..002fca9 100644 --- a/web/src/ArticleSearchResult.css +++ b/web/src/ArticleSearchResult.css @@ -1,5 +1,6 @@ #article-form .row label.top { width: 6.5em ; } #article-form .row label { width: 5.75em ; } +#article-form .row label.parent-mode { cursor: pointer ; } #article-form .row.snippet { flex-direction: column ; align-items: initial ; margin-top: -0.5em ; } #article-form .row.snippet textarea { min-height: 6em ; } diff --git a/web/src/ArticleSearchResult.js b/web/src/ArticleSearchResult.js index 2937630..2cf9617 100644 --- a/web/src/ArticleSearchResult.js +++ b/web/src/ArticleSearchResult.js @@ -3,6 +3,7 @@ import { Link } from "react-router-dom" ; import { Menu, MenuList, MenuButton, MenuItem } from "@reach/menu-button" ; import { ArticleSearchResult2 } from "./ArticleSearchResult2.js" ; import "./ArticleSearchResult.css" ; +import { PublisherSearchResult } from "./PublisherSearchResult.js" ; import { PublicationSearchResult } from "./PublicationSearchResult.js" ; import { PreviewableImage } from "./PreviewableImage.js" ; import { RatingStars } from "./RatingStars.js" ; @@ -25,6 +26,7 @@ export class ArticleSearchResult extends React.Component this.props.data[ "article_snippet!" ] || this.props.data.article_snippet ) ; const pub = gAppRef.caches.publications[ this.props.data.pub_id ] ; + const publ = gAppRef.caches.publishers[ this.props.data.publ_id ] ; const image_url = gAppRef.makeFlaskImageUrl( "article", this.props.data.article_image_id ) ; // prepare the article's URL @@ -118,6 +120,7 @@ export class ArticleSearchResult extends React.Component // 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. const pub_display_name = pub ? PublicationSearchResult.makeDisplayName( pub ) : null ; + const publ_display_name = publ ? PublisherSearchResult.makeDisplayName( publ ) : null ; return (
gAppRef.setTestAttribute( r, "article_id", this.props.data.article_id ) } > @@ -129,6 +132,12 @@ export class ArticleSearchResult extends React.Component dangerouslySetInnerHTML = {{ __html: pub_display_name }} /> } + { publ_display_name && + + } diff --git a/web/src/ArticleSearchResult2.js b/web/src/ArticleSearchResult2.js index cb8ac72..946c5ff 100644 --- a/web/src/ArticleSearchResult2.js +++ b/web/src/ArticleSearchResult2.js @@ -18,6 +18,23 @@ export class ArticleSearchResult2 let refs = {} ; const isNew = Object.keys( vals ).length === 0 ; + // set the parent mode + let parentMode = vals.publ_id ? "publisher" : "publication" ; + let publicationParentRowRef = null ; + let publisherParentRowRef = null ; + function onPublicationParent() { + parentMode = "publication" ; + publicationParentRowRef.style.display = "flex" ; + publisherParentRowRef.style.display = "none" ; + refs.pub_id.focus() ; + } + function onPublisherParent() { + parentMode = "publisher" ; + publicationParentRowRef.style.display = "none" ; + publisherParentRowRef.style.display = "flex" ; + refs.publ_id.focus() ; + } + // prepare to save the initial values let initialVals = null ; function onReady() { @@ -86,6 +103,19 @@ export class ArticleSearchResult2 } } + // initialize the publishers + let publishers = [ { value: null, label: (none) } ] ; + let currPubl = publishers[0] ; + for ( let p of Object.entries(gAppRef.caches.publishers) ) { + publishers.push( { + value: p[1].publ_id, + label: + } ) ; + if ( p[1].publ_id === vals.publ_id ) + currPubl = publishers[ publishers.length-1 ] ; + } + sortSelectableOptions( publishers ) ; + // initialize the authors let allAuthors = [] ; for ( let a of Object.entries(gAppRef.caches.authors) ) @@ -150,13 +180,27 @@ export class ArticleSearchResult2
refs.article_subtitle=r} />
-
+
publicationParentRowRef=r} > + refs.article_pageno=r} title="Page number." />
+
publisherParentRowRef=r} > + +