Merge branch 'master' into guruprasad/randomize-question-order-pr

This commit is contained in:
David Ormsbee
2019-05-06 16:33:51 -04:00
committed by GitHub
704 changed files with 688182 additions and 7619 deletions

View File

@@ -4,7 +4,6 @@ data_file = reports/.coverage
source =
cms
common/djangoapps
common/lib/calc
common/lib/capa
common/lib/xmodule
lms

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
))

View File

@@ -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):

View File

@@ -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")

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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'])

View File

@@ -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',

View File

@@ -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()

View File

@@ -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):

View File

@@ -1,6 +1,8 @@
"""
Course modes API serializers.
"""
from __future__ import absolute_import
from rest_framework import serializers
from course_modes.models import CourseMode

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
from datetime import timedelta

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,6 +1,8 @@
"""
Factories for course mode models.
"""
from __future__ import absolute_import
import random
from factory import lazy_attribute

View File

@@ -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,

View File

@@ -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. """

View File

@@ -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]}

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View 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
==================

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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'),
),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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):

View File

@@ -1,6 +1,8 @@
"""
This file contains celery tasks for sending email
"""
from __future__ import absolute_import
import logging
from boto.exception import NoAuthHandlerFound

View File

@@ -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

View File

@@ -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

View 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)

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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')),
]

View File

@@ -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()

View 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>`_

View File

@@ -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 *

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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))")

View File

@@ -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)

View File

@@ -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",
],
)

View File

@@ -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",
]

View 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/>`_

View File

@@ -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'])

View File

@@ -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"

View File

@@ -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')

View File

@@ -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

View File

@@ -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()

View File

@@ -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):

View File

@@ -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

View File

@@ -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.

View File

@@ -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