Commit a40bcfcf by Clinton Blackburn Committed by Clinton Blackburn

WIP: Recommendation Demo

parent e52fc811
......@@ -28,6 +28,7 @@
"backbone-route-filter": "~0.1.2",
"backbone-relational": "~0.9.0",
"backbone-validation": "~0.11.5",
"backbone.stickit": "~0.9.2"
"backbone.stickit": "~0.9.2",
"select2": "~4.0.3"
}
}
from rest_framework import serializers
class UserSerializer(serializers.Serializer):
id = serializers.IntegerField()
username = serializers.CharField()
class CourseSerializer(serializers.Serializer):
id = serializers.CharField()
name = serializers.CharField()
class RecommendedCourseSerializer(CourseSerializer):
weight = serializers.IntegerField()
from django.conf.urls import url
from ecommerce.extensions.api.demo import views
urlpatterns = [
url(r'^courses/$', views.CourseListView.as_view()),
url(r'^courses/(?P<pk>[\w.-]+)/recommendations/$', views.CourseRecommendationView.as_view()),
url(r'^users/$', views.UserListView.as_view()),
url(r'^users/(?P<username>[\w.-]+)/enrollments/$', views.UserEnrollmentsView.as_view()),
url(r'^users/(?P<username>[\w.-]+)/recommendations/$', views.UserRecommendationView.as_view()),
]
from django.utils.functional import cached_property
from py2neo import Graph
from rest_framework import generics
from rest_framework.response import Response
from ecommerce.extensions.api.demo import serializers
NEO4J_URI = 'http://neo4j:edx@localhost:7474/db/data/'
class GraphMixin(object):
@cached_property
def graph(self):
return Graph(NEO4J_URI)
@property
def run(self):
return self.graph.run
class UserListView(GraphMixin, generics.ListAPIView):
serializer_class = serializers.UserSerializer
def get_queryset(self):
username = self.request.GET.get('q')
if not username:
return Response({'error': 'You must supply a value for the q parameter.'}, status=400)
username = username.lower()
statement = "MATCH (student:Student) " \
"WHERE student.username =~ '(?i){username}.*' " \
"RETURN student.id AS id, student.username AS username;".format(username=username)
results = self.run.execute(statement)
return results
class UserRecommendationView(GraphMixin, generics.ListAPIView):
serializer_class = serializers.RecommendedCourseSerializer
pagination_class = None
def get_queryset(self):
username = self.kwargs['username'].lower()
statement = "MATCH (student:Student {{username: \"{username}\"}}) " \
"MATCH (student)-[:ENROLLED_IN]->(course_run)<-[:ENROLLED_IN]-(other_student)-[:ENROLLED_IN]-(other_run)-[:IS_RUN_OF]->(recommendations) " \
"WHERE NOT(student = other_student) " \
"RETURN COUNT(*) AS weight, recommendations.id AS id, recommendations.name AS name " \
"ORDER BY weight DESC, id, name " \
"LIMIT 20".format(username=username)
results = self.run(statement)
return results
class UserEnrollmentsView(GraphMixin, generics.ListAPIView):
serializer_class = serializers.CourseSerializer
pagination_class = None
def get_queryset(self):
username = self.kwargs['username'].lower()
statement = "MATCH (student:Student {{username: \"{username}\"}}) " \
"MATCH (student)-[:ENROLLED_IN]-(course_run)-[:IS_RUN_OF]->(courses) " \
"RETURN courses.id AS id, courses.name AS name".format(username=username)
results = self.run(statement)
return results
class CourseListView(GraphMixin, generics.ListAPIView):
serializer_class = serializers.CourseSerializer
def get_queryset(self):
query = self.request.GET.get('q')
statement = "MATCH (course:Course) RETURN course.id AS id, course.name AS name;"
if query:
statement = "MATCH (course:Course) " \
"WHERE course.id =~ '(?i).*{q}.*' OR course.name =~ '.*{q}.*' " \
"RETURN course.id AS id, course.name AS name".format(q=query)
results = self.run(statement)
return list(results)
class CourseRecommendationView(GraphMixin, generics.ListAPIView):
serializer_class = serializers.RecommendedCourseSerializer
pagination_class = None
def get_queryset(self):
course_id = self.kwargs['pk'].lower()
statement = "MATCH (course:Course {{id: '{course_id}'}}) " \
"MATCH (course)-[:IS_RUN_OF]-(course_run)-[:ENROLLED_IN]-(student)-[:ENROLLED_IN]-(other_run)-[:IS_RUN_OF]->(recommendations) " \
"RETURN COUNT(*) AS weight, recommendations.id AS id, recommendations.name AS name " \
"ORDER BY weight DESC, id, name " \
"LIMIT 20".format(course_id=course_id)
results = self.run(statement)
return results
......@@ -2,4 +2,5 @@ from django.conf.urls import url, include
urlpatterns = [
url(r'^v2/', include('ecommerce.extensions.api.v2.urls', namespace='v2')),
url(r'^demo/', include('ecommerce.extensions.api.demo.urls', namespace='demo')),
]
require([
'jquery',
'dataTablesBootstrap',
'select2'
],
function ($) {
'use strict';
function templateCourseResult(result) {
if (result.loading) {
return result.text;
}
return result.id + ' - ' + (result.name || result.text);
}
function templateCourseSelection(result) {
if (result.id) {
return result.id + ' - ' + (result.name || result.text);
}
return result.text;
}
function initializeTable($table, url, showWeightColumn) {
var columns = [
{data: 'id', sTitle: 'Course ID'},
{data: 'name', sTitle: 'Course Title'}
];
if (typeof showWeightColumn == 'undefined') {
showWeightColumn = true;
}
if (showWeightColumn) {
columns.unshift({data: 'weight', sTitle: 'Weight'})
}
$table.removeClass('hidden');
return $table.DataTable({
ajax: {
url: url,
dataSrc: ''
},
bFilter: false,
bPaginate: false,
columns: columns,
order: [
[0, 'desc'],
[1, 'asc']
],
processing: true
})
}
$(function () {
var $courseGroup = $('.course-specific'),
$courseSpecificForm = $courseGroup.find('form'),
$courseFormButton = $courseSpecificForm.find('button'),
$courseField = $courseSpecificForm.find('select'),
$userGroup = $('.user-specific'),
$userSpecificForm = $userGroup.find('form'),
$usernameField = $userSpecificForm.find('input'),
$courseTable = null,
$userRecommendationTable = null,
$userEnrollmentTable = null;
$courseField.select2({
ajax: {
url: '/api/demo/courses/',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term
};
},
minimumInputLength: 2,
cache: true
},
placeholder: 'Select a course',
templateResult: templateCourseResult,
templateSelection: templateCourseSelection
});
$courseField.on('select2:select', function(){
$courseFormButton.focus();
});
$courseSpecificForm.submit(function (e) {
var url = '/api/demo/courses/' + $courseField.val() + '/recommendations/';
e.preventDefault();
if ($courseTable) {
$courseTable.ajax.url(url).load();
} else {
$courseTable = initializeTable($('.course-specific table.recommendations'), url);
}
});
$userSpecificForm.submit(function (e) {
var username = $usernameField.val(),
recommendationsUrl = '/api/demo/users/' + username + '/recommendations/',
enrollmentsUrl = '/api/demo/users/' + username + '/enrollments/';
e.preventDefault();
if (!username) {
alert('Input a username.');
return;
}
if ($userRecommendationTable) {
$userRecommendationTable.ajax.url(recommendationsUrl).load();
$userEnrollmentTable.ajax.url(enrollmentsUrl).load();
} else {
$userRecommendationTable = initializeTable($('.user-specific table.recommendations'), recommendationsUrl);
$userEnrollmentTable = initializeTable($('.user-specific table.enrollments'), enrollmentsUrl, false);
}
});
});
}
);
......@@ -21,6 +21,7 @@ require.config({
'pikaday': 'bower_components/pikaday/pikaday',
'requirejs': 'bower_components/requirejs/require',
'routers': 'js/routers',
'select2': 'bower_components/select2/dist/js/select2',
'templates': 'templates',
'test': 'js/test',
'text': 'bower_components/text/text',
......
......@@ -34,5 +34,6 @@
@import 'views/course_admin';
@import 'views/coupon_admin';
@import 'views/coupon_offer';
@import 'views/recommendation_demo';
@import "default";
.recommendation-demo {
height: 100%;
//padding-bottom: $padding-large-vertical * 2;
dl {
margin-left: $padding-large-horizontal;
dd {
margin-bottom: $padding-small-vertical;
}
}
.recommendation-group {
margin-top: $padding-base-vertical;
&.fake-height {
padding-bottom: 500px;
}
form, .dataTables_wrapper {
margin-bottom: $padding-base-vertical;
}
table caption {
font-weight: bold;
}
}
}
\ No newline at end of file
{% extends "edx/base.html" %}
{% load staticfiles %}
{% block title %}Course Recommendation Demo{% endblock title %}
{% block navbar %}{% endblock navbar %}
{% block stylesheets %}
<link rel="stylesheet" href="{% static 'bower_components/select2/dist/css/select2.css' %}">
{% endblock %}
{% block content %}
<div class="container recommendation-demo">
<h1>Course Recommendation Demo</h1>
<p>This is a demonstration of a <em>very basic</em> recommendation engine. The engine offers two types of
recommendations:</p>
<dl>
<dt>Course-specific</dt>
<dd>Given a course, what other courses might interest a learner?</dd>
<dt>Learner-specific</dt>
<dd>Given a learner's current enrollments, what other courses might interest the learner?</dd>
</dl>
<p>In both cases recommendations are based upon the enrollments of other students. For example, say Students A and B
are both enrolled in a run of CS50x. Student A is also enrolled in LFS101x (the Linux course). If Student C
expresses an interest (e.g. views the about page or enrolls) in CS50x, we might also recommend LFS101x.</p>
<h2>Notes</h2>
<ul>
<li>Enrollment data is accurate as of November 8, 2016 2:45 AM EST.</li>
<li>Course names may not be accurate if certain runs of the course have inaccurate names, such as "DELETE (wrong
url)".
</li>
<li>Recommendations are calculated on-the-fly. <strong>Responses may take up to 15 seconds to be
returned.</strong> This is especially true for courses with a large number of learners, and learners enrolled in
a
large number of courses. In a real-world scenario we would either pre-calculate recommendations or use better
hardware to speed-up real-time calculations.
</li>
<li>Weights represent the number of students co-enrolled in a course, or with the given learner.</li>
</ul>
<div>
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#course" aria-controls="course" role="tab" data-toggle="tab">Course-Specific</a>
</li>
<li role="presentation"><a href="#learner" aria-controls="learner" role="tab"
data-toggle="tab">Learner-Specific</a></li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="course">
<div class="course-specific recommendation-group">
<form class="form-inline">
<div class="form-group">
<select class="form-control" name="course">
<option value="00690242.1x" selected="selected">Relics in Chinese History - Part 1: Agriculture
and
Manufacturing
</option>
</select>
</div>
<button class="btn btn-primary" type="submit">Get Recommendations</button>
</form>
<table class="recommendations table table-striped table-bordered hidden">
<caption>Recommended Courses</caption>
</table>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="learner">
<div class="user-specific recommendation-group">
<form class="form-inline">
<div class="form-group">
<input class="form-control" name="username" type="text" placeholder="Enter a username"
value="clintonblackburn">
<button class="btn btn-primary" type="submit">Get Recommendations</button>
</div>
</form>
<table class="enrollments table table-striped table-bordered hidden">
<caption>Current Enrollments</caption>
</table>
<table class="recommendations table table-striped table-bordered hidden">
<caption>Recommended Courses</caption>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block javascript %}
<script src="{% static 'js/apps/recommendation_demo.js' %}"></script>
{% endblock javascript %}
......@@ -55,6 +55,7 @@ urlpatterns = AUTH_URLS + [
url(r'^health/$', core_views.health, name='health'),
url(r'^i18n/', include('django.conf.urls.i18n')),
url(r'^jsi18n/$', 'django.views.i18n.javascript_catalog', js_info_dict),
url(r'^recommendation-demo/$', TemplateView.as_view(template_name='recommendation-demo.html')),
]
# Install Oscar extension URLs
......
......@@ -27,6 +27,7 @@ ndg-httpsclient==0.4.0
path.py==7.2
paypalrestsdk==1.11.5
premailer==2.9.2
py2neo==3.1.2
pycountry==1.18
python-dateutil==2.4.2
pytz==2015.7
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment