Milimetric has uploaded a new change for review.
https://gerrit.wikimedia.org/r/72847
Change subject: starting to port the cohort upload
......................................................................
starting to port the cohort upload
Change-Id: Ie18b420d5581139069b8650f61c2af40fbae3979
---
M tests/fixtures.py
M tests/test_controllers/test_cohorts.py
M wikimetrics/controllers/cohorts.py
M wikimetrics/metrics/bytes_added.py
M wikimetrics/models/cohort.py
M wikimetrics/models/mediawiki/user.py
A wikimetrics/static/js/cohortUpload.js
M wikimetrics/templates/csv_upload_form.html
A wikimetrics/utils.py
9 files changed, 376 insertions(+), 55 deletions(-)
git pull ssh://gerrit.wikimedia.org:29418/analytics/wikimetrics
refs/changes/47/72847/1
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 9232698..68d3d32 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -3,6 +3,7 @@
__all__ = [
'DatabaseTest',
+ 'DatabaseWithCohortTest',
'QueueTest',
'QueueDatabaseTest',
'WebTest',
@@ -98,6 +99,11 @@
cohort_id=private_cohort2.id,
role=CohortUserRole.OWNER,
)
+ web_user_owns_test = CohortUser(
+ user_id=web_test_user.id,
+ cohort_id=test_cohort.id,
+ role=CohortUserRole.OWNER,
+ )
web_user_owns_private = CohortUser(
user_id=web_test_user.id,
cohort_id=private_cohort.id,
@@ -117,6 +123,7 @@
dan_owns_test,
evan_owns_private,
evan_owns_private2,
+ web_user_owns_test,
web_user_owns_private,
web_user_owns_private2,
dan_views_private2
diff --git a/tests/test_controllers/test_cohorts.py
b/tests/test_controllers/test_cohorts.py
index a983f24..8973271 100644
--- a/tests/test_controllers/test_cohorts.py
+++ b/tests/test_controllers/test_cohorts.py
@@ -27,8 +27,20 @@
response = self.app.get('/cohorts/detail/1', follow_redirects=True)
parsed = json.loads(response.data)
assert_equal(
+ response.status_code,
+ 200,
+ )
+ assert_equal(
len(parsed['wikiusers']),
4,
'/cohorts/detail/1 should return JSON object with key `wikiusers`'
'for a list of length 4== `test`, but instead returned:
{0}'.format(parsed)
)
+
+ def test_not_found(self):
+ response =
self.app.get('/cohorts/detail/no_way_anybody_names_a_cohort_this_23982739873')
+
+ assert_equal(
+ response.status_code,
+ 404,
+ )
diff --git a/wikimetrics/controllers/cohorts.py
b/wikimetrics/controllers/cohorts.py
index 5d5a333..21eede3 100644
--- a/wikimetrics/controllers/cohorts.py
+++ b/wikimetrics/controllers/cohorts.py
@@ -1,7 +1,14 @@
-from flask import render_template, redirect, request, jsonify
+import json
+from flask import url_for, flash, render_template, redirect, request, jsonify
from flask.ext.login import current_user
+from sqlalchemy.sql import exists
+from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
+from ..utils import DateTimeCapableEncoder
from ..configurables import app, db
-from ..models import Cohort, CohortUser, CohortUserRole, User, WikiUser,
CohortWikiUser
+from ..models import (
+ Cohort, CohortUser, CohortUserRole,
+ User, WikiUser, CohortWikiUser, MediawikiUser
+)
import logging
logger = logging.getLogger(__name__)
@@ -29,8 +36,8 @@
return jsonify(cohorts=cohorts)
[email protected]('/cohorts/detail/<int:id>')
-def cohort_detail(id):
[email protected]('/cohorts/detail/<string:name_or_id>')
+def cohort_detail(name_or_id):
"""
Returns a JSON object of the form:
{id: 2, name: 'Berlin Beekeeping Society', description: '', wikiusers: [
@@ -40,18 +47,300 @@
{mediawiki_username: 'Gabriele', mediawiki_userid: 8, project:
'dewiki'},
]}
"""
+ d = db.get_session()
+ cohort = None
+ if str(name_or_id).isdigit():
+ cohort = get_cohort_by_id(int(name_or_id))
+ else:
+ cohort = get_cohort_by_name(name_or_id)
+
+ if cohort:
+ cohort_with_wikiusers = populate_cohort_wikiusers(cohort)
+ return json.dumps(cohort_with_wikiusers, cls=DateTimeCapableEncoder)
+
+ return '{}', 404
+
+
+def get_cohort_query():
db_session = db.get_session()
- cohort = db_session.query(Cohort)\
+ return db_session.query(Cohort)\
.join(CohortUser)\
.join(User)\
+ .filter(User.id == current_user.id)\
.filter(CohortUser.role.in_([CohortUserRole.OWNER,
CohortUserRole.VIEWER]))\
- .filter(Cohort.enabled)\
- .filter(Cohort.id == id)\
- .one()
+ .filter(Cohort.enabled)
+
+
+def get_cohort_by_id(id):
+ try:
+ return get_cohort_query().filter(Cohort.id == id).one()
+ # MultipleResultsFound NoResultFound
+ except:
+ return None
+
+
+def get_cohort_by_name(name):
+ try:
+ return get_cohort_query().filter(Cohort.name == name).one()
+ # MultipleResultsFound NoResultFound
+ except:
+ return None
+
+
+def populate_cohort_wikiusers(cohort):
+ db_session = db.get_session()
wikiusers = db_session.query(WikiUser)\
.join(CohortWikiUser)\
.filter(CohortWikiUser.cohort_id == cohort.id)\
.all()
cohort_dict = cohort._asdict()
cohort_dict['wikiusers'] = [wu._asdict() for wu in wikiusers]
- return jsonify(cohort_dict)
+ return cohort_dict
+
+
[email protected]('/cohorts/upload')
+def upload_csv_cohort():
+ """ View for uploading and validating a new cohort via CSV """
+ if request.method == 'GET':
+ return render_template('csv_upload.html')
+
+ elif request.method == 'POST':
+ try:
+ csv = request.files['csv']
+ name = request.form['name']
+ project = request.form['project']
+ if not csv or not name or len(name) is 0:
+ flash('The form was invalid, please select a file and name the
cohort.')
+ return redirect(url_for('upload_csv_cohort'))
+
+ if get_cohort_by_name(name):
+ flash('That Cohort name is already taken.')
+ return redirect(url_for('upload_csv_cohort'))
+
+ unparsed = csv.reader(normalize_newlines(csv.stream))
+ unvalidated = parse_records(unparsed, project)
+ (valid, invalid) = validate_records(unvalidated)
+
+ return render_template(
+ 'csv_upload_review.html',
+ valid=valid,
+ invalid=invalid,
+ valid_json=to_safe_json(valid),
+ invalid_json=to_safe_json(invalid),
+ name=name,
+ project=project,
+ )
+ except Exception, e:
+ logging.exception(str(e))
+ flash(
+ 'The file you uploaded was not in a valid format, could not be
validated,'
+ 'or the project you specified is not configured on this
instance of Wiki Metrics.'
+ )
+ return redirect('/uploads/cohort')
+
+
[email protected]('/cohorts/create', methods=['POST'])
+def upload_csv_cohort_finish():
+ try:
+ name = request.form.get('name')
+ project = request.form.get('project')
+ users_json = request.form.get('users')
+ users = json.loads(users_json)
+ # re-validate
+ if get_cohort_by_name(name):
+ raise Exception('Cohort name {0} is already used'.format(name))
+
+ # TODO: re-enable validation when either
+ # 1. the site is used by external, potentially untrusted users
+ # 2. the performance of validation is improved
+ #(valid, invalid) = validate_records(users)
+ #if invalid:
+ #raise Exception('Cohort changed since last validation')
+ # save the cohort
+ valid = users
+
+ if not project:
+ if all([user['project'] == users[0]['project'] for user in users]):
+ project = users[0]['project']
+ logging.debug('adding cohort: {0}, with project: {1}'.format(name,
project))
+ cohort = create_cohort(name, 'TODO: add description', project, valid)
+ return url_for('cohort_details', name_or_id=cohort.id)
+
+ except Exception, e:
+ logging.exception(str(e))
+ flash('There was a problem finishing the upload. The cohort was not
saved.')
+ return '<<error>>'
+
+
+def create_cohort(name, description, project, valid_users):
+ db_session = db.get_session()
+ cohort = Cohort(
+ name=name,
+ default_project=project,
+ description=description,
+ )
+ db_session.add(cohort)
+ db_session.commit()
+
+ cohort_owner = CohortUser(
+ cohort_id=cohort.id,
+ user_id=current_user.id,
+ )
+ db_session.add(cohort_owner)
+
+ wikiusers = []
+ for valid_user in valid_users:
+ wikiuser = WikiUser(
+ mediawiki_userid=valid_user['userid'],
+ mediawiki_username=valid_user['username'],
+ )
+ wikiusers.append(wikiuser)
+ db_session.add_all(wikiusers)
+ db_session.commit()
+
+ cohort_wikiusers = []
+ for wikiuser in wikiusers:
+ cohort_wikiuser = CohortWikiUser(
+ cohort_id=cohort.id,
+ wiki_user_id=wikiuser.id,
+ )
+ cohort_wikiusers.append(cohort_wikiuser)
+ db_session.add_all(cohort_wikiusers)
+ db_session.commit()
+
+
[email protected]('/cohorts/validate/name')
+def validate_cohort_name_allowed():
+ name = request.args.get('name')
+ return jsonify(get_cohort_by_name(name) is None)
+
+
+def normalize_newlines(stream):
+ for line in stream:
+ if '\r' in line:
+ for tok in line.split('\r'):
+ yield tok
+ else:
+ yield line
+
+
+def to_safe_json(s):
+ return json.dumps(s).replace("'", "\\'").replace('"', '\\"')
+
+
+def parse_records(records, default_project):
+ # NOTE: the reason for the crazy -1 and comma joins
+ # is that some users can have commas in their name
+ # TODO: This makes it impossible to add fields to the csv in the future,
+ # so maybe require the project to be the first field and the username to
be the last
+ # or maybe change to a tsv format
+ return [{
+ 'username': parse_username(",".join([str(p) for p in r[:-1]])),
+ 'project': r[-1].decode('utf8') if len(r) > 1 else default_project
+ } for r in records if r]
+
+
+def parse_username(raw_name):
+ stripped = str(raw_name).decode('utf8').strip()
+ # unfortunately .title() or .capitalize() don't work
+ # because 'miliMetric'.capitalize() == 'Milimetric'
+ return stripped[0].upper() + stripped[1:]
+
+
+def normalize_project(project):
+ project = project.strip().lower()
+ if project in db.project_host_map:
+ return project
+ else:
+ # try adding wiki to end
+ new_proj = project + 'wiki'
+ if new_proj not in db.project_host_map:
+ return None
+ else:
+ return new_proj
+
+
+def get_wikiuser_by_name(username, project):
+ # NOTE: Not needed right? username = username.encode('utf-8')
+ db_session = db.get_mw_session(project)
+ try:
+ return db_session.query(MediaWikiUser)\
+ .filter(MediaWikiUser.user_name == username)\
+ .one()
+ except:
+ return None
+
+
+def get_wikiuser_by_id(id, project):
+ db_session = db.get_mw_session(project)
+ try:
+ return db_session.query(MediaWikiUser)\
+ .filter(MediaWikiUser.user_id == id)\
+ .one()
+ except:
+ return None
+
+
+def normalize_user(user_str, project):
+ wikiuser = get_wikiuser_by_name(user_str, project)
+ if wikiuser is not None:
+ return (wikiuser.user_id, wikiuser.user_name)
+
+ if not user_str.isdigit():
+ return None
+
+ wikiuser = get_wikiuser_by_id(user_str, project)
+ if wikiuser is not None:
+ return (wikiuser.user_id, wikiuser.user_name)
+
+ return None
+
+
+def deduplicate(list_of_objects, key_function):
+ uniques = dict()
+ for o in list_of_objects:
+ key = key_function(o)
+ if not key in uniques:
+ uniques[key] = o
+
+ return uniques.values()
+
+
+def project_name_for_link(project):
+ if project.endswith('wiki'):
+ return project[:len(project) - 4]
+ return project
+
+
+def link_to_user_page(username, project):
+ project = project_name_for_link(project)
+ return 'https://%s.wikipedia.org/wiki/User:%s' % (project, username)
+
+
+def validate_records(records):
+ valid = []
+ invalid = []
+ for record in records:
+ record['user_str'] = record['username']
+ normalized_project = normalize_project(record['project'])
+ if normalized_project is None:
+ record['reason_invalid'] = 'invalid project: %s' %
record['project']
+ invalid.append(record)
+ continue
+ normalized_user = normalize_user(record['user_str'],
normalized_project)
+ # make a link to the potential user page even if user doesn't exist
+ # this gives a chance to see any misspelling etc.
+ record['link'] = link_to_user_page(record['username'],
normalized_project)
+ if normalized_user is None:
+ logging.debug('invalid: %s', record['user_str'])
+ record['reason_invalid'] = 'invalid user_name / user_id: %s' %
record['user_str']
+ invalid.append(record)
+ continue
+ # set the normalized values and append to valid
+ logging.debug('found a valid user_str: %s', record['user_str'])
+ record['project'] = normalized_project
+ record['user_id'], record['username'] = normalized_user
+ valid.append(record)
+
+ valid = deduplicate(valid, lambda record: record['username'])
+ return (valid, invalid)
diff --git a/wikimetrics/metrics/bytes_added.py
b/wikimetrics/metrics/bytes_added.py
index 21cc4fc..736ac1a 100644
--- a/wikimetrics/metrics/bytes_added.py
+++ b/wikimetrics/metrics/bytes_added.py
@@ -57,7 +57,6 @@
description = 'Compute different aggregations of the bytes contributed or
removed from a\
mediawiki project'
-
start_date = DateField()
end_date = DateField()
namespaces = CommaSeparatedIntegerListField(default=[0],
description='0, 2, 4, etc.')
diff --git a/wikimetrics/models/cohort.py b/wikimetrics/models/cohort.py
index c15a90b..37b1842 100644
--- a/wikimetrics/models/cohort.py
+++ b/wikimetrics/models/cohort.py
@@ -1,6 +1,6 @@
import itertools
from operator import itemgetter
-from sqlalchemy import Column, Integer, Boolean, DateTime, String
+from sqlalchemy import Column, Integer, Boolean, DateTime, String, func
from wikimetrics.configurables import db
from .wikiuser import WikiUser
from .cohort_wikiuser import CohortWikiUser
@@ -29,7 +29,7 @@
name = Column(String(50))
description = Column(String(254))
default_project = Column(String(50))
- created = Column(DateTime)
+ created = Column(DateTime, default=func.now())
changed = Column(DateTime)
enabled = Column(Boolean)
public = Column(Boolean, default=False)
diff --git a/wikimetrics/models/mediawiki/user.py
b/wikimetrics/models/mediawiki/user.py
index 54b2eda..2268c8d 100644
--- a/wikimetrics/models/mediawiki/user.py
+++ b/wikimetrics/models/mediawiki/user.py
@@ -12,14 +12,14 @@
user_id = Column(Integer, primary_key=True)
user_name = Column(String(255))
user_real_name = Column(String(255))
- user_password = None # TODO: Password? = Column(String(255))
- user_newpassword = None # TODO: Password? = Column(String(255))
+ # do not map: user_password
+ # do not map: user_newpassword
user_newpass_time = Column(DateTime)
user_email = Column(String(255))
user_touched = Column(DateTime)
- user_token = None # TODO: Token? binary(32) DEFAULT NULL,
+ # do not map: user_token
user_email_authenticated = Column(DateTime)
- user_email_token = None # TODO: Token? binary(32) DEFAULT NULL,
- user_email_token_expires = Column(DateTime)
+ # do not map: user_email_token
+ # do not map: user_email_token_expires = Column(DateTime)
user_registration = Column(DateTime)
user_editcount = Column(Integer)
diff --git a/wikimetrics/static/js/cohortUpload.js
b/wikimetrics/static/js/cohortUpload.js
new file mode 100644
index 0000000..d877a1b
--- /dev/null
+++ b/wikimetrics/static/js/cohortUpload.js
@@ -0,0 +1,24 @@
+$(document).ready(function(){
+
+ jQuery.validator.addMethod('cohortName', function(value, element) {
+ return /^[0-9_\-A-Za-z ]*$/.test(value);
+ }, 'Cohort names should only contain letters, numbers, spaces, dashes, and
underscores');
+
+ $('form.upload-cohort').validate({
+ messages: {
+ name: {
+ remote: 'This cohort name is taken.',
+ }
+ },
+ rules: {
+ name: {
+ required: true,
+ cohortName: true,
+ remote: '/validate/cohort/allowed'
+ },
+ csv: {
+ required: true
+ }
+ }
+ });
+});
diff --git a/wikimetrics/templates/csv_upload_form.html
b/wikimetrics/templates/csv_upload_form.html
index 0c44dfd..3a47773 100644
--- a/wikimetrics/templates/csv_upload_form.html
+++ b/wikimetrics/templates/csv_upload_form.html
@@ -1,55 +1,26 @@
<form enctype="multipart/form-data" action="/uploads/cohort" method="POST"
class="upload-cohort form-horizontal">
<div class="control-group">
- <label for="cohort_name" class="control-label">Cohort Name</label>
+ <label for="name" class="control-label">Cohort Name</label>
<div class="controls">
- <input type="text" name="cohort_name" id="cohort_name" value="{%
if cohort_name %}{{cohort_name}}{% endif %}"/>
- <label for="cohort_name">(automatically checks
availability)</label>
+ <input type="text" name="name" id="name" value="{% if name
%}{{name}}{% endif %}"/>
+ <label for="name">(automatically checks availability)</label>
</div>
</div>
- <div class="control-group"> <label for="cohort_project"
class="control-label">Wiki Project</label>
+ <div class="control-group"> <label for="project"
class="control-label">Wiki Project</label>
+ <label for="project" class="control-label">Default Cohort
Project</label>
<div class="controls">
- <select name="cohort_project" id="cohort_project">
- <option value="">(select one or specify in file)</option>
- {% for project in wiki_projects %}
- <option {% if project == cohort_project %}selected{% endif
%}>{{ project }}</option>
- {% endfor %}
- </select>
+ <input type="text" name="project" id="project" value="{% project
%}{{project}}{% endif %}"/>
</div>
</div>
<div class="control-group">
- <label for="csv_cohort" class="control-label">CSV File</label>
+ <label for="csv" class="control-label">CSV File</label>
<div class="controls">
- <input type="file" name="csv_cohort" id="csv_cohort"/>
+ <input type="file" name="csv" id="csv"/>
</div>
</div>
<div class="form-actions">
<input type="submit" class="btn btn-primary" value="Upload CSV"/>
</div>
</form>
+
<script
src="//ajax.aspnetcdn.com/ajax/jquery.validate/1.11.1/jquery.validate.min.js"></script>
-<script>
- $(document).ready(function(){
-
- jQuery.validator.addMethod('cohortName', function(value, element) {
- return /^[0-9_\-A-Za-z ]*$/.test(value);
- }, 'Cohort names should only contain letters, numbers, spaces, dashes,
and underscores');
-
- $('form.upload-cohort').validate({
- messages: {
- cohort_name: {
- remote: 'This cohort name is taken.',
- }
- },
- rules: {
- cohort_name: {
- required: true,
- cohortName: true,
- remote: '/validate/cohort/allowed'
- },
- csv_cohort: {
- required: true
- }
- }
- });
- });
-</script>
diff --git a/wikimetrics/utils.py b/wikimetrics/utils.py
new file mode 100644
index 0000000..8596d4b
--- /dev/null
+++ b/wikimetrics/utils.py
@@ -0,0 +1,19 @@
+import json
+import datetime
+from time import mktime
+
+class DateTimeCapableEncoder(json.JSONEncoder):
+ """
+ Date/Time objects are not serializable by the built-in
+ json library because there is no agreed upon standard of how to do so
+ This class can be used as follows to allow your json.dumps to serialize
dates properly.
+ You should make sure your client is happy with this serialization:
+ print json.dumps(obj, cls=DateTimeCapableEncoder)
+ """
+
+ def default(self, obj):
+ if isinstance(obj, datetime.datetime):
+ return int(mktime(obj.timetuple()))
+
+ return json.JSONEncoder.default(self, obj)
+
--
To view, visit https://gerrit.wikimedia.org/r/72847
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: newchange
Gerrit-Change-Id: Ie18b420d5581139069b8650f61c2af40fbae3979
Gerrit-PatchSet: 1
Gerrit-Project: analytics/wikimetrics
Gerrit-Branch: master
Gerrit-Owner: Milimetric <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits