Milimetric has submitted this change and it was merged.
Change subject: Adding test for cohort uploading for cohort with cyrilic and
arabic usernames.
......................................................................
Adding test for cohort uploading for cohort with cyrilic and arabic usernames.
Added ability to specify an arbitrary cohort file and test that it validates
correctly.
In order to have testing bindings in mediawiki testing db create tables that
mimic production as close as possible, changes were done to mediawiki user model
and wikimetrics database. Alembic migration is included.
Without the database and binding changes the cohort uploading tests will fail.
Much testing was done regarding database connection args and encoding in
both wikimetrics and mediwiki database on staging with real data.
Bug: 63933
Change-Id: I0771e74f3d0745737e3ea96482614382fe09d961
---
A
database_migrations/versions/43970813b4bb_changed_mediawiki_username_to_varbinary.py
A tests/static/public/testing-cohort-arabic.txt
A tests/static/public/testing-cohort-cyrilic.txt
M tests/test_models/test_validate_cohort.py
M wikimetrics/controllers/forms/cohort_upload.py
M wikimetrics/models/mediawiki/user.py
M wikimetrics/models/validate_cohort.py
M wikimetrics/utils.py
8 files changed, 281 insertions(+), 73 deletions(-)
Approvals:
Milimetric: Looks good to me, approved
jenkins-bot: Verified
diff --git
a/database_migrations/versions/43970813b4bb_changed_mediawiki_username_to_varbinary.py
b/database_migrations/versions/43970813b4bb_changed_mediawiki_username_to_varbinary.py
new file mode 100644
index 0000000..b342612
--- /dev/null
+++
b/database_migrations/versions/43970813b4bb_changed_mediawiki_username_to_varbinary.py
@@ -0,0 +1,28 @@
+"""Changed mediawiki_username to VARBINARY
+
+Revision ID: 43970813b4bb
+Revises: 1a5740750a28
+Create Date: 2014-04-25 10:27:08.597354
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '43970813b4bb'
+down_revision = '1a5740750a28'
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.mysql import VARBINARY
+from sqlalchemy import Column, Integer, String, Boolean
+
+
+def upgrade():
+ op.alter_column('wiki_user', 'mediawiki_username', type_=VARBINARY(255),
+ existing_type=String(255), existing_nullable=True)
+ ## end Alembic commands ###
+
+
+def downgrade():
+ op.alter_column('wiki_user', 'mediawiki_username', type_=String(255),
+ existing_type=VARBINARY(255), existing_nullable=True)
+ ### end Alembic commands ###
diff --git a/tests/static/public/testing-cohort-arabic.txt
b/tests/static/public/testing-cohort-arabic.txt
new file mode 100644
index 0000000..9857ae2
--- /dev/null
+++ b/tests/static/public/testing-cohort-arabic.txt
@@ -0,0 +1,11 @@
+juanita
+ الموسوعة
+ الحرة
+ التي
+ يستطيع
+ الجميع
+ تحريرها.
+ توجد
+ الآن
+ 276245 مقالة
+ بالعربية
\ No newline at end of file
diff --git a/tests/static/public/testing-cohort-cyrilic.txt
b/tests/static/public/testing-cohort-cyrilic.txt
new file mode 100644
index 0000000..600eec1
--- /dev/null
+++ b/tests/static/public/testing-cohort-cyrilic.txt
@@ -0,0 +1,16 @@
+juanita
+pepita
+fulanita
+18Наталь
+Абрам
+Александр
+Алексей
+АльбертAlbert
+Анатолий
+Андрей
+Антон
+Аркадий
+Марат
+Марк
+Матвей
+Михаил
\ No newline at end of file
diff --git a/tests/test_models/test_validate_cohort.py
b/tests/test_models/test_validate_cohort.py
index 377a26a..f4c91a8 100644
--- a/tests/test_models/test_validate_cohort.py
+++ b/tests/test_models/test_validate_cohort.py
@@ -1,35 +1,181 @@
import unittest
-from nose.tools import assert_equal, raises, assert_true, assert_false
-from wikimetrics.configurables import app
-from tests.fixtures import WebTest, QueueDatabaseTest, mediawiki_project
+import os
+from nose.tools import assert_equal, raises, assert_true, assert_false, nottest
+from wikimetrics.configurables import app, get_absolute_path
+from tests.fixtures import WebTest, QueueDatabaseTest, DatabaseTest,
mediawiki_project
from wikimetrics.controllers.forms import CohortUpload
from wikimetrics.models import (
MediawikiUser, Cohort, WikiUser, ValidateCohort, User,
normalize_project,
)
+from wikimetrics.utils import parse_username
+
+
+class MockCohort(object):
+ pass
+
+
+class ValidateCohortEncodingTest(DatabaseTest):
+
+ def setUp(self):
+ DatabaseTest.setUp(self)
+ self.test_report_path = os.path.join(get_absolute_path(), os.pardir,
'tests')
+
+ def tearDown(self):
+ pass
+
+ def test_validate_arabic_cohort(self):
+ '''
+ Cohort with arabic names should validate
+
+ If cohorts uploads are failing you could substitute
+ the file on this test by your file to test uploads.
+
+ Note this test does not test the parsing of the
+ cohort file done at the controller layer.
+ '''
+ self.validate_cohort('testing-cohort-arabic.txt')
+
+ def test_validate_cyrilic_cohort(self):
+ '''
+ Cohort with cyrilic names should validate
+
+ Note this test does not test the parsing of the
+ cohort file done at the controller layer.
+ '''
+ self.validate_cohort('testing-cohort-cyrilic.txt')
+
+ @nottest
+ def validate_cohort(self, filename):
+ '''
+ Given a cohort file with usernames all users but one should validate.
+ It will mingle the name of the 1st user.
+
+ Parameters:
+ filename : Name of a file that contains a cohort with user names
+ test will search for file in tests/static/public folder
+ '''
+
+ names = self.create_users_from_file(filename)
+
+ # establish ownership for this cohort otherwise things do not work
+ owner_user = User(username='test cohort owner', email='[email protected]')
+ self.session.add(owner_user)
+ self.session.commit()
+
+ # creating here kind of like a cohortupload mock
+ # flask forms do not lend themselves to easy mocking
+ cohort_upload = MockCohort()
+ cohort_upload.name = MockCohort()
+ cohort_upload.name.data = 'testing-cohort'
+ cohort_upload.description = MockCohort()
+ cohort_upload.description.data = 'testing-cohort'
+ cohort_upload.project = MockCohort()
+ cohort_upload.project.data = mediawiki_project
+ cohort_upload.validate_as_user_ids = MockCohort()
+ cohort_upload.validate_as_user_ids.data = False
+ cohort_upload.records = []
+
+ # mingle the name of the first user user
+ not_valid_editor_name = 'Mr Not Valid'
+ names[0] = not_valid_editor_name
+
+ for name in names:
+ cohort_upload.records.append({
+ 'username' : name,
+ 'project' : mediawiki_project,
+ })
+
+ # TODO clear session situation?
+ # all operations need to happen on the scope of the same session
+ # but this session passed in is going to be closed
+ vc = ValidateCohort.from_upload(cohort_upload, owner_user.id,
self.session)
+
+ cohort = self.session.query(Cohort).first()
+ self.session.commit()
+ vc.validate_records(self.session, cohort)
+
+ # now we need to assert that all users but the first one validate
+ assert_equal(len(
+ self.session.query(WikiUser)
+ .filter(WikiUser.validating_cohort == cohort.id)
+ .filter(WikiUser.valid)
+ .all()
+ ), len(names) - 1)
+
+ # retrieve the user that should not be valid, make sure it is not
indeed
+ wiki_user = self.session.query(WikiUser)\
+ .filter(WikiUser.validating_cohort == cohort.id)\
+ .filter(WikiUser.mediawiki_username == not_valid_editor_name).one()
+
+ assert_false(wiki_user.valid)
+
+ @nottest
+ def create_users_from_file(self, filename):
+ """
+ Adds a bunch of users to mediawiki user table from a file
+ with usernames.
+
+ In order to test encoding make sure the bindings of the testing and
production
+ databases match, we try to replicate as accurate as possible the
structure
+ of mediawiki db in our testing db but that is ongoing work that needs
to be
+ maintaned.
+
+ Parameters:
+ filename : Name of a file that contains a cohort with user names
+ test will search for file in tests/static/public folder
+ Return:
+ names: Array with the names of the users created as they appear on
the file
+ but capitalized to mediawiki convention
+ """
+
+ # open the cohort file
+ test_cohort_file = os.sep.join((self.test_report_path, 'static',
+ 'public', filename))
+ f = open(test_cohort_file, 'r')
+ names = []
+
+ # format names according to our convention
+ for line in f:
+ name = parse_username(line.strip())
+ names.append(name)
+
+ self.mwSession.bind.engine.execute(
+ MediawikiUser.__table__.insert(), [
+ {
+ 'user_name': '{0}'.format(n),
+ 'user_registration': 20130101000000,
+ 'user_email_token_expires': 20200101000000
+ }
+ for n in names
+ ]
+ )
+ self.mwSession.commit()
+
+ return names
class ValidateCohortTest(WebTest):
-
+
def test_normalize_project_shorthand(self):
normal = normalize_project('en')
assert_equal(normal, 'enwiki')
-
+
def test_normalize_project_uppercase(self):
normal = normalize_project(mediawiki_project.upper())
assert_equal(normal, mediawiki_project)
-
+
def test_normalize_project_nonexistent(self):
normal = normalize_project('blah')
assert_equal(normal, None)
-
+
def test_validate_cohorts(self):
self.helper_reset_validation()
self.cohort.validate_as_user_ids = False
self.session.commit()
v = ValidateCohort(self.cohort)
v.validate_records(self.session, self.cohort)
-
+
assert_equal(self.cohort.validated, True)
assert_equal(len(
self.session.query(WikiUser)
@@ -37,7 +183,7 @@
.filter(WikiUser.valid)
.all()
), 4)
-
+
def test_validate_cohorts_with_invalid_wikiusers(self):
self.helper_reset_validation()
self.cohort.validate_as_user_ids = False
@@ -47,7 +193,7 @@
self.session.commit()
v = ValidateCohort(self.cohort)
v.validate_records(self.session, self.cohort)
-
+
assert_equal(self.cohort.validated, True)
assert_equal(len(
self.session.query(WikiUser)
@@ -67,18 +213,16 @@
def setUp(self):
QueueDatabaseTest.setUp(self)
-
self.mwSession.add(MediawikiUser(user_name='Editor test-specific-0'))
self.mwSession.add(MediawikiUser(user_name='Editor test-specific-1'))
self.mwSession.commit()
-
+
owner_user = User()
self.session.add(owner_user)
self.session.commit()
self.owner_user_id = owner_user.id
-
- def test_small_cohort(self):
+ def test_small_cohort(self):
cohort_upload = CohortUpload()
cohort_upload.name.data = 'small_cohort'
cohort_upload.project.data = mediawiki_project
@@ -91,11 +235,11 @@
# one user with invalid project
{'username': 'Nonexisting2', 'project': 'Nonexisting'},
]
-
+
v = ValidateCohort.from_upload(cohort_upload, self.owner_user_id)
v.task.delay(v).get()
self.session.commit()
-
+
assert_equal(self.session.query(WikiUser).filter(
WikiUser.mediawiki_username == 'Editor
test-specific-0').one().valid, True)
assert_equal(self.session.query(WikiUser).filter(
@@ -104,19 +248,19 @@
WikiUser.mediawiki_username == 'Nonexisting').one().valid, False)
assert_equal(self.session.query(WikiUser).filter(
WikiUser.mediawiki_username == 'Nonexisting2').one().valid, False)
-
+
def test_from_upload_exception(self):
cohort_upload = CohortUpload()
cohort_upload.name.data = 'small_cohort'
cohort_upload.project.data = 'wiki'
cohort_upload.records = [{'fake': 'dict'}]
-
+
v = ValidateCohort.from_upload(cohort_upload, self.owner_user_id)
assert_equal(v, None)
class BasicTests(unittest.TestCase):
-
+
def test_repr(self):
cohort = Cohort(id=1)
v = ValidateCohort(cohort)
diff --git a/wikimetrics/controllers/forms/cohort_upload.py
b/wikimetrics/controllers/forms/cohort_upload.py
index 9e5d8a7..e890b1e 100644
--- a/wikimetrics/controllers/forms/cohort_upload.py
+++ b/wikimetrics/controllers/forms/cohort_upload.py
@@ -2,7 +2,7 @@
from wtforms import StringField, FileField, TextAreaField, RadioField
from wtforms.validators import Required
from wikimetrics.metrics.form_fields import RequiredIfNot
-
+from wikimetrics.utils import parse_username
from secure_form import WikimetricsSecureForm
@@ -19,7 +19,7 @@
('True', 'User Ids (Numbers found in the user_id column of the user
table)'),
('False', 'User Names (Names found in the user_name column of the user
table)')
])
-
+
@classmethod
def from_request(cls, request):
"""
@@ -28,14 +28,14 @@
values = request.form.copy()
values.update(request.files)
return cls(values)
-
+
def parse_records(self):
"""
You must call this to parse self.records out of the csv file
-
+
Parameters
request : the request with the file to parse
-
+
Returns
nothing, but sets self.records to the parsed lines of the csv
"""
@@ -54,11 +54,11 @@
def parse_records(unparsed, default_project):
"""
Parses records read from a csv file
-
+
Parameters
unparsed : records in array form, as read from a csv
default_project : the default project to attribute to records without
one
-
+
Returns
the parsed records in this form:
{'username':'parsed username', 'project':'as specified or default'}
@@ -77,27 +77,13 @@
else:
username = r[0]
project = default_project
-
+
if username is not None and len(username):
records.append({
'username' : parse_username(username),
'project' : project,
})
return records
-
-
-def parse_username(username):
- """
- parses uncapitalized, whitespace-padded, and weird-charactered mediawiki
- user names into ones that have a chance of being found in the database
- """
- username = str(username)
- username = username.decode('utf8', errors='ignore')
- parsed = username.strip()
- if len(parsed) != 0:
- parsed = parsed[0].upper() + parsed[1:]
-
- return parsed.encode('utf8')
def normalize_newlines(lines):
diff --git a/wikimetrics/models/mediawiki/user.py
b/wikimetrics/models/mediawiki/user.py
index 0ea0c62..0305af5 100644
--- a/wikimetrics/models/mediawiki/user.py
+++ b/wikimetrics/models/mediawiki/user.py
@@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String
from wikimetrics.configurables import db
from custom_columns import MediawikiTimestamp
-from sqlalchemy.dialects.mysql import TINYBLOB
+from sqlalchemy.dialects.mysql import TINYBLOB, VARBINARY
from wikimetrics.utils import UNICODE_NULL
__all__ = ['MediawikiUser']
@@ -11,9 +11,11 @@
__tablename__ = 'user'
# defaults are for user generating data methods
+ # VARBINARY bindings are needed so the table user we create
+ # in the mediawiki testing database resembles the table in production
user_id = Column(Integer, primary_key=True)
- user_name = Column(String(255))
- user_real_name = Column(String(255), nullable=False, default='')
+ user_name = Column(VARBINARY(255))
+ user_real_name = Column(VARBINARY(255), nullable=False, default='')
user_password = Column(TINYBLOB, nullable=False, default='')
user_newpassword = Column(TINYBLOB, nullable=False, default='')
user_newpass_time = Column(MediawikiTimestamp)
diff --git a/wikimetrics/models/validate_cohort.py
b/wikimetrics/models/validate_cohort.py
index ab3c97a..24e71ec 100644
--- a/wikimetrics/models/validate_cohort.py
+++ b/wikimetrics/models/validate_cohort.py
@@ -2,6 +2,7 @@
from celery import current_task
from celery.utils.log import get_task_logger
from flask.ext.login import current_user
+import traceback
from wikimetrics.configurables import app, db, queue
from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
from sqlalchemy.sql.expression import label, between, and_, or_
@@ -30,14 +31,14 @@
* Updating the cohort to validated == True once all users have been
validated
"""
task = async_validate
-
+
def __init__(self, cohort):
"""
Parameters:
cohort : an existing cohort
config : global config, we need to know
if we are on dev or testing to validate project name
-
+
Instantiates with these properties:
cohort_id : id of an existing cohort with validated
== False
validate_as_user_ids : if True, records will be checked against
user_id
@@ -45,16 +46,16 @@
"""
self.cohort_id = cohort.id
self.validate_as_user_ids = cohort.validate_as_user_ids
-
+
@classmethod
- def from_upload(cls, cohort_upload, owner_user_id):
+ def from_upload(cls, cohort_upload, owner_user_id, session=None):
"""
Create a new cohort and validate a list of uploaded users for it
-
+
Parameters:
cohort_upload : the cohort upload form, parsed by WTForms
owner_user_id : the Wikimetrics user id that is uploading
-
+
Returns:
An instance of ValidateCohort
"""
@@ -67,11 +68,11 @@
validated=False,
validate_as_user_ids=cohort_upload.validate_as_user_ids.data ==
'True',
)
- session = db.get_session()
+ session = session or db.get_session()
try:
session.add(cohort)
session.commit()
-
+
cohort_user = CohortUser(
user_id=owner_user_id,
cohort_id=cohort.id,
@@ -79,7 +80,7 @@
)
session.add(cohort_user)
session.commit()
-
+
session.execute(
WikiUser.__table__.insert(), [
{
@@ -98,7 +99,7 @@
return None
finally:
session.close()
-
+
def run(self):
session = db.get_session()
try:
@@ -108,7 +109,7 @@
self.validate_records(session, cohort)
finally:
session.close()
-
+
def validate_records(self, session, cohort):
"""
Fetches the wiki_user(s) already added for self.cohort_id and validates
@@ -116,9 +117,9 @@
or user_name. Once done, sets the valid state and deletes any
duplicates.
Then, it finishes filling in the data model by inserting corresponding
records into the cohort_wiki_users table.
-
+
This is meant to execute asynchronously on celery
-
+
Parameters
session : an active wikimetrics db session to use
cohort : the cohort to validate; must belong to session
@@ -134,16 +135,16 @@
CohortWikiUser.cohort_id == cohort.id
))
session.commit()
-
+
wikiusers = session.query(WikiUser) \
.filter(WikiUser.validating_cohort == cohort.id) \
.all()
-
+
deduplicated = deduplicate_by_key(
wikiusers,
lambda r: (r.mediawiki_username, r.project)
)
-
+
wikiusers_by_project = {}
for wu in deduplicated:
try:
@@ -152,12 +153,12 @@
wu.reason_invalid = 'invalid project:
{0}'.format(wu.project)
wu.valid = False
continue
-
+
wu.project = normalized_project
if wu.project not in wikiusers_by_project:
wikiusers_by_project[wu.project] = []
wikiusers_by_project[wu.project].append(wu)
-
+
# validate bunches of records to update the UI but not kill
performance
if len(wikiusers_by_project[wu.project]) > 999:
validate_users(
@@ -169,18 +170,18 @@
wikiusers_by_project[wu.project] = []
except:
continue
-
+
# validate anything that wasn't big enough for a batch
for project, wikiusers in wikiusers_by_project.iteritems():
if len(wikiusers) > 0:
validate_users(wikiusers, project, self.validate_as_user_ids)
session.commit()
-
+
unique_and_validated = deduplicate_by_key(
deduplicated,
lambda r: (r.mediawiki_username, r.project)
)
-
+
session.execute(
CohortWikiUser.__table__.insert(), [
{
@@ -189,7 +190,7 @@
} for wu in unique_and_validated
]
)
-
+
# clean up any duplicate wiki_user records
session.execute(WikiUser.__table__.delete().where(and_(
WikiUser.validating_cohort == cohort.id,
@@ -197,7 +198,7 @@
)))
cohort.validated = True
session.commit()
-
+
def __repr__(self):
return '<ValidateCohort("{0}")>'.format(self.cohort_id)
@@ -232,7 +233,7 @@
"""
session = db.get_mw_session(project)
users_dict = {wu.mediawiki_username: wu for wu in wikiusers}
-
+
try:
# validate
if validate_as_user_ids:
@@ -240,7 +241,7 @@
clause = MediawikiUser.user_id.in_(keys_as_ints)
else:
clause = MediawikiUser.user_name.in_(users_dict.keys())
-
+
matches = session.query(MediawikiUser).filter(clause).all()
# update results
for match in matches:
@@ -248,22 +249,28 @@
key = str(match.user_id)
else:
key = match.user_name
+
users_dict[key].mediawiki_username = match.user_name
users_dict[key].mediawiki_userid = match.user_id
users_dict[key].valid = True
users_dict[key].reason_invalid = None
# remove valid matches
users_dict.pop(key)
-
+
# mark the rest invalid
+ # key is going to be a string if bindings are correct, but careful!
+ # it might be a string with chars that cannot be represented w/ ascii
+ # the 'reason_invalid' does not need to have the user_id,
+ # it is on the record on the table
for key in users_dict.keys():
if validate_as_user_ids:
- users_dict[key].reason_invalid = u'invalid user_id:
{0}'.format(key)
+ users_dict[key].reason_invalid = "invalid user_id"
else:
- users_dict[key].reason_invalid = u'invalid user_name:
{0}'.format(key)
+ users_dict[key].reason_invalid = "invalid user_name"
users_dict[key].valid = False
except Exception, e:
- task_logger.error(e)
+ msg = traceback.print_exc()
+ task_logger.error(msg)
# clear out the dictionary in case of an exception, and raise the
exception
for key in users_dict.keys():
diff --git a/wikimetrics/utils.py b/wikimetrics/utils.py
index 9c2a1df..eeca6f5 100644
--- a/wikimetrics/utils.py
+++ b/wikimetrics/utils.py
@@ -221,3 +221,17 @@
Converts a date to a datetime
"""
return datetime.combine(d, datetime.min.time())
+
+
+def parse_username(username):
+ """
+ parses uncapitalized, whitespace-padded, and weird-charactered mediawiki
+ user names into ones that have a chance of being found in the database
+ """
+ username = str(username)
+ username = username.decode('utf8', errors='ignore')
+ parsed = username.strip()
+ if len(parsed) != 0:
+ parsed = parsed[0].upper() + parsed[1:]
+
+ return parsed.encode('utf8')
--
To view, visit https://gerrit.wikimedia.org/r/129672
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I0771e74f3d0745737e3ea96482614382fe09d961
Gerrit-PatchSet: 5
Gerrit-Project: analytics/wikimetrics
Gerrit-Branch: master
Gerrit-Owner: Nuria <[email protected]>
Gerrit-Reviewer: Milimetric <[email protected]>
Gerrit-Reviewer: Nuria <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits