Added support for images.

master
Pacman Ghost 4 years ago
parent 6e37dba10b
commit f60986d478
  1. 50
      alembic/versions/23e928dda837_added_the_image_tables.py
  2. 1
      asl_articles/__init__.py
  3. 29
      asl_articles/articles.py
  4. 25
      asl_articles/images.py
  5. 59
      asl_articles/models.py
  6. 29
      asl_articles/publications.py
  7. 29
      asl_articles/publishers.py
  8. BIN
      asl_articles/tests/fixtures/images/1.gif
  9. BIN
      asl_articles/tests/fixtures/images/2.gif
  10. BIN
      asl_articles/tests/fixtures/images/3.gif
  11. BIN
      asl_articles/tests/fixtures/images/big.png
  12. BIN
      asl_articles/tests/fixtures/images/tall.png
  13. BIN
      asl_articles/tests/fixtures/images/wide.png
  14. 118
      asl_articles/tests/test_articles.py
  15. 2
      asl_articles/tests/test_basic.py
  16. 114
      asl_articles/tests/test_publications.py
  17. 103
      asl_articles/tests/test_publishers.py
  18. 31
      asl_articles/tests/utils.py
  19. 4
      conftest.py
  20. BIN
      web/public/images/placeholder.png
  21. 16
      web/src/App.js
  22. 37
      web/src/ArticleSearchResult.js
  23. 76
      web/src/FileUploader.js
  24. 2
      web/src/ModalForm.css
  25. 37
      web/src/PublicationSearchResult.js
  26. 38
      web/src/PublisherSearchResult.js
  27. 1
      web/src/constants.js
  28. 12
      web/src/utils.js

@ -0,0 +1,50 @@
"""Added the 'image' tables.
Revision ID: 23e928dda837
Revises: 1ee62841eb90
Create Date: 2019-12-17 07:15:55.472022
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '23e928dda837'
down_revision = '1ee62841eb90'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('publisher_image',
sa.Column('publ_id', sa.Integer(), nullable=False),
sa.Column('image_filename', sa.String(length=500), nullable=False),
sa.Column('image_data', sa.LargeBinary(), nullable=False),
sa.ForeignKeyConstraint(['publ_id'], ['publisher.publ_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('publ_id')
)
op.create_table('publication_image',
sa.Column('pub_id', sa.Integer(), nullable=False),
sa.Column('image_filename', sa.String(length=500), nullable=False),
sa.Column('image_data', sa.LargeBinary(), nullable=False),
sa.ForeignKeyConstraint(['pub_id'], ['publication.pub_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('pub_id')
)
op.create_table('article_image',
sa.Column('article_id', sa.Integer(), nullable=False),
sa.Column('image_filename', sa.String(length=500), nullable=False),
sa.Column('image_data', sa.LargeBinary(), nullable=False),
sa.ForeignKeyConstraint(['article_id'], ['article.article_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('article_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('article_image')
op.drop_table('publication_image')
op.drop_table('publisher_image')
# ### end Alembic commands ###

@ -78,6 +78,7 @@ import asl_articles.publications #pylint: disable=cyclic-import
import asl_articles.articles #pylint: disable=cyclic-import
import asl_articles.authors #pylint: disable=cyclic-import
import asl_articles.tags #pylint: disable=cyclic-import
import asl_articles.images #pylint: disable=cyclic-import
import asl_articles.utils #pylint: disable=cyclic-import
# initialize

@ -1,12 +1,13 @@
""" Handle article requests. """
import datetime
import base64
import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Article, Author, ArticleAuthor
from asl_articles.models import Article, Author, ArticleAuthor, ArticleImage
from asl_articles.authors import do_get_authors
from asl_articles.tags import do_get_tags
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
@ -65,6 +66,7 @@ def create_article():
db.session.flush()
new_article_id = article.article_id
_save_authors( article, updated )
_save_image( article )
db.session.commit()
_logger.debug( "- New ID: %d", new_article_id )
@ -110,6 +112,28 @@ def _save_authors( article, updated_fields ):
# yup - let the caller know about them
updated_fields[ "article_authors"] = author_ids
def _save_image( article ):
"""Save the article's image."""
# check if a new image was provided
image_data = request.json.get( "imageData" )
if not image_data:
return
# yup - delete the old one from the database
ArticleImage.query.filter( ArticleImage.article_id == article.article_id ).delete()
if image_data == "{remove}":
# NOTE: The front-end sends this if it wants the article to have no image.
return
# add the new image to the database
image_data = base64.b64decode( image_data )
fname = request.json.get( "imageFilename" )
img = ArticleImage( article_id=article.article_id, image_filename=fname, image_data=image_data )
db.session.add( img )
db.session.flush()
_logger.debug( "Created new image: %s, #bytes=%d", fname, len(image_data) )
# ---------------------------------------------------------------------
@app.route( "/article/update", methods=["POST"] )
@ -129,9 +153,10 @@ def update_article():
article = Article.query.get( article_id )
if not article:
abort( 404 )
apply_attrs( article, vals )
_save_authors( article, updated )
_save_image( article )
vals[ "time_updated" ] = datetime.datetime.now()
apply_attrs( article, vals )
db.session.commit()
# generate the response

@ -0,0 +1,25 @@
""" Handle image requests. """
import io
from flask import send_file, abort
from asl_articles import app
#from asl_articles.models import PublisherImage, PublicationImage, ArticleImage
import asl_articles.models
# ---------------------------------------------------------------------
@app.route( "/images/<image_type>/<image_id>" )
def get_image( image_type, image_id ):
"""Return an image."""
model = getattr( asl_articles.models, image_type.capitalize()+"Image" )
if not model:
abort( 404 )
img = model.query.get( image_id )
if not img:
abort( 404 )
return send_file(
io.BytesIO( img.image_data ),
attachment_filename = img.image_filename # nb: so that Flask can set the MIME type
)

@ -1,5 +1,7 @@
""" Define the database models. """
# NOTE: Don't forget to keep the list of tables in init_db() in sync with the models defined here.
from asl_articles import db
# ---------------------------------------------------------------------
@ -100,3 +102,60 @@ class ArticleAuthor( db.Model ):
return "<ArticleAuthor:{}|{}:{},{}>".format( self.article_author_id,
self.seq_no, self.article_id, self.author_id
)
# ---------------------------------------------------------------------
# NOTE: I initially put all the images in a single table, but this makes cascading deletes tricky,
# since we can't set up a foreign key relationship between these rows and their parent.
# While it probably won't matter for a database this size, keeping large blobs in the main tables
# is a bit icky, so we create separate tables for each type of image.
class PublisherImage( db.Model ):
"""Define the PublisherImage model."""
publ_id = db.Column( db.Integer,
db.ForeignKey( Publisher.__table__.c.publ_id, ondelete="CASCADE" ),
primary_key = True
)
image_filename = db.Column( db.String(500), nullable=False )
image_data = db.Column( db.LargeBinary, nullable=False )
def __repr__( self ):
return "<PublisherImage:{}|{}>".format( self.publ_id, len(self.image_data) )
class PublicationImage( db.Model ):
"""Define the PublicationImage model."""
pub_id = db.Column( db.Integer,
db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" ),
primary_key = True
)
image_filename = db.Column( db.String(500), nullable=False )
image_data = db.Column( db.LargeBinary, nullable=False )
def __repr__( self ):
return "<PublicationImage:{}|{}>".format( self.pub_id, len(self.image_data) )
class ArticleImage( db.Model ):
"""Define the ArticleImage model."""
article_id = db.Column( db.Integer,
db.ForeignKey( Article.__table__.c.article_id, ondelete="CASCADE" ),
primary_key = True
)
image_filename = db.Column( db.String(500), nullable=False )
image_data = db.Column( db.LargeBinary, nullable=False )
def __repr__( self ):
return "<ArticleImage:{}|{}>".format( self.article_id, len(self.image_data) )
# ---------------------------------------------------------------------
def get_model_from_table_name( table_name ):
"""Return the model class for the specified table."""
pos = table_name.find( "_" )
if pos >= 0:
model_name = table_name[:pos].capitalize() + table_name[pos+1:].capitalize()
else:
model_name = table_name.capitalize()
return globals()[ model_name ]

@ -1,12 +1,13 @@
""" Handle publication requests. """
import datetime
import base64
import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publication, Article
from asl_articles.models import Publication, PublicationImage, Article
from asl_articles.tags import do_get_tags
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
@ -75,6 +76,7 @@ def create_publication():
vals[ "time_created" ] = datetime.datetime.now()
pub = Publication( **vals )
db.session.add( pub )
_save_image( pub )
db.session.commit()
_logger.debug( "- New ID: %d", pub.pub_id )
@ -85,6 +87,28 @@ def create_publication():
extras[ "tags" ] = do_get_tags()
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
def _save_image( pub ):
"""Save the publication's image."""
# check if a new image was provided
image_data = request.json.get( "imageData" )
if not image_data:
return
# yup - delete the old one from the database
PublicationImage.query.filter( PublicationImage.pub_id == pub.pub_id ).delete()
if image_data == "{remove}":
# NOTE: The front-end sends this if it wants the publication to have no image.
return
# add the new image to the database
image_data = base64.b64decode( image_data )
fname = request.json.get( "imageFilename" )
img = PublicationImage( pub_id=pub.pub_id, image_filename=fname, image_data=image_data )
db.session.add( img )
db.session.flush()
_logger.debug( "Created new image: %s, #bytes=%d", fname, len(image_data) )
# ---------------------------------------------------------------------
@app.route( "/publication/update", methods=["POST"] )
@ -104,8 +128,9 @@ def update_publication():
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
vals[ "time_updated" ] = datetime.datetime.now()
apply_attrs( pub, vals )
_save_image( pub )
vals[ "time_updated" ] = datetime.datetime.now()
db.session.commit()
# generate the response

@ -1,12 +1,13 @@
""" Handle publisher requests. """
import datetime
import base64
import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publisher, Publication, Article
from asl_articles.models import Publisher, PublisherImage, 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
@ -76,6 +77,7 @@ def create_publisher():
vals[ "time_created" ] = datetime.datetime.now()
publ = Publisher( **vals )
db.session.add( publ )
_save_image( publ )
db.session.commit()
_logger.debug( "- New ID: %d", publ.publ_id )
@ -85,6 +87,28 @@ def create_publisher():
extras[ "publishers" ] = _do_get_publishers()
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
def _save_image( publ ):
"""Save the publisher's image."""
# check if a new image was provided
image_data = request.json.get( "imageData" )
if not image_data:
return
# yup - delete the old one from the database
PublisherImage.query.filter( PublisherImage.publ_id == publ.publ_id ).delete()
if image_data == "{remove}":
# NOTE: The front-end sends this if it wants the publisher to have no image.
return
# add the new image to the database
image_data = base64.b64decode( image_data )
fname = request.json.get( "imageFilename" )
img = PublisherImage( publ_id=publ.publ_id, image_filename=fname, image_data=image_data )
db.session.add( img )
db.session.flush()
_logger.debug( "Created new image: %s, #bytes=%d", fname, len(image_data) )
# ---------------------------------------------------------------------
@app.route( "/publisher/update", methods=["POST"] )
@ -103,8 +127,9 @@ def update_publisher():
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
vals[ "time_updated" ] = datetime.datetime.now()
_save_image( publ )
apply_attrs( publ, vals )
vals[ "time_updated" ] = datetime.datetime.now()
db.session.commit()
# generate the response

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@ -1,11 +1,14 @@
""" Test article operations. """
import os
import urllib.request
import urllib.error
import json
import base64
from asl_articles.tests.utils import init_tests, 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
set_toast_marker, check_toast, send_upload_data, check_ask_dialog, check_error_msg
from asl_articles.tests.react_select import ReactSelect
# ---------------------------------------------------------------------
@ -14,7 +17,7 @@ def test_edit_article( webdriver, flask_app, dbconn ):
"""Test editing articles."""
# initialize
init_tests( webdriver, flask_app, dbconn, "articles.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="articles.json" )
# edit "What To Do If You Have A Tin Can"
results = do_search( "tin can" )
@ -103,7 +106,7 @@ def test_delete_article( webdriver, flask_app, dbconn ):
"""Test deleting articles."""
# initialize
init_tests( webdriver, flask_app, dbconn, "articles.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="articles.json" )
# start to delete article "Smoke Gets In Your Eyes", but cancel the operation
results = do_search( "smoke" )
@ -139,11 +142,81 @@ def test_delete_article( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def test_images( webdriver, flask_app, dbconn ):
"""Test article images."""
# initialize
init_tests( webdriver, flask_app, dbconn, max_image_upload_size=2*1024 )
def check_image( expected ):
find_child( ".edit", article_sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
if expected:
# make sure there is an image
img = find_child( ".row.image img.image", dlg )
image_url = img.get_attribute( "src" )
assert "/images/article/{}".format( article_id ) in image_url
# make sure the "remove image" icon is visible
btn = find_child( ".row.image .remove-image", dlg )
assert btn.is_displayed()
# make sure the article's image is correct
resp = urllib.request.urlopen( image_url ).read()
assert resp == open(expected,"rb").read()
else:
# make sure there is no image
img = find_child( ".row.image img.image", dlg )
assert img.get_attribute( "src" ).endswith( "/images/placeholder.png" )
# make sure the "remove image" icon is hidden
btn = find_child( ".row.image .remove-image", dlg )
assert not btn.is_displayed()
# make sure the article's image is not available
url = flask_app.url_for( "get_image", image_type="article", image_id=article_id )
try:
resp = urllib.request.urlopen( url )
assert False, "Should never get here!"
except urllib.error.HTTPError as ex:
assert ex.code == 404
find_child( ".cancel", dlg ).click()
# create an article with no image
_create_article( { "title": "Test Article" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
article_sr = results[0]
article_id = article_sr.get_attribute( "testing--article_id" )
check_image( None )
# add an image to the article
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )
_edit_article( article_sr, { "image": fname } )
check_image( fname )
# change the article's image
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" )
_edit_article( article_sr, { "image": fname } )
check_image( fname )
# remove the article's image
_edit_article( article_sr, { "image": None } )
check_image( None )
# try to upload an image that's too large
find_child( ".edit", article_sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
data = base64.b64encode( 5000 * b" " )
data = "{}|{}".format( "too-big.png", data.decode("ascii") )
send_upload_data( data,
lambda: find_child( ".row.image img.image", dlg ).click()
)
check_error_msg( "The file must be no more than 2 KB in size." )
# ---------------------------------------------------------------------
def test_parent_publisher( webdriver, flask_app, dbconn ):
"""Test setting an article's parent publication."""
# initialize
init_tests( webdriver, flask_app, dbconn, "parents.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="parents.json" )
article_sr = None
def check_results( expected_parent ):
@ -268,13 +341,13 @@ def _create_article( vals, toast_type="info" ):
# create the new article
find_child( "#menu .new-article" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
if k == "tags":
for key,val in vals.items():
if key == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
select.update_multiselect_values( *val )
else:
sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), v )
sel = ".{} {}".format( key , "textarea" if key == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), val )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new article was created successfully
@ -287,19 +360,28 @@ def _edit_article( result, vals, toast_type="info", expected_error=None ):
# update the specified article's details
find_child( ".edit", result ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
if k == "authors":
for key,val in vals.items():
if key == "image":
if val:
data = base64.b64encode( open( val, "rb" ).read() )
data = "{}|{}".format( os.path.split(val)[1], data.decode("ascii") )
send_upload_data( data,
lambda: find_child( ".image img", dlg ).click()
)
else:
find_child( ".remove-image", dlg ).click()
elif key == "authors":
select = ReactSelect( find_child( ".authors .react-select", dlg ) )
select.update_multiselect_values( *v )
elif k == "publication":
select.update_multiselect_values( *val )
elif key == "publication":
select = ReactSelect( find_child( ".publication .react-select", dlg ) )
select.select_by_name( v )
elif k == "tags":
select.select_by_name( val )
elif key == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
select.update_multiselect_values( *val )
else:
sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), v )
sel = ".{} {}".format( key , "textarea" if key == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), val )
set_toast_marker( toast_type )
find_child( "button.ok", dlg ).click()
if expected_error:

@ -8,7 +8,7 @@ def test_basic( webdriver, flask_app, dbconn ):
"""Basic tests."""
# initialize
init_tests( webdriver, flask_app, dbconn, "basic.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="basic.json" )
# make sure the home page loaded correctly
elem = find_child( "#search-form .caption" )

@ -1,11 +1,14 @@
""" Test publication operations. """
import os
import urllib.request
import urllib.error
import json
import base64
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
set_toast_marker, check_toast, send_upload_data, check_ask_dialog, check_error_msg
from asl_articles.tests.react_select import ReactSelect
# ---------------------------------------------------------------------
@ -14,7 +17,7 @@ def test_edit_publication( webdriver, flask_app, dbconn ):
"""Test editing publications."""
# initialize
init_tests( webdriver, flask_app, dbconn, "publications.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="publications.json" )
# edit "ASL Journal #2"
results = do_search( "asl journal" )
@ -103,7 +106,7 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
"""Test deleting publications."""
# initialize
init_tests( webdriver, flask_app, dbconn, "publications.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="publications.json" )
# start to delete publication "ASL Journal #1", but cancel the operation
results = do_search( "ASL Journal" )
@ -141,11 +144,81 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def test_images( webdriver, flask_app, dbconn ):
"""Test publication images."""
# initialize
init_tests( webdriver, flask_app, dbconn, max_image_upload_size=2*1024 )
def check_image( expected ):
find_child( ".edit", pub_sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
if expected:
# make sure there is an image
img = find_child( ".row.image img.image", dlg )
image_url = img.get_attribute( "src" )
assert "/images/publication/{}".format( pub_id ) in image_url
# make sure the "remove image" icon is visible
btn = find_child( ".row.image .remove-image", dlg )
assert btn.is_displayed()
# make sure the publication's image is correct
resp = urllib.request.urlopen( image_url ).read()
assert resp == open(expected,"rb").read()
else:
# make sure there is no image
img = find_child( ".row.image img.image", dlg )
assert img.get_attribute( "src" ).endswith( "/images/placeholder.png" )
# make sure the "remove image" icon is hidden
btn = find_child( ".row.image .remove-image", dlg )
assert not btn.is_displayed()
# make sure the publication's image is not available
url = flask_app.url_for( "get_image", image_type="publication", image_id=pub_id )
try:
resp = urllib.request.urlopen( url )
assert False, "Should never get here!"
except urllib.error.HTTPError as ex:
assert ex.code == 404
find_child( ".cancel", dlg ).click()
# create an publication with no image
_create_publication( {"name": "Test Publication" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
pub_sr = results[0]
pub_id = pub_sr.get_attribute( "testing--pub_id" )
check_image( None )
# add an image to the publication
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )
_edit_publication( pub_sr, { "image": fname } )
check_image( fname )
# change the publication's image
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" )
_edit_publication( pub_sr, { "image": fname } )
check_image( fname )
# remove the publication's image
_edit_publication( pub_sr, { "image": None } )
check_image( None )
# try to upload an image that's too large
find_child( ".edit", pub_sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
data = base64.b64encode( 5000 * b" " )
data = "{}|{}".format( "too-big.png", data.decode("ascii") )
send_upload_data( data,
lambda: find_child( ".row.image img.image", dlg ).click()
)
check_error_msg( "The file must be no more than 2 KB in size." )
# ---------------------------------------------------------------------
def test_parent_publisher( webdriver, flask_app, dbconn ):
"""Test setting a publication's parent publisher."""
# initialize
init_tests( webdriver, flask_app, dbconn, "parents.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="parents.json" )
pub_sr = None
def check_results( expected_parent ):
@ -309,13 +382,13 @@ def _create_publication( vals, toast_type="info" ):
# create the new publication
find_child( "#menu .new-publication" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
if k == "tags":
for key,val in vals.items():
if key == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
select.update_multiselect_values( *val )
else:
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
sel = ".{} {}".format( key , "textarea" if key == "description" else "input" )
set_elem_text( find_child( sel, dlg ), val )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new publication was created successfully
@ -328,16 +401,25 @@ def _edit_publication( result, vals, toast_type="info", expected_error=None ):
# update the specified publication's details
find_child( ".edit", result ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
if k == "publisher":
for key,val in vals.items():
if key == "image":
if val:
data = base64.b64encode( open( val, "rb" ).read() )
data = "{}|{}".format( os.path.split(val)[1], data.decode("ascii") )
send_upload_data( data,
lambda: find_child( ".image img", dlg ).click()
)
else:
find_child( ".remove-image", dlg ).click()
elif key == "publisher":
select = ReactSelect( find_child( ".publisher .react-select", dlg ) )
select.select_by_name( v )
elif k == "tags":
select.select_by_name( val )
elif key == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
select.update_multiselect_values( *val )
else:
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
sel = ".{} {}".format( key , "textarea" if key == "description" else "input" )
set_elem_text( find_child( sel, dlg ), val )
set_toast_marker( toast_type )
find_child( "button.ok", dlg ).click()
if expected_error:

@ -1,8 +1,13 @@
""" Test publisher operations. """
import os
import urllib.request
import urllib.error
import base64
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
set_toast_marker, check_toast, send_upload_data, check_ask_dialog, check_error_msg
# ---------------------------------------------------------------------
@ -10,7 +15,7 @@ def test_edit_publisher( webdriver, flask_app, dbconn ):
"""Test editing publishers."""
# initialize
init_tests( webdriver, flask_app, dbconn, "basic.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="basic.json" )
# edit "Avalon Hill"
results = do_search( "" )
@ -89,7 +94,7 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
"""Test deleting publishers."""
# initialize
init_tests( webdriver, flask_app, dbconn, "basic.json" )
init_tests( webdriver, flask_app, dbconn, fixtures="basic.json" )
# start to delete publisher "Le Franc Tireur", but cancel the operation
results = do_search( "" )
@ -126,6 +131,76 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def test_images( webdriver, flask_app, dbconn ):
"""Test publisher images."""
# initialize
init_tests( webdriver, flask_app, dbconn, max_image_upload_size=2*1024 )
def check_image( expected ):
find_child( ".edit", publ_sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
if expected:
# make sure there is an image
img = find_child( ".row.image img.image", dlg )
image_url = img.get_attribute( "src" )
assert "/images/publisher/{}".format( publ_id ) in image_url
# make sure the "remove image" icon is visible
btn = find_child( ".row.image .remove-image", dlg )
assert btn.is_displayed()
# make sure the publisher's image is correct
resp = urllib.request.urlopen( image_url ).read()
assert resp == open(expected,"rb").read()
else:
# make sure there is no image
img = find_child( ".row.image img.image", dlg )
assert img.get_attribute( "src" ).endswith( "/images/placeholder.png" )
# make sure the "remove image" icon is hidden
btn = find_child( ".row.image .remove-image", dlg )
assert not btn.is_displayed()
# make sure the publisher's image is not available
url = flask_app.url_for( "get_image", image_type="publisher", image_id=publ_id )
try:
resp = urllib.request.urlopen( url )
assert False, "Should never get here!"
except urllib.error.HTTPError as ex:
assert ex.code == 404
find_child( ".cancel", dlg ).click()
# create an publisher with no image
_create_publisher( { "name": "Test Publisher" } )
results = find_children( "#search-results .search-result" )
assert len(results) == 1
publ_sr = results[0]
publ_id = publ_sr.get_attribute( "testing--publ_id" )
check_image( None )
# add an image to the publisher
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )
_edit_publisher( publ_sr, { "image": fname } )
check_image( fname )
# change the publisher's image
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" )
_edit_publisher( publ_sr, { "image": fname } )
check_image( fname )
# remove the publisher's image
_edit_publisher( publ_sr, { "image": None } )
check_image( None )
# try to upload an image that's too large
find_child( ".edit", publ_sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
data = base64.b64encode( 5000 * b" " )
data = "{}|{}".format( "too-big.png", data.decode("ascii") )
send_upload_data( data,
lambda: find_child( ".row.image img.image", dlg ).click()
)
check_error_msg( "The file must be no more than 2 KB in size." )
# ---------------------------------------------------------------------
def test_cascading_deletes( webdriver, flask_app, dbconn ):
"""Test cascading deletes."""
@ -275,9 +350,9 @@ def _create_publisher( vals, toast_type="info" ):
# create the new publisher
find_child( "#menu .new-publisher" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
for key,val in vals.items():
sel = ".{} {}".format( key , "textarea" if key == "description" else "input" )
set_elem_text( find_child( sel, dlg ), val )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new publisher was created successfully
@ -290,9 +365,19 @@ def _edit_publisher( result, vals, toast_type="info", expected_error=None ):
# update the specified publisher'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 == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
for key,val in vals.items():
if key == "image":
if val:
data = base64.b64encode( open( val, "rb" ).read() )
data = "{}|{}".format( os.path.split(val)[1], data.decode("ascii") )
send_upload_data( data,
lambda: find_child( ".image img", dlg ).click()
)
else:
find_child( ".remove-image", dlg ).click()
else:
sel = ".{} {}".format( key , "textarea" if key == "description" else "input" )
set_elem_text( find_child( sel, dlg ), val )
set_toast_marker( toast_type )
find_child( "button.ok", dlg ).click()
if expected_error:

@ -21,7 +21,7 @@ _flask_app = None # nb: this may not be set (if we're talking to an existing Fla
# ---------------------------------------------------------------------
def init_tests( webdriver, flask_app, dbconn, fixtures_fname=None ):
def init_tests( webdriver, flask_app, dbconn, **kwargs ):
"""Prepare to run tests."""
# initialize
@ -30,13 +30,14 @@ def init_tests( webdriver, flask_app, dbconn, fixtures_fname=None ):
_flask_app = flask_app
# initialize the database
fixtures = kwargs.pop( "fixtures", None )
if dbconn:
init_db( dbconn, fixtures_fname )
init_db( dbconn, fixtures )
else:
assert fixtures_fname is None
assert fixtures is None
# load the home page
webdriver.get( webdriver.make_url( "/" ) )
webdriver.get( webdriver.make_url( "/", **kwargs ) )
wait_for_elem( 2, "#search-form" )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -57,8 +58,11 @@ def init_db( dbconn, fixtures_fname ):
data = {}
# load the test data into the database
for table_name in ["publisher","publication","article"]:
model = getattr( asl_articles.models, table_name.capitalize() )
table_names = [ "publisher", "publication", "article" ]
table_names.extend( [ "author", "article_author" ] )
table_names.extend( [ "publisher_image", "publication_image", "article_image" ] )
for table_name in table_names:
model = asl_articles.models.get_model_from_table_name( table_name )
session.query( model ).delete()
if table_name in data:
session.bulk_insert_mappings( model, data[table_name] )
@ -208,6 +212,21 @@ def check_toast( toast_type, expected, contains=False, check_others=True ):
def _make_toast_stored_msg_id( toast_type ):
return "{}_toast".format( toast_type )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def send_upload_data( data, func ):
"""Send data to the front-end, to simulate uploading a file.
Because Selenium can't control a browser's native "open file" dialog, we need a different mechanism
to test features that require uploading a file. We store the data we want to upload as a base64-encoded
string in a hidden textarea, and the front-end Javascript loads it from there.
"""
# send the data to the front-end
set_stored_msg( "upload", data )
func() # nb: the caller must initiate the upload process
# wait for the front-end to acknowledge receipt of the data
wait_for( 2, lambda: get_stored_msg("upload") == "" )
# ---------------------------------------------------------------------
def set_elem_text( elem, val ):

@ -155,12 +155,12 @@ def webdriver( request ):
# initialize
web_url = request.config.getoption( "--web-url" )
assert web_url
def make_web_url( url ):
def make_web_url( url, **kwargs ):
"""Generate a URL for the React frontend."""
url = "{}/{}".format( web_url, url )
kwargs = {}
kwargs[ "_flask"] = flask_url
kwargs[ "store_msgs"] = 1 # stop notification messages from building up and obscuring clicks
kwargs[ "fake_uploads"] = 1 # alternate mechanism for uploading files
url += "&" if "?" in url else "?"
url += urllib.parse.urlencode( kwargs )
return url

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@ -31,14 +31,15 @@ export default class App extends React.Component
} ;
// initialize
const args = queryString.parse( window.location.search ) ;
this._storeMsgs = this.isTestMode() && args.store_msgs ;
this.args = queryString.parse( window.location.search ) ;
this._storeMsgs = this.isTestMode() && this.args.store_msgs ;
this._fakeUploads = this.isTestMode() && this.args.fake_uploads ;
// figure out the base URL of the Flask backend server
// NOTE: We allow the caller to do this since the test suite will usually spin up
// it's own Flask server, but talks to an existing React server, so we need some way
// for pytest to change which Flask server the React frontend code should tak to.
this._flaskBaseUrl = this.isTestMode() ? args._flask : null ;
this._flaskBaseUrl = this.isTestMode() ? this.args._flask : null ;
if ( ! this._flaskBaseUrl )
this._flaskBaseUrl = process.env.REACT_APP_FLASK_URL ;
}
@ -79,6 +80,9 @@ export default class App extends React.Component
<textarea id="_stored_msg-warning_toast_" ref="_stored_msg-warning_toast_" defaultValue="" hidden={true} />
<textarea id="_stored_msg-error_toast_" ref="_stored_msg-error_toast_" defaultValue="" hidden={true} />
</div> }
{ this._fakeUploads && <div>
<textarea id="_stored_msg-upload_" ref="_stored_msg-upload_" defaultValue="" hidden={true} />
</div> }
</div> ) ;
}
@ -182,12 +186,15 @@ export default class App extends React.Component
// save the message for the test suite to retrieve (nb: we also don't show the toast itself
// since these build up when tests are running at high speed, and obscure elements that
// we want to click on :-/
this.refs[ "_stored_msg-" + type + "_toast_" ].value = ReactDOMServer.renderToStaticMarkup( msg ) ;
this.setStoredMsg( type+"_toast", ReactDOMServer.renderToStaticMarkup(msg) ) ;
return ;
}
toast( msg, { type: type, autoClose: autoClose } ) ;
}
setStoredMsg( msgType, msgData ) { this.refs[ "_stored_msg-" + msgType + "_" ].value = msgData ; }
getStoredMsg( msgType ) { return this.refs[ "_stored_msg-" + msgType + "_" ].value }
showErrorMsg( content ) {
// show the error message in a modal dialog
this.ask( content, "error",
@ -275,6 +282,7 @@ export default class App extends React.Component
}
isTestMode() { return process.env.REACT_APP_TEST_MODE ; }
isFakeUploads() { return this._fakeUploads ; }
setTestAttribute( obj, attrName, attrVal ) {
// set an attribute on an element (for testing porpoises)
if ( obj && this.isTestMode() )

@ -3,6 +3,7 @@ import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { ImageFileUploader } from "./FileUploader.js" ;
import { makeOptionalLink, unloadCreatableSelect, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -82,6 +83,32 @@ export class ArticleSearchResult extends React.Component
static _doEditArticle( vals, notify ) {
let refs = {} ;
// initialize the image
let imageFilename=null, imageData=null ;
let imageRef=null, uploadImageRef=null, removeImageRef=null ;
let imageUrl = gAppRef.makeFlaskUrl( "/images/article/" + vals.article_id ) ;
imageUrl += "?foo=" + Math.random() ; // FUDGE! To bypass the cache :-/
let onMissingImage = (evt) => {
imageRef.src = "/images/placeholder.png" ;
removeImageRef.style.display = "none" ;
} ;
let onUploadImage = (evt) => {
if ( evt === null && !gAppRef.isFakeUploads() ) {
// nb: the article image was clicked - trigger an upload request
uploadImageRef.click() ;
return ;
}
let fileUploader = new ImageFileUploader() ;
fileUploader.getFile( evt, imageRef, removeImageRef, (fname,data) => {
imageFilename = fname ;
imageData = data ;
} ) ;
} ;
let onRemoveImage = () => {
imageData = "{remove}" ;
imageRef.src = "/images/placeholder.png" ;
removeImageRef.style.display = "none" ;
} ;
// initialize the publications
let publications = [ { value: null, label: <i>(none)</i> } ] ;
let currPub = 0 ;
@ -110,7 +137,13 @@ export class ArticleSearchResult extends React.Component
// initialize the tags
const tags = gAppRef.makeTagLists( vals.article_tags ) ;
// prepare the form content
/* eslint-disable jsx-a11y/img-redundant-alt */
const content = <div>
<div className="row image">
<img src={imageUrl} className="image" onError={onMissingImage} onClick={() => onUploadImage(null)} ref={r => imageRef=r} alt="Click to upload an image for this article." />
<img src="/images/delete.png" className="remove-image" onClick={onRemoveImage} ref={r => removeImageRef=r} alt="Remove the article's image." />
<input type="file" accept="image/*" onChange={onUploadImage} style={{display:"none"}} ref={r => uploadImageRef=r} />
</div>
<div className="row title"> <label> Title: </label>
<input type="text" defaultValue={vals.article_title} ref={(r) => refs.article_title=r} />
</div>
@ -165,6 +198,10 @@ export class ArticleSearchResult extends React.Component
} else
newVals[ r ] = refs[r].value.trim() ;
}
if ( imageData ) {
newVals.imageData = imageData ;
newVals.imageFilename = imageFilename ;
}
if ( newVals.article_title === "" ) {
gAppRef.showErrorMsg( <div> Please specify the article's title. </div>) ;
return ;

@ -0,0 +1,76 @@
import { MAX_IMAGE_UPLOAD_SIZE } from "./constants.js" ;
import { bytesDisplayString } from "./utils.js" ;
import { gAppRef } from "./index.js" ;
// --------------------------------------------------------------------
export class FileUploader {
// Because Selenium can't control a browser's native "open file" dialog, we need a different mechanism
// to test features that require uploading a file. The test suite stores the data it wants to upload
// as a base64-encoded string in a hidden textarea, and we load it from there.
getFile( evt, maxSize, onLoad ) {
function onLoadWrapper( fname, data ) {
// check that the uploaded file is not too big
if ( maxSize && data.length > maxSize ) {
gAppRef.showErrorMsg( "The file must be no more than " + bytesDisplayString(maxSize) + " in size." ) ;
return ;
}
// notify the caller about the uploaded data
onLoad( fname, data ) ;
}
// check if we're being run by the test suite
if ( gAppRef.isFakeUploads() ) {
// yup - load the file data sent to us by the test suite
let data = gAppRef.getStoredMsg( "upload" ) ;
let pos = data.indexOf( "|" ) ;
let fname = data.substr( 0, pos ) ;
data = data.substr( pos+1 ) ;
onLoadWrapper( fname, data ) ;
// let the test suite know we've received the data
gAppRef.setStoredMsg( "upload", "" ) ;
return ;
} else {
// nope - read the file data normally
let fname = evt.target.files[0].name ;
let fileReader = new FileReader() ;
fileReader.onload = () => { onLoadWrapper( fname, fileReader.result ) } ;
fileReader.readAsDataURL( evt.target.files[0] ) ;
}
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
export class ImageFileUploader {
getFile( evt, imageRef, removeImageRef, onLoad ) {
let fileUploader = new FileUploader() ;
let maxSize = MAX_IMAGE_UPLOAD_SIZE ;
if ( gAppRef.isTestMode() && gAppRef.args.max_image_upload_size )
maxSize = gAppRef.args.max_image_upload_size ;
fileUploader.getFile( evt, maxSize, (fname,data) => {
// fix-up the image data received
let prefix ;
if ( gAppRef.isFakeUploads() )
prefix = "data:image/unknown;base64," ;
else {
let pos = data.indexOf( ";base64," ) ;
prefix = data.substr( 0, pos+8 ) ;
data = data.substring( pos+8 ) ;
}
// update the UI
imageRef.src = prefix + data ;
removeImageRef.style.display = "inline" ;
// notify the caller
onLoad( fname, data ) ;
} ) ;
}
}

@ -0,0 +1,2 @@
.row.image img.image { height: 2em ; cursor: pointer ; }
.row.image img.remove-image { height: 1em ; margin-left: 0.25em ; cursor: pointer ; }

@ -3,6 +3,7 @@ import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { ImageFileUploader } from "./FileUploader.js" ;
import { makeOptionalLink, unloadCreatableSelect, pluralString, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -79,6 +80,32 @@ export class PublicationSearchResult extends React.Component
static _doEditPublication( vals, notify ) {
let refs = {} ;
// initialize the image
let imageFilename=null, imageData=null ;
let imageRef=null, uploadImageRef=null, removeImageRef=null ;
let imageUrl = gAppRef.makeFlaskUrl( "/images/publication/" + vals.pub_id ) ;
imageUrl += "?foo=" + Math.random() ; // FUDGE! To bypass the cache :-/
let onMissingImage = (evt) => {
imageRef.src = "/images/placeholder.png" ;
removeImageRef.style.display = "none" ;
} ;
let onUploadImage = (evt) => {
if ( evt === null && !gAppRef.isFakeUploads() ) {
// nb: the publication image was clicked - trigger an upload request
uploadImageRef.click() ;
return ;
}
let fileUploader = new ImageFileUploader() ;
fileUploader.getFile( evt, imageRef, removeImageRef, (fname,data) => {
imageFilename = fname ;
imageData = data ;
} ) ;
} ;
let onRemoveImage = () => {
imageData = "{remove}" ;
imageRef.src = "/images/placeholder.png" ;
removeImageRef.style.display = "none" ;
} ;
// initialize the publishers
let publishers = [ { value: null, label: <i>(none)</i> } ] ;
let currPubl = 0 ;
@ -96,7 +123,13 @@ export class PublicationSearchResult extends React.Component
// initialize the tags
const tags = gAppRef.makeTagLists( vals.pub_tags ) ;
// prepare the form content
/* eslint-disable jsx-a11y/img-redundant-alt */
const content = <div>
<div className="row image">
<img src={imageUrl} className="image" onError={onMissingImage} onClick={() => onUploadImage(null)} ref={r => imageRef=r} alt="Click to upload an image for this publication." />
<img src="/images/delete.png" className="remove-image" onClick={onRemoveImage} ref={r => removeImageRef=r} alt="Remove the publication's image." />
<input type="file" accept="image/*" onChange={onUploadImage} style={{display:"none"}} ref={r => uploadImageRef=r} />
</div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.pub_name} ref={(r) => refs.pub_name=r} />
</div>
@ -135,6 +168,10 @@ export class PublicationSearchResult extends React.Component
} else
newVals[ r ] = refs[r].value.trim() ;
}
if ( imageData ) {
newVals.imageData = imageData ;
newVals.imageFilename = imageFilename ;
}
if ( newVals.pub_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publication's name. </div>) ;
return ;

@ -1,5 +1,6 @@
import React from "react" ;
import { gAppRef } from "./index.js" ;
import { ImageFileUploader } from "./FileUploader.js" ;
import { makeOptionalLink, pluralString, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -68,7 +69,40 @@ export class PublisherSearchResult extends React.Component
static _doEditPublisher( vals, notify ) {
let refs = {} ;
// initialize the image
let imageFilename=null, imageData=null ;
let imageRef=null, uploadImageRef=null, removeImageRef=null ;
let imageUrl = gAppRef.makeFlaskUrl( "/images/publisher/" + vals.publ_id ) ;
imageUrl += "?foo=" + Math.random() ; // FUDGE! To bypass the cache :-/
let onMissingImage = (evt) => {
imageRef.src = "/images/placeholder.png" ;
removeImageRef.style.display = "none" ;
} ;
let onUploadImage = (evt) => {
if ( evt === null && !gAppRef.isFakeUploads() ) {
// nb: the publisher image was clicked - trigger an upload request
uploadImageRef.click() ;
return ;
}
let fileUploader = new ImageFileUploader() ;
fileUploader.getFile( evt, imageRef, removeImageRef, (fname,data) => {
imageFilename = fname ;
imageData = data ;
} ) ;
} ;
let onRemoveImage = () => {
imageData = "{remove}" ;
imageRef.src = "/images/placeholder.png" ;
removeImageRef.style.display = "none" ;
} ;
// prepare the form content
/* eslint-disable jsx-a11y/img-redundant-alt */
const content = <div>
<div className="row image">
<img src={imageUrl} className="image" onError={onMissingImage} onClick={() => onUploadImage(null)} ref={r => imageRef=r} alt="Click to upload an image for this publisher." />
<img src="/images/delete.png" className="remove-image" onClick={onRemoveImage} ref={r => removeImageRef=r} alt="Remove the publisher's image." />
<input type="file" accept="image/*" onChange={onUploadImage} style={{display:"none"}} ref={r => uploadImageRef=r} />
</div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.publ_name} ref={(r) => refs.publ_name=r} />
</div>
@ -85,6 +119,10 @@ export class PublisherSearchResult extends React.Component
let newVals = {} ;
for ( let r in refs )
newVals[ r ] = refs[r].value.trim() ;
if ( imageData ) {
newVals.imageData = imageData ;
newVals.imageFilename = imageFilename ;
}
if ( newVals.publ_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publisher's name. </div>) ;
return ;

@ -0,0 +1 @@
export let MAX_IMAGE_UPLOAD_SIZE = ( 100 * 1024 ) ;

@ -38,6 +38,18 @@ export function makeOptionalLink( caption, url ) {
return link ;
}
export function bytesDisplayString( nBytes )
{
if ( nBytes === 1 )
return "1 byte" ;
var vals = [ "bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" ] ;
for ( let i=1 ; i < vals.length ; i++ ) {
if ( nBytes < Math.pow( 1024, i ) )
return ( Math.round( ( nBytes / Math.pow(1024,i-1) ) * 100 ) / 100 ) + " " + vals[i-1] ;
}
return nBytes ;
}
export function slugify( val ) {
return val.toLowerCase().replace( " ", "-" ) ;
}

Loading…
Cancel
Save