diff --git a/alembic/versions/21ec84874208_added_article_ratings.py b/alembic/versions/21ec84874208_added_article_ratings.py new file mode 100644 index 0000000..8d3b013 --- /dev/null +++ b/alembic/versions/21ec84874208_added_article_ratings.py @@ -0,0 +1,28 @@ +"""Added article ratings. + +Revision ID: 21ec84874208 +Revises: 3d58e8ebf8c6 +Create Date: 2020-03-19 01:10:12.194485 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '21ec84874208' +down_revision = '3d58e8ebf8c6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('article', sa.Column('article_rating', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('article', 'article_rating') + # ### end Alembic commands ### diff --git a/asl_articles/articles.py b/asl_articles/articles.py index 3e6cbf1..c30aee3 100644 --- a/asl_articles/articles.py +++ b/asl_articles/articles.py @@ -55,6 +55,7 @@ def get_article_vals( article, add_type=False ): "article_url": article.article_url, "article_scenarios": [ s.scenario_id for s in scenarios ], "article_tags": decode_tags( article.article_tags ), + "article_rating": article.article_rating, "pub_id": article.pub_id, } if add_type: @@ -268,6 +269,28 @@ def update_article(): extras[ "_publications" ] = pubs return make_ok_response( updated=updated, extras=extras, warnings=warnings ) +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +@app.route( "/article/update-rating", methods=["POST"] ) +def update_article_rating(): + """Update an article's rating.""" + + # parse the input + article_id = request.json[ "article_id" ] + new_rating = int( request.json[ "rating" ] ) + if new_rating < 0 or new_rating > 3: + raise ValueError( "Invalid rating." ) + + # update the article's rating + article = Article.query.get( article_id ) + if not article: + abort( 404 ) + article.article_rating = new_rating + db.session.commit() + search.add_or_update_article( None, article ) + + return "OK" + # --------------------------------------------------------------------- @app.route( "/article/delete/" ) diff --git a/asl_articles/models.py b/asl_articles/models.py index fc4dc40..d4ef159 100644 --- a/asl_articles/models.py +++ b/asl_articles/models.py @@ -67,6 +67,7 @@ class Article( db.Model ): article_pageno = db.Column( db.String(20) ) article_url = db.Column( db.String(500) ) article_tags = db.Column( db.String(1000) ) + article_rating = db.Column( db.Integer ) pub_id = db.Column( db.Integer, db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" ) ) diff --git a/asl_articles/search.py b/asl_articles/search.py index de1fe05..4fce274 100644 --- a/asl_articles/search.py +++ b/asl_articles/search.py @@ -99,7 +99,8 @@ _FIELD_MAPPINGS = { }, "article": { "name": "article_title", "name2": "article_subtitle", "description": "article_snippet", "authors": _get_authors, "scenarios": _get_scenarios, - "tags": lambda article: _get_tags( article.article_tags ) + "tags": lambda article: _get_tags( article.article_tags ), + "rating": "article_rating" } } @@ -257,9 +258,9 @@ def _do_fts_search( fts_query_string, col_names, results=None ): #pylint: disabl return "highlight( searchable, {}, '{}', '{}' )".format( n, hilites[0], hilites[1] ) - sql = "SELECT owner, rank, {}, {}, {}, {}, {}, {} FROM searchable" \ + sql = "SELECT owner, rank, {}, {}, {}, {}, {}, {}, rating FROM searchable" \ " WHERE searchable MATCH ?" \ - " ORDER BY rank".format( + " ORDER BY rating DESC, rank".format( highlight(1), highlight(2), highlight(3), highlight(4), highlight(5), highlight(6) ) match = "{{ {} }}: {}".format( @@ -280,6 +281,7 @@ def _do_fts_search( fts_query_string, col_names, results=None ): #pylint: disabl # prepare the result for the front-end result = globals()[ "_get_{}_vals".format( owner_type ) ]( obj ) result[ "type" ] = owner_type + result[ "rank" ] = row[1] # return highlighted versions of the content to the caller fields = _FIELD_MAPPINGS[ owner_type ] @@ -387,7 +389,7 @@ def init_search( session, logger ): # (nor UNIQUE constraints), so we have to manage this manually :-( dbconn.conn.execute( "CREATE VIRTUAL TABLE searchable USING fts5" - " ( owner, {}, tokenize='porter unicode61' )".format( + " ( owner, {}, rating, tokenize='porter unicode61' )".format( ", ".join( _SEARCHABLE_COL_NAMES ) ) ) @@ -481,14 +483,15 @@ def _do_add_or_update_searchable( dbconn, owner_type, owner, obj ): def do_add_or_update( dbconn ): sql = "INSERT INTO searchable" \ - " ( owner, {} )" \ - " VALUES (?,?,?,?,?,?,?)".format( + " ( owner, {}, rating )" \ + " VALUES (?,?,?,?,?,?,?,?)".format( ",".join( _SEARCHABLE_COL_NAMES ) ) dbconn.conn.execute( sql, ( owner, vals.get("name"), vals.get("name2"), vals.get("description"), - vals.get("authors"), vals.get("scenarios"), vals.get("tags") + vals.get("authors"), vals.get("scenarios"), vals.get("tags"), + vals.get("rating") ) ) # update the database diff --git a/asl_articles/tests/test_articles.py b/asl_articles/tests/test_articles.py index fbfdc99..7cb7a62 100644 --- a/asl_articles/tests/test_articles.py +++ b/asl_articles/tests/test_articles.py @@ -484,6 +484,61 @@ def test_timestamps( webdriver, flask_app, dbconn ): # --------------------------------------------------------------------- +def test_article_ratings( webdriver, flask_app, dbconn ): + """Test article ratings.""" + + # initialize + init_tests( webdriver, flask_app, dbconn, fixtures="articles.json" ) + + def do_test( article_sr, star_no, expected ): + + # click the specified article star + stars = find_children( ".rating-stars img", article_sr ) + stars[ star_no ].click() + for sr_no,sr in enumerate(results): + assert get_rating(sr) == expected[sr_no] + + # compare the ratings on-screen with what's in the database + for sr in results: + article_id = sr.get_attribute( "testing--article_id" ) + ui_rating = get_rating( sr ) + db_rating = dbconn.execute( + "SELECT article_rating FROM article WHERE article_id={}".format( article_id ) + ).scalar() + if db_rating is None: + assert ui_rating == 0 + else: + assert ui_rating == db_rating + + def get_rating( article_sr ): + stars = [ + "disabled" not in star.get_attribute("src") + for star in find_children( ".rating-stars img", article_sr ) + ] + rating = 0 + for star in stars: + if not star: + assert all( not s for s in stars[rating+1:] ) + break + rating += 1 + return rating + + # get the test articles + results = do_search( SEARCH_ALL_ARTICLES ) + + # do the tests + do_test( results[0], 2, [3,0] ) + do_test( results[1], 1, [3,2] ) + + # do the tests + do_test( results[0], 2, [2,2] ) + do_test( results[0], 2, [3,2] ) + do_test( results[0], 0, [1,2] ) + do_test( results[0], 0, [0,2] ) + do_test( results[0], 0, [1,2] ) + +# --------------------------------------------------------------------- + def create_article( vals, toast_type="info", expected_error=None, expected_constraints=None, dlg=None ): """Create a new article.""" diff --git a/web/public/images/rating-star-disabled.png b/web/public/images/rating-star-disabled.png new file mode 100644 index 0000000..1a18c8b Binary files /dev/null and b/web/public/images/rating-star-disabled.png differ diff --git a/web/public/images/rating-star.png b/web/public/images/rating-star.png new file mode 100644 index 0000000..c6c58c2 Binary files /dev/null and b/web/public/images/rating-star.png differ diff --git a/web/src/ArticleSearchResult.js b/web/src/ArticleSearchResult.js index 34ef83c..5073057 100644 --- a/web/src/ArticleSearchResult.js +++ b/web/src/ArticleSearchResult.js @@ -4,6 +4,7 @@ import { Menu, MenuList, MenuButton, MenuItem } from "@reach/menu-button" ; import { ArticleSearchResult2 } from "./ArticleSearchResult2.js" ; import "./ArticleSearchResult.css" ; import { PublicationSearchResult } from "./PublicationSearchResult.js" ; +import { RatingStars } from "./RatingStars.js" ; import { gAppRef } from "./App.js" ; import { makeScenarioDisplayName, applyUpdatedVals, removeSpecialFields, makeCommaList, isLink } from "./utils.js" ; @@ -125,6 +126,9 @@ export class ArticleSearchResult extends React.Component dangerouslySetInnerHTML = {{ __html: pub_display_name }} /> } + { article_url && @@ -145,6 +149,17 @@ export class ArticleSearchResult extends React.Component ) ; } + onRatingChange( newRating, onFailed ) { + axios.post( gAppRef.makeFlaskUrl( "/article/update-rating", null ), { + article_id: this.props.data.article_id, + rating: newRating, + } ).catch( err => { + gAppRef.showErrorMsg(
Couldn't update the rating:
{err.toString()}
) ; + if ( onFailed ) + onFailed() ; + } ) ; + } + static onNewArticle( notify ) { ArticleSearchResult2._doEditArticle( {}, (newVals,refs) => { axios.post( gAppRef.makeFlaskUrl( "/article/create", {list:1} ), newVals ) diff --git a/web/src/RatingStars.css b/web/src/RatingStars.css new file mode 100644 index 0000000..8f621ca --- /dev/null +++ b/web/src/RatingStars.css @@ -0,0 +1,2 @@ +.rating-stars { float: right ; margin-top: -0.1em ; margin-right: 0.25em ; } +.rating-stars img { height: 0.75em ; margin-right: 0.1em ; cursor: pointer ; } diff --git a/web/src/RatingStars.js b/web/src/RatingStars.js new file mode 100644 index 0000000..24b6a0c --- /dev/null +++ b/web/src/RatingStars.js @@ -0,0 +1,63 @@ +import React from "react" ; +import "./RatingStars.css" ; + +// -------------------------------------------------------------------- + +export class RatingStars extends React.Component +{ + + constructor( props ) { + // initialize + super( props ) ; + this.state = { + rating: props.rating, + } ; + } + + render() { + + let changeRating = ( starIndex ) => { + // update the rating + let newRating ; + if ( starRefs[starIndex].src.indexOf( "rating-star-disabled.png" ) !== -1 ) + newRating = starIndex + 1 ; + else { + // NOTE: We get here if the clicked-on star is enabled. If this is the highest enabled star, + // we disable it (i.e. set the rating to N-1), otherwise we make it the highest star (i.e. set + // the rating to N). This has the down-side that if the user wants to set a rating of 0, + // they have to set the rating to 1 first, then set it to 0, but this is unlikely to be an issue + // i.e. going from 1 to 0 will be far more common than 3 to 0. + if ( starIndex+1 === this.state.rating ) + newRating = starIndex ; + else + newRating = starIndex + 1 ; + } + const prevRating = this.state.rating ; + this.setState( { rating: newRating } ) ; + // notify the parent + if ( this.props.onChange ) { + this.props.onChange( newRating, () => { + this.setState( { rating: prevRating } ) ; // nb: the update failed, rollback + } ) ; + } + } + + // prepare the rating stars + let stars=[], starRefs={} ; + for ( let i=0 ; i < 3 ; ++i ) { + const fname = this.state.rating > i ? "rating-star.png" : "rating-star-disabled.png" ; + stars.push( + Rating star. starRefs[i] = r } + onClick={ () => changeRating(i) } + /> + ) ; + } + + // render the component + return ( + {stars} + ) ; + } + +}