Allow articles to be associated with a publisher.

master
Pacman Ghost 3 years ago
parent 41c5d261af
commit fdc287bb61
  1. 40
      alembic/versions/a33edb7272a2_allow_articles_to_be_associated_with_a_.py
  2. 3
      asl_articles/articles.py
  3. 4
      asl_articles/models.py
  4. 15
      asl_articles/publishers.py
  5. 10
      asl_articles/search.py
  6. 17
      asl_articles/tests/fixtures/publisher-articles.json
  7. 107
      asl_articles/tests/test_articles.py
  8. 1
      web/src/ArticleSearchResult.css
  9. 9
      web/src/ArticleSearchResult.js
  10. 59
      web/src/ArticleSearchResult2.js
  11. 26
      web/src/PublisherSearchResult.js
  12. 2
      web/src/SearchResults.css
  13. 1
      web/src/constants.js

@ -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 ###

@ -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"

@ -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 "<Publisher:{}|{}>".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) )

@ -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

@ -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 )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

@ -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 }
]
}

@ -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 ) )

@ -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 ; }

@ -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 ( <div className="search-result article"
ref = { r => 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 &&
<Link className="publisher" title="Show this publisher."
to = { gAppRef.makeAppUrl( "/publisher/" + this.props.data.publ_id ) }
dangerouslySetInnerHTML = {{ __html: publ_display_name }}
/>
}
<RatingStars rating={this.props.data.article_rating} title="Rate this article."
onChange = { this.onRatingChange.bind( this ) }
/>

@ -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: <i>(none)</i> } ] ;
let currPubl = publishers[0] ;
for ( let p of Object.entries(gAppRef.caches.publishers) ) {
publishers.push( {
value: p[1].publ_id,
label: <span dangerouslySetInnerHTML={{__html: p[1].publ_name}} />
} ) ;
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
<div className="row subtitle"> <label className="top"> Subtitle: </label>
<input type="text" defaultValue={vals.article_subtitle} ref={r => refs.article_subtitle=r} />
</div>
<div className="row publication"> <label className="select top"> Publication: </label>
<div className="row publication" style={{display:parentMode==="publication"?"flex":"none"}} ref={r => publicationParentRowRef=r} >
<label className="select top parent-mode"
title = "Click to associate this article with a publisher."
onClick = {onPublisherParent}
> Publication: </label>
<Select className="react-select" classNamePrefix="react-select" options={publications} isSearchable={true}
defaultValue = {currPub}
ref = { r => refs.pub_id=r }
/>
<input className="pageno" type="text" defaultValue={vals.article_pageno} ref={r => refs.article_pageno=r} title="Page number." />
</div>
<div className="row publisher" style={{display:parentMode==="publisher"?"flex":"none"}} ref={r => publisherParentRowRef=r} >
<label className="select top parent-mode"
title="Click to associate this article with a publication."
onClick = {onPublicationParent}
> Publisher: </label>
<Select className="react-select" classNamePrefix="react-select" options={publishers} isSearchable={true}
defaultValue = {currPubl}
ref = { r => refs.publ_id=r }
/>
</div>
<div className="row snippet"> <label> Snippet: </label>
<textarea defaultValue={vals.article_snippet} ref={r => refs.article_snippet=r} />
</div>
@ -190,9 +234,13 @@ export class ArticleSearchResult2
function unloadVals() {
let newVals = {} ;
for ( let r in refs ) {
if ( r === "pub_id" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
else if ( r === "article_authors" ) {
if ( r === "pub_id" ) {
if ( parentMode === "publication" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
} else if ( r === "publ_id" ) {
if ( parentMode === "publisher" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
} else if ( r === "article_authors" ) {
let vals = unloadCreatableSelect( refs[r] ) ;
newVals.article_authors = [] ;
vals.forEach( v => {
@ -233,7 +281,8 @@ export class ArticleSearchResult2
[ () => newVals.article_title === "", "Please give it a title.", refs.article_title ],
] ;
const optional = [
[ () => newVals.pub_id === null, "No publication was specified.", refs.pub_id ],
[ () => parentMode === "publication" && newVals.pub_id === null, "No publication was specified.", refs.pub_id ],
[ () => parentMode === "publisher" && newVals.publ_id === null, "No publisher was specified.", refs.pub_id ],
[ () => newVals.article_pageno === "" && newVals.pub_id !== null, "No page number was specified.", refs.article_pageno ],
[ () => newVals.article_pageno !== "" && newVals.pub_id === null, "A page number was specified but no publication.", refs.pub_id ],
[ () => newVals.article_pageno !== "" && !isNumeric(newVals.article_pageno), "The page number is not numeric.", refs.article_pageno ],

@ -5,7 +5,7 @@ import { PublisherSearchResult2 } from "./PublisherSearchResult2.js"
import "./PublisherSearchResult.css" ;
import { PublicationSearchResult } from "./PublicationSearchResult.js"
import { PreviewableImage } from "./PreviewableImage.js" ;
import { PUBLISHER_EXCESS_PUBLICATION_THRESHOLD } from "./constants.js" ;
import { PUBLISHER_EXCESS_PUBLICATION_THRESHOLD, PUBLISHER_EXCESS_ARTICLE_THRESHOLD } from "./constants.js" ;
import { gAppRef } from "./App.js" ;
import { makeCollapsibleList, pluralString, applyUpdatedVals, removeSpecialFields } from "./utils.js" ;
@ -19,7 +19,7 @@ export class PublisherSearchResult extends React.Component
render() {
// prepare the basic details
const display_name = this.props.data[ "publ_name!" ] || this.props.data.publ_name ;
const display_name = this._makeDisplayName() ;
const display_description = PreviewableImage.adjustHtmlForPreviewableImages(
this.props.data[ "publ_description!" ] || this.props.data.publ_description
) ;
@ -46,6 +46,16 @@ export class PublisherSearchResult extends React.Component
dangerouslySetInnerHTML = {{ __html: PublicationSearchResult.makeDisplayName(p) }}
/> ) ;
// prepare any associated articles
let articles = [] ;
if ( this.props.data.articles ) {
articles = this.props.data.articles.map( a => <Link title="Show this article."
to = { gAppRef.makeAppUrl( "/article/" + a.article_id ) }
dangerouslySetInnerHTML = {{ __html: a.article_title }}
/> ) ;
articles.reverse() ;
}
// prepare the menu
const menu = ( <Menu>
<MenuButton className="sr-menu" />
@ -77,8 +87,10 @@ export class PublisherSearchResult extends React.Component
<div className="content">
{ image_url && <PreviewableImage url={image_url} className="image" alt="Publisher." /> }
<div className="description" dangerouslySetInnerHTML={{__html: display_description}} />
{ makeCollapsibleList( "Publications", pubs, PUBLISHER_EXCESS_PUBLICATION_THRESHOLD, {float:"left",marginBottom:"0.25em"} ) }
{ makeCollapsibleList( "Publications", pubs, PUBLISHER_EXCESS_PUBLICATION_THRESHOLD, {float:"left"} ) }
{ makeCollapsibleList( "Articles", articles, PUBLISHER_EXCESS_ARTICLE_THRESHOLD, {clear:"both",float:"left"} ) }
</div>
<div className="footer" />
</div> ) ;
}
@ -158,7 +170,7 @@ export class PublisherSearchResult extends React.Component
}
let content = ( <div>
Delete this publisher?
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML={{__html: this.props.data.publ_name}} />
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML={{__html: this._makeDisplayName()}} />
{warning}
</div> ) ;
gAppRef.ask( content, "ask", {
@ -199,4 +211,10 @@ export class PublisherSearchResult extends React.Component
} ) ;
}
static makeDisplayName( vals ) {
// return the publisher's display name
return vals["publ_name!"] || vals.publ_name ;
}
_makeDisplayName() { return PublisherSearchResult.makeDisplayName( this.props.data ) ; }
}

@ -24,7 +24,7 @@
.search-result.article .header .subtitle { font-size: 80% ; font-style: italic ; color: #333 ; }
.search-result.article .header .subtitle i { color: #666 ; }
.search-result.publication .header .publisher , .search-result.article .header .publication {
.search-result.publication .header .publisher , .search-result.article .header .publication, .search-result.article .header .publisher {
float: right ; margin-right: 0.5em ; font-size: 80% ; font-style: italic ; color: #444 ;
}

@ -3,6 +3,7 @@ export const APP_NAME = "ASL Articles" ;
export const MAX_IMAGE_UPLOAD_SIZE = ( 1 * 1024*1024 ) ;
export const PUBLISHER_EXCESS_PUBLICATION_THRESHOLD = 5 ;
export const PUBLISHER_EXCESS_ARTICLE_THRESHOLD = 5 ;
export const PUBLICATION_EXCESS_ARTICLE_THRESHOLD = 8 ;
export const NEW_ARTICLE_PUB_PRIORITY_CUTOFF = ( 24 * 60 * 60 ) ;

Loading…
Cancel
Save