From 39cf70ecf417eff16d70bf15fc94526c48cc769f Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 8 Jun 2016 17:12:28 -0400 Subject: [PATCH] Add new xblock config models. TNL-4666 --- common/djangoapps/xblock_django/admin.py | 60 +++++++- common/djangoapps/xblock_django/api.py | 53 +++++++ .../migrations/0003_add_new_config_models.py | 53 +++++++ common/djangoapps/xblock_django/models.py | 68 +++++++++ .../xblock_django/tests/test_api.py | 138 ++++++++++++++++++ 5 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 common/djangoapps/xblock_django/api.py create mode 100644 common/djangoapps/xblock_django/migrations/0003_add_new_config_models.py create mode 100644 common/djangoapps/xblock_django/tests/test_api.py diff --git a/common/djangoapps/xblock_django/admin.py b/common/djangoapps/xblock_django/admin.py index 8114460671..1f9cc6a20a 100644 --- a/common/djangoapps/xblock_django/admin.py +++ b/common/djangoapps/xblock_django/admin.py @@ -3,7 +3,63 @@ Django admin dashboard configuration. """ from django.contrib import admin -from config_models.admin import ConfigurationModelAdmin -from xblock_django.models import XBlockDisableConfig +from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin +from xblock_django.models import ( + XBlockDisableConfig, XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag +) +from django.utils.translation import ugettext_lazy as _ + admin.site.register(XBlockDisableConfig, ConfigurationModelAdmin) + + +class XBlockConfigurationAdmin(KeyedConfigurationModelAdmin): + """ + Admin for XBlockConfiguration. + """ + fieldsets = ( + ('XBlock Name', { + 'fields': ('name',) + }), + ('Enable/Disable XBlock', { + 'description': _('To disable the XBlock and prevent rendering in the LMS, leave "Enabled" deselected; ' + 'for clarity, update XBlockStudioConfiguration support state accordingly.'), + 'fields': ('enabled',) + }), + ('Deprecate XBlock', { + 'description': _("Only XBlocks listed in a course's Advanced Module List can be flagged as deprecated. " + "Remember to update XBlockStudioConfiguration support state accordingly, as deprecated " + "does not impact whether or not new XBlock instances can be created in Studio."), + 'fields': ('deprecated',) + }), + ) + + +class XBlockStudioConfigurationAdmin(KeyedConfigurationModelAdmin): + """ + Admin for XBlockStudioConfiguration. + """ + fieldsets = ( + ('', { + 'fields': ('name', 'template') + }), + ('Enable Studio Authoring', { + 'description': _( + 'XBlock/template combinations that are disabled cannot be edited in Studio, regardless of support ' + 'level. Remember to also check if all instances of the XBlock are disabled in XBlockConfiguration.' + ), + 'fields': ('enabled',) + }), + ('Support Level', { + 'description': _( + "Enabled XBlock/template combinations with full or provisional support can always be created " + "in Studio. Unsupported XBlock/template combinations require course author opt-in." + ), + 'fields': ('support_level',) + }), + ) + + +admin.site.register(XBlockConfiguration, XBlockConfigurationAdmin) +admin.site.register(XBlockStudioConfiguration, XBlockStudioConfigurationAdmin) +admin.site.register(XBlockStudioConfigurationFlag, ConfigurationModelAdmin) diff --git a/common/djangoapps/xblock_django/api.py b/common/djangoapps/xblock_django/api.py new file mode 100644 index 0000000000..1abb431916 --- /dev/null +++ b/common/djangoapps/xblock_django/api.py @@ -0,0 +1,53 @@ +""" +API methods related to xblock state. +""" +from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag + + +def deprecated_xblocks(): + """ + Return the QuerySet of deprecated XBlock types. Note that this method is independent of + `XBlockStudioConfigurationFlag` and `XBlockStudioConfiguration`. + """ + return XBlockConfiguration.objects.current_set().filter(deprecated=True) + + +def disabled_xblocks(): + """ + Return the QuerySet of disabled XBlock types (which should not render in the LMS). + Note that this method is independent of `XBlockStudioConfigurationFlag` and `XBlockStudioConfiguration`. + """ + return XBlockConfiguration.objects.current_set().filter(enabled=False) + + +def authorable_xblocks(allow_unsupported=False, name=None): + """ + If Studio XBlock support state is enabled (via `XBlockStudioConfigurationFlag`), this method returns + the QuerySet of XBlocks that can be created in Studio (by default, only fully supported and provisionally + supported). If `XBlockStudioConfigurationFlag` is not enabled, this method returns None. + Note that this method does not take into account fully disabled xblocks (as returned + by `disabled_xblocks`) or deprecated xblocks (as returned by `deprecated_xblocks`). + + Arguments: + allow_unsupported (bool): If `True`, enabled but unsupported XBlocks will also be returned. + Note that unsupported XBlocks are not recommended for use in courses due to non-compliance + with one or more of the base requirements, such as testing, accessibility, internationalization, + and documentation. Default value is `False`. + name (str): If provided, filters the returned XBlocks to those with the provided name. This is + useful for XBlocks with lots of template types. + Returns: + QuerySet: If `XBlockStudioConfigurationFlag` is enabled, returns authorable XBlocks, + taking into account `support_level`, `enabled` and `name` (if specified). + If `XBlockStudioConfigurationFlag` is disabled, returns None. + """ + if not XBlockStudioConfigurationFlag.is_enabled(): + return None + + blocks = XBlockStudioConfiguration.objects.current_set().filter(enabled=True) + if not allow_unsupported: + blocks = blocks.exclude(support_level=XBlockStudioConfiguration.UNSUPPORTED) + + if name: + blocks = blocks.filter(name=name) + + return blocks diff --git a/common/djangoapps/xblock_django/migrations/0003_add_new_config_models.py b/common/djangoapps/xblock_django/migrations/0003_add_new_config_models.py new file mode 100644 index 0000000000..ea619e5861 --- /dev/null +++ b/common/djangoapps/xblock_django/migrations/0003_add_new_config_models.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('xblock_django', '0002_auto_20160204_0809'), + ] + + operations = [ + migrations.CreateModel( + name='XBlockConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('name', models.CharField(max_length=255, db_index=True)), + ('deprecated', models.BooleanField(default=False, verbose_name='show deprecation messaging in Studio')), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + options={ + 'ordering': ('-change_date',), + 'abstract': False, + }, + ), + migrations.CreateModel( + name='XBlockStudioConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('name', models.CharField(max_length=255, db_index=True)), + ('template', models.CharField(default=b'', max_length=255, blank=True)), + ('support_level', models.CharField(default=b'us', max_length=2, choices=[(b'fs', 'Fully Supported'), (b'ps', 'Provisionally Supported'), (b'us', 'Unsupported')])), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + ), + migrations.CreateModel( + name='XBlockStudioConfigurationFlag', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + ), + ] diff --git a/common/djangoapps/xblock_django/models.py b/common/djangoapps/xblock_django/models.py index 35cfb52802..fb175754e9 100644 --- a/common/djangoapps/xblock_django/models.py +++ b/common/djangoapps/xblock_django/models.py @@ -6,6 +6,7 @@ from django.utils.translation import ugettext_lazy as _ from django.conf import settings from django.db.models import TextField +from django.db import models from config_models.models import ConfigurationModel @@ -72,3 +73,70 @@ class XBlockDisableConfig(ConfigurationModel): disabled_xblocks=config.disabled_blocks, disabled_create_block_types=config.disabled_create_block_types ) + + +class XBlockConfiguration(ConfigurationModel): + """ + XBlock configuration used by both LMS and Studio, and not specific to a particular template. + """ + + KEY_FIELDS = ('name',) # xblock name is unique + + class Meta(ConfigurationModel.Meta): + app_label = 'xblock_django' + + # boolean field 'enabled' inherited from parent ConfigurationModel + name = models.CharField(max_length=255, null=False, db_index=True) + deprecated = models.BooleanField( + default=False, + verbose_name=_('show deprecation messaging in Studio') + ) + + def __unicode__(self): + return ( + "XBlockConfiguration(name={}, enabled={}, deprecated={})" + ).format(self.name, self.enabled, self.deprecated) + + +class XBlockStudioConfigurationFlag(ConfigurationModel): + """ + Enables site-wide Studio configuration for XBlocks. + """ + + class Meta(object): + app_label = "xblock_django" + + # boolean field 'enabled' inherited from parent ConfigurationModel + + def __unicode__(self): + return "XBlockStudioConfigurationFlag(enabled={})".format(self.enabled) + + +class XBlockStudioConfiguration(ConfigurationModel): + """ + Studio editing configuration for a specific XBlock/template combination. + """ + KEY_FIELDS = ('name', 'template') # xblock name/template combination is unique + + FULL_SUPPORT = 'fs' + PROVISIONAL_SUPPORT = 'ps' + UNSUPPORTED = 'us' + + SUPPORT_CHOICES = ( + (FULL_SUPPORT, _('Fully Supported')), + (PROVISIONAL_SUPPORT, _('Provisionally Supported')), + (UNSUPPORTED, _('Unsupported')) + ) + + # boolean field 'enabled' inherited from parent ConfigurationModel + name = models.CharField(max_length=255, null=False, db_index=True) + template = models.CharField(max_length=255, blank=True, default='') + support_level = models.CharField(max_length=2, choices=SUPPORT_CHOICES, default=UNSUPPORTED) + + class Meta(object): + app_label = "xblock_django" + + def __unicode__(self): + return ( + "XBlockStudioConfiguration(name={}, template={}, enabled={}, support_level={})" + ).format(self.name, self.template, self.enabled, self.support_level) diff --git a/common/djangoapps/xblock_django/tests/test_api.py b/common/djangoapps/xblock_django/tests/test_api.py new file mode 100644 index 0000000000..a1e56eafc9 --- /dev/null +++ b/common/djangoapps/xblock_django/tests/test_api.py @@ -0,0 +1,138 @@ +""" +Tests related to XBlock support API. +""" +from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag +from xblock_django.api import deprecated_xblocks, disabled_xblocks, authorable_xblocks +from openedx.core.djangolib.testing.utils import CacheIsolationTestCase + + +class XBlockSupportTestCase(CacheIsolationTestCase): + """ + Tests for XBlock Support methods. + """ + def setUp(self): + super(XBlockSupportTestCase, self).setUp() + + # Set up XBlockConfigurations for disabled and deprecated states + block_config = [ + ("poll", True, True), + ("survey", False, True), + ("done", True, False), + ] + + for name, enabled, deprecated in block_config: + XBlockConfiguration(name=name, enabled=enabled, deprecated=deprecated).save() + + # Set up XBlockStudioConfigurations for studio support level + studio_block_config = [ + ("poll", "", False, XBlockStudioConfiguration.FULL_SUPPORT), # FULL_SUPPORT negated by enabled=False + ("survey", "", True, XBlockStudioConfiguration.UNSUPPORTED), + ("done", "", True, XBlockStudioConfiguration.FULL_SUPPORT), + ("problem", "", True, XBlockStudioConfiguration.FULL_SUPPORT), + ("problem", "multiple_choice", True, XBlockStudioConfiguration.FULL_SUPPORT), + ("problem", "circuit_schematic_builder", True, XBlockStudioConfiguration.UNSUPPORTED), + ("problem", "ora1", False, XBlockStudioConfiguration.FULL_SUPPORT), + ("html", "zoom", True, XBlockStudioConfiguration.PROVISIONAL_SUPPORT), + ("split_module", "", True, XBlockStudioConfiguration.UNSUPPORTED), + ] + + for name, template, enabled, support_level in studio_block_config: + XBlockStudioConfiguration(name=name, template=template, enabled=enabled, support_level=support_level).save() + + def test_deprecated_blocks(self): + """ Tests the deprecated_xblocks method """ + + deprecated_xblock_names = [block.name for block in deprecated_xblocks()] + self.assertItemsEqual(["poll", "survey"], deprecated_xblock_names) + + XBlockConfiguration(name="poll", enabled=True, deprecated=False).save() + + deprecated_xblock_names = [block.name for block in deprecated_xblocks()] + self.assertItemsEqual(["survey"], deprecated_xblock_names) + + def test_disabled_blocks(self): + """ Tests the disabled_xblocks method """ + + disabled_xblock_names = [block.name for block in disabled_xblocks()] + self.assertItemsEqual(["survey"], disabled_xblock_names) + + XBlockConfiguration(name="poll", enabled=False, deprecated=True).save() + + disabled_xblock_names = [block.name for block in disabled_xblocks()] + self.assertItemsEqual(["survey", "poll"], disabled_xblock_names) + + def test_authorable_blocks_flag_disabled(self): + """ + Tests authorable_xblocks returns None if the configuration flag is not enabled. + """ + self.assertFalse(XBlockStudioConfigurationFlag.is_enabled()) + self.assertIsNone(authorable_xblocks()) + + def test_authorable_blocks_empty_model(self): + """ + Tests authorable_xblocks returns an empty list if the configuration flag is enabled but + the XBlockStudioConfiguration table is empty. + """ + XBlockStudioConfigurationFlag(enabled=True).save() + XBlockStudioConfiguration.objects.all().delete() + self.assertEqual(0, len(authorable_xblocks(allow_unsupported=True))) + + def test_authorable_blocks(self): + """ + Tests authorable_xblocks when configuration flag is enabled and name is not specified. + """ + XBlockStudioConfigurationFlag(enabled=True).save() + + authorable_xblock_names = [block.name for block in authorable_xblocks()] + self.assertItemsEqual(["done", "problem", "problem", "html"], authorable_xblock_names) + + # Note that "survey" is disabled in XBlockConfiguration, but it is still returned by + # authorable_xblocks because it is marked as enabled and unsupported in XBlockStudioConfiguration. + # Since XBlockConfiguration is a blacklist and relates to xblock type, while XBlockStudioConfiguration + # is a whitelist and uses a combination of xblock type and template (and in addition has a global feature flag), + # it is expected that Studio code will need to filter by both disabled_xblocks and authorable_xblocks. + authorable_xblock_names = [block.name for block in authorable_xblocks(allow_unsupported=True)] + self.assertItemsEqual( + ["survey", "done", "problem", "problem", "problem", "html", "split_module"], + authorable_xblock_names + ) + + def test_authorable_blocks_by_name(self): + """ + Tests authorable_xblocks when configuration flag is enabled and name is specified. + """ + def verify_xblock_fields(name, template, support_level, block): + """ + Verifies the returned xblock state. + """ + self.assertEqual(name, block.name) + self.assertEqual(template, block.template) + self.assertEqual(support_level, block.support_level) + + XBlockStudioConfigurationFlag(enabled=True).save() + + # There are no xblocks with name video. + authorable_blocks = authorable_xblocks(name="video") + self.assertEqual(0, len(authorable_blocks)) + + # There is only a single html xblock. + authorable_blocks = authorable_xblocks(name="html") + self.assertEqual(1, len(authorable_blocks)) + verify_xblock_fields("html", "zoom", XBlockStudioConfiguration.PROVISIONAL_SUPPORT, authorable_blocks[0]) + + authorable_blocks = authorable_xblocks(name="problem", allow_unsupported=True) + self.assertEqual(3, len(authorable_blocks)) + no_template = None + circuit = None + multiple_choice = None + for block in authorable_blocks: + if block.template == '': + no_template = block + elif block.template == 'circuit_schematic_builder': + circuit = block + elif block.template == 'multiple_choice': + multiple_choice = block + + verify_xblock_fields("problem", "", XBlockStudioConfiguration.FULL_SUPPORT, no_template) + verify_xblock_fields("problem", "circuit_schematic_builder", XBlockStudioConfiguration.UNSUPPORTED, circuit) + verify_xblock_fields("problem", "multiple_choice", XBlockStudioConfiguration.FULL_SUPPORT, multiple_choice)