@@ -681,6 +681,16 @@ class MiscCourseTests(ContentStoreTestCase):
|
||||
for expected in expected_types:
|
||||
self.assertIn(expected, resp.content)
|
||||
|
||||
@ddt.data("<script>alert(1)</script>", "alert('hi')", "</script><script>alert(1)</script>")
|
||||
def test_container_handler_xss_prevent(self, malicious_code):
|
||||
"""
|
||||
Test that XSS attack is prevented
|
||||
"""
|
||||
resp = self.client.get_html(get_url('container_handler', self.vert_loc) + '?action=' + malicious_code)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# Test that malicious code does not appear in html
|
||||
self.assertNotIn(malicious_code, resp.content)
|
||||
|
||||
@patch('django.conf.settings.DEPRECATED_ADVANCED_COMPONENT_TYPES', [])
|
||||
def test_advanced_components_in_edit_unit(self):
|
||||
# This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Public views
|
||||
"""
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.clickjacking import xframe_options_deny
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.shortcuts import redirect
|
||||
@@ -17,6 +18,7 @@ __all__ = ['signup', 'login_page', 'howitworks']
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@xframe_options_deny
|
||||
def signup(request):
|
||||
"""
|
||||
Display the signup form.
|
||||
@@ -34,6 +36,7 @@ def signup(request):
|
||||
|
||||
@ssl_login_shortcut
|
||||
@ensure_csrf_cookie
|
||||
@xframe_options_deny
|
||||
def login_page(request):
|
||||
"""
|
||||
Display the login form.
|
||||
|
||||
@@ -13,28 +13,28 @@ import json
|
||||
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="title">${xblock.display_name_with_default} ${xblock_type_display_name(xblock)}</%block>
|
||||
<%block name="title">${xblock.display_name_with_default} ${xblock_type_display_name(xblock) | h}</%block>
|
||||
<%block name="bodyclass">is-signedin course container view-container</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in templates:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<script type="text/template" id="${template_name | h}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
<script type="text/template" id="image-modal-tpl">
|
||||
<%static:include path="common/templates/image-modal.underscore" />
|
||||
</script>
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css') | h}" />
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/factories/container"], function(ContainerFactory) {
|
||||
ContainerFactory(
|
||||
${component_templates | n}, ${json.dumps(xblock_info) | n},
|
||||
"${action}",
|
||||
"${action | h}",
|
||||
{
|
||||
isUnitPage: ${json.dumps(is_unit_page)},
|
||||
canEdit: true
|
||||
@@ -55,7 +55,7 @@ from django.utils.translation import ugettext as _
|
||||
ancestor_url = xblock_studio_url(ancestor)
|
||||
%>
|
||||
% if ancestor_url:
|
||||
<a href="${ancestor_url}" class="navigation-item navigation-link navigation-parent">${ancestor.display_name_with_default | h}</a>
|
||||
<a href="${ancestor_url | h}" class="navigation-item navigation-link navigation-parent">${ancestor.display_name_with_default | h}</a>
|
||||
% else:
|
||||
<span class="navigation-item navigation-parent">${ancestor.display_name_with_default | h}</span>
|
||||
% endif
|
||||
@@ -72,12 +72,12 @@ from django.utils.translation import ugettext as _
|
||||
<ul>
|
||||
% if is_unit_page:
|
||||
<li class="action-item action-view nav-item">
|
||||
<a href="${published_preview_link}" class="button button-view action-button is-disabled" aria-disabled="true" rel="external" title="${_('Open the courseware in the LMS')}">
|
||||
<a href="${published_preview_link | h}" class="button button-view action-button is-disabled" aria-disabled="true" rel="external" title="${_('Open the courseware in the LMS')}">
|
||||
<span class="action-button-text">${_("View Live Version")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-preview nav-item">
|
||||
<a href="${draft_preview_link}" class="button button-preview action-button" rel="external" title="${_('Preview the courseware in the LMS')}">
|
||||
<a href="${draft_preview_link | h}" class="button button-preview action-button" rel="external" title="${_('Preview the courseware in the LMS')}">
|
||||
<span class="action-button-text">${_("Preview")}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -110,10 +110,10 @@ from django.utils.translation import ugettext as _
|
||||
% if xblock.category == 'split_test':
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("Adding components")}</h3>
|
||||
<p>${_("Select a component type under {em_start}Add New Component{em_end}. Then select a template.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
<p>${_("Select a component type under {em_start}Add New Component{em_end}. Then select a template.").format(em_start='<strong>', em_end="</strong>") | h}</p>
|
||||
<p>${_("The new component is added at the bottom of the page or group. You can then edit and move the component.")}</p>
|
||||
<h3 class="title-3">${_("Editing components")}</h3>
|
||||
<p>${_("Click the {em_start}Edit{em_end} icon in a component to edit its content.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
<p>${_("Click the {em_start}Edit{em_end} icon in a component to edit its content.").format(em_start='<strong>', em_end="</strong>") | h}</p>
|
||||
<h3 class="title-3">${_("Reorganizing components")}</h3>
|
||||
<p>${_("Drag components to new locations within this component.")}</p>
|
||||
<p>${_("For content experiments, you can drag components to other groups.")}</p>
|
||||
@@ -121,7 +121,7 @@ from django.utils.translation import ugettext as _
|
||||
<p>${_("Confirm that you have properly configured content in each of your experiment groups.")}</p>
|
||||
</div>
|
||||
<div class="bit external-help">
|
||||
<a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about component containers")}</a>
|
||||
<a href="${get_online_help_info(online_help_token())['doc_url'] | h}" target="_blank" class="button external-help-button">${_("Learn more about component containers")}</a>
|
||||
</div>
|
||||
% elif is_unit_page:
|
||||
<div id="publish-unit"></div>
|
||||
|
||||
32
common/djangoapps/third_party_auth/decorators.py
Normal file
32
common/djangoapps/third_party_auth/decorators.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Decorators that can be used to interact with third_party_auth.
|
||||
"""
|
||||
from functools import wraps
|
||||
from urlparse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.decorators import available_attrs
|
||||
|
||||
from third_party_auth.models import LTIProviderConfig
|
||||
|
||||
|
||||
def xframe_allow_whitelisted(view_func):
|
||||
"""
|
||||
Modifies a view function so that its response has the X-Frame-Options HTTP header
|
||||
set to 'DENY' if the request HTTP referrer is not from a whitelisted hostname.
|
||||
"""
|
||||
|
||||
def wrapped_view(request, *args, **kwargs):
|
||||
""" Modify the response with the correct X-Frame-Options. """
|
||||
resp = view_func(request, *args, **kwargs)
|
||||
x_frame_option = 'DENY'
|
||||
if settings.FEATURES['ENABLE_THIRD_PARTY_AUTH']:
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
if referer is not None:
|
||||
parsed_url = urlparse(referer)
|
||||
hostname = parsed_url.hostname
|
||||
if LTIProviderConfig.objects.current_set().filter(lti_hostname=hostname, enabled=True).exists():
|
||||
x_frame_option = 'ALLOW'
|
||||
resp['X-Frame-Options'] = x_frame_option
|
||||
return resp
|
||||
return wraps(view_func, assigned=available_attrs(view_func))(wrapped_view)
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- 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 field 'LTIProviderConfig.lti_hostname'
|
||||
db.add_column('third_party_auth_ltiproviderconfig', 'lti_hostname',
|
||||
self.gf('django.db.models.fields.CharField')(default='localhost', max_length=255, db_index=True),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'LTIProviderConfig.lti_hostname'
|
||||
db.delete_column('third_party_auth_ltiproviderconfig', 'lti_hostname')
|
||||
|
||||
|
||||
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'})
|
||||
},
|
||||
'third_party_auth.ltiproviderconfig': {
|
||||
'Meta': {'object_name': 'LTIProviderConfig'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'lti_consumer_key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'lti_consumer_secret': ('django.db.models.fields.CharField', [], {'default': "'ae8c9adcb7764ad67272c57c602dabd0b71acf22'", 'max_length': '255', 'blank': 'True'}),
|
||||
'lti_hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'lti_max_timestamp_age': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
|
||||
},
|
||||
'third_party_auth.oauth2providerconfig': {
|
||||
'Meta': {'object_name': 'OAuth2ProviderConfig'},
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
|
||||
},
|
||||
'third_party_auth.samlconfiguration': {
|
||||
'Meta': {'object_name': 'SAMLConfiguration'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
|
||||
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
|
||||
'private_key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {'blank': 'True'})
|
||||
},
|
||||
'third_party_auth.samlproviderconfig': {
|
||||
'Meta': {'object_name': 'SAMLProviderConfig'},
|
||||
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
|
||||
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
|
||||
},
|
||||
'third_party_auth.samlproviderdata': {
|
||||
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['third_party_auth']
|
||||
@@ -493,6 +493,15 @@ class LTIProviderConfig(ProviderConfig):
|
||||
'The name that the LTI Tool Consumer will use to identify itself'
|
||||
)
|
||||
)
|
||||
|
||||
lti_hostname = models.CharField(
|
||||
max_length=255,
|
||||
help_text=(
|
||||
'The domain that will be acting as the LTI consumer.'
|
||||
),
|
||||
db_index=True
|
||||
)
|
||||
|
||||
lti_consumer_secret = models.CharField(
|
||||
default=long_token,
|
||||
max_length=255,
|
||||
|
||||
59
common/djangoapps/third_party_auth/tests/test_decorators.py
Normal file
59
common/djangoapps/third_party_auth/tests/test_decorators.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Tests for third_party_auth decorators.
|
||||
"""
|
||||
import ddt
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
|
||||
from third_party_auth.decorators import xframe_allow_whitelisted
|
||||
from third_party_auth.tests.testutil import TestCase
|
||||
|
||||
|
||||
@xframe_allow_whitelisted
|
||||
def mock_view(_request):
|
||||
""" A test view for testing purposes. """
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
# remove this decorator once third_party_auth is enabled in CMS
|
||||
@unittest.skipIf(
|
||||
'third_party_auth' not in settings.INSTALLED_APPS,
|
||||
'third_party_auth is not currently installed in CMS'
|
||||
)
|
||||
@ddt.ddt
|
||||
class TestXFrameWhitelistDecorator(TestCase):
|
||||
""" Test the xframe_allow_whitelisted decorator. """
|
||||
|
||||
def setUp(self):
|
||||
super(TestXFrameWhitelistDecorator, self).setUp()
|
||||
self.configure_lti_provider(name='Test', lti_hostname='localhost', lti_consumer_key='test_key', enabled=True)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def construct_request(self, referer):
|
||||
""" Add the given referer to a request and then return it. """
|
||||
request = self.factory.get('/login')
|
||||
request.META['HTTP_REFERER'] = referer
|
||||
return request
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
('http://localhost:8000/login', 'ALLOW'),
|
||||
('http://not-a-real-domain.com/login', 'DENY'),
|
||||
(None, 'DENY')
|
||||
)
|
||||
def test_x_frame_options(self, url, expected_result):
|
||||
request = self.construct_request(url)
|
||||
|
||||
response = mock_view(request)
|
||||
|
||||
self.assertEqual(response['X-Frame-Options'], expected_result)
|
||||
|
||||
@ddt.data('http://localhost/login', 'http://not-a-real-domain.com', None)
|
||||
def test_feature_flag_off(self, url):
|
||||
with self.settings(FEATURES={'ENABLE_THIRD_PARTY_AUTH': False}):
|
||||
request = self.construct_request(url)
|
||||
response = mock_view(request)
|
||||
self.assertEqual(response['X-Frame-Options'], 'DENY')
|
||||
@@ -46,6 +46,11 @@ class TeamCardsMixin(object):
|
||||
"""Return the names of each team on the page."""
|
||||
return self.q(css=self._bounded_selector('p.card-description')).map(lambda e: e.text).results
|
||||
|
||||
@property
|
||||
def team_memberships(self):
|
||||
"""Return the team memberships text for each card on the page."""
|
||||
return self.q(css=self._bounded_selector('.member-count')).map(lambda e: e.text).results
|
||||
|
||||
|
||||
class BreadcrumbsMixin(object):
|
||||
"""Provides common operations on teams page breadcrumb links."""
|
||||
|
||||
@@ -78,6 +78,18 @@ class TeamsTabBase(EventsTestMixin, UniqueCourseTest):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return json.loads(response.text)
|
||||
|
||||
def create_memberships(self, num_memberships, team_id):
|
||||
"""Create `num_memberships` users and assign them to `team_id`. The
|
||||
last user created becomes the current user."""
|
||||
memberships = []
|
||||
for __ in xrange(num_memberships):
|
||||
user_info = AutoAuthPage(self.browser, course_id=self.course_id).visit().user_info
|
||||
memberships.append(user_info)
|
||||
self.create_membership(user_info['username'], team_id)
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.user_info = memberships[-1]
|
||||
return memberships
|
||||
|
||||
def create_membership(self, username, team_id):
|
||||
"""Assign `username` to `team_id`."""
|
||||
response = self.course_fixture.session.post(
|
||||
@@ -339,6 +351,18 @@ class MyTeamsTest(TeamsTabBase):
|
||||
self.my_teams_page.visit()
|
||||
self.verify_teams(self.my_teams_page, teams)
|
||||
|
||||
def test_multiple_team_members(self):
|
||||
"""
|
||||
Scenario: Visiting the My Teams page when user is a member of a team should display the teams.
|
||||
Given I am a member of a team with multiple members
|
||||
When I visit the My Teams page
|
||||
Then I should see the correct number of team members on my membership
|
||||
"""
|
||||
teams = self.create_teams(self.topic, 1)
|
||||
self.create_memberships(4, teams[0]['id'])
|
||||
self.my_teams_page.visit()
|
||||
self.assertEqual(self.my_teams_page.team_memberships[0], '4 / 10 Members')
|
||||
|
||||
|
||||
@attr('shard_5')
|
||||
@ddt.ddt
|
||||
|
||||
@@ -680,6 +680,16 @@ class ProgressPageTests(ModuleStoreTestCase):
|
||||
self.section = ItemFactory.create(category='sequential', parent_location=self.chapter.location)
|
||||
self.vertical = ItemFactory.create(category='vertical', parent_location=self.section.location)
|
||||
|
||||
@ddt.data('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
|
||||
def test_progress_page_xss_prevent(self, malicious_code):
|
||||
"""
|
||||
Test that XSS attack is prevented
|
||||
"""
|
||||
resp = views.progress(self.request, course_id=unicode(self.course.id), student_id=self.user.id)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# Test that malicious code does not appear in html
|
||||
self.assertNotIn(malicious_code, resp.content)
|
||||
|
||||
def test_pure_ungraded_xblock(self):
|
||||
ItemFactory.create(category='acid', parent_location=self.vertical.location)
|
||||
|
||||
|
||||
@@ -450,7 +450,7 @@ class SingleCohortedThreadTestCase(CohortedTestCase):
|
||||
html = response.content
|
||||
|
||||
# Verify that the group name is correctly included in the HTML
|
||||
self.assertRegexpMatches(html, r'"group_name": "student_cohort"')
|
||||
self.assertRegexpMatches(html, r'"group_name": "student_cohort"')
|
||||
|
||||
|
||||
@patch('lms.lib.comment_client.utils.requests.request')
|
||||
@@ -1152,10 +1152,10 @@ class UserProfileTestCase(ModuleStoreTestCase):
|
||||
self.assertRegexpMatches(html, r'data-num-pages="1"')
|
||||
self.assertRegexpMatches(html, r'<span>1</span> discussion started')
|
||||
self.assertRegexpMatches(html, r'<span>2</span> comments')
|
||||
self.assertRegexpMatches(html, r'"id": "{}"'.format(self.TEST_THREAD_ID))
|
||||
self.assertRegexpMatches(html, r'"title": "{}"'.format(self.TEST_THREAD_TEXT))
|
||||
self.assertRegexpMatches(html, r'"body": "{}"'.format(self.TEST_THREAD_TEXT))
|
||||
self.assertRegexpMatches(html, r'"username": "{}"'.format(self.student.username))
|
||||
self.assertRegexpMatches(html, r'"id": "{}"'.format(self.TEST_THREAD_ID))
|
||||
self.assertRegexpMatches(html, r'"title": "{}"'.format(self.TEST_THREAD_TEXT))
|
||||
self.assertRegexpMatches(html, r'"body": "{}"'.format(self.TEST_THREAD_TEXT))
|
||||
self.assertRegexpMatches(html, r'"username": "{}"'.format(self.student.username))
|
||||
|
||||
def check_ajax(self, mock_request, **params):
|
||||
response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
|
||||
@@ -1326,6 +1326,56 @@ class ForumFormDiscussionUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin):
|
||||
self.assertEqual(response_data["discussion_data"][0]["body"], text)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@patch('lms.lib.comment_client.utils.requests.request')
|
||||
class ForumDiscussionXSSTestCase(UrlResetMixin, ModuleStoreTestCase):
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
super(ForumDiscussionXSSTestCase, self).setUp()
|
||||
|
||||
username = "foo"
|
||||
password = "bar"
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
self.student = UserFactory.create(username=username, password=password)
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
self.assertTrue(self.client.login(username=username, password=password))
|
||||
|
||||
@ddt.data('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
|
||||
@patch('student.models.cc.User.from_django_user')
|
||||
def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req): # pylint: disable=unused-argument
|
||||
"""
|
||||
Test that XSS attack is prevented
|
||||
"""
|
||||
reverse_url = "%s%s" % (reverse(
|
||||
"django_comment_client.forum.views.forum_form_discussion",
|
||||
kwargs={"course_id": unicode(self.course.id)}), '/forum_form_discussion')
|
||||
# Test that malicious code does not appear in html
|
||||
url = "%s?%s=%s" % (reverse_url, 'sort_key', malicious_code)
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotIn(malicious_code, resp.content)
|
||||
|
||||
@ddt.data('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
|
||||
@patch('student.models.cc.User.from_django_user')
|
||||
@patch('student.models.cc.User.active_threads')
|
||||
def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request):
|
||||
"""
|
||||
Test that XSS attack is prevented
|
||||
"""
|
||||
mock_threads.return_value = [], 1, 1
|
||||
mock_from_django_user.return_value = Mock()
|
||||
mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy')
|
||||
|
||||
url = reverse('django_comment_client.forum.views.user_profile',
|
||||
kwargs={'course_id': unicode(self.course.id), 'user_id': str(self.student.id)})
|
||||
# Test that malicious code does not appear in html
|
||||
url_string = "%s?%s=%s" % (url, 'page', malicious_code)
|
||||
resp = self.client.get(url_string)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotIn(malicious_code, resp.content)
|
||||
|
||||
|
||||
class ForumDiscussionSearchUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin):
|
||||
def setUp(self):
|
||||
super(ForumDiscussionSearchUnicodeTestCase, self).setUp()
|
||||
|
||||
@@ -5,7 +5,6 @@ Views handling read (GET) requests for the Discussion tab and inline discussions
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.conf import settings
|
||||
@@ -73,13 +72,6 @@ class DiscussionTab(EnrolledTab):
|
||||
return settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE')
|
||||
|
||||
|
||||
def _attr_safe_json(obj):
|
||||
"""
|
||||
return a JSON string for obj which is safe to embed as the value of an attribute in a DOM node
|
||||
"""
|
||||
return saxutils.escape(json.dumps(obj), {'"': '"'})
|
||||
|
||||
|
||||
@newrelic.agent.function_trace()
|
||||
def make_course_settings(course, user):
|
||||
"""
|
||||
@@ -278,28 +270,28 @@ def forum_form_discussion(request, course_key):
|
||||
'course': course,
|
||||
#'recent_active_threads': recent_active_threads,
|
||||
'staff_access': bool(has_access(request.user, 'staff', course)),
|
||||
'threads': _attr_safe_json(threads),
|
||||
'threads': json.dumps(threads),
|
||||
'thread_pages': query_params['num_pages'],
|
||||
'user_info': _attr_safe_json(user_info),
|
||||
'can_create_comment': _attr_safe_json(
|
||||
'user_info': json.dumps(user_info, default=lambda x: None),
|
||||
'can_create_comment': json.dumps(
|
||||
has_permission(request.user, "create_comment", course.id)),
|
||||
'can_create_subcomment': _attr_safe_json(
|
||||
'can_create_subcomment': json.dumps(
|
||||
has_permission(request.user, "create_sub_comment", course.id)),
|
||||
'can_create_thread': has_permission(request.user, "create_thread", course.id),
|
||||
'flag_moderator': bool(
|
||||
has_permission(request.user, 'openclose_thread', course.id) or
|
||||
has_access(request.user, 'staff', course)
|
||||
),
|
||||
'annotated_content_info': _attr_safe_json(annotated_content_info),
|
||||
'annotated_content_info': json.dumps(annotated_content_info),
|
||||
'course_id': course.id.to_deprecated_string(),
|
||||
'roles': _attr_safe_json(utils.get_role_ids(course_key)),
|
||||
'roles': json.dumps(utils.get_role_ids(course_key)),
|
||||
'is_moderator': has_permission(request.user, "see_all_cohorts", course_key),
|
||||
'cohorts': course_settings["cohorts"], # still needed to render _thread_list_template
|
||||
'user_cohort': user_cohort_id, # read from container in NewPostView
|
||||
'is_course_cohorted': is_course_cohorted(course_key), # still needed to render _thread_list_template
|
||||
'sort_preference': user.default_sort_key,
|
||||
'category_map': course_settings["category_map"],
|
||||
'course_settings': _attr_safe_json(course_settings)
|
||||
'course_settings': json.dumps(course_settings)
|
||||
}
|
||||
# print "start rendering.."
|
||||
return render_to_response('discussion/index.html', context)
|
||||
@@ -385,19 +377,19 @@ def single_thread(request, course_key, discussion_id, thread_id):
|
||||
'discussion_id': discussion_id,
|
||||
'csrf': csrf(request)['csrf_token'],
|
||||
'init': '', # TODO: What is this?
|
||||
'user_info': _attr_safe_json(user_info),
|
||||
'can_create_comment': _attr_safe_json(
|
||||
'user_info': json.dumps(user_info),
|
||||
'can_create_comment': json.dumps(
|
||||
has_permission(request.user, "create_comment", course.id)),
|
||||
'can_create_subcomment': _attr_safe_json(
|
||||
'can_create_subcomment': json.dumps(
|
||||
has_permission(request.user, "create_sub_comment", course.id)),
|
||||
'can_create_thread': has_permission(request.user, "create_thread", course.id),
|
||||
'annotated_content_info': _attr_safe_json(annotated_content_info),
|
||||
'annotated_content_info': json.dumps(annotated_content_info),
|
||||
'course': course,
|
||||
#'recent_active_threads': recent_active_threads,
|
||||
'course_id': course.id.to_deprecated_string(), # TODO: Why pass both course and course.id to template?
|
||||
'thread_id': thread_id,
|
||||
'threads': _attr_safe_json(threads),
|
||||
'roles': _attr_safe_json(utils.get_role_ids(course_key)),
|
||||
'threads': json.dumps(threads),
|
||||
'roles': json.dumps(utils.get_role_ids(course_key)),
|
||||
'is_moderator': is_moderator,
|
||||
'thread_pages': query_params['num_pages'],
|
||||
'is_course_cohorted': is_course_cohorted(course_key),
|
||||
@@ -409,7 +401,7 @@ def single_thread(request, course_key, discussion_id, thread_id):
|
||||
'user_cohort': user_cohort,
|
||||
'sort_preference': cc_user.default_sort_key,
|
||||
'category_map': course_settings["category_map"],
|
||||
'course_settings': _attr_safe_json(course_settings)
|
||||
'course_settings': json.dumps(course_settings)
|
||||
}
|
||||
return render_to_response('discussion/index.html', context)
|
||||
|
||||
@@ -458,7 +450,7 @@ def user_profile(request, course_key, user_id):
|
||||
'discussion_data': threads,
|
||||
'page': query_params['page'],
|
||||
'num_pages': query_params['num_pages'],
|
||||
'annotated_content_info': _attr_safe_json(annotated_content_info),
|
||||
'annotated_content_info': json.dumps(annotated_content_info),
|
||||
})
|
||||
else:
|
||||
django_user = User.objects.get(id=user_id)
|
||||
@@ -467,9 +459,9 @@ def user_profile(request, course_key, user_id):
|
||||
'user': request.user,
|
||||
'django_user': django_user,
|
||||
'profiled_user': profiled_user.to_dict(),
|
||||
'threads': _attr_safe_json(threads),
|
||||
'user_info': _attr_safe_json(user_info),
|
||||
'annotated_content_info': _attr_safe_json(annotated_content_info),
|
||||
'threads': json.dumps(threads),
|
||||
'user_info': json.dumps(user_info, default=lambda x: None),
|
||||
'annotated_content_info': json.dumps(annotated_content_info),
|
||||
'page': query_params['page'],
|
||||
'num_pages': query_params['num_pages'],
|
||||
'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username})
|
||||
@@ -546,9 +538,9 @@ def followed_threads(request, course_key, user_id):
|
||||
'user': request.user,
|
||||
'django_user': User.objects.get(id=user_id),
|
||||
'profiled_user': profiled_user.to_dict(),
|
||||
'threads': _attr_safe_json(threads),
|
||||
'user_info': _attr_safe_json(user_info),
|
||||
'annotated_content_info': _attr_safe_json(annotated_content_info),
|
||||
'threads': json.dumps(threads),
|
||||
'user_info': json.dumps(user_info),
|
||||
'annotated_content_info': json.dumps(annotated_content_info),
|
||||
# 'content': content,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ class GroupIdAssertionMixin(object):
|
||||
|
||||
def _assert_html_response_contains_group_info(self, response):
|
||||
group_info = {"group_id": None, "group_name": None}
|
||||
match = re.search(r'"group_id": ([\d]*)', response.content)
|
||||
match = re.search(r'"group_id": ([\d]*)', response.content)
|
||||
if match and match.group(1) != '':
|
||||
group_info["group_id"] = int(match.group(1))
|
||||
match = re.search(r'"group_name": "([^&]*)"', response.content)
|
||||
match = re.search(r'"group_name": "([^&]*)"', response.content)
|
||||
if match:
|
||||
group_info["group_name"] = match.group(1)
|
||||
self._assert_thread_contains_group_info(group_info)
|
||||
|
||||
@@ -354,6 +354,24 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
self.assertContains(resp, "Register for Test Microsite")
|
||||
self.assertContains(resp, "register-form")
|
||||
|
||||
def test_login_registration_xframe_protected(self):
|
||||
resp = self.client.get(
|
||||
reverse("register_user"),
|
||||
{},
|
||||
HTTP_REFERER="http://localhost/iframe"
|
||||
)
|
||||
|
||||
self.assertEqual(resp['X-Frame-Options'], 'DENY')
|
||||
|
||||
self.configure_lti_provider(name='Test', lti_hostname='localhost', lti_consumer_key='test_key', enabled=True)
|
||||
|
||||
resp = self.client.get(
|
||||
reverse("register_user"),
|
||||
HTTP_REFERER="http://localhost/iframe"
|
||||
)
|
||||
|
||||
self.assertEqual(resp['X-Frame-Options'], 'ALLOW')
|
||||
|
||||
def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers):
|
||||
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """
|
||||
finish_auth_url = None
|
||||
|
||||
@@ -34,6 +34,7 @@ from student.views import (
|
||||
from student.helpers import get_next_url_for_login_page
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline
|
||||
from third_party_auth.decorators import xframe_allow_whitelisted
|
||||
from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
|
||||
@@ -45,6 +46,7 @@ AUDIT_LOG = logging.getLogger("audit")
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
@ensure_csrf_cookie
|
||||
@xframe_allow_whitelisted
|
||||
def login_and_registration_form(request, initial_mode="login"):
|
||||
"""Render the combined login/registration form, defaulting to login
|
||||
|
||||
|
||||
15
lms/djangoapps/teams/static/teams/js/collections/my_teams.js
Normal file
15
lms/djangoapps/teams/static/teams/js/collections/my_teams.js
Normal file
@@ -0,0 +1,15 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
define(['teams/js/collections/team'], function (TeamCollection) {
|
||||
var MyTeamsCollection = TeamCollection.extend({
|
||||
initialize: function (teams, options) {
|
||||
TeamCollection.prototype.initialize.call(this, teams, options);
|
||||
delete this.server_api.topic_id;
|
||||
this.server_api = _.extend(this.server_api, {
|
||||
username: options.username
|
||||
});
|
||||
}
|
||||
});
|
||||
return MyTeamsCollection;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
this.perPage = options.per_page || 10;
|
||||
this.username = options.username;
|
||||
this.privileged = options.privileged;
|
||||
this.staff = options.staff;
|
||||
|
||||
this.server_api = _.extend(
|
||||
{
|
||||
@@ -24,15 +22,7 @@
|
||||
delete this.server_api['order_by']; // Order by is not specified for the TeamMembership API
|
||||
},
|
||||
|
||||
model: TeamMembershipModel,
|
||||
|
||||
canUserCreateTeam: function() {
|
||||
// Note: non-staff and non-privileged users are automatically added to any team
|
||||
// that they create. This means that if multiple team membership is
|
||||
// disabled that they cannot create a new team when they already
|
||||
// belong to one.
|
||||
return this.privileged || this.staff || this.length === 0;
|
||||
}
|
||||
model: TeamMembershipModel
|
||||
});
|
||||
return TeamMembershipCollection;
|
||||
});
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
define([
|
||||
'backbone',
|
||||
'teams/js/collections/team',
|
||||
'teams/js/collections/team_membership',
|
||||
'teams/js/collections/my_teams',
|
||||
'teams/js/views/my_teams',
|
||||
'teams/js/spec_helpers/team_spec_helpers',
|
||||
'common/js/spec_helpers/ajax_helpers'
|
||||
], function (Backbone, TeamCollection, TeamMembershipCollection, MyTeamsView, TeamSpecHelpers, AjaxHelpers) {
|
||||
], function (Backbone, MyTeamsCollection, MyTeamsView, TeamSpecHelpers, AjaxHelpers) {
|
||||
'use strict';
|
||||
describe('My Teams View', function () {
|
||||
beforeEach(function () {
|
||||
setFixtures('<div class="teams-container"></div>');
|
||||
});
|
||||
|
||||
var createMyTeamsView = function(options) {
|
||||
return new MyTeamsView(_.extend(
|
||||
{
|
||||
el: '.teams-container',
|
||||
collection: options.teams || TeamSpecHelpers.createMockTeams(),
|
||||
teamMemberships: TeamSpecHelpers.createMockTeamMemberships(),
|
||||
showActions: true,
|
||||
context: TeamSpecHelpers.testContext
|
||||
},
|
||||
options
|
||||
)).render();
|
||||
var createMyTeamsView = function(myTeams) {
|
||||
return new MyTeamsView({
|
||||
el: '.teams-container',
|
||||
collection: myTeams,
|
||||
showActions: true,
|
||||
context: TeamSpecHelpers.testContext
|
||||
}).render();
|
||||
};
|
||||
|
||||
it('can render itself', function () {
|
||||
var teamMembershipsData = TeamSpecHelpers.createMockTeamMembershipsData(1, 5),
|
||||
teamMemberships = TeamSpecHelpers.createMockTeamMemberships(teamMembershipsData),
|
||||
myTeamsView = createMyTeamsView({
|
||||
teams: teamMemberships,
|
||||
teamMemberships: teamMemberships
|
||||
});
|
||||
|
||||
TeamSpecHelpers.verifyCards(myTeamsView, teamMembershipsData);
|
||||
var teamsData = TeamSpecHelpers.createMockTeamData(1, 5),
|
||||
teams = TeamSpecHelpers.createMockTeams({results: teamsData}),
|
||||
myTeamsView = createMyTeamsView(teams);
|
||||
TeamSpecHelpers.verifyCards(myTeamsView, teamsData);
|
||||
|
||||
// Verify that there is no header or footer
|
||||
expect(myTeamsView.$('.teams-paging-header').text().trim()).toBe('');
|
||||
@@ -41,36 +32,37 @@ define([
|
||||
});
|
||||
|
||||
it('shows a message when the user is not a member of any teams', function () {
|
||||
var teamMemberships = TeamSpecHelpers.createMockTeamMemberships([]),
|
||||
myTeamsView = createMyTeamsView({
|
||||
teams: teamMemberships,
|
||||
teamMemberships: teamMemberships
|
||||
});
|
||||
var teams = TeamSpecHelpers.createMockTeams({results: []}),
|
||||
myTeamsView = createMyTeamsView(teams);
|
||||
TeamSpecHelpers.verifyCards(myTeamsView, []);
|
||||
expect(myTeamsView.$el.text().trim()).toBe('You are not currently a member of any team.');
|
||||
});
|
||||
|
||||
it('refreshes a stale membership collection when rendering', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
teamMemberships = TeamSpecHelpers.createMockTeamMemberships([]),
|
||||
myTeamsView = createMyTeamsView({
|
||||
teams: teamMemberships,
|
||||
teamMemberships: teamMemberships
|
||||
});
|
||||
teams = TeamSpecHelpers.createMockTeams({
|
||||
results: []
|
||||
}, {
|
||||
per_page: 2,
|
||||
url: TeamSpecHelpers.testContext.myTeamsUrl,
|
||||
username: TeamSpecHelpers.testContext.userInfo.username
|
||||
}, MyTeamsCollection),
|
||||
myTeamsView = createMyTeamsView(teams);
|
||||
TeamSpecHelpers.verifyCards(myTeamsView, []);
|
||||
expect(myTeamsView.$el.text().trim()).toBe('You are not currently a member of any team.');
|
||||
teamMemberships.teamEvents.trigger('teams:update', { action: 'create' });
|
||||
TeamSpecHelpers.teamEvents.trigger('teams:update', { action: 'create' });
|
||||
myTeamsView.render();
|
||||
AjaxHelpers.expectRequestURL(
|
||||
requests,
|
||||
TeamSpecHelpers.testContext.teamMembershipsUrl,
|
||||
TeamSpecHelpers.testContext.myTeamsUrl,
|
||||
{
|
||||
expand : 'team,user',
|
||||
expand : 'user',
|
||||
username : TeamSpecHelpers.testContext.userInfo.username,
|
||||
course_id : TeamSpecHelpers.testContext.courseID,
|
||||
page : '1',
|
||||
page_size : '10',
|
||||
text_search: ''
|
||||
page_size : '2',
|
||||
text_search: '',
|
||||
order_by: 'last_activity_at'
|
||||
}
|
||||
);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
define([
|
||||
'backbone',
|
||||
'teams/js/collections/team',
|
||||
'teams/js/collections/team_membership',
|
||||
'teams/js/views/teams',
|
||||
'teams/js/spec_helpers/team_spec_helpers'
|
||||
], function (Backbone, TeamCollection, TeamMembershipCollection, TeamsView, TeamSpecHelpers) {
|
||||
], function (Backbone, TeamCollection, TeamsView, TeamSpecHelpers) {
|
||||
'use strict';
|
||||
describe('Teams View', function () {
|
||||
beforeEach(function () {
|
||||
@@ -15,7 +14,6 @@ define([
|
||||
return new TeamsView({
|
||||
el: '.teams-container',
|
||||
collection: options.teams || TeamSpecHelpers.createMockTeams(),
|
||||
teamMemberships: options.teamMemberships || TeamSpecHelpers.createMockTeamMemberships(),
|
||||
showActions: true,
|
||||
context: TeamSpecHelpers.testContext
|
||||
}).render();
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
define([
|
||||
'backbone',
|
||||
'teams/js/collections/team',
|
||||
'teams/js/collections/team_membership',
|
||||
'underscore',
|
||||
'teams/js/views/topic_teams',
|
||||
'teams/js/spec_helpers/team_spec_helpers',
|
||||
'common/js/spec_helpers/ajax_helpers',
|
||||
'common/js/spec_helpers/page_helpers'
|
||||
], function (Backbone, TeamCollection, TeamMembershipCollection, TopicTeamsView, TeamSpecHelpers,
|
||||
AjaxHelpers, PageHelpers) {
|
||||
], function (Backbone, _, TopicTeamsView, TeamSpecHelpers, PageHelpers) {
|
||||
'use strict';
|
||||
describe('Topic Teams View', function () {
|
||||
var createTopicTeamsView = function(options) {
|
||||
options = options || {};
|
||||
var myTeamsCollection = options.myTeamsCollection || TeamSpecHelpers.createMockTeams({results: []});
|
||||
return new TopicTeamsView({
|
||||
el: '.teams-container',
|
||||
model: TeamSpecHelpers.createMockTopic(),
|
||||
collection: options.teams || TeamSpecHelpers.createMockTeams(),
|
||||
teamMemberships: options.teamMemberships || TeamSpecHelpers.createMockTeamMemberships(),
|
||||
myTeamsCollection: myTeamsCollection,
|
||||
showActions: true,
|
||||
context: TeamSpecHelpers.testContext
|
||||
context: _.extend({}, TeamSpecHelpers.testContext, options)
|
||||
}).render();
|
||||
};
|
||||
|
||||
@@ -49,8 +48,7 @@ define([
|
||||
teamsView = createTopicTeamsView({
|
||||
teams: TeamSpecHelpers.createMockTeams({
|
||||
results: testTeamData
|
||||
}),
|
||||
teamMemberships: TeamSpecHelpers.createMockTeamMemberships([])
|
||||
})
|
||||
});
|
||||
|
||||
expect(teamsView.$('.teams-paging-header').text()).toMatch('Showing 1-5 out of 6 total');
|
||||
@@ -64,24 +62,21 @@ define([
|
||||
});
|
||||
|
||||
it('can browse all teams', function () {
|
||||
var emptyMembership = TeamSpecHelpers.createMockTeamMemberships([]),
|
||||
teamsView = createTopicTeamsView({ teamMemberships: emptyMembership });
|
||||
var teamsView = createTopicTeamsView();
|
||||
spyOn(Backbone.history, 'navigate');
|
||||
teamsView.$('.browse-teams').click();
|
||||
expect(Backbone.history.navigate.calls[0].args).toContain('browse');
|
||||
});
|
||||
|
||||
it('gives the search field focus when clicking on the search teams link', function () {
|
||||
var emptyMembership = TeamSpecHelpers.createMockTeamMemberships([]),
|
||||
teamsView = createTopicTeamsView({ teamMemberships: emptyMembership });
|
||||
var teamsView = createTopicTeamsView();
|
||||
spyOn($.fn, 'focus').andCallThrough();
|
||||
teamsView.$('.search-teams').click();
|
||||
expect(teamsView.$('.search-field').first().focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can show the create team modal', function () {
|
||||
var emptyMembership = TeamSpecHelpers.createMockTeamMemberships([]),
|
||||
teamsView = createTopicTeamsView({ teamMemberships: emptyMembership });
|
||||
var teamsView = createTopicTeamsView();
|
||||
spyOn(Backbone.history, 'navigate');
|
||||
teamsView.$('a.create-team').click();
|
||||
expect(Backbone.history.navigate.calls[0].args).toContain(
|
||||
@@ -90,25 +85,17 @@ define([
|
||||
});
|
||||
|
||||
it('does not show actions for a user already in a team', function () {
|
||||
var teamsView = createTopicTeamsView({});
|
||||
var teamsView = createTopicTeamsView({myTeamsCollection: TeamSpecHelpers.createMockTeams()});
|
||||
verifyActions(teamsView, {showActions: false});
|
||||
});
|
||||
|
||||
it('shows actions for a privileged user already in a team', function () {
|
||||
var staffMembership = TeamSpecHelpers.createMockTeamMemberships(
|
||||
TeamSpecHelpers.createMockTeamMembershipsData(1, 5),
|
||||
{ privileged: true }
|
||||
),
|
||||
teamsView = createTopicTeamsView({ teamMemberships: staffMembership });
|
||||
var teamsView = createTopicTeamsView({ privileged: true });
|
||||
verifyActions(teamsView);
|
||||
});
|
||||
|
||||
it('shows actions for a staff user already in a team', function () {
|
||||
var staffMembership = TeamSpecHelpers.createMockTeamMemberships(
|
||||
TeamSpecHelpers.createMockTeamMembershipsData(1, 5),
|
||||
{ privileged: false, staff: true }
|
||||
),
|
||||
teamsView = createTopicTeamsView({ teamMemberships: staffMembership });
|
||||
var teamsView = createTopicTeamsView({ privileged: false, staff: true });
|
||||
verifyActions(teamsView);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,14 +56,18 @@ define([
|
||||
);
|
||||
};
|
||||
|
||||
var createMockTeams = function(options) {
|
||||
return new TeamCollection(
|
||||
createMockTeamsResponse(options),
|
||||
{
|
||||
var createMockTeams = function(responseOptions, options, collectionType) {
|
||||
if(_.isUndefined(collectionType)) {
|
||||
collectionType = TeamCollection;
|
||||
}
|
||||
return new collectionType(
|
||||
createMockTeamsResponse(responseOptions),
|
||||
_.extend({
|
||||
teamEvents: teamEvents,
|
||||
course_id: testCourseID,
|
||||
per_page: 2,
|
||||
parse: true
|
||||
}
|
||||
}, options)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -83,35 +87,6 @@ define([
|
||||
});
|
||||
};
|
||||
|
||||
var createMockTeamMemberships = function(teamMembershipData, options) {
|
||||
if (!teamMembershipData) {
|
||||
teamMembershipData = createMockTeamMembershipsData(1, 5);
|
||||
}
|
||||
return new TeamMembershipCollection(
|
||||
{
|
||||
count: 11,
|
||||
num_pages: 3,
|
||||
current_page: 1,
|
||||
start: 0,
|
||||
sort_order: 'last_activity_at',
|
||||
results: teamMembershipData
|
||||
},
|
||||
_.extend(
|
||||
{},
|
||||
{
|
||||
teamEvents: teamEvents,
|
||||
course_id: testCourseID,
|
||||
parse: true,
|
||||
url: testContext.teamMembershipsUrl,
|
||||
username: testUser,
|
||||
privileged: false,
|
||||
staff: false
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
var createMockUserInfo = function(options) {
|
||||
return _.extend(
|
||||
{
|
||||
@@ -291,6 +266,7 @@ define([
|
||||
teamsDetailUrl: '/api/team/v0/teams/team_id',
|
||||
teamMembershipsUrl: '/api/team/v0/team_memberships/',
|
||||
teamMembershipDetailUrl: '/api/team/v0/team_membership/team_id,' + testUser,
|
||||
myTeamsUrl: '/api/team/v0/teams/',
|
||||
userInfo: createMockUserInfo()
|
||||
};
|
||||
|
||||
@@ -331,8 +307,6 @@ define([
|
||||
createMockTeamData: createMockTeamData,
|
||||
createMockTeamsResponse: createMockTeamsResponse,
|
||||
createMockTeams: createMockTeams,
|
||||
createMockTeamMembershipsData: createMockTeamMembershipsData,
|
||||
createMockTeamMemberships: createMockTeamMemberships,
|
||||
createMockUserInfo: createMockUserInfo,
|
||||
createMockContext: createMockContext,
|
||||
createMockTopic: createMockTopic,
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
},
|
||||
|
||||
initialize: function(options) {
|
||||
this.teamMembershipDetailUrl = options.context.teamMembershipDetailUrl;
|
||||
// The URL ends with team_id,request_username. We want to replace
|
||||
// the last occurrence of team_id with the actual team_id, and remove request_username
|
||||
// as the actual user to be removed from the team will be added on before calling DELETE.
|
||||
this.teamMembershipDetailUrl = this.teamMembershipDetailUrl.substring(
|
||||
0, this.teamMembershipDetailUrl.lastIndexOf('team_id')
|
||||
this.teamMembershipDetailUrl = options.context.teamMembershipDetailUrl.substring(
|
||||
0, this.options.context.teamMembershipDetailUrl.lastIndexOf('team_id')
|
||||
) + this.model.get('id') + ",";
|
||||
|
||||
this.teamEvents = options.teamEvents;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
;(function (define) {
|
||||
(function (define) {
|
||||
'use strict';
|
||||
define([
|
||||
'jquery',
|
||||
@@ -99,48 +99,34 @@
|
||||
CardView.prototype.initialize.apply(this, arguments);
|
||||
// TODO: show last activity detail view
|
||||
this.detailViews = [
|
||||
new TeamMembershipView({memberships: this.getMemberships(), maxTeamSize: this.maxTeamSize}),
|
||||
new TeamMembershipView({memberships: this.model.get('membership'), maxTeamSize: this.maxTeamSize}),
|
||||
new TeamCountryLanguageView({
|
||||
model: this.teamModel(),
|
||||
model: this.model,
|
||||
countries: this.countries,
|
||||
languages: this.languages
|
||||
}),
|
||||
new TeamActivityView({date: this.teamModel().get('last_activity_at')})
|
||||
new TeamActivityView({date: this.model.get('last_activity_at')})
|
||||
];
|
||||
this.model.on('change:membership', function () {
|
||||
this.detailViews[0].memberships = this.getMemberships();
|
||||
this.detailViews[0].memberships = this.model.get('membership');
|
||||
}, this);
|
||||
},
|
||||
|
||||
teamModel: function () {
|
||||
if (this.model.has('team')) { return this.model.get('team'); }
|
||||
return this.model;
|
||||
},
|
||||
|
||||
getMemberships: function () {
|
||||
if (this.model.has('team')) {
|
||||
return [this.model.attributes];
|
||||
}
|
||||
else {
|
||||
return this.model.get('membership');
|
||||
}
|
||||
},
|
||||
|
||||
configuration: 'list_card',
|
||||
cardClass: 'team-card',
|
||||
title: function () { return this.teamModel().get('name'); },
|
||||
description: function () { return this.teamModel().get('description'); },
|
||||
title: function () { return this.model.get('name'); },
|
||||
description: function () { return this.model.get('description'); },
|
||||
details: function () { return this.detailViews; },
|
||||
actionClass: 'action-view',
|
||||
actionContent: function() {
|
||||
return interpolate(
|
||||
gettext('View %(span_start)s %(team_name)s %(span_end)s'),
|
||||
{span_start: '<span class="sr">', team_name: _.escape(this.teamModel().get('name')), span_end: '</span>'},
|
||||
{span_start: '<span class="sr">', team_name: _.escape(this.model.get('name')), span_end: '</span>'},
|
||||
true
|
||||
);
|
||||
},
|
||||
actionUrl: function () {
|
||||
return '#teams/' + this.teamModel().get('topic_id') + '/' + this.teamModel().get('id');
|
||||
return '#teams/' + this.model.get('topic_id') + '/' + this.model.get('id');
|
||||
}
|
||||
});
|
||||
return TeamCardView;
|
||||
|
||||
@@ -16,12 +16,9 @@
|
||||
},
|
||||
|
||||
initialize: function (options) {
|
||||
this.topic = options.topic;
|
||||
this.teamMemberships = options.teamMemberships;
|
||||
this.context = options.context;
|
||||
this.itemViewClass = TeamCardView.extend({
|
||||
router: options.router,
|
||||
topic: options.topic,
|
||||
maxTeamSize: this.context.maxTeamSize,
|
||||
srInfo: this.srInfo,
|
||||
countries: TeamUtils.selectorOptionsArrayToHashWithBlank(this.context.countries),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
'teams/js/collections/topic',
|
||||
'teams/js/models/team',
|
||||
'teams/js/collections/team',
|
||||
'teams/js/collections/team_membership',
|
||||
'teams/js/collections/my_teams',
|
||||
'teams/js/utils/team_analytics',
|
||||
'teams/js/views/teams_tabbed_view',
|
||||
'teams/js/views/topics',
|
||||
@@ -26,7 +26,7 @@
|
||||
'teams/js/views/instructor_tools',
|
||||
'text!teams/templates/teams_tab.underscore'],
|
||||
function (Backbone, $, _, gettext, SearchFieldView, HeaderView, HeaderModel,
|
||||
TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection, TeamAnalytics,
|
||||
TopicModel, TopicCollection, TeamModel, TeamCollection, MyTeamsCollection, TeamAnalytics,
|
||||
TeamsTabbedView, TopicsView, TeamProfileView, MyTeamsView, TopicTeamsView, TeamEditView,
|
||||
TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView, teamsTemplate) {
|
||||
var TeamsHeaderModel = HeaderModel.extend({
|
||||
@@ -95,26 +95,22 @@
|
||||
|
||||
// Create an event queue to track team changes
|
||||
this.teamEvents = _.clone(Backbone.Events);
|
||||
|
||||
this.teamMemberships = new TeamMembershipCollection(
|
||||
this.context.userInfo.team_memberships_data,
|
||||
this.myTeamsCollection = new MyTeamsCollection(
|
||||
this.context.userInfo.teams,
|
||||
{
|
||||
teamEvents: this.teamEvents,
|
||||
url: this.context.teamMembershipsUrl,
|
||||
course_id: this.context.courseID,
|
||||
username: this.context.userInfo.username,
|
||||
privileged: this.context.userInfo.privileged,
|
||||
staff: this.context.userInfo.staff,
|
||||
parse: true
|
||||
per_page: 2,
|
||||
parse: true,
|
||||
url: this.context.myTeamsUrl
|
||||
}
|
||||
).bootstrap();
|
||||
|
||||
);
|
||||
this.myTeamsView = new MyTeamsView({
|
||||
router: this.router,
|
||||
teamEvents: this.teamEvents,
|
||||
context: this.context,
|
||||
collection: this.teamMemberships,
|
||||
teamMemberships: this.teamMemberships
|
||||
collection: this.myTeamsCollection
|
||||
});
|
||||
|
||||
this.topicsCollection = new TopicCollection(
|
||||
@@ -176,7 +172,7 @@
|
||||
// 1. If the user belongs to at least one team, jump to the "My Teams" page
|
||||
// 2. If not, then jump to the "Browse" page
|
||||
if (Backbone.history.getFragment() === '') {
|
||||
if (this.teamMemberships.length > 0) {
|
||||
if (this.myTeamsCollection.length > 0) {
|
||||
this.router.navigate('my-teams', {trigger: true});
|
||||
} else {
|
||||
this.router.navigate('browse', {trigger: true});
|
||||
@@ -351,12 +347,13 @@
|
||||
createTeamsListView: function(options) {
|
||||
var topic = options.topic,
|
||||
collection = options.collection,
|
||||
self = this,
|
||||
teamsView = new TopicTeamsView({
|
||||
router: this.router,
|
||||
context: this.context,
|
||||
model: topic,
|
||||
collection: collection,
|
||||
teamMemberships: this.teamMemberships,
|
||||
myTeamsCollection: this.myTeamsCollection,
|
||||
showSortControls: options.showSortControls
|
||||
}),
|
||||
searchFieldView = new SearchFieldView({
|
||||
|
||||
@@ -16,38 +16,44 @@
|
||||
|
||||
initialize: function(options) {
|
||||
this.showSortControls = options.showSortControls;
|
||||
this.context = options.context;
|
||||
this.myTeamsCollection = options.myTeamsCollection;
|
||||
TeamsView.prototype.initialize.call(this, options);
|
||||
},
|
||||
|
||||
canUserCreateTeam: function () {
|
||||
// Note: non-staff and non-privileged users are automatically added to any team
|
||||
// that they create. This means that if multiple team membership is
|
||||
// disabled that they cannot create a new team when they already
|
||||
// belong to one.
|
||||
return this.context.staff || this.context.privileged || this.myTeamsCollection.length === 0;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var self = this;
|
||||
$.when(
|
||||
this.collection.refresh(),
|
||||
this.teamMemberships.refresh()
|
||||
).done(function() {
|
||||
TeamsView.prototype.render.call(self);
|
||||
|
||||
if (self.teamMemberships.canUserCreateTeam()) {
|
||||
var message = interpolate_text(
|
||||
// Translators: this string is shown at the bottom of the teams page
|
||||
// to find a team to join or else to create a new one. There are three
|
||||
// links that need to be included in the message:
|
||||
// 1. Browse teams in other topics
|
||||
// 2. search teams
|
||||
// 3. create a new team
|
||||
// Be careful to start each link with the appropriate start indicator
|
||||
// (e.g. {browse_span_start} for #1) and finish it with {span_end}.
|
||||
_.escape(gettext("{browse_span_start}Browse teams in other topics{span_end} or {search_span_start}search teams{span_end} in this topic. If you still can't find a team to join, {create_span_start}create a new team in this topic{span_end}.")),
|
||||
{
|
||||
'browse_span_start': '<a class="browse-teams" href="">',
|
||||
'search_span_start': '<a class="search-teams" href="">',
|
||||
'create_span_start': '<a class="create-team" href="">',
|
||||
'span_end': '</a>'
|
||||
}
|
||||
);
|
||||
self.$el.append(_.template(teamActionsTemplate, {message: message}));
|
||||
}
|
||||
});
|
||||
this.collection.refresh().done(function() {
|
||||
TeamsView.prototype.render.call(self);
|
||||
if (self.canUserCreateTeam()) {
|
||||
var message = interpolate_text(
|
||||
// Translators: this string is shown at the bottom of the teams page
|
||||
// to find a team to join or else to create a new one. There are three
|
||||
// links that need to be included in the message:
|
||||
// 1. Browse teams in other topics
|
||||
// 2. search teams
|
||||
// 3. create a new team
|
||||
// Be careful to start each link with the appropriate start indicator
|
||||
// (e.g. {browse_span_start} for #1) and finish it with {span_end}.
|
||||
_.escape(gettext("{browse_span_start}Browse teams in other topics{span_end} or {search_span_start}search teams{span_end} in this topic. If you still can't find a team to join, {create_span_start}create a new team in this topic{span_end}.")),
|
||||
{
|
||||
'browse_span_start': '<a class="browse-teams" href="">',
|
||||
'search_span_start': '<a class="search-teams" href="">',
|
||||
'create_span_start': '<a class="create-team" href="">',
|
||||
'span_end': '</a>'
|
||||
}
|
||||
);
|
||||
self.$el.append(_.template(teamActionsTemplate, {message: message}));
|
||||
}
|
||||
});
|
||||
return this;
|
||||
},
|
||||
|
||||
@@ -68,7 +74,10 @@
|
||||
|
||||
showCreateTeamForm: function (event) {
|
||||
event.preventDefault();
|
||||
Backbone.history.navigate('topics/' + this.model.id + '/create-team', {trigger: true});
|
||||
Backbone.history.navigate(
|
||||
'topics/' + this.model.id + '/create-team',
|
||||
{trigger: true}
|
||||
);
|
||||
},
|
||||
|
||||
createHeaderView: function () {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
teamsDetailUrl: '${ teams_detail_url }',
|
||||
teamMembershipsUrl: '${ team_memberships_url }',
|
||||
teamMembershipDetailUrl: '${ team_membership_detail_url }',
|
||||
myTeamsUrl: '${ my_teams_url }',
|
||||
maxTeamSize: ${ course.teams_max_size },
|
||||
languages: ${ json.dumps(languages, cls=EscapedEdxJSONEncoder) },
|
||||
countries: ${ json.dumps(countries, cls=EscapedEdxJSONEncoder) },
|
||||
|
||||
@@ -507,6 +507,10 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
|
||||
def test_filter_topic_id(self):
|
||||
self.verify_names({'course_id': self.test_course_1.id, 'topic_id': 'topic_0'}, 200, [u'Sólar team'])
|
||||
|
||||
def test_filter_username(self):
|
||||
self.verify_names({'course_id': self.test_course_1.id, 'username': 'student_enrolled'}, 200, [u'Sólar team'])
|
||||
self.verify_names({'course_id': self.test_course_1.id, 'username': 'staff'}, 200, [])
|
||||
|
||||
@ddt.data(
|
||||
(None, 200, ['Nuclear Team', u'Sólar team', 'Wind Team']),
|
||||
('name', 200, ['Nuclear Team', u'Sólar team', 'Wind Team']),
|
||||
|
||||
@@ -107,8 +107,8 @@ class TopicsPagination(TeamAPIPagination):
|
||||
page_size = TOPICS_PER_PAGE
|
||||
|
||||
|
||||
class MembershipPagination(TeamAPIPagination):
|
||||
"""Paginate memberships. """
|
||||
class MyTeamsPagination(TeamAPIPagination):
|
||||
"""Paginate the user's teams. """
|
||||
page_size = TEAM_MEMBERSHIPS_PER_PAGE
|
||||
|
||||
|
||||
@@ -153,14 +153,15 @@ class TeamsDashboardView(GenericAPIView):
|
||||
)
|
||||
topics_data["sort_order"] = sort_order
|
||||
|
||||
# Paginate and serialize team membership data.
|
||||
team_memberships = CourseTeamMembership.get_memberships(user.username, [course.id])
|
||||
memberships_data = self._serialize_and_paginate(
|
||||
MembershipPagination,
|
||||
team_memberships,
|
||||
user = request.user
|
||||
|
||||
user_teams = CourseTeam.objects.filter(membership__user=user)
|
||||
user_teams_data = self._serialize_and_paginate(
|
||||
MyTeamsPagination,
|
||||
user_teams,
|
||||
request,
|
||||
MembershipSerializer,
|
||||
{'expand': ('team', 'user',)}
|
||||
CourseTeamSerializer,
|
||||
{'expand': ('user',)}
|
||||
)
|
||||
|
||||
context = {
|
||||
@@ -173,7 +174,7 @@ class TeamsDashboardView(GenericAPIView):
|
||||
"username": user.username,
|
||||
"privileged": has_discussion_privileges(user, course_key),
|
||||
"staff": bool(has_access(user, 'staff', course_key)),
|
||||
"team_memberships_data": memberships_data,
|
||||
"teams": user_teams_data
|
||||
},
|
||||
"topic_url": reverse(
|
||||
'topics_detail', kwargs={'topic_id': 'topic_id', 'course_id': str(course_id)}, request=request
|
||||
@@ -182,6 +183,7 @@ class TeamsDashboardView(GenericAPIView):
|
||||
"teams_url": reverse('teams_list', request=request),
|
||||
"teams_detail_url": reverse('teams_detail', args=['team_id']),
|
||||
"team_memberships_url": reverse('team_membership_list', request=request),
|
||||
"my_teams_url": reverse('teams_list', request=request),
|
||||
"team_membership_detail_url": reverse('team_membership_detail', args=['team_id', user.username]),
|
||||
"languages": [[lang[0], _(lang[1])] for lang in settings.ALL_LANGUAGES], # pylint: disable=translation-of-non-string
|
||||
"countries": list(countries),
|
||||
@@ -283,6 +285,8 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
* last_activity_at: Orders result by team activity, with most active first
|
||||
(for tie-breaking, open_slots is used, with most open slots first).
|
||||
|
||||
* username: Return teams whose membership contains the given user.
|
||||
|
||||
* page_size: Number of results to return per page.
|
||||
|
||||
* page: Page number to retrieve.
|
||||
@@ -414,6 +418,10 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
username = request.QUERY_PARAMS.get('username', None)
|
||||
if username is not None:
|
||||
result_filter.update({'membership__user__username': username})
|
||||
|
||||
topic_id = request.QUERY_PARAMS.get('topic_id', None)
|
||||
if topic_id is not None:
|
||||
if topic_id not in [topic['id'] for topic in course_module.teams_configuration['topics']]:
|
||||
|
||||
@@ -21,13 +21,13 @@ from django.utils.http import urlquote_plus
|
||||
<%block name="nav_skip">#course-info-progress</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.stack.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.symbol.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/courseware/certificates_api.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/courseware/credit_progress.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js') | h}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.stack.js') | h}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.symbol.js') | h}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/courseware/certificates_api.js') | h}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/courseware/credit_progress.js') | h}"></script>
|
||||
<script>
|
||||
${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", not course.no_grade, not course.no_grade)}
|
||||
${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", not course.no_grade, not course.no_grade) | h}
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
@@ -40,12 +40,12 @@ from django.utils.http import urlquote_plus
|
||||
<div class="course-info" id="course-info-progress" aria-label="${_('Course Progress')}">
|
||||
% if staff_access and studio_url is not None:
|
||||
<div class="wrap-instructor-info">
|
||||
<a class="instructor-info-action studio-view" href="${studio_url}">${_("View Grading in studio")}</a>
|
||||
<a class="instructor-info-action studio-view" href="${studio_url | h}">${_("View Grading in studio")}</a>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
<header class="progress-certificates">
|
||||
<h1 class="progress-certificates-title">${_("Course Progress for Student '{username}' ({email})").format(username=student.username, email=student.email)}</h1>
|
||||
<h1 class="progress-certificates-title">${_("Course Progress for Student '{username}' ({email})").format(username=student.username, email=student.email) | h}</h1>
|
||||
</header>
|
||||
|
||||
%if show_generate_cert_btn:
|
||||
@@ -64,11 +64,11 @@ from django.utils.http import urlquote_plus
|
||||
</div>
|
||||
<div class="msg-actions">
|
||||
%if show_cert_web_view and cert_web_view_url:
|
||||
<a class="btn" href="${cert_web_view_url}" target="_blank" title="${_('View certificate in a new browser window or tab.')}">
|
||||
<a class="btn" href="${cert_web_view_url | h}" target="_blank" title="${_('View certificate in a new browser window or tab.')}">
|
||||
${_("View Certificate")}
|
||||
</a>
|
||||
%elif download_url:
|
||||
<a class="btn" href="${download_url}" target="_blank" title="${_('PDF will open in a new browser window or tab.')}">
|
||||
<a class="btn" href="${download_url | h}" target="_blank" title="${_('PDF will open in a new browser window or tab.')}">
|
||||
${_("Download Your Certificate")}
|
||||
</a>
|
||||
%endif
|
||||
@@ -86,7 +86,7 @@ from django.utils.http import urlquote_plus
|
||||
<p class="copy">${_("You can keep working for a higher grade, or request your certificate now.")}</p>
|
||||
</div>
|
||||
<div class="msg-actions">
|
||||
<button class="btn generate_certs" data-endpoint="${post_url}" id="btn_generate_cert">${_('Request Certificate')}</button>
|
||||
<button class="btn generate_certs" data-endpoint="${post_url | h}" id="btn_generate_cert">${_('Request Certificate')}</button>
|
||||
</div>
|
||||
%endif
|
||||
</div>
|
||||
@@ -106,25 +106,25 @@ from django.utils.http import urlquote_plus
|
||||
<h2>${_("Requirements for Course Credit")}</h2>
|
||||
</div>
|
||||
%if credit_course_requirements['eligibility_status'] == 'not_eligible':
|
||||
<span class="eligibility_msg">${_("{student_name}, you are no longer eligible for credit in this course.").format(student_name=student.profile.name)}</span>
|
||||
<span class="eligibility_msg">${_("{student_name}, you are no longer eligible for credit in this course.").format(student_name=student.profile.name) | h}</span>
|
||||
%elif credit_course_requirements['eligibility_status'] == 'eligible':
|
||||
<span class="eligibility_msg">${_("{student_name}, you have met the requirements for credit in this course.").format(student_name=student.profile.name)}
|
||||
<span class="eligibility_msg">${_("{student_name}, you have met the requirements for credit in this course.").format(student_name=student.profile.name) | h}
|
||||
${_("{a_start}Go to your dashboard{a_end} to purchase course credit.").format(
|
||||
a_start=u"<a href={url}>".format(url=reverse('dashboard')),
|
||||
a_end="</a>"
|
||||
)}
|
||||
) | h}
|
||||
</span>
|
||||
%elif credit_course_requirements['eligibility_status'] == 'partial_eligible':
|
||||
<span>${_("{student_name}, you have not yet met the requirements for credit.").format(student_name=student.profile.name)}</span>
|
||||
<span>${_("{student_name}, you have not yet met the requirements for credit.").format(student_name=student.profile.name) | h}</span>
|
||||
%endif
|
||||
<a href="${settings.CREDIT_HELP_LINK_URL}" class="credit-help"><i class="fa fa-question"></i><span class="sr">${_("Information about course credit requirements")}</span></a><br>
|
||||
<div class="requirement-container" data-eligible="${credit_course_requirements['eligibility_status']}">
|
||||
<a href="${settings.CREDIT_HELP_LINK_URL | h}" class="credit-help"><i class="fa fa-question"></i><span class="sr">${_("Information about course credit requirements")}</span></a><br>
|
||||
<div class="requirement-container" data-eligible="${credit_course_requirements['eligibility_status'] | h}">
|
||||
%for requirement in credit_course_requirements['requirements']:
|
||||
<div class="requirement">
|
||||
<div class="requirement-name">
|
||||
${_(requirement['display_name'])}
|
||||
${_(requirement['display_name']) | h}
|
||||
%if requirement['namespace'] == 'grade':
|
||||
<span>${int(requirement['criteria']['min_grade'] * 100)}%</span>
|
||||
<span>${int(requirement['criteria']['min_grade'] * 100) | h}%</span>
|
||||
%endif
|
||||
</div>
|
||||
<div class="requirement-status">
|
||||
@@ -137,9 +137,9 @@ from django.utils.http import urlquote_plus
|
||||
%elif requirement['status'] == 'satisfied':
|
||||
<i class="fa fa-check"></i>
|
||||
% if requirement['namespace'] == 'reverification':
|
||||
<span>Verified on ${get_time_display(requirement['status_date'], DEFAULT_SHORT_DATE_FORMAT, settings.TIME_ZONE)}</span>
|
||||
<span>Verified on ${get_time_display(requirement['status_date'], DEFAULT_SHORT_DATE_FORMAT, settings.TIME_ZONE) | h}</span>
|
||||
% elif requirement['namespace'] == 'grade' and 'final_grade' in requirement['reason']:
|
||||
<span>${int(requirement['reason']['final_grade'] * 100)}%</span>
|
||||
<span>${int(requirement['reason']['final_grade'] * 100) | h}%</span>
|
||||
% else:
|
||||
<span>Completed</span>
|
||||
% endif
|
||||
@@ -162,7 +162,7 @@ from django.utils.http import urlquote_plus
|
||||
%for chapter in courseware_summary:
|
||||
%if not chapter['display_name'] == "hidden":
|
||||
<section>
|
||||
<h2>${ chapter['display_name'] }</h2>
|
||||
<h2>${ chapter['display_name'] | h}</h2>
|
||||
|
||||
<div class="sections">
|
||||
%for section in chapter['sections']:
|
||||
@@ -173,20 +173,20 @@ from django.utils.http import urlquote_plus
|
||||
percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 and total > 0 else ""
|
||||
%>
|
||||
|
||||
<h3><a href="${reverse('courseware_section', kwargs=dict(course_id=course.id.to_deprecated_string(), chapter=chapter['url_name'], section=section['url_name']))}">
|
||||
${ section['display_name'] }
|
||||
<h3><a href="${reverse('courseware_section', kwargs=dict(course_id=course.id.to_deprecated_string(), chapter=chapter['url_name'], section=section['url_name'])) | h}">
|
||||
${ section['display_name'] | h}
|
||||
%if total > 0 or earned > 0:
|
||||
<span class="sr">
|
||||
${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))}
|
||||
${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total))) | h}
|
||||
</span>
|
||||
%endif
|
||||
</a>
|
||||
%if total > 0 or earned > 0:
|
||||
<span> ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )}</span>
|
||||
<span> ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString ) | h}</span>
|
||||
%endif
|
||||
</h3>
|
||||
<p>
|
||||
${section['format']}
|
||||
${section['format'] | h}
|
||||
|
||||
%if section.get('due') is not None:
|
||||
<%
|
||||
@@ -194,7 +194,7 @@ from django.utils.http import urlquote_plus
|
||||
due_date = '' if len(formatted_string)==0 else _(u'due {date}').format(date=formatted_string)
|
||||
%>
|
||||
<em>
|
||||
${due_date}
|
||||
${due_date | h}
|
||||
</em>
|
||||
%endif
|
||||
</p>
|
||||
@@ -204,7 +204,7 @@ from django.utils.http import urlquote_plus
|
||||
<h3> ${ _("Problem Scores: ") if section['graded'] else _("Practice Scores: ")} </h3>
|
||||
<ol>
|
||||
%for score in section['scores']:
|
||||
<li>${"{0:.3n}/{1:.3n}".format(float(score.earned),float(score.possible))}</li>
|
||||
<li>${"{0:.3n}/{1:.3n}".format(float(score.earned),float(score.possible)) | h}</li>
|
||||
%endfor
|
||||
</ol>
|
||||
%else:
|
||||
|
||||
@@ -25,20 +25,20 @@ from django.core.urlresolvers import reverse
|
||||
<%include file="_discussion_course_navigation.html" args="active_page='discussion'" />
|
||||
|
||||
<section class="discussion container" id="discussion-container"
|
||||
data-roles="${roles}"
|
||||
data-roles="${roles | h}"
|
||||
data-course-id="${course_id | h}"
|
||||
data-course-name="${course.display_name_with_default}"
|
||||
data-user-info="${user_info}"
|
||||
data-user-create-comment="${can_create_comment}"
|
||||
data-user-create-subcomment="${can_create_subcomment}"
|
||||
data-course-name="${course.display_name_with_default | h}"
|
||||
data-user-info="${user_info | h}"
|
||||
data-user-create-comment="${can_create_comment | h}"
|
||||
data-user-create-subcomment="${can_create_subcomment | h}"
|
||||
data-read-only="false"
|
||||
data-threads="${threads}"
|
||||
data-thread-pages="${thread_pages}"
|
||||
data-content-info="${annotated_content_info}"
|
||||
data-sort-preference="${sort_preference}"
|
||||
data-flag-moderator="${flag_moderator}"
|
||||
data-user-cohort-id="${user_cohort}"
|
||||
data-course-settings="${course_settings}">
|
||||
data-threads="${threads | h}"
|
||||
data-thread-pages="${thread_pages | h}"
|
||||
data-content-info="${annotated_content_info | h}"
|
||||
data-sort-preference="${sort_preference | h}"
|
||||
data-flag-moderator="${flag_moderator | h}"
|
||||
data-user-cohort-id="${user_cohort | h}"
|
||||
data-course-settings="${course_settings | h}">
|
||||
<div class="discussion-body">
|
||||
<div class="forum-nav" role="complementary" aria-label="${_("Discussion thread list")}"></div>
|
||||
<div class="discussion-column" role="main" aria-label="Discussion" id="discussion-column">
|
||||
|
||||
@@ -33,8 +33,7 @@ from django.template.defaultfilters import escapejs
|
||||
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section class="course-content container discussion-user-threads" data-course-id="${course.id | h}" data-course-name="${course.display_name_with_default}" data-threads="${threads}" data-user-info="${user_info}" data-page="${page}" data-num-pages="${num_pages}"/>
|
||||
<section class="course-content container discussion-user-threads" data-course-id="${course.id | h}" data-course-name="${course.display_name_with_default | h}" data-threads="${threads | h}" data-user-info="${user_info | h}" data-page="${page | h}" data-num-pages="${num_pages | h}"/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
4
lms/templates/google_tag_manager.html
Normal file
4
lms/templates/google_tag_manager.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<%doc>
|
||||
Yet, installing google tag manager for microsite(s).
|
||||
So intentionally left it blank
|
||||
</%doc>
|
||||
@@ -103,6 +103,7 @@ from branding import api as branding_api
|
||||
google_analytics_file = microsite.get_template_path('google_analytics.html')
|
||||
|
||||
style_overrides_file = microsite.get_value('css_overrides_file')
|
||||
google_tag_manager_file = microsite.get_template_path('google_tag_manager.html')
|
||||
%>
|
||||
|
||||
% if header_extra_file:
|
||||
@@ -124,6 +125,7 @@ from branding import api as branding_api
|
||||
</head>
|
||||
|
||||
<body class="${static.dir_rtl()} <%block name='bodyclass'/> lang_${LANGUAGE_CODE}">
|
||||
<%include file="${google_tag_manager_file}" />
|
||||
<div id="page-prompt"></div>
|
||||
% if not disable_window_wrap:
|
||||
<div class="window-wrap" dir="${static.dir_rtl()}">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# For Harvard courses:
|
||||
-e git+https://github.com/gsehub/xblock-mentoring.git@4d1cce78dc232d5da6ffd73817b5c490e87a6eee#egg=xblock-mentoring
|
||||
-e git+https://github.com/open-craft/problem-builder.git@859df4155c0031b5a70e7f7e9744b67b3ed331d7#egg=xblock-problem-builder
|
||||
-e git+https://github.com/open-craft/problem-builder.git@1cb40ca523502ca2a8a2abe5aef4d1b6735cb5c7#egg=xblock-problem-builder
|
||||
|
||||
# Prototype XBlocks from edX learning sciences limited roll-outs and user testing.
|
||||
# Concept XBlock, in particular, is nowhere near finished and an early prototype.
|
||||
|
||||
Reference in New Issue
Block a user