Merge branch 'master' into guruprasad/randomize-question-order-pr
This commit is contained in:
@@ -4,7 +4,6 @@ data_file = reports/.coverage
|
||||
source =
|
||||
cms
|
||||
common/djangoapps
|
||||
common/lib/calc
|
||||
common/lib/capa
|
||||
common/lib/xmodule
|
||||
lms
|
||||
|
||||
@@ -4,7 +4,6 @@ data_file = reports/.coverage
|
||||
source =
|
||||
cms
|
||||
common/djangoapps
|
||||
common/lib/calc
|
||||
common/lib/capa
|
||||
common/lib/xmodule
|
||||
lms
|
||||
|
||||
40
.github/CODEOWNERS
vendored
Normal file
40
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# This does not cover all the code in edx-platform but it's a good start.
|
||||
|
||||
# Core
|
||||
common/djangoapps/student/ @edx/platform-core
|
||||
common/djangoapps/third_party_auth/ @edx/platform-authn
|
||||
common/lib/xmodule/xmodule/ @edx/platform-core
|
||||
lms/djangoapps/course_api/blocks @edx/platform-core
|
||||
lms/djangoapps/courseware/ @edx/platform-core
|
||||
lms/djangoapps/grades/ @edx/platform-grades
|
||||
lms/djangoapps/instructor/ @edx/platform-core
|
||||
lms/djangoapps/instructor_task/ @edx/platform-core
|
||||
lms/djangoapps/mobile_api/ @edx/platform-mobile
|
||||
openedx/core/djangoapps/contentserver/ @edx/platform-core
|
||||
openedx/core/djangoapps/heartbeat/ @edx/platform-core
|
||||
openedx/core/djangoapps/oauth_dispatch @edx/platform-authn
|
||||
openedx/core/djangoapps/user_api/ @edx/platform-authn
|
||||
openedx/core/djangoapps/user_authn/ @edx/platform-authn
|
||||
openedx/features/course_experience/ @edx/platform-courseware
|
||||
|
||||
# Core Extensions
|
||||
common/lib/xmodule/xmodule/capa_module.py @edx/platform-core-extensions
|
||||
common/lib/xmodule/xmodule/html_module.py @edx/platform-core-extensions
|
||||
common/lib/xmodule/xmodule/video_module @edx/platform-core-extensions
|
||||
lms/djangoapps/discussion/ @edx/platform-core-extensions
|
||||
lms/djangoapps/edxnotes @edx/platform-core-extensions
|
||||
|
||||
# Analytics
|
||||
common/djangoapps/track/ @edx/edx-data-engineering
|
||||
|
||||
# Credentials
|
||||
lms/djangoapps/certificates/ @edx/platform-credentials
|
||||
|
||||
# Discovery
|
||||
common/djangoapps/course_modes/ @edx/platform-discovery
|
||||
common/djangoapps/enrollment/ @edx/platform-discovery
|
||||
lms/djangoapps/commerce/ @edx/ecommerce
|
||||
lms/djangoapps/experiments/ @edx/rev-team
|
||||
lms/djangoapps/learner_dashboard/ @edx/platform-discovery
|
||||
openedx/features/content_type_gating/ @edx/rev-team
|
||||
openedx/features/course_duration_limits/ @edx/rev-team
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -132,7 +132,7 @@ build
|
||||
/src/
|
||||
\#*\#
|
||||
.env/
|
||||
lms/lib/comment_client/python
|
||||
openedx/core/djangoapps/django_comment_common/comment_client/python
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
dist
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Script for importing courseware from XML format
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_comment_common.utils import are_permissions_roles_seeded, seed_permissions_roles
|
||||
from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded, seed_permissions_roles
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -9,7 +9,7 @@ import tempfile
|
||||
from django.core.management import call_command
|
||||
from path import Path as path
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
@@ -10,7 +10,7 @@ from pytz import UTC
|
||||
|
||||
from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer
|
||||
from contentstore.proctoring import register_special_exams
|
||||
from lms.djangoapps.grades.tasks import compute_all_grades_for_course
|
||||
from lms.djangoapps.grades.api import task_compute_all_grades_for_course
|
||||
from openedx.core.djangoapps.credit.signals import on_course_publish
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
|
||||
@@ -118,9 +118,9 @@ def handle_grading_policy_changed(sender, **kwargs):
|
||||
'event_transaction_id': unicode(get_event_transaction_id()),
|
||||
'event_transaction_type': unicode(get_event_transaction_type()),
|
||||
}
|
||||
result = compute_all_grades_for_course.apply_async(kwargs=kwargs, countdown=GRADING_POLICY_COUNTDOWN_SECONDS)
|
||||
result = task_compute_all_grades_for_course.apply_async(kwargs=kwargs, countdown=GRADING_POLICY_COUNTDOWN_SECONDS)
|
||||
log.info(u"Grades: Created {task_name}[{task_id}] with arguments {kwargs}".format(
|
||||
task_name=compute_all_grades_for_course.name,
|
||||
task_name=task_compute_all_grades_for_course.name,
|
||||
task_id=result.task_id,
|
||||
kwargs=kwargs,
|
||||
))
|
||||
|
||||
@@ -38,7 +38,7 @@ from user_tasks.tasks import UserTask
|
||||
|
||||
from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer, SearchIndexingError
|
||||
from contentstore.storage import course_import_export_storage
|
||||
from contentstore.utils import initialize_permissions, reverse_usage_url, execute_and_log_time
|
||||
from contentstore.utils import initialize_permissions, reverse_usage_url
|
||||
from contentstore.video_utils import scrape_youtube_thumbnail
|
||||
from course_action_state.models import CourseRerunState
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
@@ -458,18 +458,16 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i
|
||||
# as the Mongo modulestore doesn't support multiple runs of the same course.
|
||||
store = modulestore()
|
||||
with store.default_store('split'):
|
||||
execute_and_log_time(
|
||||
store.clone_course, source_course_key, destination_course_key, user_id, fields=fields
|
||||
)
|
||||
store.clone_course(source_course_key, destination_course_key, user_id, fields=fields)
|
||||
|
||||
# set initial permissions for the user to access the course.
|
||||
execute_and_log_time(initialize_permissions, destination_course_key, User.objects.get(id=user_id))
|
||||
initialize_permissions(destination_course_key, User.objects.get(id=user_id))
|
||||
|
||||
# update state: Succeeded
|
||||
CourseRerunState.objects.succeeded(course_key=destination_course_key)
|
||||
|
||||
# call edxval to attach videos to the rerun
|
||||
execute_and_log_time(copy_course_videos, source_course_key, destination_course_key)
|
||||
copy_course_videos(source_course_key, destination_course_key)
|
||||
|
||||
# Copy OrganizationCourse
|
||||
organization_course = OrganizationCourse.objects.filter(course_id=source_course_key_string).first()
|
||||
@@ -477,7 +475,14 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i
|
||||
if organization_course:
|
||||
clone_instance(organization_course, {'course_id': destination_course_key_string})
|
||||
|
||||
execute_and_log_time(add_course_restrictions, source_course_key, destination_course_key)
|
||||
# Copy RestrictedCourse
|
||||
restricted_course = RestrictedCourse.objects.filter(course_key=source_course_key).first()
|
||||
|
||||
if restricted_course:
|
||||
country_access_rules = CountryAccessRule.objects.filter(restricted_course=restricted_course)
|
||||
new_restricted_course = clone_instance(restricted_course, {'course_key': destination_course_key})
|
||||
for country_access_rule in country_access_rules:
|
||||
clone_instance(country_access_rule, {'restricted_course': new_restricted_course})
|
||||
|
||||
return "succeeded"
|
||||
|
||||
@@ -503,17 +508,6 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i
|
||||
return u"exception: " + text_type(exc)
|
||||
|
||||
|
||||
def add_course_restrictions(source_course_key, destination_course_key):
|
||||
"""Adds course restrictions"""
|
||||
restricted_course = RestrictedCourse.objects.filter(course_key=source_course_key).first()
|
||||
|
||||
if restricted_course:
|
||||
country_access_rules = CountryAccessRule.objects.filter(restricted_course=restricted_course)
|
||||
new_restricted_course = clone_instance(restricted_course, {'course_key': destination_course_key})
|
||||
for country_access_rule in country_access_rules:
|
||||
clone_instance(country_access_rule, {'restricted_course': new_restricted_course})
|
||||
|
||||
|
||||
def deserialize_fields(json_fields):
|
||||
fields = json.loads(json_fields)
|
||||
for field_name, value in iteritems(fields):
|
||||
|
||||
@@ -35,12 +35,12 @@ from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
from contentstore.config import waffle
|
||||
from course_action_state.managers import CourseActionStateItemNotFoundError
|
||||
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded
|
||||
from openedx.core.lib.tempdir import mkdtemp_clean
|
||||
from student import auth
|
||||
from student.models import CourseEnrollment
|
||||
from student.roles import CourseCreatorRole, CourseInstructorRole
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.capa_module import ProblemBlock
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.utils import empty_asset_trashcan, restore_asset_from_trashcan
|
||||
@@ -1534,8 +1534,7 @@ class ContentStoreTest(ContentStoreTestCase):
|
||||
payload = parse_json(resp)
|
||||
problem_loc = UsageKey.from_string(payload['locator'])
|
||||
problem = self.store.get_item(problem_loc)
|
||||
# should be a CapaDescriptor
|
||||
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
|
||||
self.assertIsInstance(problem, ProblemBlock, "New problem is not a ProblemBlock")
|
||||
context = problem.get_context()
|
||||
self.assertIn('markdown', context, "markdown is missing from context")
|
||||
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from xmodule import templates
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.capa_module import ProblemBlock
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
@@ -102,7 +102,7 @@ class TemplateTests(ModuleStoreTestCase):
|
||||
test_course.system, test_course.id, 'problem', fields={'data': test_def_content},
|
||||
parent_xblock=test_chapter
|
||||
)
|
||||
self.assertIsInstance(test_problem, CapaDescriptor)
|
||||
self.assertIsInstance(test_problem, ProblemBlock)
|
||||
self.assertEqual(test_problem.data, test_def_content)
|
||||
self.assertIn(test_problem, test_chapter.get_children())
|
||||
test_problem.display_name = 'test problem'
|
||||
|
||||
@@ -25,7 +25,7 @@ class LockedTest(ModuleStoreTestCase):
|
||||
|
||||
@patch('cms.djangoapps.contentstore.signals.handlers.cache.add')
|
||||
@patch('cms.djangoapps.contentstore.signals.handlers.cache.delete')
|
||||
@patch('cms.djangoapps.contentstore.signals.handlers.compute_all_grades_for_course.apply_async')
|
||||
@patch('cms.djangoapps.contentstore.signals.handlers.task_compute_all_grades_for_course.apply_async')
|
||||
@ddt.data(True, False)
|
||||
def test_locked(self, lock_available, compute_grades_async_mock, delete_mock, add_mock):
|
||||
add_mock.return_value = lock_available
|
||||
|
||||
@@ -4,7 +4,6 @@ Common utility functions useful throughout the contentstore
|
||||
from __future__ import print_function
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
@@ -15,8 +14,8 @@ from opaque_keys.edx.locator import LibraryLocator
|
||||
from pytz import UTC
|
||||
from six import text_type
|
||||
|
||||
from django_comment_common.models import assign_default_role
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
from openedx.core.djangoapps.django_comment_common.models import assign_default_role
|
||||
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
|
||||
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
|
||||
@@ -518,18 +517,3 @@ def is_self_paced(course):
|
||||
Returns True if course is self-paced, False otherwise.
|
||||
"""
|
||||
return course and course.self_paced
|
||||
|
||||
|
||||
def execute_and_log_time(func, *args, **kwargs):
|
||||
"""
|
||||
Call func passed in method with logging the time it took to complete.
|
||||
Temporarily added for EDUCATOR-4013, we will remove this once we get the required information.
|
||||
"""
|
||||
course_key = args[1]
|
||||
start_time = time.time()
|
||||
output = func(*args, **kwargs)
|
||||
if 'MITx+7.00x' in unicode(course_key):
|
||||
log.info(
|
||||
u'Execution time for [%s] [%s] completed in [%f]',
|
||||
func.__name__, course_key, (time.time() - start_time))
|
||||
return output
|
||||
|
||||
@@ -42,7 +42,7 @@ from lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION
|
||||
from student.tests.factories import UserFactory
|
||||
from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag
|
||||
from xblock_django.user_service import DjangoXBlockUserService
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.capa_module import ProblemBlock
|
||||
from xmodule.course_module import DEFAULT_START_DATE
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -523,7 +523,7 @@ class TestCreateItem(ItemTest):
|
||||
prob_usage_key = self.response_usage_key(resp)
|
||||
problem = self.get_item_from_modulestore(prob_usage_key, verify_is_draft=True)
|
||||
# check against the template
|
||||
template = CapaDescriptor.get_template(template_id)
|
||||
template = ProblemBlock.get_template(template_id)
|
||||
self.assertEqual(problem.data, template['data'])
|
||||
self.assertEqual(problem.display_name, template['metadata']['display_name'])
|
||||
self.assertEqual(problem.markdown, template['metadata']['markdown'])
|
||||
|
||||
@@ -1055,8 +1055,8 @@ INSTALLED_APPS = [
|
||||
# Ability to detect and special-case crawler behavior
|
||||
'openedx.core.djangoapps.crawlers',
|
||||
|
||||
# comment common
|
||||
'django_comment_common',
|
||||
# Discussion
|
||||
'openedx.core.djangoapps.django_comment_common',
|
||||
|
||||
# for course creator table
|
||||
'django.contrib.admin',
|
||||
|
||||
@@ -10,7 +10,7 @@ from xblock.core import XBlock, XBlockAside
|
||||
from xblock.fields import Dict, Scope
|
||||
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from xmodule.capa_module import CapaModule
|
||||
from xmodule.capa_module import ProblemBlock
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
|
||||
_ = lambda text: text
|
||||
@@ -44,7 +44,7 @@ class StructuredTagsAside(XBlockAside):
|
||||
Display the tag selector with specific categories and allowed values,
|
||||
depending on the context.
|
||||
"""
|
||||
if isinstance(block, CapaModule):
|
||||
if isinstance(block, ProblemBlock):
|
||||
tags = []
|
||||
for tag in self.get_available_tags():
|
||||
tag_available_values = tag.get_values()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""Django admin for course_modes"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import six
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -22,7 +25,8 @@ from lms.djangoapps.verify_student import models as verification_models
|
||||
from openedx.core.lib.courses import clean_course_id
|
||||
from util.date_utils import get_time_display
|
||||
|
||||
COURSE_MODE_SLUG_CHOICES = [(key, enrollment_mode['display_name']) for key, enrollment_mode in six.iteritems(settings.COURSE_ENROLLMENT_MODES)]
|
||||
COURSE_MODE_SLUG_CHOICES = [(key, enrollment_mode['display_name'])
|
||||
for key, enrollment_mode in six.iteritems(settings.COURSE_ENROLLMENT_MODES)]
|
||||
|
||||
|
||||
class CourseModeForm(forms.ModelForm):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Course modes API serializers.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
URL definitions for the course_modes API.
|
||||
"""
|
||||
from django.conf.urls import include, url
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.conf.urls import include, url
|
||||
|
||||
app_name = 'common.djangoapps.course_modes.api'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
URL definitions for the course_modes v1 API.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Defines the "ReSTful" API for course modes.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
import logging
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Django App config for course_modes"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
@@ -7,4 +9,4 @@ class CourseModesConfig(AppConfig):
|
||||
verbose_name = "Course Modes"
|
||||
|
||||
def ready(self):
|
||||
import course_modes.signals # pylint: disable=unused-import
|
||||
import course_modes.signals # pylint: disable=unused-variable
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
""" Helper methods for CourseModes. """
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import six
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
@@ -48,10 +51,10 @@ def enrollment_mode_display(mode, verification_status, course_id):
|
||||
enrollment_value = _("Professional Ed")
|
||||
|
||||
return {
|
||||
'enrollment_title': unicode(enrollment_title),
|
||||
'enrollment_value': unicode(enrollment_value),
|
||||
'enrollment_title': six.text_type(enrollment_title),
|
||||
'enrollment_value': six.text_type(enrollment_value),
|
||||
'show_image': show_image,
|
||||
'image_alt': unicode(image_alt),
|
||||
'image_alt': six.text_type(image_alt),
|
||||
'display_mode': _enrollment_mode_display(mode, verification_status, course_id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import re
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import re
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.8 on 2018-01-30 17:38
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import re
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
Add and create new modes for running courses on this particular LMS
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
from datetime import timedelta
|
||||
|
||||
import six
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -14,8 +17,8 @@ from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from edx_django_utils.cache import RequestCache
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.lib.cache_utils import request_cached
|
||||
@@ -59,7 +62,7 @@ class CourseMode(models.Model):
|
||||
|
||||
@course_id.setter
|
||||
def course_id(self, value):
|
||||
if isinstance(value, basestring):
|
||||
if isinstance(value, six.string_types):
|
||||
self._course_id = CourseKey.from_string(value)
|
||||
else:
|
||||
self._course_id = value
|
||||
@@ -296,7 +299,7 @@ class CourseMode(models.Model):
|
||||
mode for mode in modes
|
||||
if mode.expiration_datetime is None or mode.expiration_datetime >= now_dt
|
||||
]
|
||||
for course_id, modes in all_modes.iteritems()
|
||||
for course_id, modes in six.iteritems(all_modes)
|
||||
}
|
||||
|
||||
return (all_modes, unexpired_modes)
|
||||
@@ -910,4 +913,4 @@ class CourseModeExpirationConfig(ConfigurationModel):
|
||||
|
||||
def __unicode__(self):
|
||||
""" Returns the unicode date of the verification window. """
|
||||
return unicode(self.verification_window)
|
||||
return six.text_type(self.verification_window)
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
"""
|
||||
Signal handler for setting default course mode expiration dates
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from crum import get_current_user
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from xmodule.modulestore.django import SignalHandler, modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
|
||||
|
||||
from .models import CourseMode, CourseModeExpirationConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(SignalHandler.course_published)
|
||||
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
@@ -35,3 +46,38 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable
|
||||
def _should_update_date(verified_mode):
|
||||
""" Returns whether or not the verified mode should be updated. """
|
||||
return not(verified_mode is None or verified_mode.expiration_datetime_is_explicit)
|
||||
|
||||
|
||||
@receiver(post_save, sender=CourseMode)
|
||||
def update_masters_access_course(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Update all blocks in the verified content group to include the master's content group
|
||||
"""
|
||||
if instance.mode_slug != CourseMode.MASTERS:
|
||||
return
|
||||
masters_id = getattr(settings, 'COURSE_ENROLLMENT_MODES', {}).get('masters', {}).get('id', None)
|
||||
verified_id = getattr(settings, 'COURSE_ENROLLMENT_MODES', {}).get('verified', {}).get('id', None)
|
||||
if not (masters_id and verified_id):
|
||||
log.error("Missing settings.COURSE_ENROLLMENT_MODES -> verified:%s masters:%s", verified, masters)
|
||||
return
|
||||
|
||||
course_id = instance.course_id
|
||||
user = get_current_user()
|
||||
user_id = user.id if user else None
|
||||
store = modulestore()
|
||||
|
||||
with store.bulk_operations(course_id):
|
||||
try:
|
||||
items = store.get_items(course_id, settings={'group_access': {'$exists': True}}, include_orphans=False)
|
||||
except ItemNotFoundError:
|
||||
return
|
||||
for item in items:
|
||||
group_access = item.group_access
|
||||
enrollment_groups = group_access.get(ENROLLMENT_TRACK_PARTITION_ID, None)
|
||||
if enrollment_groups is not None:
|
||||
if verified_id in enrollment_groups and masters_id not in enrollment_groups:
|
||||
enrollment_groups.append(masters_id)
|
||||
item.group_access = group_access
|
||||
log.info("Publishing %s with Master's group access", item.location)
|
||||
store.update_item(item, user_id)
|
||||
store.publish(item.location, user_id)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Factories for course mode models.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import random
|
||||
|
||||
from factory import lazy_attribute
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
Tests for the course modes Django admin interface.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ddt
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from pytz import UTC, timezone
|
||||
@@ -12,12 +15,12 @@ from pytz import UTC, timezone
|
||||
from course_modes.admin import CourseModeForm
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
# Technically, we shouldn't be importing verify_student, since it's
|
||||
# defined in the LMS and course_modes is in common. However, the benefits
|
||||
# of putting all this configuration in one place outweigh the downsides.
|
||||
# Once the course admin tool is deployed, we can remove this dependency.
|
||||
from lms.djangoapps.verify_student.models import VerificationDeadline
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.tests.factories import UserFactory
|
||||
from util.date_utils import get_time_display
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
@@ -44,7 +47,7 @@ class AdminCourseModePageTest(ModuleStoreTestCase):
|
||||
CourseOverview.load_from_module_store(course.id)
|
||||
|
||||
data = {
|
||||
'course': unicode(course.id),
|
||||
'course': six.text_type(course.id),
|
||||
'mode_slug': 'verified',
|
||||
'mode_display_name': 'verified',
|
||||
'min_price': 10,
|
||||
@@ -199,7 +202,7 @@ class AdminCourseModeFormTest(ModuleStoreTestCase):
|
||||
mode_slug=mode,
|
||||
)
|
||||
return CourseModeForm({
|
||||
"course": unicode(self.course.id),
|
||||
"course": six.text_type(self.course.id),
|
||||
"mode_slug": mode,
|
||||
"mode_display_name": mode,
|
||||
"_expiration_datetime": upgrade_deadline,
|
||||
|
||||
@@ -4,6 +4,7 @@ when you run "manage.py test".
|
||||
|
||||
Replace this with more appropriate tests for your application.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
@@ -14,14 +15,13 @@ from django.test import TestCase, override_settings
|
||||
from django.utils.timezone import now
|
||||
from mock import patch
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from six.moves import zip
|
||||
|
||||
from course_modes.helpers import enrollment_mode_display
|
||||
from course_modes.models import CourseMode, Mode, invalidate_course_mode_cache, get_cosmetic_display_price
|
||||
from course_modes.models import CourseMode, Mode, get_cosmetic_display_price, invalidate_course_mode_cache
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
ModuleStoreTestCase,
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -41,6 +41,7 @@ class CourseModeModelTest(TestCase):
|
||||
CourseMode.objects.all().delete()
|
||||
|
||||
def tearDown(self):
|
||||
super(CourseModeModelTest, self).tearDown()
|
||||
invalidate_course_mode_cache(sender=None)
|
||||
|
||||
def create_mode(
|
||||
@@ -398,11 +399,11 @@ class CourseModeModelTest(TestCase):
|
||||
|
||||
# Check the selectable modes, which should exclude credit
|
||||
selectable_modes = CourseMode.modes_for_course_dict(self.course_key)
|
||||
self.assertItemsEqual(selectable_modes.keys(), expected_selectable_modes)
|
||||
self.assertItemsEqual(list(selectable_modes.keys()), expected_selectable_modes)
|
||||
|
||||
# When we get all unexpired modes, we should see credit as well
|
||||
all_modes = CourseMode.modes_for_course_dict(self.course_key, only_selectable=False)
|
||||
self.assertItemsEqual(all_modes.keys(), available_modes)
|
||||
self.assertItemsEqual(list(all_modes.keys()), available_modes)
|
||||
|
||||
def _enrollment_display_modes_dicts(self, dict_type):
|
||||
"""
|
||||
@@ -421,11 +422,11 @@ class CourseModeModelTest(TestCase):
|
||||
'professional']
|
||||
}
|
||||
if dict_type in ['verify_need_to_verify', 'verify_submitted']:
|
||||
return dict(zip(dict_keys, display_values.get('verify_need_to_verify')))
|
||||
return dict(list(zip(dict_keys, display_values.get('verify_need_to_verify'))))
|
||||
elif dict_type is None or dict_type == 'dummy':
|
||||
return dict(zip(dict_keys, display_values.get('verify_none')))
|
||||
return dict(list(zip(dict_keys, display_values.get('verify_none'))))
|
||||
else:
|
||||
return dict(zip(dict_keys, display_values.get(dict_type)))
|
||||
return dict(list(zip(dict_keys, display_values.get(dict_type))))
|
||||
|
||||
def test_expiration_datetime_explicitly_set(self):
|
||||
""" Verify that setting the expiration_date property sets the explicit flag. """
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the course_mode signals
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -10,8 +11,11 @@ from pytz import UTC
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.signals import _listen_for_course_publish
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -87,3 +91,31 @@ class CourseModeSignalTest(ModuleStoreTestCase):
|
||||
course_mode.refresh_from_db()
|
||||
|
||||
self.assertEqual(course_mode.expiration_datetime, self.end - timedelta(days=verification_window))
|
||||
|
||||
def test_masters_mode(self):
|
||||
# create an xblock with verified group access
|
||||
AUDIT_ID = settings.COURSE_ENROLLMENT_MODES['audit']['id']
|
||||
VERIFIED_ID = settings.COURSE_ENROLLMENT_MODES['verified']['id']
|
||||
MASTERS_ID = settings.COURSE_ENROLLMENT_MODES['masters']['id']
|
||||
verified_section = ItemFactory.create(
|
||||
category="sequential",
|
||||
metadata={'group_access': {ENROLLMENT_TRACK_PARTITION_ID: [VERIFIED_ID]}}
|
||||
)
|
||||
# and a section with no restriction
|
||||
section2 = ItemFactory.create(
|
||||
category="sequential",
|
||||
)
|
||||
section3 = ItemFactory.create(
|
||||
category='sequential',
|
||||
metadata={'group_access': {ENROLLMENT_TRACK_PARTITION_ID: [AUDIT_ID]}}
|
||||
)
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
||||
# create the master's mode. signal will add masters to the verified section
|
||||
self.create_mode('masters', 'masters')
|
||||
verified_section_ret = self.store.get_item(verified_section.location)
|
||||
section2_ret = self.store.get_item(section2.location)
|
||||
section3_ret = self.store.get_item(section3.location)
|
||||
# the verified section will now also be visible to master's
|
||||
assert verified_section_ret.group_access[ENROLLMENT_TRACK_PARTITION_ID] == [VERIFIED_ID, MASTERS_ID]
|
||||
assert section2_ret.group_access == {}
|
||||
assert section3_ret.group_access == {ENROLLMENT_TRACK_PARTITION_ID: [AUDIT_ID]}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Tests for course_modes views.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import decimal
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
@@ -10,6 +12,7 @@ import ddt
|
||||
import freezegun
|
||||
import httpretty
|
||||
import pytz
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from mock import patch
|
||||
@@ -84,7 +87,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
)
|
||||
|
||||
# Configure whether we're upgrading or not
|
||||
url = reverse('course_modes_choose', args=[unicode(course.id)])
|
||||
url = reverse('course_modes_choose', args=[six.text_type(course.id)])
|
||||
response = self.client.get(url)
|
||||
|
||||
# Check whether we were correctly redirected
|
||||
@@ -111,11 +114,11 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
)
|
||||
|
||||
# Configure whether we're upgrading or not
|
||||
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(url)
|
||||
# Check whether we were correctly redirected
|
||||
purchase_workflow = "?purchase_workflow=single"
|
||||
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + purchase_workflow
|
||||
start_flow_url = reverse('verify_student_start_flow', args=[six.text_type(self.course.id)]) + purchase_workflow
|
||||
self.assertRedirects(response, start_flow_url)
|
||||
|
||||
def test_no_id_redirect_otto(self):
|
||||
@@ -132,7 +135,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
user=self.user
|
||||
)
|
||||
# Configure whether we're upgrading or not
|
||||
url = reverse('course_modes_choose', args=[unicode(prof_course.id)])
|
||||
url = reverse('course_modes_choose', args=[six.text_type(prof_course.id)])
|
||||
response = self.client.get(url)
|
||||
self.assertRedirects(response, 'http://testserver/test_basket/add/?sku=TEST', fetch_redirect_response=False)
|
||||
ecomm_test_utils.update_commerce_config(enabled=False)
|
||||
@@ -166,7 +169,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
|
||||
# Verify that the prices render correctly
|
||||
response = self.client.get(
|
||||
reverse('course_modes_choose', args=[unicode(self.course.id)]),
|
||||
reverse('course_modes_choose', args=[six.text_type(self.course.id)]),
|
||||
follow=False,
|
||||
)
|
||||
|
||||
@@ -187,7 +190,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
|
||||
# Check whether credit upsell is shown on the page
|
||||
# This should *only* be shown when a credit mode is available
|
||||
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(url)
|
||||
|
||||
if show_upsell:
|
||||
@@ -201,13 +204,13 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id, min_price=1)
|
||||
|
||||
# Go to the "choose your track" page
|
||||
choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
choose_track_url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(choose_track_url)
|
||||
|
||||
# Since the only available track is professional ed, expect that
|
||||
# we're redirected immediately to the start of the payment flow.
|
||||
purchase_workflow = "?purchase_workflow=single"
|
||||
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + purchase_workflow
|
||||
start_flow_url = reverse('verify_student_start_flow', args=[six.text_type(self.course.id)]) + purchase_workflow
|
||||
self.assertRedirects(response, start_flow_url)
|
||||
|
||||
# Now enroll in the course
|
||||
@@ -215,7 +218,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
user=self.user,
|
||||
is_active=True,
|
||||
mode=mode,
|
||||
course_id=unicode(self.course.id),
|
||||
course_id=six.text_type(self.course.id),
|
||||
)
|
||||
|
||||
# Expect that this time we're redirected to the dashboard (since we're already registered)
|
||||
@@ -244,7 +247,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id, min_price=min_price)
|
||||
|
||||
# Choose the mode (POST request)
|
||||
choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
choose_track_url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.post(choose_track_url, self.POST_PARAMS_FOR_COURSE_MODE[course_mode])
|
||||
|
||||
# Verify the redirect
|
||||
@@ -253,7 +256,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
elif expected_redirect == 'start-flow':
|
||||
redirect_url = reverse(
|
||||
'verify_student_start_flow',
|
||||
kwargs={'course_id': unicode(self.course.id)}
|
||||
kwargs={'course_id': six.text_type(self.course.id)}
|
||||
)
|
||||
else:
|
||||
self.fail("Must provide a valid redirect URL name")
|
||||
@@ -273,7 +276,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
self.assertIsNone(is_active)
|
||||
|
||||
# Choose the audit mode (POST request)
|
||||
choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
choose_track_url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
self.client.post(choose_track_url, self.POST_PARAMS_FOR_COURSE_MODE[audit_mode])
|
||||
|
||||
# Assert learner is enrolled in Audit track post-POST
|
||||
@@ -301,14 +304,14 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
CourseModeFactory.create(mode_slug='verified', course_id=self.course.id, min_price=1)
|
||||
|
||||
# Choose the mode (POST request)
|
||||
choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
choose_track_url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
self.client.post(choose_track_url, self.POST_PARAMS_FOR_COURSE_MODE['verified'])
|
||||
|
||||
# Expect that the contribution amount is stored in the user's session
|
||||
self.assertIn('donation_for_course', self.client.session)
|
||||
self.assertIn(unicode(self.course.id), self.client.session['donation_for_course'])
|
||||
self.assertIn(six.text_type(self.course.id), self.client.session['donation_for_course'])
|
||||
|
||||
actual_amount = self.client.session['donation_for_course'][unicode(self.course.id)]
|
||||
actual_amount = self.client.session['donation_for_course'][six.text_type(self.course.id)]
|
||||
expected_amount = decimal.Decimal(self.POST_PARAMS_FOR_COURSE_MODE['verified']['contribution'])
|
||||
self.assertEqual(actual_amount, expected_amount)
|
||||
|
||||
@@ -321,12 +324,12 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
# automatic enrollment
|
||||
params = {
|
||||
'enrollment_action': 'enroll',
|
||||
'course_id': unicode(self.course.id)
|
||||
'course_id': six.text_type(self.course.id)
|
||||
}
|
||||
self.client.post(reverse('change_enrollment'), params)
|
||||
|
||||
# Explicitly select the honor mode (POST request)
|
||||
choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
choose_track_url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
self.client.post(choose_track_url, self.POST_PARAMS_FOR_COURSE_MODE[CourseMode.DEFAULT_MODE_SLUG])
|
||||
|
||||
# Verify that the user's enrollment remains unchanged
|
||||
@@ -340,7 +343,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
|
||||
|
||||
# Choose an unsupported mode (POST request)
|
||||
choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
choose_track_url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.post(choose_track_url, self.POST_PARAMS_FOR_COURSE_MODE['unsupported'])
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
@@ -348,7 +351,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_default_mode_creation(self):
|
||||
# Hit the mode creation endpoint with no querystring params, to create an honor mode
|
||||
url = reverse('create_mode', args=[unicode(self.course.id)])
|
||||
url = reverse('create_mode', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
@@ -372,7 +375,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
parameters['suggested_prices'] = suggested_prices
|
||||
parameters['currency'] = currency
|
||||
|
||||
url = reverse('create_mode', args=[unicode(self.course.id)])
|
||||
url = reverse('create_mode', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(url, parameters)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
@@ -397,7 +400,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_multiple_mode_creation(self):
|
||||
# Create an honor mode
|
||||
base_url = reverse('create_mode', args=[unicode(self.course.id)])
|
||||
base_url = reverse('create_mode', args=[six.text_type(self.course.id)])
|
||||
self.client.get(base_url)
|
||||
|
||||
# Excluding the currency parameter implicitly tests the mode creation endpoint's ability to
|
||||
@@ -409,7 +412,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
parameters['suggested_prices'] = '10,20'
|
||||
|
||||
# Create a verified mode
|
||||
url = reverse('create_mode', args=[unicode(self.course.id)])
|
||||
url = reverse('create_mode', args=[six.text_type(self.course.id)])
|
||||
self.client.get(url, parameters)
|
||||
|
||||
honor_mode = Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None, None, None)
|
||||
@@ -428,7 +431,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
|
||||
|
||||
# Load the track selection page
|
||||
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(url)
|
||||
|
||||
# Verify that the header navigation links are hidden for the edx.org version
|
||||
@@ -445,7 +448,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
self.course.enrollment_end = datetime(2015, 1, 1)
|
||||
modulestore().update_item(self.course, self.user.id)
|
||||
|
||||
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
response = self.client.get(url)
|
||||
# URL-encoded version of 1/1/15, 12:00 AM
|
||||
redirect_url = reverse('dashboard') + '?course_closed=1%2F1%2F15%2C+12%3A00+AM'
|
||||
@@ -472,7 +475,7 @@ class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
|
||||
# Construct the URL for the track selection page
|
||||
self.url = reverse('course_modes_choose', args=[unicode(self.course.id)])
|
||||
self.url = reverse('course_modes_choose', args=[six.text_type(self.course.id)])
|
||||
|
||||
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
def test_embargo_restrict(self):
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""URLs for course_mode API"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""
|
||||
Views for the course_mode module
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import decimal
|
||||
import json
|
||||
import urllib
|
||||
|
||||
import six
|
||||
import six.moves.urllib.error
|
||||
import six.moves.urllib.parse
|
||||
import six.moves.urllib.request
|
||||
import waffle
|
||||
from babel.dates import format_datetime
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -96,7 +100,7 @@ class ChooseModeView(View):
|
||||
has_enrolled_professional = (CourseMode.is_professional_slug(enrollment_mode) and is_active)
|
||||
if CourseMode.has_professional_mode(modes) and not has_enrolled_professional:
|
||||
purchase_workflow = request.GET.get("purchase_workflow", "single")
|
||||
verify_url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_key)})
|
||||
verify_url = reverse('verify_student_start_flow', kwargs={'course_id': six.text_type(course_key)})
|
||||
redirect_url = "{url}?purchase_workflow={workflow}".format(url=verify_url, workflow=purchase_workflow)
|
||||
if ecommerce_service.is_enabled(request.user):
|
||||
professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL)
|
||||
@@ -121,12 +125,12 @@ class ChooseModeView(View):
|
||||
return redirect(reverse('dashboard'))
|
||||
|
||||
donation_for_course = request.session.get("donation_for_course", {})
|
||||
chosen_price = donation_for_course.get(unicode(course_key), None)
|
||||
chosen_price = donation_for_course.get(six.text_type(course_key), None)
|
||||
|
||||
if CourseEnrollment.is_enrollment_closed(request.user, course):
|
||||
locale = to_locale(get_language())
|
||||
enrollment_end_date = format_datetime(course.enrollment_end, 'short', locale=locale)
|
||||
params = urllib.urlencode({'course_closed': enrollment_end_date})
|
||||
params = six.moves.urllib.parse.urlencode({'course_closed': enrollment_end_date})
|
||||
return redirect('{0}?{1}'.format(reverse('dashboard'), params))
|
||||
|
||||
# When a credit mode is available, students will be given the option
|
||||
@@ -278,13 +282,13 @@ class ChooseModeView(View):
|
||||
return self.get(request, course_id, error=error_msg)
|
||||
|
||||
donation_for_course = request.session.get("donation_for_course", {})
|
||||
donation_for_course[unicode(course_key)] = amount_value
|
||||
donation_for_course[six.text_type(course_key)] = amount_value
|
||||
request.session["donation_for_course"] = donation_for_course
|
||||
|
||||
return redirect(
|
||||
reverse(
|
||||
'verify_student_start_flow',
|
||||
kwargs={'course_id': unicode(course_key)}
|
||||
kwargs={'course_id': six.text_type(course_key)}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -342,7 +346,7 @@ def create_mode(request, course_id):
|
||||
}
|
||||
|
||||
# Try pulling querystring parameters out of the request
|
||||
for parameter, default in PARAMETERS.iteritems():
|
||||
for parameter, default in six.iteritems(PARAMETERS):
|
||||
PARAMETERS[parameter] = request.GET.get(parameter, default)
|
||||
|
||||
# Attempt to create the new mode for the given course
|
||||
|
||||
14
common/djangoapps/student/README.rst
Normal file
14
common/djangoapps/student/README.rst
Normal file
@@ -0,0 +1,14 @@
|
||||
Status: Maintenance
|
||||
|
||||
Responsibilities
|
||||
================
|
||||
The Student app supplements Django's default user information with student-specific information like User Profiles and Enrollments. This has made it a catch all place for a lot of functionality that we want to move into more dedicated places. For instance, while the CourseEnrollment models remain in this app for now, most Enrollment related functionality has already moved to the Enrollment app.
|
||||
|
||||
If you are thinking of adding something here, strongly consider adding a new Django app instead. If you are extending something here, please consider extracting it into a separate app.
|
||||
|
||||
Glossary
|
||||
========
|
||||
|
||||
|
||||
More Documentation
|
||||
==================
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Student app helpers and settings
|
||||
"""
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
|
||||
from __future__ import absolute_import
|
||||
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
|
||||
|
||||
# Namespace for student app waffle switches
|
||||
STUDENT_WAFFLE_NAMESPACE = WaffleSwitchNamespace(name='student')
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
""" Django admin pages for student app """
|
||||
from __future__ import absolute_import
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from config_models.admin import ConfigurationModelAdmin
|
||||
from django import forms
|
||||
from django.db import router, transaction
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.sites import NotRegistered
|
||||
from django.contrib.admin.utils import unquote
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.contrib.auth.forms import ReadOnlyPasswordHashField, UserChangeForm as BaseUserChangeForm
|
||||
from django.db import models
|
||||
from django.contrib.auth.forms import ReadOnlyPasswordHashField
|
||||
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
|
||||
from django.db import models, router, transaction
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import ugettext_lazy as _, ngettext
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ngettext
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
@@ -27,13 +31,13 @@ from student.models import (
|
||||
CourseEnrollmentAllowed,
|
||||
DashboardConfiguration,
|
||||
LinkedInAddToProfileConfiguration,
|
||||
LoginFailures,
|
||||
PendingNameChange,
|
||||
Registration,
|
||||
RegistrationCookieConfiguration,
|
||||
UserAttribute,
|
||||
UserProfile,
|
||||
UserTestGroup,
|
||||
LoginFailures,
|
||||
UserTestGroup
|
||||
)
|
||||
from student.roles import REGISTERED_ACCESS_ROLES
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -4,6 +4,8 @@ authorization has authorization to do so, which infers authorization via role hi
|
||||
(GlobalStaff is superset of auths of course instructor, ...), which consults the config
|
||||
to decide whether to check course creator role, and other such functions.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from ccx_keys.locator import CCXBlockUsageLocator, CCXLocator
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Utility functions for validating forms
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import re
|
||||
from importlib import import_module
|
||||
|
||||
@@ -11,14 +13,14 @@ from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.core.validators import RegexValidator, slug_re
|
||||
from django.forms import widgets
|
||||
from django.urls import reverse
|
||||
from django.utils.http import int_to_base36
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from edx_ace import ace
|
||||
from edx_ace.recipient import Recipient
|
||||
|
||||
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
@@ -26,7 +28,8 @@ from openedx.core.djangoapps.theming.helpers import get_current_site
|
||||
from openedx.core.djangoapps.user_api import accounts as accounts_settings
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from student.message_types import AccountRecovery as AccountRecoveryMessage, PasswordReset
|
||||
from student.message_types import AccountRecovery as AccountRecoveryMessage
|
||||
from student.message_types import PasswordReset
|
||||
from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired
|
||||
from util.password_policy_validators import validate_password
|
||||
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
"""
|
||||
Helpers for the student app.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import urllib
|
||||
import urlparse
|
||||
from datetime import datetime
|
||||
|
||||
import six.moves.urllib.parse
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.core.validators import ValidationError
|
||||
from django.contrib.auth import load_backend
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from pytz import UTC
|
||||
from six import iteritems, text_type
|
||||
|
||||
import third_party_auth
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.certificates.api import (
|
||||
get_certificate_url,
|
||||
has_html_certificates_enabled
|
||||
)
|
||||
from lms.djangoapps.certificates.models import (
|
||||
CertificateStatuses,
|
||||
certificate_status_for_student
|
||||
)
|
||||
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
||||
from lms.djangoapps.certificates.api import get_certificate_url, has_html_certificates_enabled
|
||||
from lms.djangoapps.certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
from lms.djangoapps.grades.api import CourseGradeFactory
|
||||
from lms.djangoapps.verify_student.models import VerificationDeadline
|
||||
from lms.djangoapps.verify_student.services import IDVerificationService
|
||||
from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime
|
||||
@@ -42,13 +38,12 @@ from student.models import (
|
||||
Registration,
|
||||
UserAttribute,
|
||||
UserProfile,
|
||||
unique_id_for_user,
|
||||
email_exists_or_retired,
|
||||
unique_id_for_user,
|
||||
username_exists_or_retired
|
||||
)
|
||||
from util.password_policy_validators import normalize_password
|
||||
|
||||
|
||||
# Enumeration of per-course verification statuses
|
||||
# we display on the student dashboard.
|
||||
VERIFY_STATUS_NEED_TO_VERIFY = "verify_need_to_verify"
|
||||
@@ -285,7 +280,7 @@ def get_next_url_for_login_page(request):
|
||||
# Before we redirect to next/dashboard, we need to handle auto-enrollment:
|
||||
params = [(param, request.GET[param]) for param in POST_AUTH_PARAMS if param in request.GET]
|
||||
params.append(('next', redirect_to)) # After auto-enrollment, user will be sent to payment page or to this URL
|
||||
redirect_to = '{}?{}'.format(reverse('finish_auth'), urllib.urlencode(params))
|
||||
redirect_to = '{}?{}'.format(reverse('finish_auth'), six.moves.urllib.parse.urlencode(params))
|
||||
# Note: if we are resuming a third party auth pipeline, then the next URL will already
|
||||
# be saved in the session as part of the pipeline state. That URL will take priority
|
||||
# over this one.
|
||||
@@ -299,12 +294,12 @@ def get_next_url_for_login_page(request):
|
||||
# Don't add tpa_hint if we're already in the TPA pipeline (prevent infinite loop),
|
||||
# and don't overwrite any existing tpa_hint params (allow tpa_hint override).
|
||||
running_pipeline = third_party_auth.pipeline.get(request)
|
||||
(scheme, netloc, path, query, fragment) = list(urlparse.urlsplit(redirect_to))
|
||||
(scheme, netloc, path, query, fragment) = list(six.moves.urllib.parse.urlsplit(redirect_to))
|
||||
if not running_pipeline and 'tpa_hint' not in query:
|
||||
params = urlparse.parse_qs(query)
|
||||
params = six.moves.urllib.parse.parse_qs(query)
|
||||
params['tpa_hint'] = [tpa_hint]
|
||||
query = urllib.urlencode(params, doseq=True)
|
||||
redirect_to = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
|
||||
query = six.moves.urllib.parse.urlencode(params, doseq=True)
|
||||
redirect_to = six.moves.urllib.parse.urlunsplit((scheme, netloc, path, query, fragment))
|
||||
|
||||
return redirect_to
|
||||
|
||||
@@ -356,7 +351,7 @@ def _get_redirect_to(request):
|
||||
redirect_to = None
|
||||
else:
|
||||
themes = get_themes()
|
||||
next_path = urlparse.urlparse(redirect_to).path
|
||||
next_path = six.moves.urllib.parse.urlparse(redirect_to).path
|
||||
for theme in themes:
|
||||
if theme.theme_dir_name in next_path:
|
||||
log.warning(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
ACE message types for the student module.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
from openedx.core.djangoapps.ace_common.message import BaseMessageType
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
Middleware that checks user standing for the purpose of keeping users with
|
||||
disabled accounts from accessing the site.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from student.models import UserStanding
|
||||
|
||||
|
||||
@@ -24,12 +27,12 @@ class UserStandingMiddleware(object):
|
||||
pass
|
||||
else:
|
||||
if user_account.account_status == UserStanding.ACCOUNT_DISABLED:
|
||||
msg = _(
|
||||
msg = Text(_(
|
||||
'Your account has been disabled. If you believe '
|
||||
'this was done in error, please contact us at '
|
||||
'{support_email}'
|
||||
).format(
|
||||
support_email=u'<a href="mailto:{address}?subject={subject_line}">{address}</a>'.format(
|
||||
)).format(
|
||||
support_email=HTML(u'<a href="mailto:{address}?subject={subject_line}">{address}</a>').format(
|
||||
address=settings.DEFAULT_FEEDBACK_EMAIL,
|
||||
subject_line=_('Disabled Account'),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.14 on 2018-07-27 01:44
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.16 on 2018-12-10 12:15
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.16 on 2018-12-19 14:30
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.16 on 2018-12-21 10:40
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
import openedx.core.djangolib.model_mixins
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.20 on 2019-02-27 20:19
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
@@ -10,18 +10,18 @@ file and check it in at the same time as your model changes. To do that,
|
||||
2. ./manage.py lms schemamigration student --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
|
||||
"""
|
||||
from __future__ import print_function
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import six
|
||||
import uuid
|
||||
from collections import OrderedDict, defaultdict, namedtuple
|
||||
from datetime import datetime, timedelta
|
||||
from functools import total_ordering
|
||||
from importlib import import_module
|
||||
from urllib import urlencode
|
||||
|
||||
import six
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
@@ -40,6 +40,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ugettext_noop
|
||||
from django_countries.fields import CountryField
|
||||
from edx_django_utils.cache import RequestCache
|
||||
from edx_rest_api_client.exceptions import SlumberBaseException
|
||||
from eventtracking import tracker
|
||||
from model_utils.models import TimeStampedModel
|
||||
@@ -47,13 +48,12 @@ from opaque_keys.edx.django.models import CourseKeyField
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import UTC
|
||||
from six import text_type
|
||||
from six.moves import range
|
||||
from six.moves.urllib.parse import urlencode
|
||||
from slumber.exceptions import HttpClientError, HttpServerError
|
||||
from user_util import user_util
|
||||
|
||||
from edx_django_utils.cache import RequestCache
|
||||
import lms.lib.comment_client as cc
|
||||
from student.signals import UNENROLL_DONE, ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
import openedx.core.djangoapps.django_comment_common.comment_client as cc
|
||||
from course_modes.models import CourseMode, get_cosmetic_verified_display_price
|
||||
from courseware.models import (
|
||||
CourseDynamicUpgradeDeadlineConfiguration,
|
||||
@@ -61,11 +61,12 @@ from courseware.models import (
|
||||
OrgDynamicUpgradeDeadlineConfiguration
|
||||
)
|
||||
from enrollment.api import _default_course_mode
|
||||
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager
|
||||
from openedx.core.djangolib.model_mixins import DeletableByUserValue
|
||||
from student.signals import ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, UNENROLL_DONE
|
||||
from track import contexts, segment
|
||||
from util.milestones_helpers import is_entrance_exams_enabled
|
||||
from util.model_utils import emit_field_changed_events, get_changed_fields_dict
|
||||
@@ -447,7 +448,7 @@ class UserProfile(models.Model):
|
||||
|
||||
# Optional demographic data we started capturing from Fall 2012
|
||||
this_year = datetime.now(UTC).year
|
||||
VALID_YEARS = range(this_year, this_year - 120, -1)
|
||||
VALID_YEARS = list(range(this_year, this_year - 120, -1))
|
||||
year_of_birth = models.IntegerField(blank=True, null=True, db_index=True)
|
||||
GENDER_CHOICES = (
|
||||
('m', ugettext_noop('Male')),
|
||||
@@ -938,7 +939,7 @@ class LoginFailures(models.Model):
|
||||
date_str = self.lockout_until.isoformat()
|
||||
|
||||
return u'LoginFailures({username}, {count}, {date})'.format(
|
||||
username=unicode(self.user.username, 'utf-8'),
|
||||
username=six.text_type(self.user.username, 'utf-8'),
|
||||
count=self.failure_count,
|
||||
date=date_str
|
||||
)
|
||||
@@ -950,7 +951,7 @@ class LoginFailures(models.Model):
|
||||
date_str = self.lockout_until.isoformat()
|
||||
|
||||
return u'{username}: {count} - {date}'.format(
|
||||
username=unicode(self.user.username, 'utf-8'),
|
||||
username=six.text_type(self.user.username, 'utf-8'),
|
||||
count=self.failure_count,
|
||||
date=date_str
|
||||
)
|
||||
@@ -1114,7 +1115,7 @@ class CourseEnrollment(models.Model):
|
||||
|
||||
@course_id.setter
|
||||
def course_id(self, value):
|
||||
if isinstance(value, basestring):
|
||||
if isinstance(value, six.string_types):
|
||||
self._course_id = CourseKey.from_string(value)
|
||||
else:
|
||||
self._course_id = value
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""
|
||||
Helpers for student roles
|
||||
"""
|
||||
from django_comment_common.models import (
|
||||
from __future__ import absolute_import
|
||||
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
FORUM_ROLE_ADMINISTRATOR,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
Role
|
||||
)
|
||||
from student.roles import (
|
||||
CourseBetaTesterRole,
|
||||
CourseInstructorRole,
|
||||
CourseStaffRole,
|
||||
OrgStaffRole,
|
||||
GlobalStaff,
|
||||
OrgInstructorRole,
|
||||
GlobalStaff
|
||||
OrgStaffRole
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ Classes used to model the roles used in the courseware. Each role is responsible
|
||||
adding users, removing users, and listing members
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import defaultdict
|
||||
|
||||
import six
|
||||
from django.contrib.auth.models import User
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
|
||||
@@ -47,7 +50,7 @@ class BulkRoleCache(object):
|
||||
for role in CourseAccessRole.objects.filter(user__in=users).select_related('user'):
|
||||
roles_by_user[role.user.id].add(role)
|
||||
|
||||
users_without_roles = filter(lambda u: u.id not in roles_by_user, users)
|
||||
users_without_roles = [u for u in users if u.id not in roles_by_user]
|
||||
for user in users_without_roles:
|
||||
roles_by_user[user.id] = set()
|
||||
|
||||
@@ -80,11 +83,10 @@ class RoleCache(object):
|
||||
)
|
||||
|
||||
|
||||
class AccessRole(object):
|
||||
class AccessRole(six.with_metaclass(ABCMeta, object)):
|
||||
"""
|
||||
Object representing a role with particular access to a resource
|
||||
"""
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
@abstractmethod
|
||||
def has_user(self, user):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
This file contains celery tasks for sending email
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
|
||||
from boto.exception import NoAuthHandlerFound
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Fragment for rendering text me the app.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
URLs for student app
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
from django.contrib.auth.views import password_reset_complete
|
||||
|
||||
30
common/djangoapps/third_party_auth/tests/test_saml.py
Normal file
30
common/djangoapps/third_party_auth/tests/test_saml.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Unit tests for third_party_auth SAML auth providers
|
||||
"""
|
||||
|
||||
import mock
|
||||
|
||||
from third_party_auth.tests.testutil import SAMLTestCase
|
||||
from third_party_auth.saml import EdXSAMLIdentityProvider, get_saml_idp_class
|
||||
from third_party_auth.tests.data.saml_identity_provider_mock_data import mock_conf, mock_attributes,\
|
||||
expected_user_details
|
||||
|
||||
|
||||
class TestEdXSAMLIdentityProvider(SAMLTestCase):
|
||||
"""
|
||||
Test EdXSAMLIdentityProvider.
|
||||
"""
|
||||
@mock.patch('third_party_auth.saml.log')
|
||||
def test_get_saml_idp_class_with_fake_identifier(self, log_mock):
|
||||
error_mock = log_mock.error
|
||||
idp_class = get_saml_idp_class('fake_idp_class_option')
|
||||
error_mock.assert_called_once_with(
|
||||
u'%s is not a valid EdXSAMLIdentityProvider subclass; using EdXSAMLIdentityProvider base class.',
|
||||
'fake_idp_class_option'
|
||||
)
|
||||
self.assertIs(idp_class, EdXSAMLIdentityProvider)
|
||||
|
||||
def test_get_user_details(self):
|
||||
""" test get_attr and get_user_details of EdXSAMLIdentityProvider"""
|
||||
edx_saml_identity_provider = EdXSAMLIdentityProvider('demo', **mock_conf)
|
||||
self.assertEqual(edx_saml_identity_provider.get_user_details(mock_attributes), expected_user_details)
|
||||
@@ -6,9 +6,11 @@ import unittest
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from lxml import etree
|
||||
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
||||
|
||||
from third_party_auth import pipeline
|
||||
# Define some XML namespaces:
|
||||
from third_party_auth.tasks import SAML_XML_NS
|
||||
|
||||
@@ -53,8 +55,8 @@ class SAMLMetadataTest(SAMLTestCase):
|
||||
self.enable_saml(
|
||||
other_config_str=(
|
||||
'{'
|
||||
'"TECHNICAL_CONTACT": {"givenName": "Jane Tech", "emailAddress": "jane@example.com"},' # pylint: disable=unicode-format-string,line-too-long
|
||||
'"SUPPORT_CONTACT": {"givenName": "Joe Support", "emailAddress": "joe@example.com"}' # pylint: disable=unicode-format-string,line-too-long
|
||||
'"TECHNICAL_CONTACT": {"givenName": "Jane Tech", "emailAddress": "jane@example.com"},' # pylint: disable=unicode-format-string
|
||||
'"SUPPORT_CONTACT": {"givenName": "Joe Support", "emailAddress": "joe@example.com"}' # pylint: disable=unicode-format-string
|
||||
'}'
|
||||
)
|
||||
)
|
||||
@@ -153,3 +155,42 @@ class SAMLAuthTest(SAMLTestCase):
|
||||
self.enable_saml(enabled=False)
|
||||
response = self.client.get(self.LOGIN_URL)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
@unittest.skipUnless(AUTH_FEATURE_ENABLED, AUTH_FEATURES_KEY + ' not enabled')
|
||||
class IdPRedirectViewTest(SAMLTestCase):
|
||||
"""
|
||||
Test IdPRedirectView.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(IdPRedirectViewTest, self).setUp()
|
||||
|
||||
self.enable_saml()
|
||||
self.configure_saml_provider(
|
||||
name="Test",
|
||||
slug="test",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def test_with_valid_provider_slug(self):
|
||||
endpoint_url = self.get_idp_redirect_url('saml-test')
|
||||
expected_url = pipeline.get_login_url('saml-test', pipeline.AUTH_ENTRY_LOGIN, reverse('dashboard'))
|
||||
|
||||
response = self.client.get(endpoint_url)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
||||
def test_with_invalid_provider_slug(self):
|
||||
endpoint_url = self.get_idp_redirect_url('saml-test-invalid')
|
||||
|
||||
response = self.client.get(endpoint_url)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@staticmethod
|
||||
def get_idp_redirect_url(provider_slug, next_destination=None):
|
||||
return '{idp_redirect_url}?{next_destination}'.format(
|
||||
idp_redirect_url=reverse('idp_redirect', kwargs={'provider_slug': provider_slug}),
|
||||
next_destination=next_destination,
|
||||
)
|
||||
|
||||
@@ -25,9 +25,6 @@ from third_party_auth.models import (
|
||||
SAMLConfiguration,
|
||||
SAMLProviderConfig
|
||||
)
|
||||
from third_party_auth.saml import EdXSAMLIdentityProvider, get_saml_idp_class
|
||||
from third_party_auth.tests.data.saml_identity_provider_mock_data import mock_conf, mock_attributes,\
|
||||
expected_user_details
|
||||
|
||||
AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
|
||||
AUTH_FEATURE_ENABLED = AUTH_FEATURES_KEY in settings.FEATURES
|
||||
@@ -217,21 +214,6 @@ class SAMLTestCase(TestCase):
|
||||
kwargs.setdefault('entity_id', "https://saml.example.none")
|
||||
super(SAMLTestCase, self).enable_saml(**kwargs)
|
||||
|
||||
@mock.patch('third_party_auth.saml.log')
|
||||
def test_get_saml_idp_class_with_fake_identifier(self, log_mock):
|
||||
error_mock = log_mock.error
|
||||
idp_class = get_saml_idp_class('fake_idp_class_option')
|
||||
error_mock.assert_called_once_with(
|
||||
u'%s is not a valid EdXSAMLIdentityProvider subclass; using EdXSAMLIdentityProvider base class.',
|
||||
'fake_idp_class_option'
|
||||
)
|
||||
self.assertIs(idp_class, EdXSAMLIdentityProvider)
|
||||
|
||||
def test_get_user_details(self):
|
||||
""" test get_attr and get_user_details of EdXSAMLIdentityProvider"""
|
||||
edx_smal_identity_provider = EdXSAMLIdentityProvider('demo', **mock_conf)
|
||||
self.assertEqual(edx_smal_identity_provider.get_user_details(mock_attributes), expected_user_details)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def simulate_running_pipeline(pipeline_target, backend, email=None, fullname=None, username=None, **kwargs):
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from .views import inactive_user_view, lti_login_and_complete_view, post_to_custom_auth_form, saml_metadata_view
|
||||
from .views import (inactive_user_view, lti_login_and_complete_view,
|
||||
post_to_custom_auth_form, saml_metadata_view, IdPRedirectView)
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^auth/inactive', inactive_user_view, name="third_party_inactive_redirect"),
|
||||
url(r'^auth/custom_auth_entry', post_to_custom_auth_form, name='tpa_post_to_custom_auth_form'),
|
||||
url(r'^auth/saml/metadata.xml', saml_metadata_view),
|
||||
url(r'^auth/login/(?P<backend>lti)/$', lti_login_and_complete_view),
|
||||
url(r'^auth/idp_redirect/(?P<provider_slug>[\w-]+)', IdPRedirectView.as_view(), name="idp_redirect"),
|
||||
url(r'^auth/', include('social_django.urls', namespace='social')),
|
||||
]
|
||||
|
||||
@@ -3,13 +3,15 @@ Extra views required for SSO
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.http import Http404, HttpResponse, HttpResponseNotAllowed, HttpResponseServerError
|
||||
from django.http import Http404, HttpResponse, HttpResponseNotAllowed, HttpResponseServerError, HttpResponseNotFound
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.generic.base import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from social_django.utils import load_strategy, load_backend, psa
|
||||
from social_django.views import complete
|
||||
from social_core.utils import setting_name
|
||||
|
||||
from student.helpers import get_next_url_for_login_page
|
||||
from student.models import UserProfile
|
||||
from student.views import compose_and_send_activation_email
|
||||
import third_party_auth
|
||||
@@ -110,3 +112,41 @@ def post_to_custom_auth_form(request):
|
||||
'hmac': pipeline_data['hmac'],
|
||||
}
|
||||
return render(request, 'third_party_auth/post_custom_auth_entry.html', data)
|
||||
|
||||
|
||||
class IdPRedirectView(View):
|
||||
"""
|
||||
Redirect to an IdP's login page if the IdP exists; otherwise, return a 404.
|
||||
|
||||
Example usage:
|
||||
|
||||
GET auth/idp_redirect/saml-default
|
||||
|
||||
"""
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Return either a redirect to the login page of an identity provider that
|
||||
corresponds to the provider_slug keyword argument or a 404 if the
|
||||
provider_slug does not correspond to an identity provider.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Keyword Args:
|
||||
provider_slug (str): a slug corresponding to a configured identity provider
|
||||
|
||||
Returns:
|
||||
HttpResponse: 302 to a provider's login url if the provider_slug kwarg matches an identity provider
|
||||
HttpResponse: 404 if the provider_slug kwarg does not match an identity provider
|
||||
"""
|
||||
# this gets the url to redirect to after login/registration/third_party_auth
|
||||
# it also handles checking the safety of the redirect url (next query parameter)
|
||||
# it checks against settings.LOGIN_REDIRECT_WHITELIST, so be sure to add the url
|
||||
# to this setting
|
||||
next_destination_url = get_next_url_for_login_page(request)
|
||||
|
||||
try:
|
||||
url = pipeline.get_login_url(kwargs['provider_slug'], pipeline.AUTH_ENTRY_LOGIN, next_destination_url)
|
||||
return redirect(url)
|
||||
except ValueError:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
15
common/djangoapps/track/README.rst
Normal file
15
common/djangoapps/track/README.rst
Normal file
@@ -0,0 +1,15 @@
|
||||
Status: Maintenance
|
||||
|
||||
Responsibilities
|
||||
================
|
||||
The Track app records student tracking events into logs for later analysis by Insights and academic research teams. It provides an endpoint for front end code to asynchronously send user tracking events, as well as providing middleware to record server calls.
|
||||
|
||||
While new event types are being added all the time, the Track app for recording those events is not under active development.
|
||||
|
||||
Glossary
|
||||
========
|
||||
|
||||
|
||||
More Documentation
|
||||
==================
|
||||
`Events in the Tracking Logs <https://edx.readthedocs.io/projects/devdata/en/stable/internal_data_formats/tracking_logs.html>`_
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Ideally, we wouldn't need to pull in all the calc symbols here,
|
||||
but courses were using 'import calc', so we need this for
|
||||
backwards compatibility
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
from .calc import *
|
||||
@@ -1,504 +0,0 @@
|
||||
"""
|
||||
Parser and evaluator for FormulaResponse and NumericalResponse
|
||||
|
||||
Uses pyparsing to parse. Main function as of now is evaluator().
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
import math
|
||||
import numbers
|
||||
import operator
|
||||
|
||||
import numpy
|
||||
from pyparsing import (
|
||||
CaselessLiteral,
|
||||
Combine,
|
||||
Forward,
|
||||
Group,
|
||||
Literal,
|
||||
MatchFirst,
|
||||
Optional,
|
||||
ParseResults,
|
||||
Suppress,
|
||||
Word,
|
||||
ZeroOrMore,
|
||||
alphanums,
|
||||
alphas,
|
||||
nums,
|
||||
stringEnd
|
||||
)
|
||||
|
||||
from . import functions
|
||||
import six
|
||||
from functools import reduce
|
||||
|
||||
# Functions available by default
|
||||
# We use scimath variants which give complex results when needed. For example:
|
||||
# np.sqrt(-4+0j) = 2j
|
||||
# np.sqrt(-4) = nan, but
|
||||
# np.lib.scimath.sqrt(-4) = 2j
|
||||
DEFAULT_FUNCTIONS = {
|
||||
'sin': numpy.sin,
|
||||
'cos': numpy.cos,
|
||||
'tan': numpy.tan,
|
||||
'sec': functions.sec,
|
||||
'csc': functions.csc,
|
||||
'cot': functions.cot,
|
||||
'sqrt': numpy.lib.scimath.sqrt,
|
||||
'log10': numpy.lib.scimath.log10,
|
||||
'log2': numpy.lib.scimath.log2,
|
||||
'ln': numpy.lib.scimath.log,
|
||||
'exp': numpy.exp,
|
||||
'arccos': numpy.lib.scimath.arccos,
|
||||
'arcsin': numpy.lib.scimath.arcsin,
|
||||
'arctan': numpy.arctan,
|
||||
'arcsec': functions.arcsec,
|
||||
'arccsc': functions.arccsc,
|
||||
'arccot': functions.arccot,
|
||||
'abs': numpy.abs,
|
||||
'fact': math.factorial,
|
||||
'factorial': math.factorial,
|
||||
'sinh': numpy.sinh,
|
||||
'cosh': numpy.cosh,
|
||||
'tanh': numpy.tanh,
|
||||
'sech': functions.sech,
|
||||
'csch': functions.csch,
|
||||
'coth': functions.coth,
|
||||
'arcsinh': numpy.arcsinh,
|
||||
'arccosh': numpy.arccosh,
|
||||
'arctanh': numpy.lib.scimath.arctanh,
|
||||
'arcsech': functions.arcsech,
|
||||
'arccsch': functions.arccsch,
|
||||
'arccoth': functions.arccoth
|
||||
}
|
||||
|
||||
DEFAULT_VARIABLES = {
|
||||
'i': numpy.complex(0, 1),
|
||||
'j': numpy.complex(0, 1),
|
||||
'e': numpy.e,
|
||||
'pi': numpy.pi,
|
||||
}
|
||||
|
||||
SUFFIXES = {
|
||||
'%': 0.01,
|
||||
}
|
||||
|
||||
|
||||
class UndefinedVariable(Exception):
|
||||
"""
|
||||
Indicate when a student inputs a variable which was not expected.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class UnmatchedParenthesis(Exception):
|
||||
"""
|
||||
Indicate when a student inputs a formula with mismatched parentheses.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def lower_dict(input_dict):
|
||||
"""
|
||||
Convert all keys in a dictionary to lowercase; keep their original values.
|
||||
|
||||
Keep in mind that it is possible (but not useful?) to define different
|
||||
variables that have the same lowercase representation. It would be hard to
|
||||
tell which is used in the final dict and which isn't.
|
||||
"""
|
||||
return {k.lower(): v for k, v in six.iteritems(input_dict)}
|
||||
|
||||
|
||||
# The following few functions define evaluation actions, which are run on lists
|
||||
# of results from each parse component. They convert the strings and (previously
|
||||
# calculated) numbers into the number that component represents.
|
||||
|
||||
def super_float(text):
|
||||
"""
|
||||
Like float, but with SI extensions. 1k goes to 1000.
|
||||
"""
|
||||
if text[-1] in SUFFIXES:
|
||||
return float(text[:-1]) * SUFFIXES[text[-1]]
|
||||
else:
|
||||
return float(text)
|
||||
|
||||
|
||||
def eval_number(parse_result):
|
||||
"""
|
||||
Create a float out of its string parts.
|
||||
|
||||
e.g. [ '7.13', 'e', '3' ] -> 7130
|
||||
Calls super_float above.
|
||||
"""
|
||||
return super_float("".join(parse_result))
|
||||
|
||||
|
||||
def eval_atom(parse_result):
|
||||
"""
|
||||
Return the value wrapped by the atom.
|
||||
|
||||
In the case of parenthesis, ignore them.
|
||||
"""
|
||||
# Find first number in the list
|
||||
result = next(k for k in parse_result if isinstance(k, numbers.Number))
|
||||
return result
|
||||
|
||||
|
||||
def eval_power(parse_result):
|
||||
"""
|
||||
Take a list of numbers and exponentiate them, right to left.
|
||||
|
||||
e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512
|
||||
(not to be interpreted (2^3)^2 = 64)
|
||||
"""
|
||||
# `reduce` will go from left to right; reverse the list.
|
||||
parse_result = reversed(
|
||||
[k for k in parse_result
|
||||
if isinstance(k, numbers.Number)] # Ignore the '^' marks.
|
||||
)
|
||||
# Having reversed it, raise `b` to the power of `a`.
|
||||
power = reduce(lambda a, b: b ** a, parse_result)
|
||||
return power
|
||||
|
||||
|
||||
def eval_parallel(parse_result):
|
||||
"""
|
||||
Compute numbers according to the parallel resistors operator.
|
||||
|
||||
BTW it is commutative. Its formula is given by
|
||||
out = 1 / (1/in1 + 1/in2 + ...)
|
||||
e.g. [ 1, 2 ] -> 2/3
|
||||
|
||||
Return NaN if there is a zero among the inputs.
|
||||
"""
|
||||
if len(parse_result) == 1:
|
||||
return parse_result[0]
|
||||
if 0 in parse_result:
|
||||
return float('nan')
|
||||
reciprocals = [1. / e for e in parse_result
|
||||
if isinstance(e, numbers.Number)]
|
||||
return 1. / sum(reciprocals)
|
||||
|
||||
|
||||
def eval_sum(parse_result):
|
||||
"""
|
||||
Add the inputs, keeping in mind their sign.
|
||||
|
||||
[ 1, '+', 2, '-', 3 ] -> 0
|
||||
|
||||
Allow a leading + or -.
|
||||
"""
|
||||
total = 0.0
|
||||
current_op = operator.add
|
||||
for token in parse_result:
|
||||
if token == '+':
|
||||
current_op = operator.add
|
||||
elif token == '-':
|
||||
current_op = operator.sub
|
||||
else:
|
||||
total = current_op(total, token)
|
||||
return total
|
||||
|
||||
|
||||
def eval_product(parse_result):
|
||||
"""
|
||||
Multiply the inputs.
|
||||
|
||||
[ 1, '*', 2, '/', 3 ] -> 0.66
|
||||
"""
|
||||
prod = 1.0
|
||||
current_op = operator.mul
|
||||
for token in parse_result:
|
||||
if token == '*':
|
||||
current_op = operator.mul
|
||||
elif token == '/':
|
||||
current_op = operator.truediv
|
||||
else:
|
||||
prod = current_op(prod, token)
|
||||
return prod
|
||||
|
||||
|
||||
def add_defaults(variables, functions, case_sensitive):
|
||||
"""
|
||||
Create dictionaries with both the default and user-defined variables.
|
||||
"""
|
||||
all_variables = dict(DEFAULT_VARIABLES)
|
||||
all_functions = dict(DEFAULT_FUNCTIONS)
|
||||
all_variables.update(variables)
|
||||
all_functions.update(functions)
|
||||
|
||||
if not case_sensitive:
|
||||
all_variables = lower_dict(all_variables)
|
||||
all_functions = lower_dict(all_functions)
|
||||
|
||||
return (all_variables, all_functions)
|
||||
|
||||
|
||||
def evaluator(variables, functions, math_expr, case_sensitive=False):
|
||||
"""
|
||||
Evaluate an expression; that is, take a string of math and return a float.
|
||||
|
||||
-Variables are passed as a dictionary from string to value. They must be
|
||||
python numbers.
|
||||
-Unary functions are passed as a dictionary from string to function.
|
||||
"""
|
||||
# No need to go further.
|
||||
if math_expr.strip() == "":
|
||||
return float('nan')
|
||||
|
||||
# Parse the tree.
|
||||
check_parens(math_expr)
|
||||
math_interpreter = ParseAugmenter(math_expr, case_sensitive)
|
||||
math_interpreter.parse_algebra()
|
||||
|
||||
# Get our variables together.
|
||||
all_variables, all_functions = add_defaults(variables, functions, case_sensitive)
|
||||
|
||||
# ...and check them
|
||||
math_interpreter.check_variables(all_variables, all_functions)
|
||||
|
||||
# Create a recursion to evaluate the tree.
|
||||
if case_sensitive:
|
||||
casify = lambda x: x
|
||||
else:
|
||||
casify = lambda x: x.lower() # Lowercase for case insens.
|
||||
|
||||
evaluate_actions = {
|
||||
'number': eval_number,
|
||||
'variable': lambda x: all_variables[casify(x[0])],
|
||||
'function': lambda x: all_functions[casify(x[0])](x[1]),
|
||||
'atom': eval_atom,
|
||||
'power': eval_power,
|
||||
'parallel': eval_parallel,
|
||||
'product': eval_product,
|
||||
'sum': eval_sum
|
||||
}
|
||||
|
||||
return math_interpreter.reduce_tree(evaluate_actions)
|
||||
|
||||
|
||||
def check_parens(formula):
|
||||
"""
|
||||
Check that any open parentheses are closed
|
||||
|
||||
Otherwise, raise an UnmatchedParenthesis exception
|
||||
"""
|
||||
count = 0
|
||||
delta = {
|
||||
'(': +1,
|
||||
')': -1
|
||||
}
|
||||
for index, char in enumerate(formula):
|
||||
if char in delta:
|
||||
count += delta[char]
|
||||
if count < 0:
|
||||
msg = "Invalid Input: A closing parenthesis was found after segment " + \
|
||||
"{}, but there is no matching opening parenthesis before it."
|
||||
raise UnmatchedParenthesis(msg.format(formula[0:index]))
|
||||
if count > 0:
|
||||
msg = "Invalid Input: Parentheses are unmatched. " + \
|
||||
"{} parentheses were opened but never closed."
|
||||
raise UnmatchedParenthesis(msg.format(count))
|
||||
|
||||
|
||||
class ParseAugmenter(object):
|
||||
"""
|
||||
Holds the data for a particular parse.
|
||||
|
||||
Retains the `math_expr` and `case_sensitive` so they needn't be passed
|
||||
around method to method.
|
||||
Eventually holds the parse tree and sets of variables as well.
|
||||
"""
|
||||
def __init__(self, math_expr, case_sensitive=False):
|
||||
"""
|
||||
Create the ParseAugmenter for a given math expression string.
|
||||
|
||||
Do the parsing later, when called like `OBJ.parse_algebra()`.
|
||||
"""
|
||||
self.case_sensitive = case_sensitive
|
||||
self.math_expr = math_expr
|
||||
self.tree = None
|
||||
self.variables_used = set()
|
||||
self.functions_used = set()
|
||||
|
||||
def vpa(tokens):
|
||||
"""
|
||||
When a variable is recognized, store it in `variables_used`.
|
||||
"""
|
||||
varname = tokens[0][0]
|
||||
self.variables_used.add(varname)
|
||||
|
||||
def fpa(tokens):
|
||||
"""
|
||||
When a function is recognized, store it in `functions_used`.
|
||||
"""
|
||||
varname = tokens[0][0]
|
||||
self.functions_used.add(varname)
|
||||
|
||||
self.variable_parse_action = vpa
|
||||
self.function_parse_action = fpa
|
||||
|
||||
def parse_algebra(self):
|
||||
"""
|
||||
Parse an algebraic expression into a tree.
|
||||
|
||||
Store a `pyparsing.ParseResult` in `self.tree` with proper groupings to
|
||||
reflect parenthesis and order of operations. Leave all operators in the
|
||||
tree and do not parse any strings of numbers into their float versions.
|
||||
|
||||
Adding the groups and result names makes the `repr()` of the result
|
||||
really gross. For debugging, use something like
|
||||
print OBJ.tree.asXML()
|
||||
"""
|
||||
# 0.33 or 7 or .34 or 16.
|
||||
number_part = Word(nums)
|
||||
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
|
||||
# pyparsing allows spaces between tokens--`Combine` prevents that.
|
||||
inner_number = Combine(inner_number)
|
||||
|
||||
# SI suffixes and percent.
|
||||
number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys())
|
||||
|
||||
# 0.33k or 17
|
||||
plus_minus = Literal('+') | Literal('-')
|
||||
number = Group(
|
||||
Optional(plus_minus) +
|
||||
inner_number +
|
||||
Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part) +
|
||||
Optional(number_suffix)
|
||||
)
|
||||
number = number("number")
|
||||
|
||||
# Predefine recursive variables.
|
||||
expr = Forward()
|
||||
|
||||
# Handle variables passed in. They must start with a letter
|
||||
# and may contain numbers and underscores afterward.
|
||||
inner_varname = Combine(Word(alphas, alphanums + "_") + ZeroOrMore("'"))
|
||||
# Alternative variable name in tensor format
|
||||
# Tensor name must start with a letter, continue with alphanums
|
||||
# Indices may be alphanumeric
|
||||
# e.g., U_{ijk}^{123}
|
||||
upper_indices = Literal("^{") + Word(alphanums) + Literal("}")
|
||||
lower_indices = Literal("_{") + Word(alphanums) + Literal("}")
|
||||
tensor_lower = Combine(Word(alphas, alphanums) + lower_indices + ZeroOrMore("'"))
|
||||
tensor_mixed = Combine(Word(alphas, alphanums) + Optional(lower_indices) + upper_indices + ZeroOrMore("'"))
|
||||
# Test for mixed tensor first, then lower tensor alone, then generic variable name
|
||||
varname = Group(tensor_mixed | tensor_lower | inner_varname)("variable")
|
||||
varname.setParseAction(self.variable_parse_action)
|
||||
|
||||
# Same thing for functions.
|
||||
function = Group(inner_varname + Suppress("(") + expr + Suppress(")"))("function")
|
||||
function.setParseAction(self.function_parse_action)
|
||||
|
||||
atom = number | function | varname | "(" + expr + ")"
|
||||
atom = Group(atom)("atom")
|
||||
|
||||
# Do the following in the correct order to preserve order of operation.
|
||||
pow_term = atom + ZeroOrMore("^" + atom)
|
||||
pow_term = Group(pow_term)("power")
|
||||
|
||||
par_term = pow_term + ZeroOrMore('||' + pow_term) # 5k || 4k
|
||||
par_term = Group(par_term)("parallel")
|
||||
|
||||
prod_term = par_term + ZeroOrMore((Literal('*') | Literal('/')) + par_term) # 7 * 5 / 4
|
||||
prod_term = Group(prod_term)("product")
|
||||
|
||||
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
|
||||
sum_term = Group(sum_term)("sum")
|
||||
|
||||
# Finish the recursion.
|
||||
expr << sum_term # pylint: disable=pointless-statement
|
||||
self.tree = (expr + stringEnd).parseString(self.math_expr)[0]
|
||||
|
||||
def reduce_tree(self, handle_actions, terminal_converter=None):
|
||||
"""
|
||||
Call `handle_actions` recursively on `self.tree` and return result.
|
||||
|
||||
`handle_actions` is a dictionary of node names (e.g. 'product', 'sum',
|
||||
etc&) to functions. These functions are of the following form:
|
||||
-input: a list of processed child nodes. If it includes any terminal
|
||||
nodes in the list, they will be given as their processed forms also.
|
||||
-output: whatever to be passed to the level higher, and what to
|
||||
return for the final node.
|
||||
`terminal_converter` is a function that takes in a token and returns a
|
||||
processed form. The default of `None` just leaves them as strings.
|
||||
"""
|
||||
def handle_node(node):
|
||||
"""
|
||||
Return the result representing the node, using recursion.
|
||||
|
||||
Call the appropriate `handle_action` for this node. As its inputs,
|
||||
feed it the output of `handle_node` for each child node.
|
||||
"""
|
||||
if not isinstance(node, ParseResults):
|
||||
# Then treat it as a terminal node.
|
||||
if terminal_converter is None:
|
||||
return node
|
||||
else:
|
||||
return terminal_converter(node)
|
||||
|
||||
node_name = node.getName()
|
||||
if node_name not in handle_actions: # pragma: no cover
|
||||
raise Exception(u"Unknown branch name '{}'".format(node_name))
|
||||
|
||||
action = handle_actions[node_name]
|
||||
handled_kids = [handle_node(k) for k in node]
|
||||
return action(handled_kids)
|
||||
|
||||
# Find the value of the entire tree.
|
||||
return handle_node(self.tree)
|
||||
|
||||
def check_variables(self, valid_variables, valid_functions):
|
||||
"""
|
||||
Confirm that all the variables used in the tree are valid/defined.
|
||||
|
||||
Otherwise, raise an UndefinedVariable containing all bad variables.
|
||||
"""
|
||||
if self.case_sensitive:
|
||||
casify = lambda x: x
|
||||
else:
|
||||
casify = lambda x: x.lower() # Lowercase for case insens.
|
||||
|
||||
bad_vars = set(var for var in self.variables_used
|
||||
if casify(var) not in valid_variables)
|
||||
|
||||
if bad_vars:
|
||||
varnames = ", ".join(sorted(bad_vars))
|
||||
message = "Invalid Input: {} not permitted in answer as a variable".format(varnames)
|
||||
|
||||
# Check to see if there is a different case version of the variables
|
||||
caselist = set()
|
||||
if self.case_sensitive:
|
||||
for var2 in bad_vars:
|
||||
for var1 in valid_variables:
|
||||
if var2.lower() == var1.lower():
|
||||
caselist.add(var1)
|
||||
if len(caselist) > 0:
|
||||
betternames = ', '.join(sorted(caselist))
|
||||
message += " (did you mean " + betternames + "?)"
|
||||
|
||||
raise UndefinedVariable(message)
|
||||
|
||||
bad_funcs = set(func for func in self.functions_used
|
||||
if casify(func) not in valid_functions)
|
||||
if bad_funcs:
|
||||
funcnames = ', '.join(sorted(bad_funcs))
|
||||
message = "Invalid Input: {} not permitted in answer as a function".format(funcnames)
|
||||
|
||||
# Check to see if there is a corresponding variable name
|
||||
if any(casify(func) in valid_variables for func in bad_funcs):
|
||||
message += " (did you forget to use * for multiplication?)"
|
||||
|
||||
# Check to see if there is a different case version of the function
|
||||
caselist = set()
|
||||
if self.case_sensitive:
|
||||
for func2 in bad_funcs:
|
||||
for func1 in valid_functions:
|
||||
if func2.lower() == func1.lower():
|
||||
caselist.add(func1)
|
||||
if len(caselist) > 0:
|
||||
betternames = ', '.join(sorted(caselist))
|
||||
message += " (did you mean " + betternames + "?)"
|
||||
|
||||
raise UndefinedVariable(message)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Provide the mathematical functions that numpy doesn't.
|
||||
|
||||
Specifically, the secant/cosecant/cotangents and their inverses and
|
||||
hyperbolic counterparts
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
import numpy
|
||||
|
||||
|
||||
# Normal Trig
|
||||
def sec(arg):
|
||||
"""
|
||||
Secant
|
||||
"""
|
||||
return 1 / numpy.cos(arg)
|
||||
|
||||
|
||||
def csc(arg):
|
||||
"""
|
||||
Cosecant
|
||||
"""
|
||||
return 1 / numpy.sin(arg)
|
||||
|
||||
|
||||
def cot(arg):
|
||||
"""
|
||||
Cotangent
|
||||
"""
|
||||
return 1 / numpy.tan(arg)
|
||||
|
||||
|
||||
# Inverse Trig
|
||||
# http://en.wikipedia.org/wiki/Inverse_trigonometric_functions#Relationships_among_the_inverse_trigonometric_functions
|
||||
def arcsec(val):
|
||||
"""
|
||||
Inverse secant
|
||||
"""
|
||||
return numpy.arccos(1. / val)
|
||||
|
||||
|
||||
def arccsc(val):
|
||||
"""
|
||||
Inverse cosecant
|
||||
"""
|
||||
return numpy.arcsin(1. / val)
|
||||
|
||||
|
||||
def arccot(val):
|
||||
"""
|
||||
Inverse cotangent
|
||||
"""
|
||||
if numpy.real(val) < 0:
|
||||
return -numpy.pi / 2 - numpy.arctan(val)
|
||||
else:
|
||||
return numpy.pi / 2 - numpy.arctan(val)
|
||||
|
||||
|
||||
# Hyperbolic Trig
|
||||
def sech(arg):
|
||||
"""
|
||||
Hyperbolic secant
|
||||
"""
|
||||
return 1 / numpy.cosh(arg)
|
||||
|
||||
|
||||
def csch(arg):
|
||||
"""
|
||||
Hyperbolic cosecant
|
||||
"""
|
||||
return 1 / numpy.sinh(arg)
|
||||
|
||||
|
||||
def coth(arg):
|
||||
"""
|
||||
Hyperbolic cotangent
|
||||
"""
|
||||
return 1 / numpy.tanh(arg)
|
||||
|
||||
|
||||
# And their inverses
|
||||
def arcsech(val):
|
||||
"""
|
||||
Inverse hyperbolic secant
|
||||
"""
|
||||
return numpy.arccosh(1. / val)
|
||||
|
||||
|
||||
def arccsch(val):
|
||||
"""
|
||||
Inverse hyperbolic cosecant
|
||||
"""
|
||||
return numpy.arcsinh(1. / val)
|
||||
|
||||
|
||||
def arccoth(val):
|
||||
"""
|
||||
Inverse hyperbolic cotangent
|
||||
"""
|
||||
return numpy.arctanh(1. / val)
|
||||
@@ -1,401 +0,0 @@
|
||||
"""
|
||||
Provide a `latex_preview` method similar in syntax to `evaluator`.
|
||||
|
||||
That is, given a math string, parse it and render each branch of the result,
|
||||
always returning valid latex.
|
||||
|
||||
Because intermediate values of the render contain more data than simply the
|
||||
string of latex, store it in a custom class `LatexRendered`.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
from .calc import DEFAULT_FUNCTIONS, DEFAULT_VARIABLES, SUFFIXES, ParseAugmenter
|
||||
from functools import reduce
|
||||
|
||||
|
||||
class LatexRendered(object):
|
||||
"""
|
||||
Data structure to hold a typeset representation of some math.
|
||||
|
||||
Fields:
|
||||
-`latex` is a generated, valid latex string (as if it were standalone).
|
||||
-`sans_parens` is usually the same as `latex` except without the outermost
|
||||
parens (if applicable).
|
||||
-`tall` is a boolean representing if the latex has any elements extending
|
||||
above or below a normal height, specifically things of the form 'a^b' and
|
||||
'\frac{a}{b}'. This affects the height of wrapping parenthesis.
|
||||
"""
|
||||
def __init__(self, latex, parens=None, tall=False):
|
||||
"""
|
||||
Instantiate with the latex representing the math.
|
||||
|
||||
Optionally include parenthesis to wrap around it and the height.
|
||||
`parens` must be one of '(', '[' or '{'.
|
||||
`tall` is a boolean (see note above).
|
||||
"""
|
||||
self.latex = latex
|
||||
self.sans_parens = latex
|
||||
self.tall = tall
|
||||
|
||||
# Generate parens and overwrite `self.latex`.
|
||||
if parens is not None:
|
||||
left_parens = parens
|
||||
if left_parens == '{':
|
||||
left_parens = r'\{'
|
||||
|
||||
pairs = {'(': ')',
|
||||
'[': ']',
|
||||
r'\{': r'\}'}
|
||||
if left_parens not in pairs:
|
||||
raise Exception(
|
||||
u"Unknown parenthesis '{}': coder error".format(left_parens)
|
||||
)
|
||||
right_parens = pairs[left_parens]
|
||||
|
||||
if self.tall:
|
||||
left_parens = r"\left" + left_parens
|
||||
right_parens = r"\right" + right_parens
|
||||
|
||||
self.latex = u"{left}{expr}{right}".format(
|
||||
left=left_parens,
|
||||
expr=latex,
|
||||
right=right_parens
|
||||
)
|
||||
|
||||
def __repr__(self): # pragma: no cover
|
||||
"""
|
||||
Give a sensible representation of the object.
|
||||
|
||||
If `sans_parens` is different, include both.
|
||||
If `tall` then have '<[]>' around the code, otherwise '<>'.
|
||||
"""
|
||||
if self.latex == self.sans_parens:
|
||||
latex_repr = u'"{}"'.format(self.latex)
|
||||
else:
|
||||
latex_repr = u'"{}" or "{}"'.format(self.latex, self.sans_parens)
|
||||
|
||||
if self.tall:
|
||||
wrap = u'<[{}]>'
|
||||
else:
|
||||
wrap = u'<{}>'
|
||||
|
||||
return wrap.format(latex_repr)
|
||||
|
||||
|
||||
def render_number(children):
|
||||
"""
|
||||
Combine the elements forming the number, escaping the suffix if needed.
|
||||
"""
|
||||
children_latex = [k.latex for k in children]
|
||||
|
||||
suffix = ""
|
||||
if children_latex[-1] in SUFFIXES:
|
||||
suffix = children_latex.pop()
|
||||
suffix = u"\\text{{{s}}}".format(s=suffix)
|
||||
|
||||
# Exponential notation-- the "E" splits the mantissa and exponent
|
||||
if "E" in children_latex:
|
||||
pos = children_latex.index("E")
|
||||
mantissa = "".join(children_latex[:pos])
|
||||
exponent = "".join(children_latex[pos + 1:])
|
||||
latex = u"{m}\\!\\times\\!10^{{{e}}}{s}".format(
|
||||
m=mantissa, e=exponent, s=suffix
|
||||
)
|
||||
return LatexRendered(latex, tall=True)
|
||||
else:
|
||||
easy_number = "".join(children_latex)
|
||||
return LatexRendered(easy_number + suffix)
|
||||
|
||||
|
||||
def enrich_varname(varname):
|
||||
"""
|
||||
Prepend a backslash if we're given a greek character.
|
||||
"""
|
||||
greek = ("alpha beta gamma delta epsilon varepsilon zeta eta theta "
|
||||
"vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon "
|
||||
"phi varphi chi psi omega").split()
|
||||
|
||||
# add capital greek letters
|
||||
greek += [x.capitalize() for x in greek]
|
||||
|
||||
# add hbar for QM
|
||||
greek.append('hbar')
|
||||
|
||||
# add infinity
|
||||
greek.append('infty')
|
||||
|
||||
if varname in greek:
|
||||
return u"\\{letter}".format(letter=varname)
|
||||
else:
|
||||
return varname.replace("_", r"\_")
|
||||
|
||||
|
||||
def variable_closure(variables, casify):
|
||||
"""
|
||||
Wrap `render_variable` so it knows the variables allowed.
|
||||
"""
|
||||
def render_variable(children):
|
||||
"""
|
||||
Replace greek letters, otherwise escape the variable names.
|
||||
"""
|
||||
varname = children[0].latex
|
||||
if casify(varname) not in variables:
|
||||
pass # TODO turn unknown variable red or give some kind of error
|
||||
|
||||
first, _, second = varname.partition("_")
|
||||
|
||||
if second:
|
||||
# Then 'a_b' must become 'a_{b}'
|
||||
varname = u"{a}_{{{b}}}".format(
|
||||
a=enrich_varname(first),
|
||||
b=enrich_varname(second)
|
||||
)
|
||||
else:
|
||||
varname = enrich_varname(varname)
|
||||
|
||||
return LatexRendered(varname) # .replace("_", r"\_"))
|
||||
return render_variable
|
||||
|
||||
|
||||
def function_closure(functions, casify):
|
||||
"""
|
||||
Wrap `render_function` so it knows the functions allowed.
|
||||
"""
|
||||
def render_function(children):
|
||||
"""
|
||||
Escape function names and give proper formatting to exceptions.
|
||||
|
||||
The exceptions being 'sqrt', 'log2', and 'log10' as of now.
|
||||
"""
|
||||
fname = children[0].latex
|
||||
if casify(fname) not in functions:
|
||||
pass # TODO turn unknown function red or give some kind of error
|
||||
|
||||
# Wrap the input of the function with parens or braces.
|
||||
inner = children[1].latex
|
||||
if fname == "sqrt":
|
||||
inner = u"{{{expr}}}".format(expr=inner)
|
||||
else:
|
||||
if children[1].tall:
|
||||
inner = u"\\left({expr}\\right)".format(expr=inner)
|
||||
else:
|
||||
inner = u"({expr})".format(expr=inner)
|
||||
|
||||
# Correctly format the name of the function.
|
||||
if fname == "sqrt":
|
||||
fname = u"\\sqrt"
|
||||
elif fname == "log10":
|
||||
fname = u"\\log_{10}"
|
||||
elif fname == "log2":
|
||||
fname = u"\\log_2"
|
||||
else:
|
||||
fname = u"\\text{{{fname}}}".format(fname=fname)
|
||||
|
||||
# Put it together.
|
||||
latex = fname + inner
|
||||
return LatexRendered(latex, tall=children[1].tall)
|
||||
# Return the function within the closure.
|
||||
return render_function
|
||||
|
||||
|
||||
def render_power(children):
|
||||
"""
|
||||
Combine powers so that the latex is wrapped in curly braces correctly.
|
||||
|
||||
Also, if you have 'a^(b+c)' don't include that last set of parens:
|
||||
'a^{b+c}' is correct, whereas 'a^{(b+c)}' is extraneous.
|
||||
"""
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
|
||||
children_latex = [k.latex for k in children if k.latex != "^"]
|
||||
children_latex[-1] = children[-1].sans_parens
|
||||
|
||||
raise_power = lambda x, y: u"{}^{{{}}}".format(y, x)
|
||||
latex = reduce(raise_power, reversed(children_latex))
|
||||
return LatexRendered(latex, tall=True)
|
||||
|
||||
|
||||
def render_parallel(children):
|
||||
"""
|
||||
Simply join the child nodes with a double vertical line.
|
||||
"""
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
|
||||
children_latex = [k.latex for k in children if k.latex != "||"]
|
||||
latex = r"\|".join(children_latex)
|
||||
tall = any(k.tall for k in children)
|
||||
return LatexRendered(latex, tall=tall)
|
||||
|
||||
|
||||
def render_frac(numerator, denominator):
|
||||
r"""
|
||||
Given a list of elements in the numerator and denominator, return a '\frac'
|
||||
|
||||
Avoid parens if they are unnecessary (i.e. the only thing in that part).
|
||||
"""
|
||||
if len(numerator) == 1:
|
||||
num_latex = numerator[0].sans_parens
|
||||
else:
|
||||
num_latex = r"\cdot ".join(k.latex for k in numerator)
|
||||
|
||||
if len(denominator) == 1:
|
||||
den_latex = denominator[0].sans_parens
|
||||
else:
|
||||
den_latex = r"\cdot ".join(k.latex for k in denominator)
|
||||
|
||||
latex = u"\\frac{{{num}}}{{{den}}}".format(num=num_latex, den=den_latex)
|
||||
return latex
|
||||
|
||||
|
||||
def render_product(children):
|
||||
r"""
|
||||
Format products and division nicely.
|
||||
|
||||
Group bunches of adjacent, equal operators. Every time it switches from
|
||||
denominator to the next numerator, call `render_frac`. Join these groupings
|
||||
together with '\cdot's, ending on a numerator if needed.
|
||||
|
||||
Examples: (`children` is formed indirectly by the string on the left)
|
||||
'a*b' -> 'a\cdot b'
|
||||
'a/b' -> '\frac{a}{b}'
|
||||
'a*b/c/d' -> '\frac{a\cdot b}{c\cdot d}'
|
||||
'a/b*c/d*e' -> '\frac{a}{b}\cdot \frac{c}{d}\cdot e'
|
||||
"""
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
|
||||
position = "numerator" # or denominator
|
||||
fraction_mode_ever = False
|
||||
numerator = []
|
||||
denominator = []
|
||||
latex = ""
|
||||
|
||||
for kid in children:
|
||||
if position == "numerator":
|
||||
if kid.latex == "*":
|
||||
pass # Don't explicitly add the '\cdot' yet.
|
||||
elif kid.latex == "/":
|
||||
# Switch to denominator mode.
|
||||
fraction_mode_ever = True
|
||||
position = "denominator"
|
||||
else:
|
||||
numerator.append(kid)
|
||||
else:
|
||||
if kid.latex == "*":
|
||||
# Switch back to numerator mode.
|
||||
# First, render the current fraction and add it to the latex.
|
||||
latex += render_frac(numerator, denominator) + r"\cdot "
|
||||
|
||||
# Reset back to beginning state
|
||||
position = "numerator"
|
||||
numerator = []
|
||||
denominator = []
|
||||
elif kid.latex == "/":
|
||||
pass # Don't explicitly add a '\frac' yet.
|
||||
else:
|
||||
denominator.append(kid)
|
||||
|
||||
# Add the fraction/numerator that we ended on.
|
||||
if position == "denominator":
|
||||
latex += render_frac(numerator, denominator)
|
||||
else:
|
||||
# We ended on a numerator--act like normal multiplication.
|
||||
num_latex = r"\cdot ".join(k.latex for k in numerator)
|
||||
latex += num_latex
|
||||
|
||||
tall = fraction_mode_ever or any(k.tall for k in children)
|
||||
return LatexRendered(latex, tall=tall)
|
||||
|
||||
|
||||
def render_sum(children):
|
||||
"""
|
||||
Concatenate elements, including the operators.
|
||||
"""
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
|
||||
children_latex = [k.latex for k in children]
|
||||
latex = "".join(children_latex)
|
||||
tall = any(k.tall for k in children)
|
||||
return LatexRendered(latex, tall=tall)
|
||||
|
||||
|
||||
def render_atom(children):
|
||||
"""
|
||||
Properly handle parens, otherwise this is trivial.
|
||||
"""
|
||||
if len(children) == 3:
|
||||
return LatexRendered(
|
||||
children[1].latex,
|
||||
parens=children[0].latex,
|
||||
tall=children[1].tall
|
||||
)
|
||||
else:
|
||||
return children[0]
|
||||
|
||||
|
||||
def add_defaults(var, fun, case_sensitive=False):
|
||||
"""
|
||||
Create sets with both the default and user-defined variables.
|
||||
|
||||
Compare to calc.add_defaults
|
||||
"""
|
||||
var_items = set(DEFAULT_VARIABLES)
|
||||
fun_items = set(DEFAULT_FUNCTIONS)
|
||||
|
||||
var_items.update(var)
|
||||
fun_items.update(fun)
|
||||
|
||||
if not case_sensitive:
|
||||
var_items = set(k.lower() for k in var_items)
|
||||
fun_items = set(k.lower() for k in fun_items)
|
||||
|
||||
return var_items, fun_items
|
||||
|
||||
|
||||
def latex_preview(math_expr, variables=(), functions=(), case_sensitive=False):
|
||||
"""
|
||||
Convert `math_expr` into latex, guaranteeing its parse-ability.
|
||||
|
||||
Analagous to `evaluator`.
|
||||
"""
|
||||
# No need to go further
|
||||
if math_expr.strip() == "":
|
||||
return ""
|
||||
|
||||
# Parse tree
|
||||
latex_interpreter = ParseAugmenter(math_expr, case_sensitive)
|
||||
latex_interpreter.parse_algebra()
|
||||
|
||||
# Get our variables together.
|
||||
variables, functions = add_defaults(variables, functions, case_sensitive)
|
||||
|
||||
# Create a recursion to evaluate the tree.
|
||||
if case_sensitive:
|
||||
casify = lambda x: x
|
||||
else:
|
||||
casify = lambda x: x.lower() # Lowercase for case insens.
|
||||
|
||||
render_actions = {
|
||||
'number': render_number,
|
||||
'variable': variable_closure(variables, casify),
|
||||
'function': function_closure(functions, casify),
|
||||
'atom': render_atom,
|
||||
'power': render_power,
|
||||
'parallel': render_parallel,
|
||||
'product': render_product,
|
||||
'sum': render_sum
|
||||
}
|
||||
|
||||
backslash = "\\"
|
||||
wrap_escaped_strings = lambda s: LatexRendered(
|
||||
s.replace(backslash, backslash * 2)
|
||||
)
|
||||
|
||||
output = latex_interpreter.reduce_tree(
|
||||
render_actions,
|
||||
terminal_converter=wrap_escaped_strings
|
||||
)
|
||||
return output.latex
|
||||
@@ -1,558 +0,0 @@
|
||||
"""
|
||||
Unit tests for calc.py
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
import unittest
|
||||
import numpy
|
||||
import calc
|
||||
from pyparsing import ParseException
|
||||
from six.moves import zip
|
||||
|
||||
# numpy's default behavior when it evaluates a function outside its domain
|
||||
# is to raise a warning (not an exception) which is then printed to STDOUT.
|
||||
# To prevent this from polluting the output of the tests, configure numpy to
|
||||
# ignore it instead.
|
||||
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
|
||||
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
|
||||
|
||||
|
||||
class EvaluatorTest(unittest.TestCase):
|
||||
"""
|
||||
Run tests for calc.evaluator
|
||||
Go through all functionalities as specifically as possible--
|
||||
work from number input to functions and complex expressions
|
||||
Also test custom variable substitutions (i.e.
|
||||
`evaluator({'x':3.0}, {}, '3*x')`
|
||||
gives 9.0) and more.
|
||||
"""
|
||||
|
||||
def test_number_input(self):
|
||||
"""
|
||||
Test different kinds of float inputs
|
||||
|
||||
See also
|
||||
test_trailing_period (slightly different)
|
||||
test_exponential_answer
|
||||
test_si_suffix
|
||||
"""
|
||||
easy_eval = lambda x: calc.evaluator({}, {}, x)
|
||||
|
||||
self.assertEqual(easy_eval("13"), 13)
|
||||
self.assertEqual(easy_eval("3.14"), 3.14)
|
||||
self.assertEqual(easy_eval(".618033989"), 0.618033989)
|
||||
|
||||
self.assertEqual(easy_eval("-13"), -13)
|
||||
self.assertEqual(easy_eval("-3.14"), -3.14)
|
||||
self.assertEqual(easy_eval("-.618033989"), -0.618033989)
|
||||
|
||||
def test_period(self):
|
||||
"""
|
||||
The string '.' should not evaluate to anything.
|
||||
"""
|
||||
with self.assertRaises(ParseException):
|
||||
calc.evaluator({}, {}, '.')
|
||||
with self.assertRaises(ParseException):
|
||||
calc.evaluator({}, {}, '1+.')
|
||||
|
||||
def test_trailing_period(self):
|
||||
"""
|
||||
Test that things like '4.' will be 4 and not throw an error
|
||||
"""
|
||||
self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
|
||||
|
||||
def test_exponential_answer(self):
|
||||
"""
|
||||
Test for correct interpretation of scientific notation
|
||||
"""
|
||||
answer = 50
|
||||
correct_responses = [
|
||||
"50", "50.0", "5e1", "5e+1",
|
||||
"50e0", "50.0e0", "500e-1"
|
||||
]
|
||||
incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"]
|
||||
|
||||
for input_str in correct_responses:
|
||||
result = calc.evaluator({}, {}, input_str)
|
||||
fail_msg = "Expected '{0}' to equal {1}".format(
|
||||
input_str, answer
|
||||
)
|
||||
self.assertEqual(answer, result, msg=fail_msg)
|
||||
|
||||
for input_str in incorrect_responses:
|
||||
result = calc.evaluator({}, {}, input_str)
|
||||
fail_msg = "Expected '{0}' to not equal {1}".format(
|
||||
input_str, answer
|
||||
)
|
||||
self.assertNotEqual(answer, result, msg=fail_msg)
|
||||
|
||||
def test_si_suffix(self):
|
||||
"""
|
||||
Test calc.py's unique functionality of interpreting si 'suffixes'.
|
||||
|
||||
For instance '%' stand for 1/100th so '1%' should be 0.01
|
||||
"""
|
||||
test_mapping = [
|
||||
('4.2%', 0.042)
|
||||
]
|
||||
|
||||
for (expr, answer) in test_mapping:
|
||||
tolerance = answer * 1e-6 # Make rel. tolerance, because of floats
|
||||
fail_msg = "Failure in testing suffix '{0}': '{1}' was not {2}"
|
||||
fail_msg = fail_msg.format(expr[-1], expr, answer)
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator({}, {}, expr), answer,
|
||||
delta=tolerance, msg=fail_msg
|
||||
)
|
||||
|
||||
def test_operator_sanity(self):
|
||||
"""
|
||||
Test for simple things like '5+2' and '5/2'
|
||||
"""
|
||||
var1 = 5.0
|
||||
var2 = 2.0
|
||||
operators = [('+', 7), ('-', 3), ('*', 10), ('/', 2.5), ('^', 25)]
|
||||
|
||||
for (operator, answer) in operators:
|
||||
input_str = "{0} {1} {2}".format(var1, operator, var2)
|
||||
result = calc.evaluator({}, {}, input_str)
|
||||
fail_msg = "Failed on operator '{0}': '{1}' was not {2}".format(
|
||||
operator, input_str, answer
|
||||
)
|
||||
self.assertEqual(answer, result, msg=fail_msg)
|
||||
|
||||
def test_raises_zero_division_err(self):
|
||||
"""
|
||||
Ensure division by zero gives an error
|
||||
"""
|
||||
with self.assertRaises(ZeroDivisionError):
|
||||
calc.evaluator({}, {}, '1/0')
|
||||
with self.assertRaises(ZeroDivisionError):
|
||||
calc.evaluator({}, {}, '1/0.0')
|
||||
with self.assertRaises(ZeroDivisionError):
|
||||
calc.evaluator({'x': 0.0}, {}, '1/x')
|
||||
|
||||
def test_parallel_resistors(self):
|
||||
"""
|
||||
Test the parallel resistor operator ||
|
||||
|
||||
The formula is given by
|
||||
a || b || c ...
|
||||
= 1 / (1/a + 1/b + 1/c + ...)
|
||||
It is the resistance of a parallel circuit of resistors with resistance
|
||||
a, b, c, etc&. See if this evaulates correctly.
|
||||
"""
|
||||
self.assertEqual(calc.evaluator({}, {}, '1||1'), 0.5)
|
||||
self.assertEqual(calc.evaluator({}, {}, '1||1||2'), 0.4)
|
||||
self.assertEqual(calc.evaluator({}, {}, "j||1"), 0.5 + 0.5j)
|
||||
|
||||
def test_parallel_resistors_with_zero(self):
|
||||
"""
|
||||
Check the behavior of the || operator with 0
|
||||
"""
|
||||
self.assertTrue(numpy.isnan(calc.evaluator({}, {}, '0||1')))
|
||||
self.assertTrue(numpy.isnan(calc.evaluator({}, {}, '0.0||1')))
|
||||
self.assertTrue(numpy.isnan(calc.evaluator({'x': 0.0}, {}, 'x||1')))
|
||||
|
||||
def assert_function_values(self, fname, ins, outs, tolerance=1e-3):
|
||||
"""
|
||||
Helper function to test many values at once
|
||||
|
||||
Test the accuracy of evaluator's use of the function given by fname
|
||||
Specifically, the equality of `fname(ins[i])` against outs[i].
|
||||
This is used later to test a whole bunch of f(x) = y at a time
|
||||
"""
|
||||
|
||||
for (arg, val) in zip(ins, outs):
|
||||
input_str = "{0}({1})".format(fname, arg)
|
||||
result = calc.evaluator({}, {}, input_str)
|
||||
fail_msg = "Failed on function {0}: '{1}' was not {2}".format(
|
||||
fname, input_str, val
|
||||
)
|
||||
self.assertAlmostEqual(val, result, delta=tolerance, msg=fail_msg)
|
||||
|
||||
def test_trig_functions(self):
|
||||
"""
|
||||
Test the trig functions provided in calc.py
|
||||
|
||||
which are: sin, cos, tan, arccos, arcsin, arctan
|
||||
"""
|
||||
|
||||
angles = ['-pi/4', '0', 'pi/6', 'pi/5', '5*pi/4', '9*pi/4', '1 + j']
|
||||
sin_values = [-0.707, 0, 0.5, 0.588, -0.707, 0.707, 1.298 + 0.635j]
|
||||
cos_values = [0.707, 1, 0.866, 0.809, -0.707, 0.707, 0.834 - 0.989j]
|
||||
tan_values = [-1, 0, 0.577, 0.727, 1, 1, 0.272 + 1.084j]
|
||||
# Cannot test tan(pi/2) b/c pi/2 is a float and not precise...
|
||||
|
||||
self.assert_function_values('sin', angles, sin_values)
|
||||
self.assert_function_values('cos', angles, cos_values)
|
||||
self.assert_function_values('tan', angles, tan_values)
|
||||
|
||||
# Include those where the real part is between -pi/2 and pi/2
|
||||
arcsin_inputs = ['-0.707', '0', '0.5', '0.588', '1.298 + 0.635*j', '-1.1', '1.1']
|
||||
arcsin_angles = [-0.785, 0, 0.524, 0.629, 1 + 1j, -1.570 + 0.443j, 1.570 + 0.443j]
|
||||
self.assert_function_values('arcsin', arcsin_inputs, arcsin_angles)
|
||||
|
||||
# Include those where the real part is between 0 and pi
|
||||
arccos_inputs = ['1', '0.866', '0.809', '0.834-0.989*j', '-1.1', '1.1']
|
||||
arccos_angles = [0, 0.524, 0.628, 1 + 1j, 3.141 - 0.443j, -0.443j]
|
||||
self.assert_function_values('arccos', arccos_inputs, arccos_angles)
|
||||
|
||||
# Has the same range as arcsin
|
||||
arctan_inputs = ['-1', '0', '0.577', '0.727', '0.272 + 1.084*j']
|
||||
arctan_angles = arcsin_angles
|
||||
self.assert_function_values('arctan', arctan_inputs, arctan_angles)
|
||||
|
||||
def test_reciprocal_trig_functions(self):
|
||||
"""
|
||||
Test the reciprocal trig functions provided in calc.py
|
||||
|
||||
which are: sec, csc, cot, arcsec, arccsc, arccot
|
||||
"""
|
||||
angles = ['-pi/4', 'pi/6', 'pi/5', '5*pi/4', '9*pi/4', '1 + j']
|
||||
sec_values = [1.414, 1.155, 1.236, -1.414, 1.414, 0.498 + 0.591j]
|
||||
csc_values = [-1.414, 2, 1.701, -1.414, 1.414, 0.622 - 0.304j]
|
||||
cot_values = [-1, 1.732, 1.376, 1, 1, 0.218 - 0.868j]
|
||||
|
||||
self.assert_function_values('sec', angles, sec_values)
|
||||
self.assert_function_values('csc', angles, csc_values)
|
||||
self.assert_function_values('cot', angles, cot_values)
|
||||
|
||||
arcsec_inputs = ['1.1547', '1.2361', '2', '-2', '-1.4142', '0.4983+0.5911*j']
|
||||
arcsec_angles = [0.524, 0.628, 1.047, 2.094, 2.356, 1 + 1j]
|
||||
self.assert_function_values('arcsec', arcsec_inputs, arcsec_angles)
|
||||
|
||||
arccsc_inputs = ['-1.1547', '-1.4142', '2', '1.7013', '1.1547', '0.6215-0.3039*j']
|
||||
arccsc_angles = [-1.047, -0.785, 0.524, 0.628, 1.047, 1 + 1j]
|
||||
self.assert_function_values('arccsc', arccsc_inputs, arccsc_angles)
|
||||
|
||||
# Has the same range as arccsc
|
||||
arccot_inputs = ['-0.5774', '-1', '1.7321', '1.3764', '0.5774', '(0.2176-0.868*j)']
|
||||
arccot_angles = arccsc_angles
|
||||
self.assert_function_values('arccot', arccot_inputs, arccot_angles)
|
||||
|
||||
def test_hyperbolic_functions(self):
|
||||
"""
|
||||
Test the hyperbolic functions
|
||||
|
||||
which are: sinh, cosh, tanh, sech, csch, coth
|
||||
"""
|
||||
inputs = ['0', '0.5', '1', '2', '1+j']
|
||||
neg_inputs = ['0', '-0.5', '-1', '-2', '-1-j']
|
||||
negate = lambda x: [-k for k in x]
|
||||
|
||||
# sinh is odd
|
||||
sinh_vals = [0, 0.521, 1.175, 3.627, 0.635 + 1.298j]
|
||||
self.assert_function_values('sinh', inputs, sinh_vals)
|
||||
self.assert_function_values('sinh', neg_inputs, negate(sinh_vals))
|
||||
|
||||
# cosh is even - do not negate
|
||||
cosh_vals = [1, 1.128, 1.543, 3.762, 0.834 + 0.989j]
|
||||
self.assert_function_values('cosh', inputs, cosh_vals)
|
||||
self.assert_function_values('cosh', neg_inputs, cosh_vals)
|
||||
|
||||
# tanh is odd
|
||||
tanh_vals = [0, 0.462, 0.762, 0.964, 1.084 + 0.272j]
|
||||
self.assert_function_values('tanh', inputs, tanh_vals)
|
||||
self.assert_function_values('tanh', neg_inputs, negate(tanh_vals))
|
||||
|
||||
# sech is even - do not negate
|
||||
sech_vals = [1, 0.887, 0.648, 0.266, 0.498 - 0.591j]
|
||||
self.assert_function_values('sech', inputs, sech_vals)
|
||||
self.assert_function_values('sech', neg_inputs, sech_vals)
|
||||
|
||||
# the following functions do not have 0 in their domain
|
||||
inputs = inputs[1:]
|
||||
neg_inputs = neg_inputs[1:]
|
||||
|
||||
# csch is odd
|
||||
csch_vals = [1.919, 0.851, 0.276, 0.304 - 0.622j]
|
||||
self.assert_function_values('csch', inputs, csch_vals)
|
||||
self.assert_function_values('csch', neg_inputs, negate(csch_vals))
|
||||
|
||||
# coth is odd
|
||||
coth_vals = [2.164, 1.313, 1.037, 0.868 - 0.218j]
|
||||
self.assert_function_values('coth', inputs, coth_vals)
|
||||
self.assert_function_values('coth', neg_inputs, negate(coth_vals))
|
||||
|
||||
def test_hyperbolic_inverses(self):
|
||||
"""
|
||||
Test the inverse hyperbolic functions
|
||||
|
||||
which are of the form arc[X]h
|
||||
"""
|
||||
results = [0, 0.5, 1, 2, 1 + 1j]
|
||||
|
||||
sinh_vals = ['0', '0.5211', '1.1752', '3.6269', '0.635+1.2985*j']
|
||||
self.assert_function_values('arcsinh', sinh_vals, results)
|
||||
|
||||
cosh_vals = ['1', '1.1276', '1.5431', '3.7622', '0.8337+0.9889*j']
|
||||
self.assert_function_values('arccosh', cosh_vals, results)
|
||||
|
||||
tanh_vals = ['0', '0.4621', '0.7616', '0.964', '1.0839+0.2718*j']
|
||||
self.assert_function_values('arctanh', tanh_vals, results)
|
||||
|
||||
sech_vals = ['1.0', '0.8868', '0.6481', '0.2658', '0.4983-0.5911*j']
|
||||
self.assert_function_values('arcsech', sech_vals, results)
|
||||
|
||||
results = results[1:]
|
||||
csch_vals = ['1.919', '0.8509', '0.2757', '0.3039-0.6215*j']
|
||||
self.assert_function_values('arccsch', csch_vals, results)
|
||||
|
||||
coth_vals = ['2.164', '1.313', '1.0373', '0.868-0.2176*j']
|
||||
self.assert_function_values('arccoth', coth_vals, results)
|
||||
|
||||
def test_other_functions(self):
|
||||
"""
|
||||
Test the non-trig functions provided in calc.py
|
||||
|
||||
Specifically:
|
||||
sqrt, log10, log2, ln, abs,
|
||||
fact, factorial
|
||||
"""
|
||||
|
||||
# Test sqrt
|
||||
self.assert_function_values(
|
||||
'sqrt',
|
||||
[0, 1, 2, 1024], # -1
|
||||
[0, 1, 1.414, 32] # 1j
|
||||
)
|
||||
# sqrt(-1) is NAN not j (!!).
|
||||
|
||||
# Test logs
|
||||
self.assert_function_values(
|
||||
'log10',
|
||||
[0.1, 1, 3.162, 1000000, '1+j'],
|
||||
[-1, 0, 0.5, 6, 0.151 + 0.341j]
|
||||
)
|
||||
self.assert_function_values(
|
||||
'log2',
|
||||
[0.5, 1, 1.414, 1024, '1+j'],
|
||||
[-1, 0, 0.5, 10, 0.5 + 1.133j]
|
||||
)
|
||||
self.assert_function_values(
|
||||
'ln',
|
||||
[0.368, 1, 1.649, 2.718, 42, '1+j'],
|
||||
[-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j]
|
||||
)
|
||||
|
||||
# Test abs
|
||||
self.assert_function_values('abs', [-1, 0, 1, 'j'], [1, 0, 1, 1])
|
||||
|
||||
# Test factorial
|
||||
fact_inputs = [0, 1, 3, 7]
|
||||
fact_values = [1, 1, 6, 5040]
|
||||
self.assert_function_values('fact', fact_inputs, fact_values)
|
||||
self.assert_function_values('factorial', fact_inputs, fact_values)
|
||||
|
||||
self.assertRaises(ValueError, calc.evaluator, {}, {}, "fact(-1)")
|
||||
self.assertRaises(ValueError, calc.evaluator, {}, {}, "fact(0.5)")
|
||||
self.assertRaises(ValueError, calc.evaluator, {}, {}, "factorial(-1)")
|
||||
self.assertRaises(ValueError, calc.evaluator, {}, {}, "factorial(0.5)")
|
||||
|
||||
def test_constants(self):
|
||||
"""
|
||||
Test the default constants provided in calc.py
|
||||
|
||||
which are: j (complex number), e, pi
|
||||
"""
|
||||
|
||||
# Of the form ('expr', python value, tolerance (or None for exact))
|
||||
default_variables = [
|
||||
('i', 1j, None),
|
||||
('j', 1j, None),
|
||||
('e', 2.7183, 1e-4),
|
||||
('pi', 3.1416, 1e-4),
|
||||
]
|
||||
for (variable, value, tolerance) in default_variables:
|
||||
fail_msg = "Failed on constant '{0}', not within bounds".format(
|
||||
variable
|
||||
)
|
||||
result = calc.evaluator({}, {}, variable)
|
||||
if tolerance is None:
|
||||
self.assertEqual(value, result, msg=fail_msg)
|
||||
else:
|
||||
self.assertAlmostEqual(
|
||||
value, result,
|
||||
delta=tolerance, msg=fail_msg
|
||||
)
|
||||
|
||||
def test_complex_expression(self):
|
||||
"""
|
||||
Calculate combinations of operators and default functions
|
||||
"""
|
||||
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator({}, {}, "(2^2+1.0)/sqrt(5e0)*5-1"),
|
||||
10.180,
|
||||
delta=1e-3
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator({}, {}, "1+1/(1+1/(1+1/(1+1)))"),
|
||||
1.6,
|
||||
delta=1e-3
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator({}, {}, "10||sin(7+5)"),
|
||||
-0.567, delta=0.01
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator({}, {}, "sin(e)"),
|
||||
0.41, delta=0.01
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator({}, {}, "e^(j*pi)"),
|
||||
-1, delta=1e-5
|
||||
)
|
||||
|
||||
def test_explicit_sci_notation(self):
|
||||
"""
|
||||
Expressions like 1.6*10^-3 (not 1.6e-3) it should evaluate.
|
||||
"""
|
||||
self.assertEqual(
|
||||
calc.evaluator({}, {}, "-1.6*10^-3"),
|
||||
-0.0016
|
||||
)
|
||||
self.assertEqual(
|
||||
calc.evaluator({}, {}, "-1.6*10^(-3)"),
|
||||
-0.0016
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calc.evaluator({}, {}, "-1.6*10^3"),
|
||||
-1600
|
||||
)
|
||||
self.assertEqual(
|
||||
calc.evaluator({}, {}, "-1.6*10^(3)"),
|
||||
-1600
|
||||
)
|
||||
|
||||
def test_simple_vars(self):
|
||||
"""
|
||||
Substitution of variables into simple equations
|
||||
"""
|
||||
variables = {'x': 9.72, 'y': 7.91, 'loooooong': 6.4, "f_0'": 2.0, "T_{ijk}^{123}''": 5.2}
|
||||
|
||||
# Should not change value of constant
|
||||
# even with different numbers of variables...
|
||||
self.assertEqual(calc.evaluator({'x': 9.72}, {}, '13'), 13)
|
||||
self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, '13'), 13)
|
||||
self.assertEqual(calc.evaluator(variables, {}, '13'), 13)
|
||||
|
||||
# Easy evaluation
|
||||
self.assertEqual(calc.evaluator(variables, {}, 'x'), 9.72)
|
||||
self.assertEqual(calc.evaluator(variables, {}, 'y'), 7.91)
|
||||
self.assertEqual(calc.evaluator(variables, {}, 'loooooong'), 6.4)
|
||||
self.assertEqual(calc.evaluator(variables, {}, "f_0'"), 2.0)
|
||||
self.assertEqual(calc.evaluator(variables, {}, "T_{ijk}^{123}''"), 5.2)
|
||||
|
||||
# Test a simple equation
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator(variables, {}, '3*x-y'),
|
||||
21.25, delta=0.01 # = 3 * 9.72 - 7.91
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator(variables, {}, 'x*y'),
|
||||
76.89, delta=0.01
|
||||
)
|
||||
|
||||
self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, "13"), 13)
|
||||
self.assertEqual(calc.evaluator(variables, {}, "13"), 13)
|
||||
self.assertEqual(
|
||||
calc.evaluator(
|
||||
{'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.6600949841121},
|
||||
{}, "5"
|
||||
),
|
||||
5
|
||||
)
|
||||
|
||||
def test_variable_case_sensitivity(self):
|
||||
"""
|
||||
Test the case sensitivity flag and corresponding behavior
|
||||
"""
|
||||
self.assertEqual(
|
||||
calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "r1*r3"),
|
||||
8.0
|
||||
)
|
||||
|
||||
variables = {'E': 1.0}
|
||||
self.assertEqual(
|
||||
calc.evaluator(variables, {}, "E", case_sensitive=True),
|
||||
1.0
|
||||
)
|
||||
# Recall 'e' is a default constant, with value 2.718
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator(variables, {}, "e", case_sensitive=True),
|
||||
2.718, delta=0.02
|
||||
)
|
||||
|
||||
def test_simple_funcs(self):
|
||||
"""
|
||||
Subsitution of custom functions
|
||||
"""
|
||||
variables = {'x': 4.712}
|
||||
functions = {'id': lambda x: x}
|
||||
self.assertEqual(calc.evaluator({}, functions, 'id(2.81)'), 2.81)
|
||||
self.assertEqual(calc.evaluator({}, functions, 'id(2.81)'), 2.81)
|
||||
self.assertEqual(calc.evaluator(variables, functions, 'id(x)'), 4.712)
|
||||
|
||||
functions.update({'f': numpy.sin})
|
||||
self.assertAlmostEqual(
|
||||
calc.evaluator(variables, functions, 'f(x)'),
|
||||
-1, delta=1e-3
|
||||
)
|
||||
|
||||
def test_function_case_insensitive(self):
|
||||
"""
|
||||
Test case insensitive evaluation
|
||||
|
||||
Normal functions with some capitals should be fine
|
||||
"""
|
||||
self.assertAlmostEqual(
|
||||
-0.28,
|
||||
calc.evaluator({}, {}, 'SiN(6)', case_sensitive=False),
|
||||
delta=1e-3
|
||||
)
|
||||
|
||||
def test_function_case_sensitive(self):
|
||||
"""
|
||||
Test case sensitive evaluation
|
||||
|
||||
Incorrectly capitilized should fail
|
||||
Also, it should pick the correct version of a function.
|
||||
"""
|
||||
with self.assertRaisesRegexp(calc.UndefinedVariable, 'SiN'):
|
||||
calc.evaluator({}, {}, 'SiN(6)', case_sensitive=True)
|
||||
|
||||
# With case sensitive turned on, it should pick the right function
|
||||
functions = {'f': lambda x: x, 'F': lambda x: x + 1}
|
||||
self.assertEqual(
|
||||
6, calc.evaluator({}, functions, 'f(6)', case_sensitive=True)
|
||||
)
|
||||
self.assertEqual(
|
||||
7, calc.evaluator({}, functions, 'F(6)', case_sensitive=True)
|
||||
)
|
||||
|
||||
def test_undefined_vars(self):
|
||||
"""
|
||||
Check to see if the evaluator catches undefined variables
|
||||
"""
|
||||
variables = {'R1': 2.0, 'R3': 4.0}
|
||||
|
||||
with self.assertRaisesRegexp(calc.UndefinedVariable, r'QWSEKO'):
|
||||
calc.evaluator({}, {}, "5+7*QWSEKO")
|
||||
with self.assertRaisesRegexp(calc.UndefinedVariable, r'r2'):
|
||||
calc.evaluator({'r1': 5}, {}, "r1+r2")
|
||||
with self.assertRaisesRegexp(calc.UndefinedVariable, r'r1, r3'):
|
||||
calc.evaluator(variables, {}, "r1*r3", case_sensitive=True)
|
||||
with self.assertRaisesRegexp(calc.UndefinedVariable, r'did you forget to use \*'):
|
||||
calc.evaluator(variables, {}, "R1(R3 + 1)")
|
||||
|
||||
def test_mismatched_parens(self):
|
||||
"""
|
||||
Check to see if the evaluator catches mismatched parens
|
||||
"""
|
||||
with self.assertRaisesRegexp(calc.UnmatchedParenthesis, 'opened but never closed'):
|
||||
calc.evaluator({}, {}, "(1+2")
|
||||
with self.assertRaisesRegexp(calc.UnmatchedParenthesis, 'no matching opening parenthesis'):
|
||||
calc.evaluator({}, {}, "(1+2))")
|
||||
@@ -1,241 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Unit tests for preview.py
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
import unittest
|
||||
from calc import preview
|
||||
import pyparsing
|
||||
|
||||
|
||||
class LatexRenderedTest(unittest.TestCase):
|
||||
"""
|
||||
Test the initializing code for LatexRendered.
|
||||
|
||||
Specifically that it stores the correct data and handles parens well.
|
||||
"""
|
||||
def test_simple(self):
|
||||
"""
|
||||
Test that the data values are stored without changing.
|
||||
"""
|
||||
math = 'x^2'
|
||||
obj = preview.LatexRendered(math, tall=True)
|
||||
self.assertEquals(obj.latex, math)
|
||||
self.assertEquals(obj.sans_parens, math)
|
||||
self.assertEquals(obj.tall, True)
|
||||
|
||||
def _each_parens(self, with_parens, math, parens, tall=False):
|
||||
"""
|
||||
Helper method to test the way parens are wrapped.
|
||||
"""
|
||||
obj = preview.LatexRendered(math, parens=parens, tall=tall)
|
||||
self.assertEquals(obj.latex, with_parens)
|
||||
self.assertEquals(obj.sans_parens, math)
|
||||
self.assertEquals(obj.tall, tall)
|
||||
|
||||
def test_parens(self):
|
||||
""" Test curvy parens. """
|
||||
self._each_parens('(x+y)', 'x+y', '(')
|
||||
|
||||
def test_brackets(self):
|
||||
""" Test brackets. """
|
||||
self._each_parens('[x+y]', 'x+y', '[')
|
||||
|
||||
def test_squiggles(self):
|
||||
""" Test curly braces. """
|
||||
self._each_parens(r'\{x+y\}', 'x+y', '{')
|
||||
|
||||
def test_parens_tall(self):
|
||||
""" Test curvy parens with the tall parameter. """
|
||||
self._each_parens(r'\left(x^y\right)', 'x^y', '(', tall=True)
|
||||
|
||||
def test_brackets_tall(self):
|
||||
""" Test brackets, also tall. """
|
||||
self._each_parens(r'\left[x^y\right]', 'x^y', '[', tall=True)
|
||||
|
||||
def test_squiggles_tall(self):
|
||||
""" Test tall curly braces. """
|
||||
self._each_parens(r'\left\{x^y\right\}', 'x^y', '{', tall=True)
|
||||
|
||||
def test_bad_parens(self):
|
||||
""" Check that we get an error with invalid parens. """
|
||||
with self.assertRaisesRegexp(Exception, 'Unknown parenthesis'):
|
||||
preview.LatexRendered('x^2', parens='not parens')
|
||||
|
||||
|
||||
class LatexPreviewTest(unittest.TestCase):
|
||||
"""
|
||||
Run integrative tests for `latex_preview`.
|
||||
|
||||
All functionality was tested `RenderMethodsTest`, but see if it combines
|
||||
all together correctly.
|
||||
"""
|
||||
def test_no_input(self):
|
||||
"""
|
||||
With no input (including just whitespace), see that no error is thrown.
|
||||
"""
|
||||
self.assertEquals('', preview.latex_preview(''))
|
||||
self.assertEquals('', preview.latex_preview(' '))
|
||||
self.assertEquals('', preview.latex_preview(' \t '))
|
||||
|
||||
def test_number_simple(self):
|
||||
""" Simple numbers should pass through. """
|
||||
self.assertEquals(preview.latex_preview('3.1415'), '3.1415')
|
||||
|
||||
def test_number_suffix(self):
|
||||
""" Suffixes should be escaped. """
|
||||
self.assertEquals(preview.latex_preview('1.618%'), r'1.618\text{%}')
|
||||
|
||||
def test_number_sci_notation(self):
|
||||
""" Numbers with scientific notation should display nicely """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('6.0221413E+23'),
|
||||
r'6.0221413\!\times\!10^{+23}'
|
||||
)
|
||||
self.assertEquals(
|
||||
preview.latex_preview('-6.0221413E+23'),
|
||||
r'-6.0221413\!\times\!10^{+23}'
|
||||
)
|
||||
|
||||
def test_variable_simple(self):
|
||||
""" Simple valid variables should pass through. """
|
||||
self.assertEquals(preview.latex_preview('x', variables=['x']), 'x')
|
||||
|
||||
def test_greek(self):
|
||||
""" Variable names that are greek should be formatted accordingly. """
|
||||
self.assertEquals(preview.latex_preview('pi'), r'\pi')
|
||||
|
||||
def test_variable_subscript(self):
|
||||
""" Things like 'epsilon_max' should display nicely """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('epsilon_max', variables=['epsilon_max']),
|
||||
r'\epsilon_{max}'
|
||||
)
|
||||
|
||||
def test_function_simple(self):
|
||||
""" Valid function names should be escaped. """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('f(3)', functions=['f']),
|
||||
r'\text{f}(3)'
|
||||
)
|
||||
|
||||
def test_function_tall(self):
|
||||
r""" Functions surrounding a tall element should have \left, \right """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('f(3^2)', functions=['f']),
|
||||
r'\text{f}\left(3^{2}\right)'
|
||||
)
|
||||
|
||||
def test_function_sqrt(self):
|
||||
""" Sqrt function should be handled specially. """
|
||||
self.assertEquals(preview.latex_preview('sqrt(3)'), r'\sqrt{3}')
|
||||
|
||||
def test_function_log10(self):
|
||||
""" log10 function should be handled specially. """
|
||||
self.assertEquals(preview.latex_preview('log10(3)'), r'\log_{10}(3)')
|
||||
|
||||
def test_function_log2(self):
|
||||
""" log2 function should be handled specially. """
|
||||
self.assertEquals(preview.latex_preview('log2(3)'), r'\log_2(3)')
|
||||
|
||||
def test_power_simple(self):
|
||||
""" Powers should wrap the elements with braces correctly. """
|
||||
self.assertEquals(preview.latex_preview('2^3^4'), '2^{3^{4}}')
|
||||
|
||||
def test_power_parens(self):
|
||||
""" Powers should ignore the parenthesis of the last math. """
|
||||
self.assertEquals(preview.latex_preview('2^3^(4+5)'), '2^{3^{4+5}}')
|
||||
|
||||
def test_parallel(self):
|
||||
r""" Parallel items should combine with '\|'. """
|
||||
self.assertEquals(preview.latex_preview('2||3'), r'2\|3')
|
||||
|
||||
def test_product_mult_only(self):
|
||||
r""" Simple products should combine with a '\cdot'. """
|
||||
self.assertEquals(preview.latex_preview('2*3'), r'2\cdot 3')
|
||||
|
||||
def test_product_big_frac(self):
|
||||
""" Division should combine with '\frac'. """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('2*3/4/5'),
|
||||
r'\frac{2\cdot 3}{4\cdot 5}'
|
||||
)
|
||||
|
||||
def test_product_single_frac(self):
|
||||
""" Division should ignore parens if they are extraneous. """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('(2+3)/(4+5)'),
|
||||
r'\frac{2+3}{4+5}'
|
||||
)
|
||||
|
||||
def test_product_keep_going(self):
|
||||
"""
|
||||
Complex products/quotients should split into many '\frac's when needed.
|
||||
"""
|
||||
self.assertEquals(
|
||||
preview.latex_preview('2/3*4/5*6'),
|
||||
r'\frac{2}{3}\cdot \frac{4}{5}\cdot 6'
|
||||
)
|
||||
|
||||
def test_sum(self):
|
||||
""" Sums should combine its elements. """
|
||||
# Use 'x' as the first term (instead of, say, '1'), so it can't be
|
||||
# interpreted as a negative number.
|
||||
self.assertEquals(
|
||||
preview.latex_preview('-x+2-3+4', variables=['x']),
|
||||
'-x+2-3+4'
|
||||
)
|
||||
|
||||
def test_sum_tall(self):
|
||||
""" A complicated expression should not hide the tallness. """
|
||||
self.assertEquals(
|
||||
preview.latex_preview('(2+3^2)'),
|
||||
r'\left(2+3^{2}\right)'
|
||||
)
|
||||
|
||||
def test_complicated(self):
|
||||
"""
|
||||
Given complicated input, ensure that exactly the correct string is made.
|
||||
"""
|
||||
self.assertEquals(
|
||||
preview.latex_preview('11*f(x)+x^2*(3||4)/sqrt(pi)'),
|
||||
r'11\cdot \text{f}(x)+\frac{x^{2}\cdot (3\|4)}{\sqrt{\pi}}'
|
||||
)
|
||||
|
||||
self.assertEquals(
|
||||
preview.latex_preview('log10(1+3/4/Cos(x^2)*(x+1))',
|
||||
case_sensitive=True),
|
||||
(r'\log_{10}\left(1+\frac{3}{4\cdot \text{Cos}\left(x^{2}\right)}'
|
||||
r'\cdot (x+1)\right)')
|
||||
)
|
||||
|
||||
def test_syntax_errors(self):
|
||||
"""
|
||||
Test a lot of math strings that give syntax errors
|
||||
|
||||
Rather than have a lot of self.assertRaises, make a loop and keep track
|
||||
of those that do not throw a `ParseException`, and assert at the end.
|
||||
"""
|
||||
bad_math_list = [
|
||||
'11+',
|
||||
'11*',
|
||||
'f((x)',
|
||||
'sqrt(x^)',
|
||||
'3f(x)', # Not 3*f(x)
|
||||
'3|4',
|
||||
'3|||4'
|
||||
]
|
||||
bad_exceptions = {}
|
||||
for math in bad_math_list:
|
||||
try:
|
||||
preview.latex_preview(math)
|
||||
except pyparsing.ParseException:
|
||||
pass # This is what we were expecting. (not excepting :P)
|
||||
except Exception as error: # pragma: no cover
|
||||
bad_exceptions[math] = error
|
||||
else: # pragma: no cover
|
||||
# If there is no exception thrown, this is a problem
|
||||
bad_exceptions[math] = None
|
||||
|
||||
self.assertEquals({}, bad_exceptions)
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import absolute_import
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="calc",
|
||||
version="0.3",
|
||||
packages=["calc"],
|
||||
install_requires=[
|
||||
"pyparsing==2.2.0",
|
||||
"numpy",
|
||||
"scipy",
|
||||
],
|
||||
)
|
||||
@@ -12,7 +12,6 @@ XMODULES = [
|
||||
"library_content = xmodule.library_content_module:LibraryContentDescriptor",
|
||||
"error = xmodule.error_module:ErrorDescriptor",
|
||||
"poll_question = xmodule.poll_module:PollDescriptor",
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
"randomize = xmodule.randomize_module:RandomizeDescriptor",
|
||||
"split_test = xmodule.split_test_module:SplitTestDescriptor",
|
||||
@@ -35,6 +34,7 @@ XMODULES = [
|
||||
]
|
||||
XBLOCKS = [
|
||||
"library = xmodule.library_root_xblock:LibraryRoot",
|
||||
"problem = xmodule.capa_module:ProblemBlock",
|
||||
"vertical = xmodule.vertical_block:VerticalBlock",
|
||||
"wrapper = xmodule.wrapper_module:WrapperBlock",
|
||||
]
|
||||
|
||||
19
common/lib/xmodule/xmodule/README.rst
Normal file
19
common/lib/xmodule/xmodule/README.rst
Normal file
@@ -0,0 +1,19 @@
|
||||
Status: Deprecated (DEPR-24)
|
||||
|
||||
Responsibilities
|
||||
================
|
||||
XModules render specific course run content types to users for both authoring and learning. For instance, there is an XModule for Videos, another for HTML snippets, and another for Sequences. This package provides both the implementations of these XModules as well as some supporting utilities.
|
||||
|
||||
Direction: Convert and Extract
|
||||
==============================
|
||||
XModule exists today as a complex set of compatibility shims on top of XBlock (all XModules currently inherit from XBlock). The goal is for all XModules to either be converted into pure XBlocks or be deleted altogether. Extracting them into separate repositories would be ideal, but even just converting them to pure XBlocks would significantly simplify the runtime.
|
||||
|
||||
Glossary
|
||||
========
|
||||
|
||||
More Documentation
|
||||
==================
|
||||
|
||||
`DEPR-24 <https://openedx.atlassian.net/browse/DEPR-24>`_
|
||||
|
||||
`Example conversion of Capa <https://github.com/edx/edx-platform/pull/20023/>`_
|
||||
@@ -11,8 +11,10 @@ import sys
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from pytz import utc
|
||||
from django.utils.encoding import smart_text
|
||||
from django.utils.functional import cached_property
|
||||
from six import text_type
|
||||
|
||||
from capa.capa_problem import LoncapaProblem, LoncapaSystem
|
||||
@@ -20,15 +22,15 @@ from capa.inputtypes import Status
|
||||
from capa.responsetypes import StudentInputError, ResponseError, LoncapaProblemError
|
||||
from capa.util import convert_files_to_filenames, get_inner_html_from_xpath
|
||||
from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from xblock.fields import String
|
||||
from xblock.scorable import ScorableXBlockMixin, Score
|
||||
from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.graders import ShowCorrectness
|
||||
from .fields import Date, Timedelta, ScoreField
|
||||
from .progress import Progress
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
@@ -40,7 +42,36 @@ NUM_RANDOMIZATION_BINS = 20
|
||||
# Never produce more than this many different seeds, no matter what.
|
||||
MAX_RANDOMIZATION_BINS = 1000
|
||||
|
||||
FEATURES = getattr(settings, 'FEATURES', {})
|
||||
|
||||
try:
|
||||
FEATURES = getattr(settings, 'FEATURES', {})
|
||||
except ImproperlyConfigured:
|
||||
FEATURES = {}
|
||||
|
||||
|
||||
class SHOWANSWER(object):
|
||||
"""
|
||||
Constants for when to show answer
|
||||
"""
|
||||
ALWAYS = "always"
|
||||
ANSWERED = "answered"
|
||||
ATTEMPTED = "attempted"
|
||||
CLOSED = "closed"
|
||||
FINISHED = "finished"
|
||||
CORRECT_OR_PAST_DUE = "correct_or_past_due"
|
||||
PAST_DUE = "past_due"
|
||||
NEVER = "never"
|
||||
AFTER_SOME_NUMBER_OF_ATTEMPTS = "after_attempts"
|
||||
|
||||
|
||||
class RANDOMIZATION(object):
|
||||
"""
|
||||
Constants for problem randomization
|
||||
"""
|
||||
ALWAYS = "always"
|
||||
ONRESET = "onreset"
|
||||
NEVER = "never"
|
||||
PER_STUDENT = "per_student"
|
||||
|
||||
|
||||
def randomization_bin(seed, problem_id):
|
||||
@@ -145,7 +176,9 @@ class CapaFields(object):
|
||||
)
|
||||
attempts_before_showanswer_button = Integer(
|
||||
display_name=_("Show Answer: Number of Attempts"),
|
||||
help=_("Number of times the student must attempt to answer the question before the Show Answer button appears."),
|
||||
help=_(
|
||||
"Number of times the student must attempt to answer the question before the Show Answer button appears."
|
||||
),
|
||||
values={"min": 0},
|
||||
default=0,
|
||||
scope=Scope.settings,
|
||||
@@ -234,75 +267,40 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
"""
|
||||
Core logic for Capa Problem, which can be used by XModules or XBlocks.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CapaMixin, self).__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def close_date(self):
|
||||
"""
|
||||
Return the date submissions should be closed from.
|
||||
"""
|
||||
due_date = self.due
|
||||
|
||||
if self.graceperiod is not None and due_date:
|
||||
self.close_date = due_date + self.graceperiod
|
||||
return due_date + self.graceperiod
|
||||
else:
|
||||
self.close_date = due_date
|
||||
return due_date
|
||||
|
||||
def get_seed(self):
|
||||
"""
|
||||
Generate the seed if not set and return it.
|
||||
"""
|
||||
if self.seed is None:
|
||||
self.choose_new_seed()
|
||||
return self.seed
|
||||
|
||||
# Need the problem location in openendedresponse to send out. Adding
|
||||
# it to the system here seems like the least clunky way to get it
|
||||
# there.
|
||||
self.runtime.set('location', text_type(self.location))
|
||||
|
||||
@cached_property
|
||||
def lcp(self):
|
||||
try:
|
||||
# TODO (vshnayder): move as much as possible of this work and error
|
||||
# checking to descriptor load time
|
||||
self.lcp = self.new_lcp(self.get_state_for_lcp())
|
||||
|
||||
# At this point, we need to persist the randomization seed
|
||||
# so that when the problem is re-loaded (to check/view/save)
|
||||
# it stays the same.
|
||||
# However, we do not want to write to the database
|
||||
# every time the module is loaded.
|
||||
# So we set the seed ONLY when there is not one set already
|
||||
if self.seed is None:
|
||||
self.seed = self.lcp.seed
|
||||
|
||||
lcp = self.new_lcp(self.get_state_for_lcp())
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
msg = u'cannot create LoncapaProblem {loc}: {err}'.format(
|
||||
loc=text_type(self.location), err=err)
|
||||
# TODO (vshnayder): do modules need error handlers too?
|
||||
# We shouldn't be switching on DEBUG.
|
||||
if self.runtime.DEBUG:
|
||||
log.warning(msg)
|
||||
# TODO (vshnayder): This logic should be general, not here--and may
|
||||
# want to preserve the data instead of replacing it.
|
||||
# e.g. in the CMS
|
||||
msg = HTML(u'<p>{msg}</p>').format(msg=msg)
|
||||
msg += HTML(u'<p><pre>{tb}</pre></p>').format(
|
||||
# just the traceback, no message - it is already present above
|
||||
tb=u''.join(
|
||||
['Traceback (most recent call last):\n'] +
|
||||
traceback.format_tb(sys.exc_info()[2])
|
||||
)
|
||||
)
|
||||
# create a dummy problem with error message instead of failing
|
||||
problem_text = (
|
||||
HTML(u'<problem><text><span class="inline-error">'
|
||||
u'Problem {url} has an error:</span>{msg}</text></problem>').format(
|
||||
url=text_type(self.location),
|
||||
msg=msg,
|
||||
)
|
||||
)
|
||||
self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text)
|
||||
else:
|
||||
# add extra info and raise
|
||||
raise Exception(msg), None, sys.exc_info()[2]
|
||||
|
||||
self.set_state_from_lcp()
|
||||
raise Exception(msg), None, sys.exc_info()[2]
|
||||
|
||||
if self.score is None:
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(lcp))
|
||||
|
||||
assert self.seed is not None
|
||||
return lcp
|
||||
|
||||
def choose_new_seed(self):
|
||||
"""
|
||||
@@ -328,7 +326,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
text = self.data
|
||||
|
||||
capa_system = LoncapaSystem(
|
||||
ajax_url=self.runtime.ajax_url,
|
||||
ajax_url=self.ajax_url,
|
||||
anonymous_student_id=self.runtime.anonymous_student_id,
|
||||
cache=self.runtime.cache,
|
||||
can_execute_unsafe_code=self.runtime.can_execute_unsafe_code,
|
||||
@@ -348,7 +346,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
problem_text=text,
|
||||
id=self.location.html_id(),
|
||||
state=state,
|
||||
seed=self.seed,
|
||||
seed=self.get_seed(),
|
||||
capa_system=capa_system,
|
||||
capa_module=self, # njp
|
||||
)
|
||||
@@ -363,7 +361,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
'student_answers': self.student_answers,
|
||||
'has_saved_answers': self.has_saved_answers,
|
||||
'input_state': self.input_state,
|
||||
'seed': self.seed,
|
||||
'seed': self.get_seed(),
|
||||
}
|
||||
|
||||
def set_state_from_lcp(self):
|
||||
@@ -376,7 +374,6 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
self.input_state = lcp_state['input_state']
|
||||
self.student_answers = lcp_state['student_answers']
|
||||
self.has_saved_answers = lcp_state['has_saved_answers']
|
||||
self.seed = lcp_state['seed']
|
||||
|
||||
def set_last_submission_time(self):
|
||||
"""
|
||||
@@ -432,7 +429,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
return self.runtime.render_template('problem_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'id': text_type(self.location),
|
||||
'ajax_url': self.runtime.ajax_url,
|
||||
'ajax_url': self.ajax_url,
|
||||
'current_score': curr_score,
|
||||
'total_possible': total_possible,
|
||||
'attempts_used': self.attempts,
|
||||
@@ -580,7 +577,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = self.new_lcp(None)
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
# Prepend a scary warning to the student
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
warning_msg = Text(_("Warning: The problem has been reset to its initial state!"))
|
||||
@@ -748,7 +745,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
|
||||
if encapsulate:
|
||||
html = HTML(u'<div id="problem_{id}" class="problem" data-url="{ajax_url}">{html}</div>').format(
|
||||
id=self.location.html_id(), ajax_url=self.runtime.ajax_url, html=HTML(html)
|
||||
id=self.location.html_id(), ajax_url=self.ajax_url, html=HTML(html)
|
||||
)
|
||||
|
||||
# Now do all the substitutions which the LMS module_render normally does, but
|
||||
@@ -958,7 +955,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
score_msg = data['xqueue_body']
|
||||
self.lcp.update_score(score_msg, queuekey)
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
self.publish_grade(grader_response=True)
|
||||
|
||||
return dict() # No AJAX return is needed
|
||||
@@ -1228,7 +1225,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
self.attempts = self.attempts + 1
|
||||
self.lcp.done = True
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
self.set_last_submission_time()
|
||||
|
||||
except (StudentInputError, ResponseError, LoncapaProblemError) as inst:
|
||||
@@ -1240,7 +1237,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
|
||||
# Save the user's state before failing
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
# If the user is a staff member, include
|
||||
# the full exception, including traceback,
|
||||
@@ -1263,7 +1260,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
except Exception as err:
|
||||
# Save the user's state before failing
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
if self.runtime.DEBUG:
|
||||
msg = u"Error checking problem: {}".format(text_type(err))
|
||||
@@ -1444,7 +1441,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
# later if necessary.
|
||||
variant = ''
|
||||
if self.rerandomize != RANDOMIZATION.NEVER:
|
||||
variant = self.seed
|
||||
variant = self.get_seed()
|
||||
|
||||
is_correct = correct_map.is_correct(input_id)
|
||||
if is_correct is None:
|
||||
@@ -1503,7 +1500,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
self.lcp.has_saved_answers = True
|
||||
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
self.track_function_unmask('save_problem_success', event_info)
|
||||
msg = _("Your answers have been saved.")
|
||||
@@ -1562,7 +1559,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
|
||||
# Pull in the new problem seed
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp())
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
# Grade may have changed, so publish new value
|
||||
self.publish_grade()
|
||||
@@ -1687,10 +1684,10 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
|
||||
new_score = self.lcp.calculate_score()
|
||||
return Score(raw_earned=new_score['score'], raw_possible=new_score['total'])
|
||||
|
||||
def score_from_lcp(self):
|
||||
def score_from_lcp(self, lcp):
|
||||
"""
|
||||
Returns the score associated with the correctness map
|
||||
currently stored by the LCP.
|
||||
"""
|
||||
lcp_score = self.lcp.calculate_score()
|
||||
lcp_score = lcp.calculate_score()
|
||||
return Score(raw_earned=lcp_score['score'], raw_possible=lcp_score['total'])
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Constants for capa_base problems
|
||||
"""
|
||||
|
||||
|
||||
class SHOWANSWER(object):
|
||||
"""
|
||||
Constants for when to show answer
|
||||
"""
|
||||
ALWAYS = "always"
|
||||
ANSWERED = "answered"
|
||||
ATTEMPTED = "attempted"
|
||||
CLOSED = "closed"
|
||||
FINISHED = "finished"
|
||||
CORRECT_OR_PAST_DUE = "correct_or_past_due"
|
||||
PAST_DUE = "past_due"
|
||||
NEVER = "never"
|
||||
AFTER_SOME_NUMBER_OF_ATTEMPTS = "after_attempts"
|
||||
|
||||
|
||||
class RANDOMIZATION(object):
|
||||
"""
|
||||
Constants for problem randomization
|
||||
"""
|
||||
ALWAYS = "always"
|
||||
ONRESET = "onreset"
|
||||
NEVER = "never"
|
||||
PER_STUDENT = "per_student"
|
||||
@@ -6,41 +6,106 @@ import sys
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
|
||||
from capa import responsetypes
|
||||
from xmodule.editing_module import EditingMixin
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.raw_module import RawMixin
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.util.misc import escape_html_characters
|
||||
from xmodule.util.sandboxing import get_python_lib_zip
|
||||
from xmodule.x_module import DEPRECATION_VSCOMPAT_EVENT, XModule, module_attr
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet, ResourceTemplates, shim_xmodule_js,
|
||||
XModuleMixin, XModuleToXBlockMixin, XModuleDescriptorToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin
|
||||
|
||||
from .capa_base import CapaFields, CapaMixin, ComplexEncoder
|
||||
from .capa_base import _, CapaMixin, ComplexEncoder
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
|
||||
class CapaModule(CapaMixin, XModule):
|
||||
@XBlock.wants('user') # pylint: disable=abstract-method
|
||||
@XBlock.needs('i18n')
|
||||
class ProblemBlock(
|
||||
CapaMixin, RawMixin, XmlMixin, EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin, XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin):
|
||||
"""
|
||||
An XModule implementing LonCapa format problems, implemented by way of
|
||||
capa.capa_problem.LoncapaProblem
|
||||
The XBlock for CAPA.
|
||||
"""
|
||||
INDEX_CONTENT_TYPE = 'CAPA'
|
||||
|
||||
resources_dir = None
|
||||
|
||||
has_score = True
|
||||
show_in_read_only_mode = True
|
||||
template_dir_name = 'problem'
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
has_author_view = True
|
||||
|
||||
# The capa format specifies that what we call max_attempts in the code
|
||||
# is the attribute `attempts`. This will do that conversion
|
||||
metadata_translations = dict(XmlMixin.metadata_translations)
|
||||
metadata_translations['attempts'] = 'max_attempts'
|
||||
|
||||
CapaModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
|
||||
"""
|
||||
icon_class = 'problem'
|
||||
|
||||
js = {
|
||||
uses_xmodule_styles_setup = True
|
||||
requires_per_student_anonymous_id = True
|
||||
|
||||
preview_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/javascript_loader.js'),
|
||||
resource_string(__name__, 'js/src/capa/display.js'),
|
||||
resource_string(__name__, 'js/src/collapsible.js'),
|
||||
resource_string(__name__, 'js/src/capa/imageinput.js'),
|
||||
resource_string(__name__, 'js/src/capa/schematic.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
|
||||
}
|
||||
|
||||
preview_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/capa/display.scss'),
|
||||
],
|
||||
}
|
||||
|
||||
studio_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/problem/edit.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
|
||||
studio_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/editor/edit.scss'),
|
||||
resource_string(__name__, 'css/problem/edit.scss'),
|
||||
]
|
||||
}
|
||||
|
||||
js_module_name = "Problem"
|
||||
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
|
||||
def bind_for_student(self, *args, **kwargs):
|
||||
super(ProblemBlock, self).bind_for_student(*args, **kwargs)
|
||||
|
||||
# Capa was an XModule. When bind_for_student() was called on it with a new runtime, a new CapaModule object
|
||||
# was initialized when XModuleDescriptor._xmodule() was called next. self.lcp was constructed in CapaModule
|
||||
# init(). To keep the same behaviour, we delete self.lcp in bind_for_student().
|
||||
if 'lcp' in self.__dict__:
|
||||
del self.__dict__['lcp']
|
||||
|
||||
def student_view(self, _context):
|
||||
"""
|
||||
Return the student view.
|
||||
"""
|
||||
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
|
||||
self.lcp
|
||||
fragment = Fragment(self.get_html())
|
||||
add_webpack_to_fragment(fragment, 'ProblemBlockPreview')
|
||||
shim_xmodule_js(fragment, 'Problem')
|
||||
return fragment
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
@@ -48,6 +113,17 @@ class CapaModule(CapaMixin, XModule):
|
||||
"""
|
||||
return self.student_view(context)
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Return the studio view.
|
||||
"""
|
||||
fragment = Fragment(
|
||||
self.system.render_template(self.mako_template, self.get_context())
|
||||
)
|
||||
add_webpack_to_fragment(fragment, 'ProblemBlockStudio')
|
||||
shim_xmodule_js(fragment, 'MarkdownEditingDescriptor')
|
||||
return fragment
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
@@ -59,6 +135,8 @@ class CapaModule(CapaMixin, XModule):
|
||||
'progress' : 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
|
||||
self.lcp
|
||||
handlers = {
|
||||
'hint_button': self.hint_button,
|
||||
'problem_get': self.get_problem,
|
||||
@@ -139,36 +217,6 @@ class CapaModule(CapaMixin, XModule):
|
||||
|
||||
return self.display_name
|
||||
|
||||
|
||||
class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
"""
|
||||
Module implementing problems in the LON-CAPA format,
|
||||
as implemented by capa.capa_problem
|
||||
"""
|
||||
INDEX_CONTENT_TYPE = 'CAPA'
|
||||
|
||||
module_class = CapaModule
|
||||
resources_dir = None
|
||||
|
||||
has_score = True
|
||||
show_in_read_only_mode = True
|
||||
template_dir_name = 'problem'
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
js = {'js': [resource_string(__name__, 'js/src/problem/edit.js')]}
|
||||
js_module_name = "MarkdownEditingDescriptor"
|
||||
has_author_view = True
|
||||
css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/editor/edit.scss'),
|
||||
resource_string(__name__, 'css/problem/edit.scss')
|
||||
]
|
||||
}
|
||||
|
||||
# The capa format specifies that what we call max_attempts in the code
|
||||
# is the attribute `attempts`. This will do that conversion
|
||||
metadata_translations = dict(RawDescriptor.metadata_translations)
|
||||
metadata_translations['attempts'] = 'max_attempts'
|
||||
|
||||
@classmethod
|
||||
def filter_templates(cls, template, course):
|
||||
"""
|
||||
@@ -180,7 +228,7 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
return 'latex' not in template['template_id'] or course.use_latex_compiler
|
||||
|
||||
def get_context(self):
|
||||
_context = RawDescriptor.get_context(self)
|
||||
_context = EditingMixin.get_context(self)
|
||||
_context.update({
|
||||
'markdown': self.markdown,
|
||||
'enable_markdown': self.markdown is not None,
|
||||
@@ -200,14 +248,14 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(CapaDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields = super(ProblemBlock, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
CapaDescriptor.due,
|
||||
CapaDescriptor.graceperiod,
|
||||
CapaDescriptor.force_save_button,
|
||||
CapaDescriptor.markdown,
|
||||
CapaDescriptor.use_latex_compiler,
|
||||
CapaDescriptor.show_correctness,
|
||||
ProblemBlock.due,
|
||||
ProblemBlock.graceperiod,
|
||||
ProblemBlock.force_save_button,
|
||||
ProblemBlock.markdown,
|
||||
ProblemBlock.use_latex_compiler,
|
||||
ProblemBlock.show_correctness,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -226,7 +274,7 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
"""
|
||||
Return dictionary prepared with module content and type for indexing.
|
||||
"""
|
||||
xblock_body = super(CapaDescriptor, self).index_dictionary()
|
||||
xblock_body = super(ProblemBlock, self).index_dictionary()
|
||||
# Removing solutions and hints, as well as script and style
|
||||
capa_content = re.sub(
|
||||
re.compile(
|
||||
@@ -400,37 +448,3 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
if correct_answer_text is not None:
|
||||
report[_("Correct Answer")] = correct_answer_text
|
||||
yield (user_state.username, report)
|
||||
|
||||
# Proxy to CapaModule for access to any of its attributes
|
||||
answer_available = module_attr('answer_available')
|
||||
submit_button_name = module_attr('submit_button_name')
|
||||
submit_button_submitting_name = module_attr('submit_button_submitting_name')
|
||||
submit_problem = module_attr('submit_problem')
|
||||
choose_new_seed = module_attr('choose_new_seed')
|
||||
closed = module_attr('closed')
|
||||
get_answer = module_attr('get_answer')
|
||||
get_problem = module_attr('get_problem')
|
||||
get_problem_html = module_attr('get_problem_html')
|
||||
get_state_for_lcp = module_attr('get_state_for_lcp')
|
||||
handle_input_ajax = module_attr('handle_input_ajax')
|
||||
hint_button = module_attr('hint_button')
|
||||
handle_problem_html_error = module_attr('handle_problem_html_error')
|
||||
handle_ungraded_response = module_attr('handle_ungraded_response')
|
||||
has_submitted_answer = module_attr('has_submitted_answer')
|
||||
is_attempted = module_attr('is_attempted')
|
||||
is_correct = module_attr('is_correct')
|
||||
is_past_due = module_attr('is_past_due')
|
||||
is_submitted = module_attr('is_submitted')
|
||||
lcp = module_attr('lcp')
|
||||
make_dict_of_responses = module_attr('make_dict_of_responses')
|
||||
new_lcp = module_attr('new_lcp')
|
||||
publish_grade = module_attr('publish_grade')
|
||||
rescore = module_attr('rescore')
|
||||
reset_problem = module_attr('reset_problem')
|
||||
save_problem = module_attr('save_problem')
|
||||
set_score = module_attr('set_score')
|
||||
set_state_from_lcp = module_attr('set_state_from_lcp')
|
||||
should_show_submit_button = module_attr('should_show_submit_button')
|
||||
should_show_reset_button = module_attr('should_show_reset_button')
|
||||
should_show_save_button = module_attr('should_show_save_button')
|
||||
update_score = module_attr('update_score')
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from pkg_resources import resource_string
|
||||
from xblock.fields import Scope, String
|
||||
|
||||
from xmodule.mako_module import MakoModuleDescriptor
|
||||
from xmodule.mako_module import MakoModuleDescriptor, MakoTemplateBlockBase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,7 +15,7 @@ class EditingFields(object):
|
||||
data = String(scope=Scope.content, default='')
|
||||
|
||||
|
||||
class EditingDescriptor(EditingFields, MakoModuleDescriptor):
|
||||
class EditingMixin(EditingFields, MakoTemplateBlockBase):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It does not
|
||||
perform any validation on its definition---just passes it along to the browser.
|
||||
@@ -31,7 +31,7 @@ class EditingDescriptor(EditingFields, MakoModuleDescriptor):
|
||||
"""
|
||||
`data` should not be editable in the Studio settings editor.
|
||||
"""
|
||||
non_editable_fields = super(EditingDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields = super(EditingMixin, self).non_editable_metadata_fields
|
||||
non_editable_fields.append(self.fields['data'])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -39,12 +39,16 @@ class EditingDescriptor(EditingFields, MakoModuleDescriptor):
|
||||
# here as with our parent class, let's call into it to get the basic fields
|
||||
# set and then add our additional fields. Trying to keep it DRY.
|
||||
def get_context(self):
|
||||
_context = MakoModuleDescriptor.get_context(self)
|
||||
_context = MakoTemplateBlockBase.get_context(self)
|
||||
# Add our specific template information (the raw data body)
|
||||
_context.update({'data': self.data})
|
||||
return _context
|
||||
|
||||
|
||||
class EditingDescriptor(EditingMixin, MakoModuleDescriptor):
|
||||
pass
|
||||
|
||||
|
||||
class TabsEditingDescriptor(EditingFields, MakoModuleDescriptor):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It does not
|
||||
|
||||
@@ -301,7 +301,10 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
|
||||
current user.
|
||||
"""
|
||||
for block_type, block_id in self.selected_children():
|
||||
yield self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
|
||||
child = self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
|
||||
if child is None:
|
||||
logger.info("Child not found for [%s] [%s]".format(str(block_type), str(block_id)))
|
||||
yield child
|
||||
|
||||
def student_view(self, context):
|
||||
fragment = Fragment()
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.core.exceptions import PermissionDenied
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator
|
||||
from search.search_engine_base import SearchEngine
|
||||
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.capa_module import ProblemBlock
|
||||
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -99,7 +99,7 @@ class LibraryToolsService(object):
|
||||
if search_engine:
|
||||
filter_clause = {
|
||||
"library": unicode(normalize_key_for_search(library.location.library_key)),
|
||||
"content_type": CapaDescriptor.INDEX_CONTENT_TYPE,
|
||||
"content_type": ProblemBlock.INDEX_CONTENT_TYPE,
|
||||
"problem_types": capa_type
|
||||
}
|
||||
search_result = search_engine.search(field_dictionary=filter_clause)
|
||||
@@ -116,7 +116,7 @@ class LibraryToolsService(object):
|
||||
return False
|
||||
|
||||
descriptor = self.store.get_item(usage_key, depth=0)
|
||||
assert isinstance(descriptor, CapaDescriptor)
|
||||
assert isinstance(descriptor, ProblemBlock)
|
||||
return capa_type in descriptor.problem_types
|
||||
|
||||
def can_use_library_content(self, block):
|
||||
|
||||
@@ -51,7 +51,7 @@ class MakoTemplateBlockBase(object):
|
||||
fragment = Fragment(
|
||||
self.system.render_template(self.mako_template, self.get_context())
|
||||
)
|
||||
shim_xmodule_js(self, fragment)
|
||||
shim_xmodule_js(fragment, self.js_module_name)
|
||||
return fragment
|
||||
|
||||
|
||||
|
||||
@@ -198,6 +198,10 @@ class SignalHandler(object):
|
||||
log.info('Sent %s signal to %s with kwargs %s. Response was: %s', signal_name, receiver, kwargs, response)
|
||||
|
||||
|
||||
# to allow easy imports
|
||||
globals().update({sig.name.upper(): sig for sig in SignalHandler.all_signals()})
|
||||
|
||||
|
||||
def load_function(path):
|
||||
"""
|
||||
Load a function by name.
|
||||
|
||||
@@ -10,10 +10,9 @@ from xmodule.xml_module import XmlDescriptor
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
class RawMixin(object):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It
|
||||
requires that the definition xml is valid.
|
||||
Common code between RawDescriptor and XBlocks converted from XModules.
|
||||
"""
|
||||
resources_dir = None
|
||||
|
||||
@@ -60,6 +59,14 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
raise SerializationError(self.location, msg)
|
||||
|
||||
|
||||
class RawDescriptor(RawMixin, XmlDescriptor, XMLEditingDescriptor):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It
|
||||
requires that the definition xml is valid.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EmptyDataRawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
"""
|
||||
Version of RawDescriptor for modules which may have no XML data,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user