From 40543ca0dec398aceab2d71794d271b47dd89b6e Mon Sep 17 00:00:00 2001 From: Douglas Hall Date: Tue, 21 Jun 2016 10:47:46 -0400 Subject: [PATCH 01/40] Upgrade edx-proctoring to 0.12.20 --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 3ef76c490c..856f1c6e9a 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -90,7 +90,7 @@ git+https://github.com/edx/xblock-utils.git@v1.0.2#egg=xblock-utils==1.0.2 -e git+https://github.com/edx/edx-reverification-block.git@0.0.5#egg=edx-reverification-block==0.0.5 git+https://github.com/edx/edx-user-state-client.git@1.0.1#egg=edx-user-state-client==1.0.1 git+https://github.com/edx/xblock-lti-consumer.git@v1.0.9#egg=xblock-lti-consumer==1.0.9 -git+https://github.com/edx/edx-proctoring.git@0.12.19#egg=edx-proctoring==0.12.19 +git+https://github.com/edx/edx-proctoring.git@0.12.20#egg=edx-proctoring==0.12.20 # Third Party XBlocks -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga From ad45681a530d098e3e82a0f393b76d9531843698 Mon Sep 17 00:00:00 2001 From: Michael Frey Date: Mon, 20 Jun 2016 16:54:13 -0400 Subject: [PATCH 02/40] mjfrey/micro-settings-merge: Override base dictionary keys with microsite configuration keys * mattdrayer: Add helpers.get_value test * mattdrayer: Change to simpler implementation, per @douglashall * mattdrayer: Address quality violations and test failures --- .../course_modes/tests/test_views.py | 19 +++++++------- common/djangoapps/student/tests/test_email.py | 2 +- common/djangoapps/student/tests/tests.py | 2 +- lms/djangoapps/branding/tests/test_views.py | 2 +- lms/djangoapps/commerce/tests/test_views.py | 2 +- .../tests/test_comprehensive_theming.py | 2 +- .../tests/test_comprehensive_theming.py | 2 +- .../courseware/tests/test_footer.py | 2 +- .../student_account/test/test_views.py | 2 +- .../verify_student/tests/test_views.py | 2 +- openedx/core/djangoapps/theming/helpers.py | 26 ++++++++++++++++--- .../core/djangoapps/theming/tests/__init__.py | 0 .../djangoapps/theming/tests/test_helpers.py | 25 ++++++++++++++++++ .../theming/{ => tests}/test_util.py | 2 +- 14 files changed, 67 insertions(+), 23 deletions(-) create mode 100644 openedx/core/djangoapps/theming/tests/__init__.py create mode 100644 openedx/core/djangoapps/theming/tests/test_helpers.py rename openedx/core/djangoapps/theming/{ => tests}/test_util.py (97%) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index b8c5161ddf..8fa21e0fde 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -9,21 +9,22 @@ import ddt import freezegun from mock import patch from nose.plugins.attrib import attr + from django.conf import settings from django.core.urlresolvers import reverse +from lms.djangoapps.commerce.tests import test_utils as ecomm_test_utils +from openedx.core.djangoapps.theming.tests import test_util as theming_test_utils from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase - -from util.testing import UrlResetMixin -from embargo.test_utils import restrict_course from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory -from course_modes.tests.factories import CourseModeFactory -from student.tests.factories import CourseEnrollmentFactory, UserFactory -from student.models import CourseEnrollment -import lms.djangoapps.commerce.tests.test_utils as ecomm_test_utils + from course_modes.models import CourseMode, Mode -from openedx.core.djangoapps.theming.test_util import with_is_edx_domain +from course_modes.tests.factories import CourseModeFactory +from embargo.test_utils import restrict_course +from student.models import CourseEnrollment +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from util.testing import UrlResetMixin @attr('shard_3') @@ -373,7 +374,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): self.assertEquals(course_modes, expected_modes) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - @with_is_edx_domain(True) + @theming_test_utils.with_is_edx_domain(True) def test_hide_nav(self): # Create the course modes for mode in ["honor", "verified"]: diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 3c44111608..2bd303c91d 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -20,7 +20,7 @@ from django.conf import settings from edxmako.shortcuts import render_to_string from util.request import safe_get_host from util.testing import EventTestMixin -from openedx.core.djangoapps.theming.test_util import with_is_edx_domain +from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain from openedx.core.djangoapps.theming import helpers as theming_helpers diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index a16101e928..26a3df4117 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -46,7 +46,7 @@ from certificates.tests.factories import GeneratedCertificateFactory # pylint: from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification import shoppingcart # pylint: disable=import-error from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin -from openedx.core.djangoapps.theming.test_util import with_is_edx_domain +from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain # Explicitly import the cache from ConfigurationModel so we can reset it after each test from config_models.models import cache diff --git a/lms/djangoapps/branding/tests/test_views.py b/lms/djangoapps/branding/tests/test_views.py index a6cb5d7249..eb62bf2282 100644 --- a/lms/djangoapps/branding/tests/test_views.py +++ b/lms/djangoapps/branding/tests/test_views.py @@ -10,7 +10,7 @@ import mock import ddt from config_models.models import cache from branding.models import BrandingApiConfig -from openedx.core.djangoapps.theming.test_util import with_edx_domain_context +from openedx.core.djangoapps.theming.tests.test_util import with_edx_domain_context @ddt.ddt diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 703a518c12..b28fd7a84b 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -8,7 +8,7 @@ from django.test import TestCase import mock from student.tests.factories import UserFactory -from openedx.core.djangoapps.theming.test_util import with_is_edx_domain +from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain class UserMixin(object): diff --git a/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py b/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py index 536ccc7f54..e32af4991f 100644 --- a/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py +++ b/lms/djangoapps/course_wiki/tests/test_comprehensive_theming.py @@ -9,7 +9,7 @@ from wiki.models import URLPath from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from openedx.core.djangoapps.theming.test_util import with_comprehensive_theme +from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme from courseware.tests.factories import InstructorFactory from course_wiki.views import get_or_create_root diff --git a/lms/djangoapps/courseware/tests/test_comprehensive_theming.py b/lms/djangoapps/courseware/tests/test_comprehensive_theming.py index 752eb81030..ee28177a15 100644 --- a/lms/djangoapps/courseware/tests/test_comprehensive_theming.py +++ b/lms/djangoapps/courseware/tests/test_comprehensive_theming.py @@ -6,7 +6,7 @@ from django.test import TestCase from path import path # pylint: disable=no-name-in-module from django.contrib import staticfiles -from openedx.core.djangoapps.theming.test_util import with_comprehensive_theme +from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme from openedx.core.lib.tempdir import mkdtemp_clean diff --git a/lms/djangoapps/courseware/tests/test_footer.py b/lms/djangoapps/courseware/tests/test_footer.py index 0a14ee1860..44cc93cbdd 100644 --- a/lms/djangoapps/courseware/tests/test_footer.py +++ b/lms/djangoapps/courseware/tests/test_footer.py @@ -9,7 +9,7 @@ from django.conf import settings from django.test import TestCase from django.test.utils import override_settings -from openedx.core.djangoapps.theming.test_util import with_is_edx_domain +from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain @attr('shard_1') diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index c5f2c7fe66..7815d24fa5 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -33,7 +33,7 @@ from student_account.views import account_settings_context, get_user_orders from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from util.testing import UrlResetMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from openedx.core.djangoapps.theming.test_util import with_edx_domain_context +from openedx.core.djangoapps.theming.tests.test_util import with_edx_domain_context @ddt.ddt diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index b937699999..d5231e2d48 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -39,7 +39,7 @@ from commerce.models import CommerceConfiguration from commerce.tests import TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY, TEST_PUBLIC_URL_ROOT from embargo.test_utils import restrict_course from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.djangoapps.theming.test_util import with_is_edx_domain +from openedx.core.djangoapps.theming.tests.test_util import with_is_edx_domain from shoppingcart.models import Order, CertificateItem from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.models import CourseEnrollment diff --git a/openedx/core/djangoapps/theming/helpers.py b/openedx/core/djangoapps/theming/helpers.py index 28ce710ebe..82434934cb 100644 --- a/openedx/core/djangoapps/theming/helpers.py +++ b/openedx/core/djangoapps/theming/helpers.py @@ -1,10 +1,10 @@ """ - Helpers for accessing comprehensive theming related variables. +Helpers for accessing comprehensive theming related variables. """ -from microsite_configuration import microsite -from microsite_configuration import page_title_breadcrumbs from django.conf import settings +from microsite_configuration import microsite, page_title_breadcrumbs + def get_page_title_breadcrumbs(*args): """ @@ -17,7 +17,25 @@ def get_value(val_name, default=None, **kwargs): """ This is a proxy function to hide microsite_configuration behind comprehensive theming. """ - return microsite.get_value(val_name, default=default, **kwargs) + + # Retrieve the requested field/value from the microsite configuration + microsite_value = microsite.get_value(val_name, default=default, **kwargs) + + # Attempt to perform a dictionary update using the provided default + # This will fail if either the default or the microsite value is not a dictionary + try: + value = dict(default) + value.update(microsite_value) + + # If the dictionary update fails, just use the microsite value + # TypeError: default is not iterable (simple value or None) + # ValueError: default is iterable but not a dict (list, not dict) + # AttributeError: default does not have an 'update' method + except (TypeError, ValueError, AttributeError): + value = microsite_value + + # Return the end result to the caller + return value def get_template_path(relative_path, **kwargs): diff --git a/openedx/core/djangoapps/theming/tests/__init__.py b/openedx/core/djangoapps/theming/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/theming/tests/test_helpers.py b/openedx/core/djangoapps/theming/tests/test_helpers.py new file mode 100644 index 0000000000..536e9cc657 --- /dev/null +++ b/openedx/core/djangoapps/theming/tests/test_helpers.py @@ -0,0 +1,25 @@ +""" +Test helpers for Comprehensive Theming. +""" +from django.test import TestCase +from mock import patch + +from openedx.core.djangoapps.theming import helpers + + +class ThemingHelpersTests(TestCase): + """ + Make sure some of the theming helper functions work + """ + + def test_get_value_returns_override(self): + """ + Tests to make sure the get_value() operation returns a combined dictionary consisting + of the base container with overridden keys from the microsite configuration + """ + with patch('microsite_configuration.microsite.get_value') as mock_get_value: + override_key = 'JWT_ISSUER' + override_value = 'testing' + mock_get_value.return_value = {override_key: override_value} + jwt_auth = helpers.get_value('JWT_AUTH') + self.assertEqual(jwt_auth[override_key], override_value) diff --git a/openedx/core/djangoapps/theming/test_util.py b/openedx/core/djangoapps/theming/tests/test_util.py similarity index 97% rename from openedx/core/djangoapps/theming/test_util.py rename to openedx/core/djangoapps/theming/tests/test_util.py index fbd871c213..6f13b9c187 100644 --- a/openedx/core/djangoapps/theming/test_util.py +++ b/openedx/core/djangoapps/theming/tests/test_util.py @@ -15,7 +15,7 @@ from django.test.utils import override_settings import edxmako -from .core import comprehensive_theme_changes +from openedx.core.djangoapps.theming.core import comprehensive_theme_changes EDX_THEME_DIR = settings.REPO_ROOT / "themes" / "edx.org" From 7b86da02f792e8f7b499e1fd955d8c57300fbefb Mon Sep 17 00:00:00 2001 From: Douglas Hall Date: Wed, 22 Jun 2016 11:49:49 -0400 Subject: [PATCH 03/40] Check theme overrides when retrieving platform name from settings --- lms/templates/emails/activation_email.txt | 9 ++++++--- lms/templates/emails/activation_email_subject.txt | 5 ++++- lms/templates/emails/email_change.txt | 10 ++++++++-- lms/templates/emails/email_change_subject.txt | 5 ++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lms/templates/emails/activation_email.txt b/lms/templates/emails/activation_email.txt index 56a49702b6..a697fada1a 100644 --- a/lms/templates/emails/activation_email.txt +++ b/lms/templates/emails/activation_email.txt @@ -1,10 +1,13 @@ <%! from django.utils.translation import ugettext as _ %> -${_("Thank you for signing up for {platform_name}.").format(platform_name=settings.PLATFORM_NAME)} +<%! from openedx.core.djangoapps.theming.helpers import get_value as get_themed_value %> +${_("Thank you for signing up for {platform_name}.").format( + platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME) +)} ${_("Change your life and start learning today by activating your " "{platform_name} account. Click on the link below or copy and " "paste it into your browser's address bar.").format( - platform_name=settings.PLATFORM_NAME + platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME) )} % if is_secure: @@ -15,4 +18,4 @@ ${_("Change your life and start learning today by activating your " ${_("If you didn't request this, you don't need to do anything; you won't " "receive any more email from us. Please do not reply to this e-mail; " "if you require assistance, check the help section of the " - "{platform_name} website.").format(platform_name=settings.PLATFORM_NAME)} + "{platform_name} website.").format(platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME))} diff --git a/lms/templates/emails/activation_email_subject.txt b/lms/templates/emails/activation_email_subject.txt index 01514b5f4f..8fb64f9abc 100644 --- a/lms/templates/emails/activation_email_subject.txt +++ b/lms/templates/emails/activation_email_subject.txt @@ -1,3 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from openedx.core.djangoapps.theming.helpers import get_value as get_themed_value %> -${_("Activate Your {platform_name} Account").format(platform_name=settings.PLATFORM_NAME)} +${_("Activate Your {platform_name} Account").format( + platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME +))} diff --git a/lms/templates/emails/email_change.txt b/lms/templates/emails/email_change.txt index 79f8929f99..618320fb49 100644 --- a/lms/templates/emails/email_change.txt +++ b/lms/templates/emails/email_change.txt @@ -1,8 +1,14 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from openedx.core.djangoapps.theming.helpers import get_value as get_themed_value %> ${_("We received a request to change the e-mail associated with your " "{platform_name} account from {old_email} to {new_email}. " "If this is correct, please confirm your new e-mail address by " - "visiting:").format(platform_name=settings.PLATFORM_NAME, old_email=old_email, new_email=new_email)} + "visiting:").format( + platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME), + old_email=old_email, + new_email=new_email + ) +} % if is_secure: https://${ site }/email_confirm/${ key } @@ -13,4 +19,4 @@ ${_("We received a request to change the e-mail associated with your " ${_("If you didn't request this, you don't need to do anything; you won't " "receive any more email from us. Please do not reply to this e-mail; " "if you require assistance, check the help section of the " - "{platform_name} web site.").format(platform_name=settings.PLATFORM_NAME)} + "{platform_name} web site.").format(platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME))} diff --git a/lms/templates/emails/email_change_subject.txt b/lms/templates/emails/email_change_subject.txt index 7c9e3fb400..acc114bb98 100644 --- a/lms/templates/emails/email_change_subject.txt +++ b/lms/templates/emails/email_change_subject.txt @@ -1,2 +1,5 @@ <%! from django.utils.translation import ugettext as _ %> -${_("Request to change {platform_name} account e-mail").format(platform_name=settings.PLATFORM_NAME)} +<%! from openedx.core.djangoapps.theming.helpers import get_value as get_themed_value %> +${_("Request to change {platform_name} account e-mail").format( + platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME) +)} From 51d85809822a4720c8d883c362dd68c37185160b Mon Sep 17 00:00:00 2001 From: Douglas Hall Date: Wed, 22 Jun 2016 12:33:58 -0400 Subject: [PATCH 04/40] Fix default from email lookups --- common/djangoapps/student/forms.py | 2 +- common/djangoapps/student/tests/test_email.py | 4 ++-- .../djangoapps/student/tests/test_reset_password.py | 2 +- common/djangoapps/student/views.py | 8 ++++---- lms/djangoapps/bulk_email/tasks.py | 2 +- lms/templates/emails/confirm_email_change.txt | 11 ++++++++--- openedx/core/djangoapps/credit/email_utils.py | 2 +- openedx/core/djangoapps/user_api/accounts/api.py | 2 +- 8 files changed, 19 insertions(+), 14 deletions(-) diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 095b7cc21c..84bdb17e0c 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -58,7 +58,7 @@ class PasswordResetFormNoActive(PasswordResetForm): email_template_name='registration/password_reset_email.html', use_https=False, token_generator=default_token_generator, - from_email=theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL), + from_email=theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), request=None ): """ diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 2bd303c91d..e97311b9cd 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -57,7 +57,7 @@ class EmailTestMixin(object): email_user.assert_called_with( mock_render_to_string(subject_template, subject_context), mock_render_to_string(body_template, body_context), - theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) + theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) ) def append_allowed_hosts(self, hostname): @@ -298,7 +298,7 @@ class EmailChangeRequestTests(EventTestMixin, TestCase): send_mail.assert_called_with( mock_render_to_string('emails/email_change_subject.txt', context), mock_render_to_string('emails/email_change.txt', context), - theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL), + theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), [new_email] ) self.assert_event_emitted( diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py index 96cca17e7a..6ca006765b 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -125,7 +125,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): (subject, msg, from_addr, to_addrs) = send_email.call_args[0] self.assertIn("Password reset", subject) self.assertIn("You're receiving this e-mail because you requested a password reset", msg) - self.assertEquals(from_addr, theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)) + self.assertEquals(from_addr, theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)) self.assertEquals(len(to_addrs), 1) self.assertIn(self.user.email, to_addrs) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 6db07a4bac..b502dc6bea 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -2229,11 +2229,11 @@ def reactivation_email_for_user(user): message = render_to_string('emails/activation_email.txt', context) try: - user.email_user(subject, message, theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)) + user.email_user(subject, message, theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)) except Exception: # pylint: disable=broad-except log.error( u'Unable to send reactivation email from "%s"', - theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL), + theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), exc_info=True ) return JsonResponse({ @@ -2357,7 +2357,7 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument user.email_user( subject, message, - theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) + theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) ) except Exception: # pylint: disable=broad-except log.warning('Unable to send confirmation email to old address', exc_info=True) @@ -2373,7 +2373,7 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument user.email_user( subject, message, - theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) + theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) ) except Exception: # pylint: disable=broad-except log.warning('Unable to send confirmation email to new address', exc_info=True) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 3fd044fd94..021fce34a8 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -389,7 +389,7 @@ def _get_source_address(course_id, course_title, truncate=True): course_title=course_title_no_quotes, course_name=course_name, from_email=theming_helpers.get_value( - 'bulk_email_default_from_email', + 'email_from_address', settings.BULK_EMAIL_DEFAULT_FROM_EMAIL ) ) diff --git a/lms/templates/emails/confirm_email_change.txt b/lms/templates/emails/confirm_email_change.txt index ae33c82609..b462d78e37 100644 --- a/lms/templates/emails/confirm_email_change.txt +++ b/lms/templates/emails/confirm_email_change.txt @@ -1,9 +1,15 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> +<%! from openedx.core.djangoapps.theming.helpers import get_value as get_themed_value %> ${_("This is to confirm that you changed the e-mail associated with " "{platform_name} from {old_email} to {new_email}. If you " "did not make this request, please contact us immediately. Contact " - "information is listed at:").format(platform_name=settings.PLATFORM_NAME, old_email=old_email, new_email=new_email)} + "information is listed at:").format( + platform_name=get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME), + old_email=old_email, + new_email=new_email + ) +} % if is_secure: https://${ site }${reverse('contact')} @@ -11,5 +17,4 @@ ${_("This is to confirm that you changed the e-mail associated with " http://${ site }${reverse('contact')} % endif -${_("We keep a log of old e-mails, so if this request was unintentional, we " - "can investigate.")} +${_("We keep a log of old e-mails, so if this request was unintentional, we can investigate.")} diff --git a/openedx/core/djangoapps/credit/email_utils.py b/openedx/core/djangoapps/credit/email_utils.py index 263cee2fce..2993020e89 100644 --- a/openedx/core/djangoapps/credit/email_utils.py +++ b/openedx/core/djangoapps/credit/email_utils.py @@ -125,7 +125,7 @@ def send_credit_notifications(username, course_key): notification_msg.attach(logo_image) # add email addresses of sender and receiver - from_address = theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) + from_address = theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) to_address = user.email # send the root email message diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 48d4e10433..0361e3a761 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -398,7 +398,7 @@ def request_password_change(email, orig_host, is_secure): # Generate a single-use link for performing a password reset # and email it to the user. form.save( - from_email=theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL), + from_email=theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), domain_override=orig_host, use_https=is_secure ) From 0a6406e863b91fe80a52ebc4688c764bd24cb396 Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 10 Jun 2016 16:36:23 -0400 Subject: [PATCH 05/40] Add ConfigurationModel deserializer and management command. TNL-4781, TNL-4782 --- .../config_models/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/populate_model.py | 72 ++++++ common/djangoapps/config_models/models.py | 58 +++++ .../config_models/tests/__init__.py | 0 .../config_models/tests/data/data.json | 14 ++ .../tests/test_model_deserialization.py | 219 ++++++++++++++++++ .../config_models/{ => tests}/tests.py | 122 +++++++++- common/djangoapps/config_models/utils.py | 69 ++++++ common/djangoapps/config_models/views.py | 11 +- 10 files changed, 556 insertions(+), 9 deletions(-) create mode 100644 common/djangoapps/config_models/management/__init__.py create mode 100644 common/djangoapps/config_models/management/commands/__init__.py create mode 100644 common/djangoapps/config_models/management/commands/populate_model.py create mode 100644 common/djangoapps/config_models/tests/__init__.py create mode 100644 common/djangoapps/config_models/tests/data/data.json create mode 100644 common/djangoapps/config_models/tests/test_model_deserialization.py rename common/djangoapps/config_models/{ => tests}/tests.py (73%) create mode 100644 common/djangoapps/config_models/utils.py diff --git a/common/djangoapps/config_models/management/__init__.py b/common/djangoapps/config_models/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/management/commands/__init__.py b/common/djangoapps/config_models/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/management/commands/populate_model.py b/common/djangoapps/config_models/management/commands/populate_model.py new file mode 100644 index 0000000000..d41ceae58b --- /dev/null +++ b/common/djangoapps/config_models/management/commands/populate_model.py @@ -0,0 +1,72 @@ +""" +Populates a ConfigurationModel by deserializing JSON data contained in a file. +""" +import os +from optparse import make_option + +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import ugettext_lazy as _ + +from config_models.utils import deserialize_json + + +class Command(BaseCommand): + """ + This command will deserialize the JSON data in the supplied file to populate + a ConfigurationModel. Note that this will add new entries to the model, but it + will not delete any entries (ConfigurationModel entries are read-only). + """ + help = """ + Populates a ConfigurationModel by deserializing the supplied JSON. + + JSON should be in a file, with the following format: + + { "model": "config_models.ExampleConfigurationModel", + "data": + [ + { "enabled": True, + "color": "black" + ... + }, + { "enabled": False, + "color": "yellow" + ... + }, + ... + ] + } + + A username corresponding to an existing user must be specified to indicate who + is executing the command. + + $ ... populate_model -f path/to/file.json -u username + """ + + option_list = BaseCommand.option_list + ( + make_option('-f', '--file', + metavar='JSON_FILE', + dest='file', + default=False, + help='JSON file to import ConfigurationModel data'), + make_option('-u', '--username', + metavar='USERNAME', + dest='username', + default=False, + help='username to specify who is executing the command'), + ) + + def handle(self, *args, **options): + if 'file' not in options or not options['file']: + raise CommandError(_("A file containing JSON must be specified.")) + + if 'username' not in options or not options['username']: + raise CommandError(_("A valid username must be specified.")) + + json_file = options['file'] + if not os.path.exists(json_file): + raise CommandError(_("File {0} does not exist").format(json_file)) + + self.stdout.write(_("Importing JSON data from file {0}").format(json_file)) + with open(json_file) as data: + created_entries = deserialize_json(data, options['username']) + self.stdout.write(_("Import complete, {0} new entries created").format(created_entries)) diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py index 5528f084cf..ab429c701d 100644 --- a/common/djangoapps/config_models/models.py +++ b/common/djangoapps/config_models/models.py @@ -6,6 +6,9 @@ from django.contrib.auth.models import User from django.core.cache import caches, InvalidCacheBackendError from django.utils.translation import ugettext_lazy as _ +from rest_framework.utils import model_meta + + try: cache = caches['configuration'] # pylint: disable=invalid-name except InvalidCacheBackendError: @@ -176,3 +179,58 @@ class ConfigurationModel(models.Model): values = list(cls.objects.values_list(*key_fields, flat=flat).order_by().distinct()) cache.set(cache_key, values, cls.cache_timeout) return values + + def fields_equal(self, instance, fields_to_ignore=("id", "change_date", "changed_by")): + """ + Compares this instance's fields to the supplied instance to test for equality. + This will ignore any fields in `fields_to_ignore`. + + Note that this method ignores many-to-many fields. + + Args: + instance: the model instance to compare + fields_to_ignore: List of fields that should not be compared for equality. By default + includes `id`, `change_date`, and `changed_by`. + + Returns: True if the checked fields are all equivalent, else False + """ + for field in self._meta.get_fields(): + if not field.many_to_many and field.name not in fields_to_ignore: + if getattr(instance, field.name) != getattr(self, field.name): + return False + + return True + + @classmethod + def equal_to_current(cls, json, fields_to_ignore=("id", "change_date", "changed_by")): + """ + Compares for equality this instance to a model instance constructed from the supplied JSON. + This will ignore any fields in `fields_to_ignore`. + + Note that this method cannot handle fields with many-to-many associations, as those can only + be set on a saved model instance (and saving the model instance will create a new entry). + All many-to-many field entries will be removed before the equality comparison is done. + + Args: + json: json representing an entry to compare + fields_to_ignore: List of fields that should not be compared for equality. By default + includes `id`, `change_date`, and `changed_by`. + + Returns: True if the checked fields are all equivalent, else False + """ + + # Remove many-to-many relationships from json. + # They require an instance to be already saved. + info = model_meta.get_field_info(cls) + for field_name, relation_info in info.relations.items(): + if relation_info.to_many and (field_name in json): + json.pop(field_name) + + new_instance = cls(**json) + key_field_args = tuple(getattr(new_instance, key) for key in cls.KEY_FIELDS) + current = cls.current(*key_field_args) + # If current.id is None, no entry actually existed and the "current" method created it. + if current.id is not None: + return current.fields_equal(new_instance, fields_to_ignore) + + return False diff --git a/common/djangoapps/config_models/tests/__init__.py b/common/djangoapps/config_models/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/tests/data/data.json b/common/djangoapps/config_models/tests/data/data.json new file mode 100644 index 0000000000..e6977c7d54 --- /dev/null +++ b/common/djangoapps/config_models/tests/data/data.json @@ -0,0 +1,14 @@ +{ + "model": "config_models.ExampleDeserializeConfig", + "data": [ + { + "name": "betty", + "enabled": true, + "int_field": 5 + }, + { + "name": "fred", + "enabled": false + } + ] +} diff --git a/common/djangoapps/config_models/tests/test_model_deserialization.py b/common/djangoapps/config_models/tests/test_model_deserialization.py new file mode 100644 index 0000000000..af1ff81e49 --- /dev/null +++ b/common/djangoapps/config_models/tests/test_model_deserialization.py @@ -0,0 +1,219 @@ +""" +Tests of the populate_model management command and its helper utils.deserialize_json method. +""" + +import textwrap +import os.path + +from django.utils import timezone +from django.utils.six import BytesIO + +from django.contrib.auth.models import User +from django.core.management.base import CommandError +from django.db import models + +from config_models.management.commands import populate_model +from config_models.models import ConfigurationModel +from config_models.utils import deserialize_json +from openedx.core.djangolib.testing.utils import CacheIsolationTestCase + + +class ExampleDeserializeConfig(ConfigurationModel): + """ + Test model for testing deserialization of ``ConfigurationModels`` with keyed configuration. + """ + KEY_FIELDS = ('name',) + + name = models.TextField() + int_field = models.IntegerField(default=10) + + def __unicode__(self): + return "ExampleDeserializeConfig(enabled={}, name={}, int_field={})".format( + self.enabled, self.name, self.int_field + ) + + +class DeserializeJSONTests(CacheIsolationTestCase): + """ + Tests of deserializing the JSON representation of ConfigurationModels. + """ + def setUp(self): + super(DeserializeJSONTests, self).setUp() + self.test_username = 'test_worker' + User.objects.create_user(username=self.test_username) + self.fixture_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json') + + def test_deserialize_models(self): + """ + Tests the "happy path", where 2 instances of the test model should be created. + A valid username is supplied for the operation. + """ + start_date = timezone.now() + with open(self.fixture_path) as data: + entries_created = deserialize_json(data, self.test_username) + self.assertEquals(2, entries_created) + + self.assertEquals(2, ExampleDeserializeConfig.objects.count()) + + betty = ExampleDeserializeConfig.current('betty') + self.assertTrue(betty.enabled) + self.assertEquals(5, betty.int_field) + self.assertGreater(betty.change_date, start_date) + self.assertEquals(self.test_username, betty.changed_by.username) + + fred = ExampleDeserializeConfig.current('fred') + self.assertFalse(fred.enabled) + self.assertEquals(10, fred.int_field) + self.assertGreater(fred.change_date, start_date) + self.assertEquals(self.test_username, fred.changed_by.username) + + def test_existing_entries_not_removed(self): + """ + Any existing configuration model entries are retained + (though they may be come history)-- deserialize_json is purely additive. + """ + ExampleDeserializeConfig(name="fred", enabled=True).save() + ExampleDeserializeConfig(name="barney", int_field=200).save() + + with open(self.fixture_path) as data: + entries_created = deserialize_json(data, self.test_username) + self.assertEquals(2, entries_created) + + self.assertEquals(4, ExampleDeserializeConfig.objects.count()) + self.assertEquals(3, len(ExampleDeserializeConfig.objects.current_set())) + + self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field) + self.assertEquals(200, ExampleDeserializeConfig.current('barney').int_field) + + # The JSON file changes "enabled" to False for Fred. + fred = ExampleDeserializeConfig.current('fred') + self.assertFalse(fred.enabled) + + def test_duplicate_entries_not_made(self): + """ + If there is no change in an entry (besides changed_by and change_date), + a new entry is not made. + """ + with open(self.fixture_path) as data: + entries_created = deserialize_json(data, self.test_username) + self.assertEquals(2, entries_created) + + with open(self.fixture_path) as data: + entries_created = deserialize_json(data, self.test_username) + self.assertEquals(0, entries_created) + + # Importing twice will still only result in 2 records (second import a no-op). + self.assertEquals(2, ExampleDeserializeConfig.objects.count()) + + # Change Betty. + betty = ExampleDeserializeConfig.current('betty') + betty.int_field = -8 + betty.save() + + self.assertEquals(3, ExampleDeserializeConfig.objects.count()) + self.assertEquals(-8, ExampleDeserializeConfig.current('betty').int_field) + + # Now importing will add a new entry for Betty. + with open(self.fixture_path) as data: + entries_created = deserialize_json(data, self.test_username) + self.assertEquals(1, entries_created) + + self.assertEquals(4, ExampleDeserializeConfig.objects.count()) + self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field) + + def test_bad_username(self): + """ + Tests the error handling when the specified user does not exist. + """ + test_json = textwrap.dedent(""" + { + "model": "config_models.ExampleDeserializeConfig", + "data": [{"name": "dino"}] + } + """) + with self.assertRaisesRegexp(Exception, "User matching query does not exist"): + deserialize_json(BytesIO(test_json), "unknown_username") + + def test_invalid_json(self): + """ + Tests the error handling when there is invalid JSON. + """ + test_json = textwrap.dedent(""" + { + "model": "config_models.ExampleDeserializeConfig", + "data": [{"name": "dino" + """) + with self.assertRaisesRegexp(Exception, "JSON parse error"): + deserialize_json(BytesIO(test_json), self.test_username) + + def test_invalid_model(self): + """ + Tests the error handling when the configuration model specified does not exist. + """ + test_json = textwrap.dedent(""" + { + "model": "xxx.yyy", + "data":[{"name": "dino"}] + } + """) + with self.assertRaisesRegexp(Exception, "No installed app"): + deserialize_json(BytesIO(test_json), self.test_username) + + +class PopulateModelTestCase(CacheIsolationTestCase): + """ + Tests of populate model management command. + """ + def setUp(self): + super(PopulateModelTestCase, self).setUp() + self.file_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json') + self.test_username = 'test_management_worker' + User.objects.create_user(username=self.test_username) + + def test_run_command(self): + """ + Tests the "happy path", where 2 instances of the test model should be created. + A valid username is supplied for the operation. + """ + _run_command(file=self.file_path, username=self.test_username) + self.assertEquals(2, ExampleDeserializeConfig.objects.count()) + + betty = ExampleDeserializeConfig.current('betty') + self.assertEquals(self.test_username, betty.changed_by.username) + + fred = ExampleDeserializeConfig.current('fred') + self.assertEquals(self.test_username, fred.changed_by.username) + + def test_no_user_specified(self): + """ + Tests that a username must be specified. + """ + with self.assertRaisesRegexp(CommandError, "A valid username must be specified"): + _run_command(file=self.file_path) + + def test_bad_user_specified(self): + """ + Tests that a username must be specified. + """ + with self.assertRaisesRegexp(Exception, "User matching query does not exist"): + _run_command(file=self.file_path, username="does_not_exist") + + def test_no_file_specified(self): + """ + Tests the error handling when no JSON file is supplied. + """ + with self.assertRaisesRegexp(CommandError, "A file containing JSON must be specified"): + _run_command(username=self.test_username) + + def test_bad_file_specified(self): + """ + Tests the error handling when the path to the JSON file is incorrect. + """ + with self.assertRaisesRegexp(CommandError, "File does/not/exist.json does not exist"): + _run_command(file="does/not/exist.json", username=self.test_username) + + +def _run_command(*args, **kwargs): + """Run the management command to deserializer JSON ConfigurationModel data. """ + command = populate_model.Command() + return command.handle(*args, **kwargs) diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests/tests.py similarity index 73% rename from common/djangoapps/config_models/tests.py rename to common/djangoapps/config_models/tests/tests.py index 15f954e336..538058c109 100644 --- a/common/djangoapps/config_models/tests.py +++ b/common/djangoapps/config_models/tests/tests.py @@ -25,6 +25,24 @@ class ExampleConfig(ConfigurationModel): string_field = models.TextField() int_field = models.IntegerField(default=10) + def __unicode__(self): + return "ExampleConfig(enabled={}, string_field={}, int_field={})".format( + self.enabled, self.string_field, self.int_field + ) + + +class ManyToManyExampleConfig(ConfigurationModel): + """ + Test model configuration with a many-to-many field. + """ + cache_timeout = 300 + + string_field = models.TextField() + many_user_field = models.ManyToManyField(User, related_name='topic_many_user_field') + + def __unicode__(self): + return "ManyToManyExampleConfig(enabled={}, string_field={})".format(self.enabled, self.string_field) + @patch('config_models.models.cache') class ConfigurationModelTests(TestCase): @@ -40,7 +58,7 @@ class ConfigurationModelTests(TestCase): ExampleConfig(changed_by=self.user).save() mock_cache.delete.assert_called_with(ExampleConfig.cache_key_name()) - def test_cache_key_name(self, _mock_cache): + def test_cache_key_name(self, __): self.assertEquals(ExampleConfig.cache_key_name(), 'configuration/ExampleConfig/current') def test_no_config_empty_cache(self, mock_cache): @@ -103,6 +121,64 @@ class ConfigurationModelTests(TestCase): self.assertEquals(2, ExampleConfig.objects.all().count()) + def test_equality(self, mock_cache): + mock_cache.get.return_value = None + + config = ExampleConfig(changed_by=self.user, string_field='first') + config.save() + + self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first"})) + self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first", "enabled": False})) + self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 10})) + + self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "enabled": True})) + self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 20})) + self.assertFalse(ExampleConfig.equal_to_current({"string_field": "second"})) + + self.assertFalse(ExampleConfig.equal_to_current({})) + + def test_equality_custom_fields_to_ignore(self, mock_cache): + mock_cache.get.return_value = None + + config = ExampleConfig(changed_by=self.user, string_field='first') + config.save() + + # id, change_date, and changed_by will all be different for a newly created entry + self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first"})) + self.assertFalse( + ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("change_date", "changed_by")) + ) + self.assertFalse( + ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("id", "changed_by")) + ) + self.assertFalse( + ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("change_date", "id")) + ) + + # Test the ability to ignore a different field ("int_field"). + self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 20})) + self.assertTrue( + ExampleConfig.equal_to_current( + {"string_field": "first", "int_field": 20}, + fields_to_ignore=("id", "change_date", "changed_by", "int_field") + ) + ) + + def test_equality_ignores_many_to_many(self, mock_cache): + mock_cache.get.return_value = None + config = ManyToManyExampleConfig(changed_by=self.user, string_field='first') + config.save() + + second_user = User(username="second_user") + second_user.save() + config.many_user_field.add(second_user) # pylint: disable=no-member + config.save() + + # The many-to-many field is ignored in comparison. + self.assertTrue( + ManyToManyExampleConfig.equal_to_current({"string_field": "first", "many_user_field": "removed"}) + ) + class ExampleKeyedConfig(ConfigurationModel): """ @@ -120,6 +196,11 @@ class ExampleKeyedConfig(ConfigurationModel): string_field = models.TextField() int_field = models.IntegerField(default=10) + def __unicode__(self): + return "ExampleKeyedConfig(enabled={}, left={}, right={}, string_field={}, int_field={})".format( + self.enabled, self.left, self.right, self.string_field, self.int_field + ) + @ddt.ddt @patch('config_models.models.cache') @@ -294,6 +375,45 @@ class KeyedConfigurationModelTests(TestCase): mock_cache.get.return_value = fake_result self.assertEquals(ExampleKeyedConfig.key_values(), fake_result) + def test_equality(self, mock_cache): + mock_cache.get.return_value = None + + config1 = ExampleKeyedConfig(left='left_a', right='right_a', int_field=1, changed_by=self.user) + config1.save() + + config2 = ExampleKeyedConfig(left='left_b', right='right_b', int_field=2, changed_by=self.user, enabled=True) + config2.save() + + config3 = ExampleKeyedConfig(left='left_c', changed_by=self.user) + config3.save() + + self.assertTrue( + ExampleKeyedConfig.equal_to_current({"left": "left_a", "right": "right_a", "int_field": 1}) + ) + self.assertTrue( + ExampleKeyedConfig.equal_to_current({"left": "left_b", "right": "right_b", "int_field": 2, "enabled": True}) + ) + self.assertTrue( + ExampleKeyedConfig.equal_to_current({"left": "left_c"}) + ) + + self.assertFalse( + ExampleKeyedConfig.equal_to_current( + {"left": "left_a", "right": "right_a", "int_field": 1, "string_field": "foo"} + ) + ) + self.assertFalse( + ExampleKeyedConfig.equal_to_current({"left": "left_a", "int_field": 1}) + ) + self.assertFalse( + ExampleKeyedConfig.equal_to_current({"left": "left_b", "right": "right_b", "int_field": 2}) + ) + self.assertFalse( + ExampleKeyedConfig.equal_to_current({"left": "left_c", "int_field": 11}) + ) + + self.assertFalse(ExampleKeyedConfig.equal_to_current({})) + @ddt.ddt class ConfigurationModelAPITests(TestCase): diff --git a/common/djangoapps/config_models/utils.py b/common/djangoapps/config_models/utils.py new file mode 100644 index 0000000000..10a293af4d --- /dev/null +++ b/common/djangoapps/config_models/utils.py @@ -0,0 +1,69 @@ +""" +Utilities for working with ConfigurationModels. +""" +from django.apps import apps +from rest_framework.parsers import JSONParser +from rest_framework.serializers import ModelSerializer +from django.contrib.auth.models import User + + +def get_serializer_class(configuration_model): + """ Returns a ConfigurationModel serializer class for the supplied configuration_model. """ + class AutoConfigModelSerializer(ModelSerializer): + """Serializer class for configuration models.""" + + class Meta(object): + """Meta information for AutoConfigModelSerializer.""" + model = configuration_model + + def create(self, validated_data): + if "changed_by_username" in self.context: + validated_data['changed_by'] = User.objects.get(username=self.context["changed_by_username"]) + return super(AutoConfigModelSerializer, self).create(validated_data) + + return AutoConfigModelSerializer + + +def deserialize_json(stream, username): + """ + Given a stream containing JSON, deserializers the JSON into ConfigurationModel instances. + + The stream is expected to be in the following format: + { "model": "config_models.ExampleConfigurationModel", + "data": + [ + { "enabled": True, + "color": "black" + ... + }, + { "enabled": False, + "color": "yellow" + ... + }, + ... + ] + } + + If the provided stream does not contain valid JSON for the ConfigurationModel specified, + an Exception will be raised. + + Arguments: + stream: The stream of JSON, as described above. + username: The username of the user making the change. This must match an existing user. + + Returns: the number of created entries + """ + parsed_json = JSONParser().parse(stream) + serializer_class = get_serializer_class(apps.get_model(parsed_json["model"])) + list_serializer = serializer_class(data=parsed_json["data"], context={"changed_by_username": username}, many=True) + if list_serializer.is_valid(): + model_class = serializer_class.Meta.model + for data in reversed(list_serializer.validated_data): + if model_class.equal_to_current(data): + list_serializer.validated_data.remove(data) + + entries_created = len(list_serializer.validated_data) + list_serializer.save() + return entries_created + else: + raise Exception(list_serializer.error_messages) diff --git a/common/djangoapps/config_models/views.py b/common/djangoapps/config_models/views.py index 3bd693ec59..c9d584f9cb 100644 --- a/common/djangoapps/config_models/views.py +++ b/common/djangoapps/config_models/views.py @@ -4,9 +4,10 @@ API view to allow manipulation of configuration models. from rest_framework.generics import CreateAPIView, RetrieveAPIView from rest_framework.permissions import DjangoModelPermissions from rest_framework.authentication import SessionAuthentication -from rest_framework.serializers import ModelSerializer from django.db import transaction +from config_models.utils import get_serializer_class + class ReadableOnlyByAuthors(DjangoModelPermissions): """Only allow access by users with `add` permissions on the model.""" @@ -58,13 +59,7 @@ class ConfigurationModelCurrentAPIView(AtomicMixin, CreateAPIView, RetrieveAPIVi def get_serializer_class(self): if self.serializer_class is None: - class AutoConfigModelSerializer(ModelSerializer): - """Serializer class for configuration models.""" - class Meta(object): - """Meta information for AutoConfigModelSerializer.""" - model = self.model - - self.serializer_class = AutoConfigModelSerializer + self.serializer_class = get_serializer_class(self.model) return self.serializer_class From b1af59ad9976972aaf1fa262c4fe22515bdf118e Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Thu, 23 Jun 2016 10:32:33 -0400 Subject: [PATCH 06/40] Move discussion .coffee files to .js --- .../discussion/content.coffee => common/js/discussion/content.js} | 0 .../discussion.coffee => common/js/discussion/discussion.js} | 0 .../js/discussion/discussion_module_view.js} | 0 .../js/discussion/discussion_router.js} | 0 .../src/discussion/main.coffee => common/js/discussion/main.js} | 0 .../js/discussion/models/discussion_course_settings.js} | 0 .../js/discussion/models/discussion_user.js} | 0 .../src/discussion/utils.coffee => common/js/discussion/utils.js} | 0 .../js/discussion/views/discussion_content_view.js} | 0 .../js}/discussion/views/discussion_thread_edit_view.js | 0 .../js/discussion/views/discussion_thread_list_view.js} | 0 .../js/discussion/views/discussion_thread_profile_view.js} | 0 .../js/discussion/views/discussion_thread_show_view.js} | 0 .../js/discussion/views/discussion_thread_view.js} | 0 .../js}/discussion/views/discussion_topic_menu_view.js | 0 .../js/discussion/views/discussion_user_profile_view.js} | 0 .../js/discussion/views/new_post_view.js} | 0 .../js/discussion/views/response_comment_edit_view.js} | 0 .../js/discussion/views/response_comment_show_view.js} | 0 .../js/discussion/views/response_comment_view.js} | 0 .../js/discussion/views/thread_response_edit_view.js} | 0 .../js/discussion/views/thread_response_show_view.js} | 0 .../js/discussion/views/thread_response_view.js} | 0 .../js/spec/discussion/content_spec.js} | 0 .../utils_spec.coffee => common/js/spec/discussion/utils_spec.js} | 0 .../js/spec/discussion/view/discussion_content_view_spec.js} | 0 .../js}/spec/discussion/view/discussion_thread_edit_view_spec.js | 0 .../js/spec/discussion/view/discussion_thread_list_view_spec.js} | 0 .../spec/discussion/view/discussion_thread_profile_view_spec.js} | 0 .../js/spec/discussion/view/discussion_thread_show_view_spec.js} | 0 .../js/spec/discussion/view/discussion_thread_view_spec.js} | 0 .../js}/spec/discussion/view/discussion_topic_menu_view_spec.js | 0 .../js/spec/discussion/view/discussion_user_profile_view_spec.js} | 0 .../js/spec/discussion/view/discussion_view_spec_helper.js} | 0 .../js/spec/discussion/view/new_post_view_spec.js} | 0 .../js/spec/discussion/view/response_comment_show_view_spec.js} | 0 .../js/spec/discussion/view/response_comment_view_spec.js} | 0 .../js/spec/discussion/view/thread_response_show_view_spec.js} | 0 .../js/spec/discussion/view/thread_response_view_spec.js} | 0 .../js/spec_helpers/discussion_spec_helper.js} | 0 40 files changed, 0 insertions(+), 0 deletions(-) rename common/static/{coffee/src/discussion/content.coffee => common/js/discussion/content.js} (100%) rename common/static/{coffee/src/discussion/discussion.coffee => common/js/discussion/discussion.js} (100%) rename common/static/{coffee/src/discussion/discussion_module_view.coffee => common/js/discussion/discussion_module_view.js} (100%) rename common/static/{coffee/src/discussion/discussion_router.coffee => common/js/discussion/discussion_router.js} (100%) rename common/static/{coffee/src/discussion/main.coffee => common/js/discussion/main.js} (100%) rename common/static/{coffee/src/discussion/models/discussion_course_settings.coffee => common/js/discussion/models/discussion_course_settings.js} (100%) rename common/static/{coffee/src/discussion/models/discussion_user.coffee => common/js/discussion/models/discussion_user.js} (100%) rename common/static/{coffee/src/discussion/utils.coffee => common/js/discussion/utils.js} (100%) rename common/static/{coffee/src/discussion/views/discussion_content_view.coffee => common/js/discussion/views/discussion_content_view.js} (100%) rename common/static/{coffee/src => common/js}/discussion/views/discussion_thread_edit_view.js (100%) rename common/static/{coffee/src/discussion/views/discussion_thread_list_view.coffee => common/js/discussion/views/discussion_thread_list_view.js} (100%) rename common/static/{coffee/src/discussion/views/discussion_thread_profile_view.coffee => common/js/discussion/views/discussion_thread_profile_view.js} (100%) rename common/static/{coffee/src/discussion/views/discussion_thread_show_view.coffee => common/js/discussion/views/discussion_thread_show_view.js} (100%) rename common/static/{coffee/src/discussion/views/discussion_thread_view.coffee => common/js/discussion/views/discussion_thread_view.js} (100%) rename common/static/{coffee/src => common/js}/discussion/views/discussion_topic_menu_view.js (100%) rename common/static/{coffee/src/discussion/views/discussion_user_profile_view.coffee => common/js/discussion/views/discussion_user_profile_view.js} (100%) rename common/static/{coffee/src/discussion/views/new_post_view.coffee => common/js/discussion/views/new_post_view.js} (100%) rename common/static/{coffee/src/discussion/views/response_comment_edit_view.coffee => common/js/discussion/views/response_comment_edit_view.js} (100%) rename common/static/{coffee/src/discussion/views/response_comment_show_view.coffee => common/js/discussion/views/response_comment_show_view.js} (100%) rename common/static/{coffee/src/discussion/views/response_comment_view.coffee => common/js/discussion/views/response_comment_view.js} (100%) rename common/static/{coffee/src/discussion/views/thread_response_edit_view.coffee => common/js/discussion/views/thread_response_edit_view.js} (100%) rename common/static/{coffee/src/discussion/views/thread_response_show_view.coffee => common/js/discussion/views/thread_response_show_view.js} (100%) rename common/static/{coffee/src/discussion/views/thread_response_view.coffee => common/js/discussion/views/thread_response_view.js} (100%) rename common/static/{coffee/spec/discussion/content_spec.coffee => common/js/spec/discussion/content_spec.js} (100%) rename common/static/{coffee/spec/discussion/utils_spec.coffee => common/js/spec/discussion/utils_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/discussion_content_view_spec.coffee => common/js/spec/discussion/view/discussion_content_view_spec.js} (100%) rename common/static/{coffee => common/js}/spec/discussion/view/discussion_thread_edit_view_spec.js (100%) rename common/static/{coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee => common/js/spec/discussion/view/discussion_thread_list_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/discussion_thread_profile_view_spec.coffee => common/js/spec/discussion/view/discussion_thread_profile_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee => common/js/spec/discussion/view/discussion_thread_show_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/discussion_thread_view_spec.coffee => common/js/spec/discussion/view/discussion_thread_view_spec.js} (100%) rename common/static/{coffee => common/js}/spec/discussion/view/discussion_topic_menu_view_spec.js (100%) rename common/static/{coffee/spec/discussion/view/discussion_user_profile_view_spec.coffee => common/js/spec/discussion/view/discussion_user_profile_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/discussion_view_spec_helper.coffee => common/js/spec/discussion/view/discussion_view_spec_helper.js} (100%) rename common/static/{coffee/spec/discussion/view/new_post_view_spec.coffee => common/js/spec/discussion/view/new_post_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/response_comment_show_view_spec.coffee => common/js/spec/discussion/view/response_comment_show_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/response_comment_view_spec.coffee => common/js/spec/discussion/view/response_comment_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/thread_response_show_view_spec.coffee => common/js/spec/discussion/view/thread_response_show_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/view/thread_response_view_spec.coffee => common/js/spec/discussion/view/thread_response_view_spec.js} (100%) rename common/static/{coffee/spec/discussion/discussion_spec_helper.coffee => common/js/spec_helpers/discussion_spec_helper.js} (100%) diff --git a/common/static/coffee/src/discussion/content.coffee b/common/static/common/js/discussion/content.js similarity index 100% rename from common/static/coffee/src/discussion/content.coffee rename to common/static/common/js/discussion/content.js diff --git a/common/static/coffee/src/discussion/discussion.coffee b/common/static/common/js/discussion/discussion.js similarity index 100% rename from common/static/coffee/src/discussion/discussion.coffee rename to common/static/common/js/discussion/discussion.js diff --git a/common/static/coffee/src/discussion/discussion_module_view.coffee b/common/static/common/js/discussion/discussion_module_view.js similarity index 100% rename from common/static/coffee/src/discussion/discussion_module_view.coffee rename to common/static/common/js/discussion/discussion_module_view.js diff --git a/common/static/coffee/src/discussion/discussion_router.coffee b/common/static/common/js/discussion/discussion_router.js similarity index 100% rename from common/static/coffee/src/discussion/discussion_router.coffee rename to common/static/common/js/discussion/discussion_router.js diff --git a/common/static/coffee/src/discussion/main.coffee b/common/static/common/js/discussion/main.js similarity index 100% rename from common/static/coffee/src/discussion/main.coffee rename to common/static/common/js/discussion/main.js diff --git a/common/static/coffee/src/discussion/models/discussion_course_settings.coffee b/common/static/common/js/discussion/models/discussion_course_settings.js similarity index 100% rename from common/static/coffee/src/discussion/models/discussion_course_settings.coffee rename to common/static/common/js/discussion/models/discussion_course_settings.js diff --git a/common/static/coffee/src/discussion/models/discussion_user.coffee b/common/static/common/js/discussion/models/discussion_user.js similarity index 100% rename from common/static/coffee/src/discussion/models/discussion_user.coffee rename to common/static/common/js/discussion/models/discussion_user.js diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/common/js/discussion/utils.js similarity index 100% rename from common/static/coffee/src/discussion/utils.coffee rename to common/static/common/js/discussion/utils.js diff --git a/common/static/coffee/src/discussion/views/discussion_content_view.coffee b/common/static/common/js/discussion/views/discussion_content_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_content_view.coffee rename to common/static/common/js/discussion/views/discussion_content_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js b/common/static/common/js/discussion/views/discussion_thread_edit_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_thread_edit_view.js rename to common/static/common/js/discussion/views/discussion_thread_edit_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/common/js/discussion/views/discussion_thread_list_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee rename to common/static/common/js/discussion/views/discussion_thread_list_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_thread_profile_view.coffee b/common/static/common/js/discussion/views/discussion_thread_profile_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_thread_profile_view.coffee rename to common/static/common/js/discussion/views/discussion_thread_profile_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/common/js/discussion/views/discussion_thread_show_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee rename to common/static/common/js/discussion/views/discussion_thread_show_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee b/common/static/common/js/discussion/views/discussion_thread_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_thread_view.coffee rename to common/static/common/js/discussion/views/discussion_thread_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js b/common/static/common/js/discussion/views/discussion_topic_menu_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_topic_menu_view.js rename to common/static/common/js/discussion/views/discussion_topic_menu_view.js diff --git a/common/static/coffee/src/discussion/views/discussion_user_profile_view.coffee b/common/static/common/js/discussion/views/discussion_user_profile_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/discussion_user_profile_view.coffee rename to common/static/common/js/discussion/views/discussion_user_profile_view.js diff --git a/common/static/coffee/src/discussion/views/new_post_view.coffee b/common/static/common/js/discussion/views/new_post_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/new_post_view.coffee rename to common/static/common/js/discussion/views/new_post_view.js diff --git a/common/static/coffee/src/discussion/views/response_comment_edit_view.coffee b/common/static/common/js/discussion/views/response_comment_edit_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/response_comment_edit_view.coffee rename to common/static/common/js/discussion/views/response_comment_edit_view.js diff --git a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee b/common/static/common/js/discussion/views/response_comment_show_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/response_comment_show_view.coffee rename to common/static/common/js/discussion/views/response_comment_show_view.js diff --git a/common/static/coffee/src/discussion/views/response_comment_view.coffee b/common/static/common/js/discussion/views/response_comment_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/response_comment_view.coffee rename to common/static/common/js/discussion/views/response_comment_view.js diff --git a/common/static/coffee/src/discussion/views/thread_response_edit_view.coffee b/common/static/common/js/discussion/views/thread_response_edit_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/thread_response_edit_view.coffee rename to common/static/common/js/discussion/views/thread_response_edit_view.js diff --git a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee b/common/static/common/js/discussion/views/thread_response_show_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/thread_response_show_view.coffee rename to common/static/common/js/discussion/views/thread_response_show_view.js diff --git a/common/static/coffee/src/discussion/views/thread_response_view.coffee b/common/static/common/js/discussion/views/thread_response_view.js similarity index 100% rename from common/static/coffee/src/discussion/views/thread_response_view.coffee rename to common/static/common/js/discussion/views/thread_response_view.js diff --git a/common/static/coffee/spec/discussion/content_spec.coffee b/common/static/common/js/spec/discussion/content_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/content_spec.coffee rename to common/static/common/js/spec/discussion/content_spec.js diff --git a/common/static/coffee/spec/discussion/utils_spec.coffee b/common/static/common/js/spec/discussion/utils_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/utils_spec.coffee rename to common/static/common/js/spec/discussion/utils_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_content_view_spec.coffee b/common/static/common/js/spec/discussion/view/discussion_content_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_content_view_spec.coffee rename to common/static/common/js/spec/discussion/view/discussion_content_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_edit_view_spec.js b/common/static/common/js/spec/discussion/view/discussion_thread_edit_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_thread_edit_view_spec.js rename to common/static/common/js/spec/discussion/view/discussion_thread_edit_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee b/common/static/common/js/spec/discussion/view/discussion_thread_list_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee rename to common/static/common/js/spec/discussion/view/discussion_thread_list_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_profile_view_spec.coffee b/common/static/common/js/spec/discussion/view/discussion_thread_profile_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_thread_profile_view_spec.coffee rename to common/static/common/js/spec/discussion/view/discussion_thread_profile_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee b/common/static/common/js/spec/discussion/view/discussion_thread_show_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee rename to common/static/common/js/spec/discussion/view/discussion_thread_show_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee b/common/static/common/js/spec/discussion/view/discussion_thread_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee rename to common/static/common/js/spec/discussion/view/discussion_thread_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_topic_menu_view_spec.js b/common/static/common/js/spec/discussion/view/discussion_topic_menu_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_topic_menu_view_spec.js rename to common/static/common/js/spec/discussion/view/discussion_topic_menu_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_user_profile_view_spec.coffee b/common/static/common/js/spec/discussion/view/discussion_user_profile_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_user_profile_view_spec.coffee rename to common/static/common/js/spec/discussion/view/discussion_user_profile_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/discussion_view_spec_helper.coffee b/common/static/common/js/spec/discussion/view/discussion_view_spec_helper.js similarity index 100% rename from common/static/coffee/spec/discussion/view/discussion_view_spec_helper.coffee rename to common/static/common/js/spec/discussion/view/discussion_view_spec_helper.js diff --git a/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee b/common/static/common/js/spec/discussion/view/new_post_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/new_post_view_spec.coffee rename to common/static/common/js/spec/discussion/view/new_post_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee b/common/static/common/js/spec/discussion/view/response_comment_show_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee rename to common/static/common/js/spec/discussion/view/response_comment_show_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/response_comment_view_spec.coffee b/common/static/common/js/spec/discussion/view/response_comment_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/response_comment_view_spec.coffee rename to common/static/common/js/spec/discussion/view/response_comment_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee b/common/static/common/js/spec/discussion/view/thread_response_show_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee rename to common/static/common/js/spec/discussion/view/thread_response_show_view_spec.js diff --git a/common/static/coffee/spec/discussion/view/thread_response_view_spec.coffee b/common/static/common/js/spec/discussion/view/thread_response_view_spec.js similarity index 100% rename from common/static/coffee/spec/discussion/view/thread_response_view_spec.coffee rename to common/static/common/js/spec/discussion/view/thread_response_view_spec.js diff --git a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee b/common/static/common/js/spec_helpers/discussion_spec_helper.js similarity index 100% rename from common/static/coffee/spec/discussion/discussion_spec_helper.coffee rename to common/static/common/js/spec_helpers/discussion_spec_helper.js From 2b8c02a759c04477f1d003a86f383a860b1b85a6 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 16 Jun 2016 10:51:34 -0700 Subject: [PATCH 07/40] Discussion coffee files to JS --- .../static/coffee/spec/discussion/.gitignore | 2 - .../static/coffee/src/discussion/.gitignore | 2 - common/static/common/js/discussion/content.js | 566 ++++--- .../static/common/js/discussion/discussion.js | 327 ++-- .../js/discussion/discussion_module_view.js | 405 +++-- .../common/js/discussion/discussion_router.js | 233 ++- common/static/common/js/discussion/main.js | 114 +- .../models/discussion_course_settings.js | 36 +- .../js/discussion/models/discussion_user.js | 61 +- common/static/common/js/discussion/utils.js | 757 ++++++---- .../views/discussion_content_view.js | 775 ++++++---- .../views/discussion_thread_edit_view.js | 9 +- .../views/discussion_thread_list_view.js | 1319 ++++++++++------- .../views/discussion_thread_profile_view.js | 89 +- .../views/discussion_thread_show_view.js | 126 +- .../views/discussion_thread_view.js | 771 ++++++---- .../views/discussion_topic_menu_view.js | 16 +- .../views/discussion_user_profile_view.js | 144 +- .../js/discussion/views/new_post_view.js | 290 ++-- .../views/response_comment_edit_view.js | 77 +- .../views/response_comment_show_view.js | 118 +- .../discussion/views/response_comment_view.js | 227 ++- .../views/thread_response_edit_view.js | 77 +- .../views/thread_response_show_view.js | 106 +- .../discussion/views/thread_response_view.js | 508 ++++--- common/static/common/js/karma.common.conf.js | 1 + .../common/js/spec/discussion/content_spec.js | 271 ++-- .../common/js/spec/discussion/utils_spec.js | 115 +- .../view/discussion_content_view_spec.js | 90 +- .../view/discussion_thread_edit_view_spec.js | 22 +- .../view/discussion_thread_list_view_spec.js | 1296 ++++++++-------- .../discussion_thread_profile_view_spec.js | 264 ++-- .../view/discussion_thread_show_view_spec.js | 346 +++-- .../view/discussion_thread_view_spec.js | 876 ++++++----- .../view/discussion_topic_menu_view_spec.js | 13 +- .../view/discussion_user_profile_view_spec.js | 474 +++--- .../view/discussion_view_spec_helper.js | 222 +-- .../discussion/view/new_post_view_spec.js | 478 +++--- .../view/response_comment_show_view_spec.js | 209 +-- .../view/response_comment_view_spec.js | 336 +++-- .../view/thread_response_show_view_spec.js | 486 +++--- .../view/thread_response_view_spec.js | 213 +-- .../js/spec_helpers/discussion_spec_helper.js | 171 ++- .../js/spec/views/team_discussion_spec.js | 2 +- .../teams/js/spec/views/team_profile_spec.js | 2 +- lms/envs/common.py | 2 +- lms/static/lms/js/spec/main.js | 112 +- scripts/safelint_thresholds.json | 28 +- 48 files changed, 7858 insertions(+), 5326 deletions(-) delete mode 100644 common/static/coffee/spec/discussion/.gitignore delete mode 100644 common/static/coffee/src/discussion/.gitignore diff --git a/common/static/coffee/spec/discussion/.gitignore b/common/static/coffee/spec/discussion/.gitignore deleted file mode 100644 index ac5223af30..0000000000 --- a/common/static/coffee/spec/discussion/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -!view/discussion_thread_edit_view_spec.js -!view/discussion_topic_menu_view_spec.js diff --git a/common/static/coffee/src/discussion/.gitignore b/common/static/coffee/src/discussion/.gitignore deleted file mode 100644 index 3c396b9365..0000000000 --- a/common/static/coffee/src/discussion/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -!views/discussion_thread_edit_view.js -!views/discussion_topic_menu_view.js diff --git a/common/static/common/js/discussion/content.js b/common/static/common/js/discussion/content.js index 49f199ab1c..111e4fd821 100644 --- a/common/static/common/js/discussion/content.js +++ b/common/static/common/js/discussion/content.js @@ -1,218 +1,436 @@ -if Backbone? - class @Content extends Backbone.Model +/* globals DiscussionUtil */ +(function() { + 'use strict'; - @contents: {} - @contentInfos: {} + var __hasProp = {}.hasOwnProperty; - template: -> DiscussionUtil.getTemplate('_content') + function __extends(child, parent) { + for (var key in parent) { + if (__hasProp.call(parent, key)) { + child[key] = parent[key]; + } + } + function ctor() { + this.constructor = child; + } - actions: - editable: '.admin-edit' - can_reply: '.discussion-reply' - can_delete: '.admin-delete' - can_openclose: '.admin-openclose' - can_report: '.admin-report' - can_vote: '.admin-vote' + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + child.__super__ = parent.prototype; + return child; + } - urlMappers: {} + var __indexOf = [].indexOf || function(item) { + for (var i = 0, l = this.length; i < l; i++) { + if (i in this && this[i] === item) { + return i; + } + } + return -1; + }; - urlFor: (name) -> - @urlMappers[name].apply(@) + if (typeof Backbone !== "undefined" && Backbone !== null) { + this.Content = (function(_super) { - can: (action) -> - (@get('ability') || {})[action] + __extends(Content, _super); - # Default implementation - canBeEndorsed: -> false + function Content() { + return Content.__super__.constructor.apply(this, arguments); + } - updateInfo: (info) -> - if info - @set('ability', info.ability) - @set('voted', info.voted) - @set('subscribed', info.subscribed) + Content.contents = {}; - addComment: (comment, options) -> - options ||= {} - if not options.silent - thread = @get('thread') - comments_count = parseInt(thread.get('comments_count')) - thread.set('comments_count', comments_count + 1) - @get('children').push comment - model = new Comment $.extend {}, comment, { thread: @get('thread') } - @get('comments').add model - @trigger "comment:add" - model + Content.contentInfos = {}; - removeComment: (comment) -> - thread = @get('thread') - comments_count = parseInt(thread.get('comments_count')) - thread.set('comments_count', comments_count - 1 - comment.getCommentsCount()) - @trigger "comment:remove" + Content.prototype.template = function() { + return DiscussionUtil.getTemplate('_content'); + }; - resetComments: (children) -> - @set 'children', [] - @set 'comments', new Comments() - for comment in (children || []) - @addComment comment, { silent: true } + Content.prototype.actions = { + editable: '.admin-edit', + can_reply: '.discussion-reply', + can_delete: '.admin-delete', + can_openclose: '.admin-openclose', + can_report: '.admin-report', + can_vote: '.admin-vote' + }; - initialize: -> - Content.addContent @id, @ - userId = @get('user_id') - if userId? - @set('staff_authored', DiscussionUtil.isStaff(userId)) - @set('community_ta_authored', DiscussionUtil.isTA(userId)) - else - @set('staff_authored', false) - @set('community_ta_authored', false) - if Content.getInfo(@id) - @updateInfo(Content.getInfo(@id)) - @set 'user_url', DiscussionUtil.urlFor('user_profile', userId) - @resetComments(@get('children')) + Content.prototype.urlMappers = {}; - remove: -> + Content.prototype.urlFor = function(name) { + return this.urlMappers[name].apply(this); + }; - if @get('type') == 'comment' - @get('thread').removeComment(@) - @get('thread').trigger "comment:remove", @ - else - @trigger "thread:remove", @ + Content.prototype.can = function(action) { + return (this.get('ability') || {})[action]; + }; - @addContent: (id, content) -> @contents[id] = content + Content.prototype.canBeEndorsed = function() { + return false; + }; - @getContent: (id) -> @contents[id] + Content.prototype.updateInfo = function(info) { + if (info) { + this.set('ability', info.ability); + this.set('voted', info.voted); + return this.set('subscribed', info.subscribed); + } + }; - @getInfo: (id) -> - @contentInfos[id] + Content.prototype.addComment = function(comment, options) { + var comments_count, model, thread; + options = (options) ? options : {}; + if (!options.silent) { + thread = this.get('thread'); + comments_count = parseInt(thread.get('comments_count')); + thread.set('comments_count', comments_count + 1); + } + this.get('children').push(comment); + model = new Comment($.extend({}, comment, { + thread: this.get('thread') + })); + this.get('comments').add(model); + this.trigger("comment:add"); + return model; + }; - @loadContentInfos: (infos) -> - for id, info of infos - if @getContent(id) - @getContent(id).updateInfo(info) - $.extend @contentInfos, infos + Content.prototype.removeComment = function(comment) { + var comments_count, thread; + thread = this.get('thread'); + comments_count = parseInt(thread.get('comments_count')); + thread.set('comments_count', comments_count - 1 - comment.getCommentsCount()); + return this.trigger("comment:remove"); + }; - pinThread: -> - pinned = @get("pinned") - @set("pinned",pinned) - @trigger "change", @ + Content.prototype.resetComments = function(children) { + var comment, _i, _len, _ref, _results; + this.set('children', []); + this.set('comments', new Comments()); // jshint ignore:line + _ref = children || []; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + comment = _ref[_i]; + _results.push(this.addComment(comment, { + silent: true + })); + } + return _results; + }; - unPinThread: -> - pinned = @get("pinned") - @set("pinned",pinned) - @trigger "change", @ + Content.prototype.initialize = function() { + var userId; + Content.addContent(this.id, this); + userId = this.get('user_id'); + if (userId) { + this.set('staff_authored', DiscussionUtil.isStaff(userId)); + this.set('community_ta_authored', DiscussionUtil.isTA(userId)); + } else { + this.set('staff_authored', false); + this.set('community_ta_authored', false); + } + if (Content.getInfo(this.id)) { + this.updateInfo(Content.getInfo(this.id)); + } + this.set('user_url', DiscussionUtil.urlFor('user_profile', userId)); + return this.resetComments(this.get('children')); + }; - flagAbuse: -> - temp_array = @get("abuse_flaggers") - temp_array.push(window.user.get('id')) - @set("abuse_flaggers",temp_array) - @trigger "change", @ + Content.prototype.remove = function() { + if (this.get('type') === 'comment') { + this.get('thread').removeComment(this); + return this.get('thread').trigger("comment:remove", this); + } else { + return this.trigger("thread:remove", this); + } + }; - unflagAbuse: -> - @get("abuse_flaggers").pop(window.user.get('id')) - @trigger "change", @ + Content.addContent = function(id, content) { + this.contents[id] = content; + }; - isFlagged: -> - user = DiscussionUtil.getUser() - flaggers = @get("abuse_flaggers") - user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0)) + Content.getContent = function(id) { + return this.contents[id]; + }; - incrementVote: (increment) -> - newVotes = _.clone(@get("votes")) - newVotes.up_count = newVotes.up_count + increment - @set("votes", newVotes) + Content.getInfo = function(id) { + return this.contentInfos[id]; + }; - vote: -> - @incrementVote(1) + Content.loadContentInfos = function(infos) { + var id, info; + for (id in infos) { + if (infos.hasOwnProperty(id)) { + info = infos[id]; + if (this.getContent(id)) { + this.getContent(id).updateInfo(info); + } + } + } + return $.extend(this.contentInfos, infos); + }; - unvote: -> - @incrementVote(-1) + Content.prototype.pinThread = function() { + var pinned; + pinned = this.get("pinned"); + this.set("pinned", pinned); + return this.trigger("change", this); + }; - class @Thread extends @Content - urlMappers: - 'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @.get('commentable_id'), @id) - 'reply' : -> DiscussionUtil.urlFor('create_comment', @id) - 'unvote' : -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id) - 'upvote' : -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id) - 'downvote' : -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id) - 'close' : -> DiscussionUtil.urlFor('openclose_thread', @id) - 'update' : -> DiscussionUtil.urlFor('update_thread', @id) - '_delete' : -> DiscussionUtil.urlFor('delete_thread', @id) - 'follow' : -> DiscussionUtil.urlFor('follow_thread', @id) - 'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id) - 'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id) - 'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id) - 'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id) - 'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id) + Content.prototype.unPinThread = function() { + var pinned; + pinned = this.get("pinned"); + this.set("pinned", pinned); + return this.trigger("change", this); + }; - initialize: -> - @set('thread', @) - super() + Content.prototype.flagAbuse = function() { + var temp_array; + temp_array = this.get("abuse_flaggers"); + temp_array.push(window.user.get('id')); + this.set("abuse_flaggers", temp_array); + return this.trigger("change", this); + }; - comment: -> - @set("comments_count", parseInt(@get("comments_count")) + 1) + Content.prototype.unflagAbuse = function() { + this.get("abuse_flaggers").pop(window.user.get('id')); + return this.trigger("change", this); + }; - follow: -> - @set('subscribed', true) + Content.prototype.isFlagged = function() { + var flaggers, user; + user = DiscussionUtil.getUser(); + flaggers = this.get("abuse_flaggers"); + return user && ( + (__indexOf.call(flaggers, user.id) >= 0) || + (DiscussionUtil.isPrivilegedUser(user.id) && flaggers.length > 0) + ); + }; - unfollow: -> - @set('subscribed', false) + Content.prototype.incrementVote = function(increment) { + var newVotes; + newVotes = _.clone(this.get("votes")); + newVotes.up_count = newVotes.up_count + increment; + return this.set("votes", newVotes); + }; - display_body: -> - if @has("highlighted_body") - String(@get("highlighted_body")).replace(//g, '').replace(/<\/highlight>/g, '') - else - @get("body") + Content.prototype.vote = function() { + return this.incrementVote(1); + }; - display_title: -> - if @has("highlighted_title") - String(@get("highlighted_title")).replace(//g, '').replace(/<\/highlight>/g, '') - else - @get("title") + Content.prototype.unvote = function() { + return this.incrementVote(-1); + }; - toJSON: -> - json_attributes = _.clone(@attributes) - _.extend(json_attributes, { title: @display_title(), body: @display_body() }) + return Content; - created_at_date: -> - new Date(@get("created_at")) + })(Backbone.Model); + this.Thread = (function(_super) { - created_at_time: -> - new Date(@get("created_at")).getTime() + __extends(Thread, _super); - hasResponses: -> - @get('comments_count') > 0 + function Thread() { + return Thread.__super__.constructor.apply(this, arguments); + } - class @Comment extends @Content - urlMappers: - 'reply': -> DiscussionUtil.urlFor('create_sub_comment', @id) - 'unvote': -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id) - 'upvote': -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id) - 'downvote': -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id) - 'endorse': -> DiscussionUtil.urlFor('endorse_comment', @id) - 'update': -> DiscussionUtil.urlFor('update_comment', @id) - '_delete': -> DiscussionUtil.urlFor('delete_comment', @id) - 'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id) - 'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id) + Thread.prototype.urlMappers = { + 'retrieve': function() { + return DiscussionUtil.urlFor('retrieve_single_thread', this.get('commentable_id'), this.id); + }, + 'reply': function() { + return DiscussionUtil.urlFor('create_comment', this.id); + }, + 'unvote': function() { + return DiscussionUtil.urlFor("undo_vote_for_" + (this.get('type')), this.id); + }, + 'upvote': function() { + return DiscussionUtil.urlFor("upvote_" + (this.get('type')), this.id); + }, + 'downvote': function() { + return DiscussionUtil.urlFor("downvote_" + (this.get('type')), this.id); + }, + 'close': function() { + return DiscussionUtil.urlFor('openclose_thread', this.id); + }, + 'update': function() { + return DiscussionUtil.urlFor('update_thread', this.id); + }, + '_delete': function() { + return DiscussionUtil.urlFor('delete_thread', this.id); + }, + 'follow': function() { + return DiscussionUtil.urlFor('follow_thread', this.id); + }, + 'unfollow': function() { + return DiscussionUtil.urlFor('unfollow_thread', this.id); + }, + 'flagAbuse': function() { + return DiscussionUtil.urlFor("flagAbuse_" + (this.get('type')), this.id); + }, + 'unFlagAbuse': function() { + return DiscussionUtil.urlFor("unFlagAbuse_" + (this.get('type')), this.id); + }, + 'pinThread': function() { + return DiscussionUtil.urlFor("pin_thread", this.id); + }, + 'unPinThread': function() { + return DiscussionUtil.urlFor("un_pin_thread", this.id); + } + }; - getCommentsCount: -> - count = 0 - @get('comments').each (comment) -> - count += comment.getCommentsCount() + 1 - count + Thread.prototype.initialize = function() { + this.set('thread', this); + return Thread.__super__.initialize.call(this); + }; - canBeEndorsed: => - user_id = window.user.get("id") - user_id && ( - DiscussionUtil.isPrivilegedUser(user_id) || - (@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id) - ) + Thread.prototype.comment = function() { + return this.set("comments_count", parseInt(this.get("comments_count")) + 1); + }; - class @Comments extends Backbone.Collection + Thread.prototype.follow = function() { + return this.set('subscribed', true); + }; - model: Comment + Thread.prototype.unfollow = function() { + return this.set('subscribed', false); + }; - initialize: -> - @bind "add", (item) => - item.collection = @ + Thread.prototype.display_body = function() { + if (this.has("highlighted_body")) { + return String(this.get("highlighted_body")) + .replace(//g, '') + .replace(/<\/highlight>/g, ''); + } else { + return this.get("body"); + } + }; - find: (id) -> - _.first @where(id: id) + Thread.prototype.display_title = function() { + if (this.has("highlighted_title")) { + return String(this.get("highlighted_title")) + .replace(//g, '') + .replace(/<\/highlight>/g, ''); + } else { + return this.get("title"); + } + }; + + Thread.prototype.toJSON = function() { + var json_attributes; + json_attributes = _.clone(this.attributes); + return _.extend(json_attributes, { + title: this.display_title(), + body: this.display_body() + }); + }; + + Thread.prototype.created_at_date = function() { + return new Date(this.get("created_at")); + }; + + Thread.prototype.created_at_time = function() { + return new Date(this.get("created_at")).getTime(); + }; + + Thread.prototype.hasResponses = function() { + return this.get('comments_count') > 0; + }; + + return Thread; + + })(this.Content); + this.Comment = (function(_super) { + + __extends(Comment, _super); + + function Comment() { + var self = this; + this.canBeEndorsed = function() { + return Comment.prototype.canBeEndorsed.apply(self, arguments); + }; + return Comment.__super__.constructor.apply(this, arguments); + } + + Comment.prototype.urlMappers = { + 'reply': function() { + return DiscussionUtil.urlFor('create_sub_comment', this.id); + }, + 'unvote': function() { + return DiscussionUtil.urlFor("undo_vote_for_" + (this.get('type')), this.id); + }, + 'upvote': function() { + return DiscussionUtil.urlFor("upvote_" + (this.get('type')), this.id); + }, + 'downvote': function() { + return DiscussionUtil.urlFor("downvote_" + (this.get('type')), this.id); + }, + 'endorse': function() { + return DiscussionUtil.urlFor('endorse_comment', this.id); + }, + 'update': function() { + return DiscussionUtil.urlFor('update_comment', this.id); + }, + '_delete': function() { + return DiscussionUtil.urlFor('delete_comment', this.id); + }, + 'flagAbuse': function() { + return DiscussionUtil.urlFor("flagAbuse_" + (this.get('type')), this.id); + }, + 'unFlagAbuse': function() { + return DiscussionUtil.urlFor("unFlagAbuse_" + (this.get('type')), this.id); + } + }; + + Comment.prototype.getCommentsCount = function() { + var count; + count = 0; + this.get('comments').each(function(comment) { + return count += comment.getCommentsCount() + 1; + }); + return count; + }; + + Comment.prototype.canBeEndorsed = function() { + var user_id; + user_id = window.user.get("id"); + return user_id && ( + DiscussionUtil.isPrivilegedUser(user_id) || + ( + this.get('thread').get('thread_type') === 'question' && + this.get('thread').get('user_id') === user_id + ) + ); + }; + + return Comment; + + })(this.Content); + + this.Comments = (function(_super) { + + __extends(Comments, _super); + + function Comments() { + return Comments.__super__.constructor.apply(this, arguments); + } + + Comments.prototype.model = Comment; + + Comments.prototype.initialize = function() { + var self = this; + return this.bind("add", function(item) { + item.collection = self; + }); + }; + + Comments.prototype.find = function(id) { + return _.first(this.where({ + id: id + })); + }; + + return Comments; + + })(Backbone.Collection); + } + +}).call(window); diff --git a/common/static/common/js/discussion/discussion.js b/common/static/common/js/discussion/discussion.js index 3b0c68b1ef..dfa272c04b 100644 --- a/common/static/common/js/discussion/discussion.js +++ b/common/static/common/js/discussion/discussion.js @@ -1,131 +1,222 @@ -if Backbone? - class @Discussion extends Backbone.Collection - model: Thread +/* globals Thread, DiscussionUtil, Content */ +(function() { + 'use strict'; + var __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { + for (var key in parent) { + if (__hasProp.call(parent, key)) { + child[key] = parent[key]; + } + } + function ctor() { + this.constructor = child; + } - initialize: (models, options={})-> - @pages = options['pages'] || 1 - @current_page = 1 - @sort_preference = options['sort'] - @bind "add", (item) => - item.discussion = @ - @setSortComparator(@sort_preference) - @on "thread:remove", (thread) => - @remove(thread) + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + child.__super__ = parent.prototype; + return child; + }; - find: (id) -> - _.first @where(id: id) + if (typeof Backbone !== "undefined" && Backbone !== null) { + this.Discussion = (function(_super) { - hasMorePages: -> - @current_page < @pages + __extends(Discussion, _super); - setSortComparator: (sortBy) -> - switch sortBy - when 'activity' then @comparator = @sortByDateRecentFirst - when 'votes' then @comparator = @sortByVotes - when 'comments' then @comparator = @sortByComments + function Discussion() { + return Discussion.__super__.constructor.apply(this, arguments); + } - addThread: (thread, options) -> - # TODO: Check for existing thread with same ID in a faster way - if not @find(thread.id) - options ||= {} - model = new Thread thread - @add model - model + Discussion.prototype.model = Thread; - retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)-> - data = { page: @current_page + 1 } - if _.contains(["unread", "unanswered", "flagged"], options.filter) - data[options.filter] = true - switch mode - when 'search' - url = DiscussionUtil.urlFor 'search' - data['text'] = options.search_text - when 'commentables' - url = DiscussionUtil.urlFor 'search' - data['commentable_ids'] = options.commentable_ids - when 'all' - url = DiscussionUtil.urlFor 'threads' - when 'followed' - url = DiscussionUtil.urlFor 'followed_threads', options.user_id - if options['group_id'] - data['group_id'] = options['group_id'] - data['sort_key'] = sort_options.sort_key || 'activity' - data['sort_order'] = sort_options.sort_order || 'desc' - DiscussionUtil.safeAjax - $elem: @$el - url: url - data: data - dataType: 'json' - success: (response, textStatus) => - models = @models - new_threads = [new Thread(data) for data in response.discussion_data][0] - new_collection = _.union(models, new_threads) - Content.loadContentInfos(response.annotated_content_info) - @pages = response.num_pages - @current_page = response.page - @reset new_collection - error: error + Discussion.prototype.initialize = function(models, options) { + var self = this; + if (!options) { + options = {}; + } + this.pages = options.pages || 1; + this.current_page = 1; + this.sort_preference = options.sort; + this.bind("add", function(item) { + item.discussion = self; + }); + this.setSortComparator(this.sort_preference); + return this.on("thread:remove", function(thread) { + self.remove(thread); + }); + }; - sortByDate: (thread) -> - # - # The comment client asks each thread for a value by which to sort the collection - # and calls this sort routine regardless of the order returned from the LMS/comments service - # so, this takes advantage of this per-thread value and returns tomorrow's date - # for pinned threads, ensuring that they appear first, (which is the intent of pinned threads) - # - @pinnedThreadsSortComparatorWithDate(thread, true) + Discussion.prototype.find = function(id) { + return _.first(this.where({ + id: id + })); + }; + Discussion.prototype.hasMorePages = function() { + return this.current_page < this.pages; + }; - sortByDateRecentFirst: (thread) -> - # - # Same as above - # but negative to flip the order (newest first) - # - @pinnedThreadsSortComparatorWithDate(thread, false) - #return String.fromCharCode.apply(String, - # _.map(thread.get("created_at").split(""), - # ((c) -> return 0xffff - c.charChodeAt())) - #) + Discussion.prototype.setSortComparator = function(sortBy) { + switch (sortBy) { + case 'activity': + this.comparator = this.sortByDateRecentFirst; + break; + case 'votes': + this.comparator = this.sortByVotes; + break; + case 'comments': + this.comparator = this.sortByComments; + break; + } + }; - sortByVotes: (thread1, thread2) -> - thread1_count = parseInt(thread1.get("votes")['up_count']) - thread2_count = parseInt(thread2.get("votes")['up_count']) - @pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count) + Discussion.prototype.addThread = function(thread) { + var model; + if (!this.find(thread.id)) { + model = new Thread(thread); + this.add(model); + return model; + } + }; - sortByComments: (thread1, thread2) -> - thread1_count = parseInt(thread1.get("comments_count")) - thread2_count = parseInt(thread2.get("comments_count")) - @pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count) + Discussion.prototype.retrieveAnotherPage = function(mode, options, sort_options, error) { + var data, url, + self = this; + if (options === null) { + options = {}; + } + if (sort_options === null) { + sort_options = {}; + } + data = { + page: this.current_page + 1 + }; + if (_.contains(["unread", "unanswered", "flagged"], options.filter)) { + data[options.filter] = true; + } + switch (mode) { + case 'search': + url = DiscussionUtil.urlFor('search'); + data.text = options.search_text; + break; + case 'commentables': + url = DiscussionUtil.urlFor('search'); + data.commentable_ids = options.commentable_ids; + break; + case 'all': + url = DiscussionUtil.urlFor('threads'); + break; + case 'followed': + url = DiscussionUtil.urlFor('followed_threads', options.user_id); + } + if (options.group_id) { + data.group_id = options.group_id; + } + data.sort_key = sort_options.sort_key || 'activity'; + data.sort_order = sort_options.sort_order || 'desc'; + return DiscussionUtil.safeAjax({ + $elem: this.$el, + url: url, + data: data, + dataType: 'json', + success: function(response) { + var models, new_collection, new_threads; + models = self.models; + new_threads = [ + (function() { + var _i, _len, _ref, _results; + _ref = response.discussion_data; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + data = _ref[_i]; + _results.push(new Thread(data)); + } + return _results; + })() + ][0]; + new_collection = _.union(models, new_threads); + Content.loadContentInfos(response.annotated_content_info); + self.pages = response.num_pages; + self.current_page = response.page; + return self.reset(new_collection); + }, + error: error + }); + }; - pinnedThreadsSortComparatorWithCount: (thread1, thread2, thread1_count, thread2_count) -> - # if threads are pinned they should be displayed on top. - # Unpinned will be sorted by their property count - if thread1.get('pinned') and not thread2.get('pinned') - -1 - else if thread2.get('pinned') and not thread1.get('pinned') - 1 - else - if thread1_count > thread2_count - -1 - else if thread2_count > thread1_count - 1 - else - if thread1.created_at_time() > thread2.created_at_time() - -1 - else - 1 + Discussion.prototype.sortByDate = function(thread) { + /* + The comment client asks each thread for a value by which to sort the collection + and calls this sort routine regardless of the order returned from the LMS/comments service + so, this takes advantage of this per-thread value and returns tomorrow's date + for pinned threads, ensuring that they appear first, (which is the intent of pinned threads) + */ + return this.pinnedThreadsSortComparatorWithDate(thread, true); + }; - pinnedThreadsSortComparatorWithDate: (thread, ascending)-> - # if threads are pinned they should be displayed on top. - # Unpinned will be sorted by their last activity date - threadLastActivityAtTime = new Date(thread.get("last_activity_at")).getTime() - if thread.get('pinned') - #use tomorrow's date - today = new Date(); - preferredDate = new Date(today.getTime() + (24 * 60 * 60 * 1000) + threadLastActivityAtTime); - else - preferredDate = threadLastActivityAtTime - if ascending - preferredDate - else - -(preferredDate) + Discussion.prototype.sortByDateRecentFirst = function(thread) { + /* + Same as above + but negative to flip the order (newest first) + */ + return this.pinnedThreadsSortComparatorWithDate(thread, false); + }; + + Discussion.prototype.sortByVotes = function(thread1, thread2) { + var thread1_count, thread2_count; + thread1_count = parseInt(thread1.get("votes").up_count); + thread2_count = parseInt(thread2.get("votes").up_count); + return this.pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count); + }; + + Discussion.prototype.sortByComments = function(thread1, thread2) { + var thread1_count, thread2_count; + thread1_count = parseInt(thread1.get("comments_count")); + thread2_count = parseInt(thread2.get("comments_count")); + return this.pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count); + }; + + Discussion.prototype.pinnedThreadsSortComparatorWithCount = function( + thread1, thread2, thread1_count, thread2_count + ) { + if (thread1.get('pinned') && !thread2.get('pinned')) { + return -1; + } else if (thread2.get('pinned') && !thread1.get('pinned')) { + return 1; + } else { + if (thread1_count > thread2_count) { + return -1; + } else if (thread2_count > thread1_count) { + return 1; + } else { + if (thread1.created_at_time() > thread2.created_at_time()) { + return -1; + } else { + return 1; + } + } + } + }; + + Discussion.prototype.pinnedThreadsSortComparatorWithDate = function(thread, ascending) { + var preferredDate, threadLastActivityAtTime, today; + threadLastActivityAtTime = new Date(thread.get("last_activity_at")).getTime(); + if (thread.get('pinned')) { + today = new Date(); + preferredDate = new Date(today.getTime() + (24 * 60 * 60 * 1000) + threadLastActivityAtTime); + } else { + preferredDate = threadLastActivityAtTime; + } + if (ascending) { + return preferredDate; + } else { + return -preferredDate; + } + }; + + return Discussion; + + })(Backbone.Collection); + } + +}).call(window); diff --git a/common/static/common/js/discussion/discussion_module_view.js b/common/static/common/js/discussion/discussion_module_view.js index 198d48d34c..7ee44fa18e 100644 --- a/common/static/common/js/discussion/discussion_module_view.js +++ b/common/static/common/js/discussion/discussion_module_view.js @@ -1,173 +1,266 @@ -if Backbone? - class @DiscussionModuleView extends Backbone.View - events: - "click .discussion-show": "toggleDiscussion" - "keydown .discussion-show": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleDiscussion) - "click .new-post-btn": "toggleNewPost" - "keydown .new-post-btn": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost) - "click .discussion-paginator a": "navigateToPage" +/* globals Discussion, DiscussionUtil, DiscussionUser, DiscussionCourseSettings, DiscussionThreadView, Content, +NewPostView */ +(function() { + 'use strict'; + var __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { + for (var key in parent) { + if (__hasProp.call(parent, key)) { + child[key] = parent[key]; + } + } + function ctor() { + this.constructor = child; + } - page_re: /\?discussion_page=(\d+)/ - initialize: (options) -> - @toggleDiscussionBtn = @$(".discussion-show") - # Set the page if it was set in the URL. This is used to allow deep linking to pages - match = @page_re.exec(window.location.href) - @context = options.context or "course" # allowed values are "course" or "standalone" - if match - @page = parseInt(match[1]) - else - @page = 1 + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + child.__super__ = parent.prototype; + return child; + }; - toggleNewPost: (event) => - event.preventDefault() - if !@newPostForm - @toggleDiscussion() - @isWaitingOnNewPost = true; - return - if @showed - @newPostForm.slideDown(300) - else - @newPostForm.show().focus() - @toggleDiscussionBtn.addClass('shown') - @toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion")) - @$("section.discussion").slideDown() - @showed = true + if (typeof Backbone !== "undefined" && Backbone !== null) { + this.DiscussionModuleView = (function(_super) { - hideNewPost: => - @newPostForm.slideUp(300) + __extends(DiscussionModuleView, _super); - hideDiscussion: => - @$("section.discussion").slideUp() - @toggleDiscussionBtn.removeClass('shown') - @toggleDiscussionBtn.find('.button-text').html(gettext("Show Discussion")) - @showed = false + function DiscussionModuleView() { + var self = this; + this.navigateToPage = function() { + return DiscussionModuleView.prototype.navigateToPage.apply(self, arguments); + }; + this.renderPagination = function() { + return DiscussionModuleView.prototype.renderPagination.apply(self, arguments); + }; + this.addThread = function() { + return DiscussionModuleView.prototype.addThread.apply(self, arguments); + }; + this.renderDiscussion = function() { + return DiscussionModuleView.prototype.renderDiscussion.apply(self, arguments); + }; + this.loadPage = function() { + return DiscussionModuleView.prototype.loadPage.apply(self, arguments); + }; + this.toggleDiscussion = function() { + return DiscussionModuleView.prototype.toggleDiscussion.apply(self, arguments); + }; + this.hideDiscussion = function() { + return DiscussionModuleView.prototype.hideDiscussion.apply(self, arguments); + }; + this.hideNewPost = function() { + return DiscussionModuleView.prototype.hideNewPost.apply(self, arguments); + }; + this.toggleNewPost = function() { + return DiscussionModuleView.prototype.toggleNewPost.apply(self, arguments); + }; + return DiscussionModuleView.__super__.constructor.apply(this, arguments); + } - toggleDiscussion: (event) => - if @showed - @hideDiscussion() - else - @toggleDiscussionBtn.addClass('shown') - @toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion")) + DiscussionModuleView.prototype.events = { + "click .discussion-show": "toggleDiscussion", + "keydown .discussion-show": function(event) { + return DiscussionUtil.activateOnSpace(event, this.toggleDiscussion); + }, + "click .new-post-btn": "toggleNewPost", + "keydown .new-post-btn": function(event) { + return DiscussionUtil.activateOnSpace(event, this.toggleNewPost); + }, + "click .discussion-paginator a": "navigateToPage" + }; - if @retrieved - @$("section.discussion").slideDown() - @showed = true - else - $elem = @toggleDiscussionBtn - @loadPage( - $elem, - => - @hideDiscussion() - DiscussionUtil.discussionAlert( - gettext("Sorry"), - gettext("We had some trouble loading the discussion. Please try again.") - ) - ) + DiscussionModuleView.prototype.page_re = /\?discussion_page=(\d+)/; - loadPage: ($elem, error) => - discussionId = @$el.data("discussion-id") - url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}" - DiscussionUtil.safeAjax - $elem: $elem - $loading: $elem - takeFocus: true - url: url - type: "GET" - dataType: 'json' - success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId) - error: error + DiscussionModuleView.prototype.initialize = function(options) { + var match; + this.toggleDiscussionBtn = this.$(".discussion-show"); + match = this.page_re.exec(window.location.href); + this.context = options.context || "course"; + if (match) { + this.page = parseInt(match[1]); + } else { + this.page = 1; + } + }; - renderDiscussion: ($elem, response, textStatus, discussionId) => - $elem.focus() - user = new DiscussionUser(response.user_info) - window.user = user - DiscussionUtil.setUser(user) - Content.loadContentInfos(response.annotated_content_info) - DiscussionUtil.loadRoles(response.roles) + DiscussionModuleView.prototype.toggleNewPost = function(event) { + event.preventDefault(); + if (!this.newPostForm) { + this.toggleDiscussion(); + this.isWaitingOnNewPost = true; + return; + } + if (this.showed) { + this.newPostForm.slideDown(300); + } else { + this.newPostForm.show().focus(); + } + this.toggleDiscussionBtn.addClass('shown'); + this.toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion")); + this.$("section.discussion").slideDown(); + this.showed = true; + }; - @course_settings = new DiscussionCourseSettings(response.course_settings) - @discussion = new Discussion() - @discussion.reset(response.discussion_data, {silent: false}) + DiscussionModuleView.prototype.hideNewPost = function() { + return this.newPostForm.slideUp(300); + }; - $discussion = _.template($("#inline-discussion-template").html())( - 'threads': response.discussion_data, - 'discussionId': discussionId - ) - if @$('section.discussion').length - @$('section.discussion').replaceWith($discussion) - else - @$el.append($discussion) + DiscussionModuleView.prototype.hideDiscussion = function() { + this.$("section.discussion").slideUp(); + this.toggleDiscussionBtn.removeClass('shown'); + this.toggleDiscussionBtn.find('.button-text').html(gettext("Show Discussion")); + this.showed = false; + }; - @newPostForm = this.$el.find('.new-post-article') - @threadviews = @discussion.map (thread) => - view = new DiscussionThreadView( - el: @$("article#thread_#{thread.id}"), - model: thread, - mode: "inline", - context: @context, - course_settings: @course_settings, - topicId: discussionId - ) - thread.on "thread:thread_type_updated", -> - view.rerender() - view.expand() - return view - _.each @threadviews, (dtv) -> dtv.render() - DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info) - @newPostView = new NewPostView( - el: @newPostForm, - collection: @discussion, - course_settings: @course_settings, - topicId: discussionId, - is_commentable_cohorted: response.is_commentable_cohorted - ) - @newPostView.render() - @listenTo( @newPostView, 'newPost:cancel', @hideNewPost ) - @discussion.on "add", @addThread + DiscussionModuleView.prototype.toggleDiscussion = function() { + var $elem, + self = this; + if (this.showed) { + return this.hideDiscussion(); + } else { + this.toggleDiscussionBtn.addClass('shown'); + this.toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion")); + if (this.retrieved) { + this.$("section.discussion").slideDown(); + this.showed = true; + } else { + $elem = this.toggleDiscussionBtn; + return this.loadPage($elem, function() { + self.hideDiscussion(); + return DiscussionUtil.discussionAlert( + gettext("Sorry"), + gettext("We had some trouble loading the discussion. Please try again.") + ); + }); + } + } + }; - @retrieved = true - @showed = true - @renderPagination(response.num_pages) + DiscussionModuleView.prototype.loadPage = function($elem, error) { + var discussionId, url, + self = this; + discussionId = this.$el.data("discussion-id"); + url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + ("?page=" + this.page); + return DiscussionUtil.safeAjax({ + $elem: $elem, + $loading: $elem, + takeFocus: true, + url: url, + type: "GET", + dataType: 'json', + success: function(response, textStatus) { + return self.renderDiscussion($elem, response, textStatus, discussionId); + }, + error: error + }); + }; - if @isWaitingOnNewPost - @newPostForm.show().focus() + DiscussionModuleView.prototype.renderDiscussion = function($elem, response, textStatus, discussionId) { + var $discussion, user, + self = this; + $elem.focus(); + user = new DiscussionUser(response.user_info); + window.user = user; + DiscussionUtil.setUser(user); + Content.loadContentInfos(response.annotated_content_info); + DiscussionUtil.loadRoles(response.roles); + this.course_settings = new DiscussionCourseSettings(response.course_settings); + this.discussion = new Discussion(); + this.discussion.reset(response.discussion_data, { + silent: false + }); + $discussion = _.template($("#inline-discussion-template").html())({ + 'threads': response.discussion_data, + 'discussionId': discussionId + }); + if (this.$('section.discussion').length) { + this.$('section.discussion').replaceWith($discussion); + } else { + this.$el.append($discussion); + } + this.newPostForm = this.$el.find('.new-post-article'); + this.threadviews = this.discussion.map(function(thread) { + var view; + view = new DiscussionThreadView({ + el: self.$("article#thread_" + thread.id), + model: thread, + mode: "inline", + context: self.context, + course_settings: self.course_settings, + topicId: discussionId + }); + thread.on("thread:thread_type_updated", function() { + view.rerender(); + return view.expand(); + }); + return view; + }); + _.each(this.threadviews, function(dtv) { + return dtv.render(); + }); + DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info); + this.newPostView = new NewPostView({ + el: this.newPostForm, + collection: this.discussion, + course_settings: this.course_settings, + topicId: discussionId, + is_commentable_cohorted: response.is_commentable_cohorted + }); + this.newPostView.render(); + this.listenTo(this.newPostView, 'newPost:cancel', this.hideNewPost); + this.discussion.on("add", this.addThread); + this.retrieved = true; + this.showed = true; + this.renderPagination(response.num_pages); + if (this.isWaitingOnNewPost) { + return this.newPostForm.show().focus(); + } + }; - addThread: (thread, collection, options) => - # TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1? - article = $("
") - @$('section.discussion > .threads').prepend(article) + DiscussionModuleView.prototype.addThread = function(thread) { + var article, threadView; + article = $("
"); + this.$('section.discussion > .threads').prepend(article); + threadView = new DiscussionThreadView({ + el: article, + model: thread, + mode: "inline", + context: this.context, + course_settings: this.course_settings, + topicId: this.$el.data("discussion-id") + }); + threadView.render(); + return this.threadviews.unshift(threadView); + }; - threadView = new DiscussionThreadView( - el: article, - model: thread, - mode: "inline", - context: @context, - course_settings: @course_settings, - topicId: @$el.data("discussion-id") - ) - threadView.render() - @threadviews.unshift threadView + DiscussionModuleView.prototype.renderPagination = function(numPages) { + var pageUrl, pagination, params; + pageUrl = function(number) { + return "?discussion_page=" + number; + }; + params = DiscussionUtil.getPaginationParams(this.page, numPages, pageUrl); + pagination = _.template($("#pagination-template").html())(params); + return this.$('section.discussion-pagination').html(pagination); + }; - renderPagination: (numPages) => - pageUrl = (number) -> - "?discussion_page=#{number}" - params = DiscussionUtil.getPaginationParams(@page, numPages, pageUrl) - pagination = _.template($("#pagination-template").html())(params) - @$('section.discussion-pagination').html(pagination) + DiscussionModuleView.prototype.navigateToPage = function(event) { + var currPage, + self = this; + event.preventDefault(); + window.history.pushState({}, window.document.title, event.target.href); + currPage = this.page; + this.page = $(event.target).data('page-number'); + return this.loadPage($(event.target), function() { + self.page = currPage; + DiscussionUtil.discussionAlert( + gettext("Sorry"), + gettext("We had some trouble loading the threads you requested. Please try again.") + ); + }); + }; - navigateToPage: (event) => - event.preventDefault() - window.history.pushState({}, window.document.title, event.target.href) - currPage = @page - @page = $(event.target).data('page-number') - @loadPage( - $(event.target), - => - @page = currPage - DiscussionUtil.discussionAlert( - gettext("Sorry"), - gettext("We had some trouble loading the threads you requested. Please try again.") - ) - ) + return DiscussionModuleView; + + })(Backbone.View); + } + +}).call(window); diff --git a/common/static/common/js/discussion/discussion_router.js b/common/static/common/js/discussion/discussion_router.js index 5b90925b67..14ce7a6bf4 100644 --- a/common/static/common/js/discussion/discussion_router.js +++ b/common/static/common/js/discussion/discussion_router.js @@ -1,90 +1,167 @@ -if Backbone? - class @DiscussionRouter extends Backbone.Router - routes: - "": "allThreads" - ":forum_name/threads/:thread_id" : "showThread" +/* globals DiscussionThreadListView, DiscussionThreadView, DiscussionUtil, NewPostView */ +(function() { + 'use strict'; + var __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { + for (var key in parent) { + if (__hasProp.call(parent, key)) { + child[key] = parent[key]; + } + } + function ctor() { + this.constructor = child; + } - initialize: (options) -> - @discussion = options['discussion'] - @course_settings = options['course_settings'] + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + child.__super__ = parent.prototype; + return child; + }; - @nav = new DiscussionThreadListView( - collection: @discussion, - el: $(".forum-nav"), - courseSettings: @course_settings - ) - @nav.on "thread:selected", @navigateToThread - @nav.on "thread:removed", @navigateToAllThreads - @nav.on "threads:rendered", @setActiveThread - @nav.on "thread:created", @navigateToThread - @nav.render() + if (typeof Backbone !== "undefined" && Backbone !== null) { + this.DiscussionRouter = (function(_super) { - @newPost = $('.new-post-article') - @newPostView = new NewPostView( - el: @newPost, - collection: @discussion, - course_settings: @course_settings, - mode: "tab" - ) - @newPostView.render() - @listenTo( @newPostView, 'newPost:cancel', @hideNewPost ) - $('.new-post-btn').bind "click", @showNewPost - $('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost) + __extends(DiscussionRouter, _super); - allThreads: -> - @nav.updateSidebar() - @nav.goHome() + function DiscussionRouter() { + var self = this; + this.hideNewPost = function() { + return DiscussionRouter.prototype.hideNewPost.apply(self, arguments); + }; + this.showNewPost = function() { + return DiscussionRouter.prototype.showNewPost.apply(self, arguments); + }; + this.navigateToAllThreads = function() { + return DiscussionRouter.prototype.navigateToAllThreads.apply(self, arguments); + }; + this.navigateToThread = function() { + return DiscussionRouter.prototype.navigateToThread.apply(self, arguments); + }; + this.showMain = function() { + return DiscussionRouter.prototype.showMain.apply(self, arguments); + }; + this.setActiveThread = function() { + return DiscussionRouter.prototype.setActiveThread.apply(self, arguments); + }; + return DiscussionRouter.__super__.constructor.apply(this, arguments); + } - setActiveThread: => - if @thread - @nav.setActiveThread(@thread.get("id")) - else - @nav.goHome + DiscussionRouter.prototype.routes = { + "": "allThreads", + ":forum_name/threads/:thread_id": "showThread" + }; - showThread: (forum_name, thread_id) -> - @thread = @discussion.get(thread_id) - @thread.set("unread_comments_count", 0) - @thread.set("read", true) - @setActiveThread() - @showMain() + DiscussionRouter.prototype.initialize = function(options) { + var self = this; + this.discussion = options.discussion; + this.course_settings = options.course_settings; + this.nav = new DiscussionThreadListView({ + collection: this.discussion, + el: $(".forum-nav"), + courseSettings: this.course_settings + }); + this.nav.on("thread:selected", this.navigateToThread); + this.nav.on("thread:removed", this.navigateToAllThreads); + this.nav.on("threads:rendered", this.setActiveThread); + this.nav.on("thread:created", this.navigateToThread); + this.nav.render(); + this.newPost = $('.new-post-article'); + this.newPostView = new NewPostView({ + el: this.newPost, + collection: this.discussion, + course_settings: this.course_settings, + mode: "tab" + }); + this.newPostView.render(); + this.listenTo(this.newPostView, 'newPost:cancel', this.hideNewPost); + $('.new-post-btn').bind("click", this.showNewPost); + return $('.new-post-btn').bind("keydown", function(event) { + return DiscussionUtil.activateOnSpace(event, self.showNewPost); + }); + }; - showMain: => - if(@main) - @main.cleanup() - @main.undelegateEvents() - unless($(".forum-content").is(":visible")) - $(".forum-content").fadeIn() - if(@newPost.is(":visible")) - @newPost.fadeOut() + DiscussionRouter.prototype.allThreads = function() { + this.nav.updateSidebar(); + return this.nav.goHome(); + }; - @main = new DiscussionThreadView( - el: $(".forum-content"), - model: @thread, - mode: "tab", - course_settings: @course_settings, - ) - @main.render() - @main.on "thread:responses:rendered", => - @nav.updateSidebar() - @thread.on "thread:thread_type_updated", @showMain + DiscussionRouter.prototype.setActiveThread = function() { + if (this.thread) { + return this.nav.setActiveThread(this.thread.get("id")); + } else { + return this.nav.goHome; + } + }; - navigateToThread: (thread_id) => - thread = @discussion.get(thread_id) - @navigate("#{thread.get("commentable_id")}/threads/#{thread_id}", trigger: true) + DiscussionRouter.prototype.showThread = function(forum_name, thread_id) { + this.thread = this.discussion.get(thread_id); + this.thread.set("unread_comments_count", 0); + this.thread.set("read", true); + this.setActiveThread(); + return this.showMain(); + }; - navigateToAllThreads: => - @navigate("", trigger: true) + DiscussionRouter.prototype.showMain = function() { + var self = this; + if (this.main) { + this.main.cleanup(); + this.main.undelegateEvents(); + } + if (!($(".forum-content").is(":visible"))) { + $(".forum-content").fadeIn(); + } + if (this.newPost.is(":visible")) { + this.newPost.fadeOut(); + } + this.main = new DiscussionThreadView({ + el: $(".forum-content"), + model: this.thread, + mode: "tab", + course_settings: this.course_settings + }); + this.main.render(); + this.main.on("thread:responses:rendered", function() { + return self.nav.updateSidebar(); + }); + return this.thread.on("thread:thread_type_updated", this.showMain); + }; - showNewPost: (event) => - $('.forum-content').fadeOut( - duration: 200 - complete: => - @newPost.fadeIn(200).focus() - ) + DiscussionRouter.prototype.navigateToThread = function(thread_id) { + var thread; + thread = this.discussion.get(thread_id); + return this.navigate("" + (thread.get("commentable_id")) + "/threads/" + thread_id, { + trigger: true + }); + }; - hideNewPost: => - @newPost.fadeOut( - duration: 200 - complete: => - $('.forum-content').fadeIn(200).find('.thread-wrapper').focus() - ) + DiscussionRouter.prototype.navigateToAllThreads = function() { + return this.navigate("", { + trigger: true + }); + }; + + DiscussionRouter.prototype.showNewPost = function() { + var self = this; + return $('.forum-content').fadeOut({ + duration: 200, + complete: function() { + return self.newPost.fadeIn(200).focus(); + } + }); + }; + + DiscussionRouter.prototype.hideNewPost = function() { + return this.newPost.fadeOut({ + duration: 200, + complete: function() { + return $('.forum-content').fadeIn(200).find('.thread-wrapper').focus(); + } + }); + }; + + return DiscussionRouter; + + })(Backbone.Router); + } + +}).call(window); diff --git a/common/static/common/js/discussion/main.js b/common/static/common/js/discussion/main.js index 5c663ffa21..866a3491d1 100644 --- a/common/static/common/js/discussion/main.js +++ b/common/static/common/js/discussion/main.js @@ -1,38 +1,76 @@ -if Backbone? - DiscussionApp = - start: (elem)-> - # TODO: Perhaps eliminate usage of global variables when possible - DiscussionUtil.loadRolesFromContainer() - element = $(elem) - window.$$course_id = element.data("course-id") - window.courseName = element.data("course-name") - user_info = element.data("user-info") - sort_preference = element.data("sort-preference") - threads = element.data("threads") - thread_pages = element.data("thread-pages") - content_info = element.data("content-info") - user = new DiscussionUser(user_info) - DiscussionUtil.setUser(user) - window.user = user - Content.loadContentInfos(content_info) - discussion = new Discussion(threads, {pages: thread_pages, sort: sort_preference}) - course_settings = new DiscussionCourseSettings(element.data("course-settings")) - new DiscussionRouter({discussion: discussion, course_settings: course_settings}) - Backbone.history.start({pushState: true, root: "/courses/#{$$course_id}/discussion/forum/"}) - DiscussionProfileApp = - start: (elem) -> - # Roles are not included in user profile page, but they are not used for anything - DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []}) - element = $(elem) - window.$$course_id = element.data("course-id") - threads = element.data("threads") - user_info = element.data("user-info") - window.user = new DiscussionUser(user_info) - page = element.data("page") - numPages = element.data("num-pages") - new DiscussionUserProfileView(el: element, collection: threads, page: page, numPages: numPages) - $ -> - $("section.discussion").each (index, elem) -> - DiscussionApp.start(elem) - $("section.discussion-user-threads").each (index, elem) -> - DiscussionProfileApp.start(elem) +/* global $$course_id, Content, Discussion, DiscussionRouter, DiscussionCourseSettings, + DiscussionUser, DiscussionUserProfileView, DiscussionUtil */ +(function() { + 'use strict'; + var DiscussionApp, DiscussionProfileApp; + + if (typeof Backbone !== "undefined" && Backbone !== null) { + DiscussionApp = { + start: function(elem) { + var content_info, course_settings, discussion, element, sort_preference, thread_pages, threads, + user, user_info; + DiscussionUtil.loadRolesFromContainer(); + element = $(elem); + window.$$course_id = element.data("course-id"); + window.courseName = element.data("course-name"); + user_info = element.data("user-info"); + sort_preference = element.data("sort-preference"); + threads = element.data("threads"); + thread_pages = element.data("thread-pages"); + content_info = element.data("content-info"); + user = new DiscussionUser(user_info); + DiscussionUtil.setUser(user); + window.user = user; + Content.loadContentInfos(content_info); + discussion = new Discussion(threads, { + pages: thread_pages, + sort: sort_preference + }); + course_settings = new DiscussionCourseSettings(element.data("course-settings")); + // suppressing Do not use 'new' for side effects. + /* jshint -W031*/ + new DiscussionRouter({ + discussion: discussion, + course_settings: course_settings + }); + /* jshint +W031*/ + return Backbone.history.start({ + pushState: true, + root: "/courses/" + $$course_id + "/discussion/forum/" + }); + } + }; + DiscussionProfileApp = { + start: function(elem) { + var element, numPages, page, threads, user_info; + DiscussionUtil.loadRoles({ + "Moderator": [], + "Administrator": [], + "Community TA": [] + }); + element = $(elem); + window.$$course_id = element.data("course-id"); + threads = element.data("threads"); + user_info = element.data("user-info"); + window.user = new DiscussionUser(user_info); + page = element.data("page"); + numPages = element.data("num-pages"); + return new DiscussionUserProfileView({ + el: element, + collection: threads, + page: page, + numPages: numPages + }); + } + }; + $(function() { + $("section.discussion").each(function(index, elem) { + return DiscussionApp.start(elem); + }); + return $("section.discussion-user-threads").each(function(index, elem) { + return DiscussionProfileApp.start(elem); + }); + }); + } + +}).call(window); diff --git a/common/static/common/js/discussion/models/discussion_course_settings.js b/common/static/common/js/discussion/models/discussion_course_settings.js index 97275a639b..931fe9832d 100644 --- a/common/static/common/js/discussion/models/discussion_course_settings.js +++ b/common/static/common/js/discussion/models/discussion_course_settings.js @@ -1,2 +1,34 @@ -if Backbone? - class @DiscussionCourseSettings extends Backbone.Model +(function() { + 'use strict'; + var __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { + for (var key in parent) { + if (__hasProp.call(parent, key)) { + child[key] = parent[key]; + } + } + function ctor() { + this.constructor = child; + } + + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + child.__super__ = parent.prototype; + return child; + }; + + if (typeof Backbone !== "undefined" && Backbone !== null) { + this.DiscussionCourseSettings = (function(_super) { + + __extends(DiscussionCourseSettings, _super); + + function DiscussionCourseSettings() { + return DiscussionCourseSettings.__super__.constructor.apply(this, arguments); + } + + return DiscussionCourseSettings; + + })(Backbone.Model); + } + +}).call(this); diff --git a/common/static/common/js/discussion/models/discussion_user.js b/common/static/common/js/discussion/models/discussion_user.js index 892727c523..6f1a849f6b 100644 --- a/common/static/common/js/discussion/models/discussion_user.js +++ b/common/static/common/js/discussion/models/discussion_user.js @@ -1,15 +1,52 @@ -if Backbone? - class @DiscussionUser extends Backbone.Model - following: (thread) -> - _.include(@get('subscribed_thread_ids'), thread.id) +(function() { + 'use strict'; + var __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { + for (var key in parent) { + if (__hasProp.call(parent, key)) { + child[key] = parent[key]; + } + } + function ctor() { + this.constructor = child; + } - voted: (thread) -> - _.include(@get('upvoted_ids'), thread.id) + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + child.__super__ = parent.prototype; + return child; + }; - vote: (thread) -> - @get('upvoted_ids').push(thread.id) - thread.vote() + if (typeof Backbone !== "undefined" && Backbone !== null) { + this.DiscussionUser = (function(_super) { - unvote: (thread) -> - @set('upvoted_ids', _.without(@get('upvoted_ids'), thread.id)) - thread.unvote() + __extends(DiscussionUser, _super); + + function DiscussionUser() { + return DiscussionUser.__super__.constructor.apply(this, arguments); + } + + DiscussionUser.prototype.following = function(thread) { + return _.include(this.get('subscribed_thread_ids'), thread.id); + }; + + DiscussionUser.prototype.voted = function(thread) { + return _.include(this.get('upvoted_ids'), thread.id); + }; + + DiscussionUser.prototype.vote = function(thread) { + this.get('upvoted_ids').push(thread.id); + return thread.vote(); + }; + + DiscussionUser.prototype.unvote = function(thread) { + this.set('upvoted_ids', _.without(this.get('upvoted_ids'), thread.id)); + return thread.unvote(); + }; + + return DiscussionUser; + + })(Backbone.Model); + } + +}).call(this); diff --git a/common/static/common/js/discussion/utils.js b/common/static/common/js/discussion/utils.js index 06f1667e13..280bfa7512 100644 --- a/common/static/common/js/discussion/utils.js +++ b/common/static/common/js/discussion/utils.js @@ -1,346 +1,489 @@ -class @DiscussionUtil +/* globals $$course_id, Content, Markdown, URI */ +(function() { + 'use strict'; + this.DiscussionUtil = (function() { - @wmdEditors: {} + function DiscussionUtil() { + } - @getTemplate: (id) -> - $("script##{id}").html() + DiscussionUtil.wmdEditors = {}; - @setUser: (user) -> - @user = user + DiscussionUtil.getTemplate = function(id) { + return $("script#" + id).html(); + }; - @getUser: () -> - @user + DiscussionUtil.setUser = function(user) { + this.user = user; + }; - @loadRoles: (roles)-> - @roleIds = roles + DiscussionUtil.getUser = function() { + return this.user; + }; - @loadRolesFromContainer: -> - @loadRoles($("#discussion-container").data("roles")) + DiscussionUtil.loadRoles = function(roles) { + this.roleIds = roles; + }; - @isStaff: (user_id) -> - user_id ?= @user?.id - staff = _.union(@roleIds['Moderator'], @roleIds['Administrator']) - _.include(staff, parseInt(user_id)) + DiscussionUtil.loadRolesFromContainer = function() { + return this.loadRoles($("#discussion-container").data("roles")); + }; - @isTA: (user_id) -> - user_id ?= @user?.id - ta = _.union(@roleIds['Community TA']) - _.include(ta, parseInt(user_id)) + DiscussionUtil.isStaff = function(userId) { + var staff; + if (userId === null) { + userId = this.user ? this.user.id : void 0; + } + staff = _.union(this.roleIds.Moderator, this.roleIds.Administrator); + return _.include(staff, parseInt(userId)); + }; - @isPrivilegedUser: (user_id) -> - @isStaff(user_id) || @isTA(user_id) + DiscussionUtil.isTA = function(userId) { + var ta; + if (userId === null) { + userId = this.user ? this.user.id : void 0; + } + ta = _.union(this.roleIds['Community TA']); + return _.include(ta, parseInt(userId)); + }; - @bulkUpdateContentInfo: (infos) -> - for id, info of infos - Content.getContent(id).updateInfo(info) + DiscussionUtil.isPrivilegedUser = function(userId) { + return this.isStaff(userId) || this.isTA(userId); + }; - @generateDiscussionLink: (cls, txt, handler) -> - $("").addClass("discussion-link") - .attr("href", "javascript:void(0)") - .addClass(cls).html(txt) - .click -> handler(this) + DiscussionUtil.bulkUpdateContentInfo = function(infos) { + var id, info, _results; + _results = []; + for (id in infos) { + if (infos.hasOwnProperty(id)) { + info = infos[id]; + _results.push(Content.getContent(id).updateInfo(info)); + } + } + return _results; + }; - @urlFor: (name, param, param1, param2) -> - { - follow_discussion : "/courses/#{$$course_id}/discussion/#{param}/follow" - unfollow_discussion : "/courses/#{$$course_id}/discussion/#{param}/unfollow" - create_thread : "/courses/#{$$course_id}/discussion/#{param}/threads/create" - update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update" - create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply" - delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete" - flagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/flagAbuse" - unFlagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unFlagAbuse" - flagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/flagAbuse" - unFlagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unFlagAbuse" - upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote" - downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote" - pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin" - un_pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unpin" - undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote" - follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow" - unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow" - update_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/update" - endorse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/endorse" - create_sub_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/reply" - delete_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/delete" - upvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/upvote" - downvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/downvote" - undo_vote_for_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unvote" - upload : "/courses/#{$$course_id}/discussion/upload" - users : "/courses/#{$$course_id}/discussion/users" - search : "/courses/#{$$course_id}/discussion/forum/search" - retrieve_discussion : "/courses/#{$$course_id}/discussion/forum/#{param}/inline" - retrieve_single_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}" - openclose_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/close" - permanent_link_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}" - permanent_link_comment : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}##{param2}" - user_profile : "/courses/#{$$course_id}/discussion/forum/users/#{param}" - followed_threads : "/courses/#{$$course_id}/discussion/forum/users/#{param}/followed" - threads : "/courses/#{$$course_id}/discussion/forum" - "enable_notifications" : "/notification_prefs/enable/" - "disable_notifications" : "/notification_prefs/disable/" - "notifications_status" : "/notification_prefs/status/" - }[name] + DiscussionUtil.generateDiscussionLink = function(cls, txt, handler) { + return $("") + .addClass("discussion-link").attr("href", "#") + .addClass(cls).html(txt).click(function() {return handler(this);}); + }; - @ignoreEnterKey: (event) => - if event.which == 13 - event.preventDefault() + DiscussionUtil.urlFor = function(name, param, param1, param2) { + return { + follow_discussion: "/courses/" + $$course_id + "/discussion/" + param + "/follow", + unfollow_discussion: "/courses/" + $$course_id + "/discussion/" + param + "/unfollow", + create_thread: "/courses/" + $$course_id + "/discussion/" + param + "/threads/create", + update_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/update", + create_comment: "/courses/" + $$course_id + "/discussion/threads/" + param + "/reply", + delete_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/delete", + flagAbuse_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/flagAbuse", + unFlagAbuse_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unFlagAbuse", + flagAbuse_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/flagAbuse", + unFlagAbuse_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/unFlagAbuse", + upvote_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/upvote", + downvote_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/downvote", + pin_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/pin", + un_pin_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unpin", + undo_vote_for_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unvote", + follow_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/follow", + unfollow_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unfollow", + update_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/update", + endorse_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/endorse", + create_sub_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/reply", + delete_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/delete", + upvote_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/upvote", + downvote_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/downvote", + undo_vote_for_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/unvote", + upload: "/courses/" + $$course_id + "/discussion/upload", + users: "/courses/" + $$course_id + "/discussion/users", + search: "/courses/" + $$course_id + "/discussion/forum/search", + retrieve_discussion: "/courses/" + $$course_id + "/discussion/forum/" + param + "/inline", + retrieve_single_thread: "/courses/" + $$course_id + "/discussion/forum/" + param + "/threads/" + param1, + openclose_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/close", + permanent_link_thread: "/courses/" + $$course_id + "/discussion/forum/" + param + "/threads/" + param1, + permanent_link_comment: "/courses/" + $$course_id + + "/discussion/forum/" + param + "/threads/" + param1 + "#" + param2, + user_profile: "/courses/" + $$course_id + "/discussion/forum/users/" + param, + followed_threads: "/courses/" + $$course_id + "/discussion/forum/users/" + param + "/followed", + threads: "/courses/" + $$course_id + "/discussion/forum", + "enable_notifications": "/notification_prefs/enable/", + "disable_notifications": "/notification_prefs/disable/", + "notifications_status": "/notification_prefs/status/" + }[name]; + }; - @activateOnSpace: (event, func) -> - if event.which == 32 - event.preventDefault() - func(event) + DiscussionUtil.ignoreEnterKey = function(event) { + if (event.which === 13) { + return event.preventDefault(); + } + }; - @makeFocusTrap: (elem) -> - elem.keydown( - (event) -> - if event.which == 9 # Tab - event.preventDefault() - ) + DiscussionUtil.activateOnSpace = function(event, func) { + if (event.which === 32) { + event.preventDefault(); + return func(event); + } + }; - @showLoadingIndicator: (element, takeFocus) -> - @$_loading = $("
" + gettext("Loading content") + "
") - element.after(@$_loading) - if takeFocus - @makeFocusTrap(@$_loading) - @$_loading.focus() + DiscussionUtil.makeFocusTrap = function(elem) { + return elem.keydown(function(event) { + if (event.which === 9) { + return event.preventDefault(); + } + }); + }; - @hideLoadingIndicator: () -> - @$_loading.remove() + DiscussionUtil.showLoadingIndicator = function(element, takeFocus) { + this.$_loading = $( + "
" + + gettext("Loading content") + + "
" + ); + element.after(this.$_loading); + if (takeFocus) { + this.makeFocusTrap(this.$_loading); + return this.$_loading.focus(); + } + }; - @discussionAlert: (header, body) -> - if $("#discussion-alert").length == 0 - alertDiv = $("