Allow articles to be rated.

master
Pacman Ghost 4 years ago
parent f6078b66e0
commit 9d4572d3d8
  1. 28
      alembic/versions/21ec84874208_added_article_ratings.py
  2. 23
      asl_articles/articles.py
  3. 1
      asl_articles/models.py
  4. 17
      asl_articles/search.py
  5. 55
      asl_articles/tests/test_articles.py
  6. BIN
      web/public/images/rating-star-disabled.png
  7. BIN
      web/public/images/rating-star.png
  8. 15
      web/src/ArticleSearchResult.js
  9. 2
      web/src/RatingStars.css
  10. 63
      web/src/RatingStars.js

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

@ -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/<article_id>" )

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@ -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 }}
/>
}
<RatingStars rating={this.props.data.article_rating} title="Rate this article."
onChange = { this.onRatingChange.bind( this ) }
/>
<span className="title name" dangerouslySetInnerHTML={{ __html: display_title }} />
{ article_url &&
<a href={article_url} className="open-link" target="_blank" rel="noopener noreferrer">
@ -145,6 +149,17 @@ export class ArticleSearchResult extends React.Component
</div> ) ;
}
onRatingChange( newRating, onFailed ) {
axios.post( gAppRef.makeFlaskUrl( "/article/update-rating", null ), {
article_id: this.props.data.article_id,
rating: newRating,
} ).catch( err => {
gAppRef.showErrorMsg( <div> Couldn't update the rating: <div className="monospace"> {err.toString()} </div> </div> ) ;
if ( onFailed )
onFailed() ;
} ) ;
}
static onNewArticle( notify ) {
ArticleSearchResult2._doEditArticle( {}, (newVals,refs) => {
axios.post( gAppRef.makeFlaskUrl( "/article/create", {list:1} ), newVals )

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

@ -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(
<img src={"/images/"+fname} key={i} alt="Rating star."
ref = { r => starRefs[i] = r }
onClick={ () => changeRating(i) }
/>
) ;
}
// render the component
return ( <span className="rating-stars" title={this.props.title} >
{stars}
</span> ) ;
}
}
Loading…
Cancel
Save