Commit f5953e35 by Jonathan Piacenti

Allow users to share table blocks.

parent bee4e702
......@@ -203,7 +203,10 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, StepMixin, StudioEditableX
if sub_api:
# Also send to the submissions API:
sub_api.create_submission(self.student_item_key, self.student_input)
item_key = self.student_item_key
# Need to do this by our own ID, since an answer can be referred to multiple times.
item_key['item_id'] = self.name
sub_api.create_submission(item_key, self.student_input)
log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input))
return self.get_results()
......@@ -283,9 +286,24 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock):
def mentoring_view(self, context=None):
""" Render this XBlock within a mentoring block. """
context = context.copy() if context else {}
student_submissions_key = context.get('student_submissions_key')
context['title'] = self.display_name
context['description'] = self.description
context['student_input'] = self.student_input
if student_submissions_key:
location = self.location.replace(branch=None, version=None) # Standardize the key in case it isn't already
target_key = {
'student_id': student_submissions_key,
'course_id': unicode(location.course_key),
'item_id': self.name,
'item_type': u'pb-answer',
}
submissions = sub_api.get_submissions(target_key, limit=1)
try:
context['student_input'] = submissions[0]['answer']
except IndexError:
context['student_input'] = None
else:
context['student_input'] = self.student_input
html = loader.render_template('templates/html/answer_read_only.html', context)
fragment = Fragment(html)
......
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Share'
db.create_table('problem_builder_share', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('shared_by', self.gf('django.db.models.fields.related.ForeignKey')(
related_name='problem_builder_shared_by', to=orm['auth.User']
)),
('submission_uid', self.gf('django.db.models.fields.CharField')(max_length=32)),
('block_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('shared_with', self.gf('django.db.models.fields.related.ForeignKey')(
related_name='problem_builder_shared_with', to=orm['auth.User']
)),
('notified', self.gf('django.db.models.fields.BooleanField')(default=False, db_index=True)),
))
db.send_create_signal('problem_builder', ['Share'])
# Adding unique constraint on 'Share', fields ['shared_by', 'shared_with', 'block_id']
db.create_unique('problem_builder_share', ['shared_by_id', 'shared_with_id', 'block_id'])
def backwards(self, orm):
# Removing unique constraint on 'Share', fields ['shared_by', 'shared_with', 'block_id']
db.delete_unique('problem_builder_share', ['shared_by_id', 'shared_with_id', 'block_id'])
# Deleting model 'Share'
db.delete_table('problem_builder_share')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {
'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'
})
},
'auth.permission': {
'Meta': {
'ordering': "('content_type__app_label', 'content_type__model', 'codename')",
'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'
},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {
'to': "orm['contenttypes.ContentType']"
}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {
'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'
}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {
'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'
}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {
'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)",
'object_name': 'ContentType', 'db_table': "'django_content_type'"
},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'problem_builder.answer': {
'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'Answer'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'student_input': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
},
'problem_builder.share': {
'Meta': {'unique_together': "(('shared_by', 'shared_with', 'block_id'),)", 'object_name': 'Share'},
'block_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'notified': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
'shared_by': ('django.db.models.fields.related.ForeignKey', [], {
'related_name': "'problem_builder_shared_by'", 'to': "orm['auth.User']"
}),
'shared_with': ('django.db.models.fields.related.ForeignKey', [], {
'related_name': "'problem_builder_shared_with'", 'to': "orm['auth.User']"
}),
'submission_uid': ('django.db.models.fields.CharField', [], {'max_length': '32'})
}
}
complete_apps = ['problem_builder']
......@@ -21,6 +21,7 @@
# Imports ###########################################################
from django.db import models
from django.contrib.auth.models import User
# Classes ###########################################################
......@@ -47,3 +48,19 @@ class Answer(models.Model):
# Force validation of max_length
self.full_clean()
super(Answer, self).save(*args, **kwargs)
class Share(models.Model):
"""
The XBlock User Service does not permit XBlocks instantiated with non-staff users
to query for arbitrary anonymous user IDs. In order to make sharing work, we have
to store them here.
"""
shared_by = models.ForeignKey(User, related_name='problem_builder_shared_by')
submission_uid = models.CharField(max_length=32)
block_id = models.CharField(max_length=255, db_index=True)
shared_with = models.ForeignKey(User, related_name='problem_builder_shared_with')
notified = models.BooleanField(default=False, db_index=True)
class Meta(object):
unique_together = (('shared_by', 'shared_with', 'block_id'),)
......@@ -52,3 +52,84 @@
position: absolute;
width: 1px;
}
.mentoring-table-container .share-with-container {
text-align: right;
}
.share-with-instructions {
max-width: 14.5em;
}
.mentoring-share-panel {
float: right;
}
.mentoring-share-panel .mentoring-share-with {
position: absolute;
right: 0;
background-color: rgb(255, 255, 255);
border: 1px solid rgb(221, 221, 221);
padding: 1em;
}
.mentoring-share-with .share-header {
text-align: left;
}
.mentoring-share-with .share-action-buttons {
text-align: center;
padding-top: .5em;
}
.mentoring-share-with .add-share-username {
margin-right: 1em;
}
.mentoring-share-with .share-errors {
color: darkred;
font-size: .75em;
text-align: center;
}
.new-share-container {
margin-top: .5em;
}
.shared-list li {
list-style-type: none;
display: block;
padding: .25em;
margin: 0;
}
.shared-list li .username {
display: inline-block;
float: left;
}
.share-panel-container {
text-align: right;
}
.share-notification {
border: 2px solid rgb(200, 200, 200);
max-width: 15em;
padding: 1em;
background-color: rgb(255, 255, 255);
position: absolute;
right: 0;
font-size: .8em;
}
.share-notification .notification-close {
float: right;
}
.report-download-container {
float: left;
}
.mentoring .identification {
padding-bottom: 1em;
}
\ No newline at end of file
......@@ -56,10 +56,153 @@ function PBDashboardBlock(runtime, element, initData) {
function MentoringTableBlock(runtime, element, initData) {
// Display an excerpt for long answers, with a "more" link to display the full text
$('.answer-table', element).shorten({
moreText: 'more',
lessText: 'less',
showChars: '500'
var $element = $(element),
$shareButton = $element.find('.mentoring-share-button'),
$doShareButton = $element.find('.do-share-button'),
$shareMenu = $element.find('.mentoring-share-with'),
$displayDropdown = $element.find('.mentoring-display-dropdown'),
$errorHolder = $element.find('.share-errors'),
$deleteShareButton = $element.find('.remove-share'),
$newShareContainer = $($element.find('.new-share-container')[0]),
$addShareField = $($element.find('.add-share-field')[0]),
$notification = $($element.find('.share-notification')),
$closeNotification = $($element.find('.notification-close')),
tableLoadURL = runtime.handlerUrl(element, 'table_render'),
deleteShareUrl = runtime.handlerUrl(element, 'remove_share'),
sharedListLoadUrl = runtime.handlerUrl(element, 'get_shared_list'),
clearNotificationUrl = runtime.handlerUrl(element, 'clear_notification'),
shareResultsUrl = runtime.handlerUrl(element, 'share_results');
function loadTable(data) {
$element.find('.mentoring-table-target').html(data['content']);
$('.answer-table', element).shorten({
moreText: 'more',
lessText: 'less',
showChars: '500'
});
}
function errorMessage(event) {
$errorHolder.text(JSON.parse(event.responseText)['error'])
}
function sharedRefresh(data) {
$element.find('.shared-with-container').html(data['content']);
$deleteShareButton = $($deleteShareButton.selector);
$deleteShareButton.on('click', deleteShare);
}
function postShareRefresh(data) {
sharedRefresh(data);
$element.find(".new-share-container").each(function(index, container) {
if (index === 0) {
$(container).find('.add-share-username').val('');
return;
}
$(container).remove()
});
$errorHolder.html('');
}
function postShare() {
$.ajax({
type: "POST",
url: sharedListLoadUrl,
data: JSON.stringify({}),
success: postShareRefresh,
error: errorMessage
});
}
function updateShare() {
var usernames = [];
$element.find('.add-share-username').each(function(index, username) {
usernames.push($(username).val())
});
$.ajax({
type: "POST",
url: shareResultsUrl,
data: JSON.stringify({'usernames': usernames}),
success: postShare,
error: errorMessage
});
}
function menuHider(event) {
if (!$(event.target).closest($shareMenu).length) {
// We're clicking outside of the menu, so hide it.
$shareMenu.hide();
$(document).off('click.mentoring_share_menu_hide');
}
}
$shareButton.on('click', function (event) {
if (!$shareMenu.is(':visible')){
event.stopPropagation();
$(document).on('click.mentoring_share_menu_hide', menuHider);
$shareMenu.show();
}
});
new ExportBase(runtime, element, initData)
$doShareButton.on('click', updateShare);
function postLoad(data) {
loadTable(data);
new ExportBase(runtime, element, initData);
}
$.ajax({
type: "POST",
url: tableLoadURL,
data: JSON.stringify({'target_username': $displayDropdown.val()}),
success: postLoad
});
$.ajax({
type: "POST",
url: sharedListLoadUrl,
data: JSON.stringify({}),
success: sharedRefresh
});
$displayDropdown.on('change', function () {
$.ajax({
type: "POST",
url: tableLoadURL,
data: JSON.stringify({'target_username': $displayDropdown.val()}),
success: loadTable
})
});
function addShare() {
var container = $newShareContainer.clone();
container.find('.add-share-username').val('');
container.insertAfter($element.find('.new-share-container').last());
container.find('.add-share-field').on('click', addShare)
}
function deleteShare(event) {
$.ajax({
type: "POST",
url: deleteShareUrl,
data: JSON.stringify({'username': $(event.target).prev()[0].innerHTML}),
success: function () {
$(event.target).parent().remove();
$errorHolder.html('');
},
error: errorMessage
});
}
$closeNotification.on('click', function () {
// Don't need server approval to hide it.
$notification.hide();
$.ajax({
type: "POST",
url: clearNotificationUrl,
data: JSON.stringify({'usernames': $notification.data('shared')})
})
});
$addShareField.on('click', addShare);
}
......@@ -21,10 +21,12 @@
# Imports ###########################################################
import errno
import json
from django.contrib.auth.models import User
from xblock.core import XBlock
from xblock.fields import Scope, String, Boolean
from xblock.exceptions import JsonHandlerError
from xblock.fields import Scope, String, Boolean, Dict
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
......@@ -33,6 +35,8 @@ from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContain
# Globals ###########################################################
from problem_builder import AnswerRecapBlock
from problem_builder.dashboard import ExportMixin
from problem_builder.models import Share
from problem_builder.sub_api import SubmittingXBlockMixin
loader = ResourceLoader(__name__)
......@@ -45,7 +49,10 @@ def _(text):
@XBlock.wants("user")
class MentoringTableBlock(StudioEditableXBlockMixin, StudioContainerXBlockMixin, ExportMixin, XBlock):
@XBlock.wants("submissions")
class MentoringTableBlock(
StudioEditableXBlockMixin, SubmittingXBlockMixin, StudioContainerXBlockMixin, ExportMixin, XBlock
):
"""
Table-type display of information from mentoring blocks
......@@ -76,26 +83,164 @@ class MentoringTableBlock(StudioEditableXBlockMixin, StudioContainerXBlockMixin,
default=False,
scope=Scope.content
)
allow_sharing = Boolean(
display_name=_("Allow Sharing"),
help=_("Allow students to share their results with other students."),
default=True,
scope=Scope.content
)
has_children = True
css_path = 'public/css/mentoring-table.css'
js_path = 'public/js/review_blocks.js'
def student_view(self, context):
context = context.copy() if context else {}
fragment = Fragment()
@XBlock.json_handler
def table_render(self, data, suffix=''):
context = {}
header_values = []
content_values = []
target_username = data.get('target_username')
try:
if target_username and target_username != self.current_user_key:
share = Share.objects.get(
shared_by__username=target_username, shared_with__username=self.current_user_key,
block_id=self.block_id,
)
context['student_submissions_key'] = share.submission_uid
except Share.DoesNotExist:
raise JsonHandlerError(403, _("You are not permitted to view this student's table."))
for child_id in self.children:
child = self.runtime.get_block(child_id)
# Child should be an instance of MentoringTableColumn
header_values.append(child.header)
child_frag = child.render('mentoring_view', context)
content_values.append(child_frag.content)
fragment.add_frag_resources(child_frag)
context['header_values'] = header_values if any(header_values) else None
context['content_values'] = content_values
html = loader.render_template('templates/html/mentoring-table.html', context)
return {'content': html}
@property
def current_user_key(self):
user = self.runtime.service(self, 'user').get_current_user()
# We may be in the SDK, in which case the username may not really be available.
return user.opt_attrs.get('edx-platform.username', 'username')
@XBlock.json_handler
def get_shared_list(self, data, suffix=''):
context = {'shared_with': Share.objects.filter(
shared_by__username=self.current_user_key,
block_id=self.block_id,
).values_list('shared_with__username', flat=True)
}
return {
'content': loader.render_template('templates/html/mentoring-table-shared-list.html', context)
}
@XBlock.json_handler
def clear_notification(self, data, suffix=''):
"""
Clear out notifications for users who shared with this user on the last page load.
Since more users might share with them while they're viewing the page, only remove the ones
that they had at the time.
"""
usernames = data.get('usernames')
if not usernames:
raise JsonHandlerError(400, "No usernames sent.")
try:
isinstance(usernames, list)
except ValueError:
raise JsonHandlerError(400, "Usernames must be a list.")
Share.objects.filter(
shared_with__username=self.current_user_key,
shared_by__username__in=usernames,
block_id=self.block_id,
).update(
notified=True
)
@property
def block_id(self):
usage_id = self.scope_ids.usage_id
if isinstance(usage_id, basestring):
return usage_id
try:
return unicode(usage_id.replace(branch=None, version_guid=None))
except AttributeError:
pass
return unicode(usage_id)
@XBlock.json_handler
def share_results(self, data, suffix=''):
target_usernames = data.get('usernames')
target_usernames = [username.strip().lower() for username in target_usernames if username.strip()]
current_user = User.objects.get(username=self.current_user_key)
failed_users = []
if not target_usernames:
raise JsonHandlerError(400, _('Usernames not provided.'))
for target_username in target_usernames:
try:
target_user = User.objects.get(username=target_username)
except User.DoesNotExist:
failed_users.append(target_username)
continue
if current_user == target_user:
continue
try:
Share.objects.get(shared_by=current_user, shared_with=target_user, block_id=self.block_id)
except Share.DoesNotExist:
Share(
shared_by=current_user, submission_uid=self.runtime.anonymous_student_id, shared_with=target_user,
block_id=self.block_id,
).save()
if failed_users:
raise JsonHandlerError(
400,
_('Some users could not be shared with. Please check these usernames: {}').format(
','.join(failed_users)
)
)
return {}
@XBlock.json_handler
def remove_share(self, data, suffix=''):
target_username = data.get('username')
if not target_username:
raise JsonHandlerError(400, _('Username not provided.'))
Share.objects.filter(
shared_by__username=self.current_user_key,
shared_with__username=target_username,
block_id=self.block_id,
).delete()
return {'message': _('Removed successfully.')}
def student_view(self, context):
context = context.copy() if context else {}
fragment = Fragment()
for child_id in self.children:
child = self.runtime.get_block(child_id)
# Child should be an instance of MentoringTableColumn
child_frag = child.render('mentoring_view', context)
fragment.add_frag_resources(child_frag)
context['allow_sharing'] = self.allow_sharing
context['allow_download'] = self.allow_download
user_service = self.runtime.service(self, 'user')
if user_service:
context['view_options'] = Share.objects.filter(
shared_with__username=self.current_user_key,
block_id=self.block_id,
).values_list('shared_by__username', flat=True)
context['username'] = self.current_user_key
share_notifications = Share.objects.filter(
shared_with__username=self.current_user_key,
notified=False, block_id=self.block_id,
).values_list('shared_by__username', flat=True)
context['share_notifications'] = share_notifications and json.dumps(list(share_notifications))
if self.type:
# Load an optional background image:
......@@ -116,7 +261,7 @@ class MentoringTableBlock(StudioEditableXBlockMixin, StudioContainerXBlockMixin,
'course_name': self._get_course_name(),
})
fragment.add_content(loader.render_template('templates/html/mentoring-table.html', context))
fragment.add_content(loader.render_template('templates/html/mentoring-table-container.html', context))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring-table.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/jquery-shorten.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, self.js_path))
......
{% load i18n %}
{% if allow_sharing %}
<div class="share-panel-container">
{% if allow_download %}
<div class="report-download-container"><a class="report-download-link" href="#report_download" download="report.html">{% trans "Download report" %}</a></div>
{% endif %}
<div class="mentoring-share-panel">
{% if view_options %}
<span>{% trans "Display Map from:" %}</span>
<select class="mentoring-display-dropdown">
<option value="{{username}}">{% blocktrans %}You ({{username}}){% endblocktrans %}</option>
{% for option in view_options %}
<option value="{{option}}">{{option}}</option>
{% endfor %}
</select>
{% endif %}
<button class="mentoring-share-button">
<i class="fa fa-share"></i>Share
</button>
<div class="mentoring-share-with" style="display: none;">
<div class="share-with-instructions">
<p>{% trans "Enter the username of another student you'd like to share this with." %}</p>
</div>
<div class="shared-with-container"></div>
<div class="new-share-container"><input class="add-share-username"><button class="add-share-field">+</button></div>
<div class="share-errors"></div>
<div class="share-action-buttons">
<button class="do-share-button">Share</button>
</div>
</div>
{% if share_notifications %}
<div class="share-notification" data-shared="{{share_notifications}}">
<button class="notification-close"><i class="fa fa-close"></i></button>
<p><strong>{% trans "Map added!" %}</strong></p>
<p>
{% blocktrans %}
Another user has shared a map with you.
</p>
<p>
You can change the user you're currently displaying using the drop-down selector above.
{% endblocktrans %}
</p>
</div>
{% endif %}
</div>
</div>
<div class="clear"></div>
{% endif %}
<div class="mentoring-table-container">
<div class="mentoring-table {{ self.type }}" style="background-image: url({{ bg_image_url }})">
<div class="cont-text-sr">{{ bg_image_description }}</div>
<div class="mentoring-table-target"></div>
</div>
</div>
{% load i18n %}
{% if shared_with %}
<div class="share-header">{% trans "Shared with:" %}</div>
{% endif %}
<ul class="shared-list">
{% for username in shared_with %}
<li><span class="username">{{username}}</span><button class="remove-share"><i class="fa fa-trash"></i></button></li>
{% endfor %}
</ul>
\ No newline at end of file
{% load i18n %}
<div class="mentoring-table-container">
<div class="mentoring-table {{ self.type }}" style="background-image: url({{ bg_image_url }})">
<div class="cont-text-sr">{{ bg_image_description }}</div>
<table>
{% if header_values %}
<thead>
{% for header in header_values %}
<th>{{ header|safe }}</th>
{% endfor %}
</thead>
{% endif %}
<tbody>
<tr>
{% for content in content_values %}
<td>
<table>
{% if header_values %}
<thead>
{% for header in header_values %}
<th>{{ header|safe }}</th>
{% endfor %}
</thead>
{% endif %}
<tbody>
<tr>
{% for content in content_values %}
<td>
<div class="mentoring-column">
{{content|safe}}
{{content|safe}}
</div>
</td>
{% endfor %}
</tr>
</tbody>
</table>
</div>
{% if allow_download %}
<p><a class="report-download-link" href="#report_download" download="report.html">{% trans "Download report" %}</a></p>
{% endif %}
</div>
\ No newline at end of file
</td>
{% endfor %}
</tr>
</tbody>
</table>
\ No newline at end of file
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