Grading for XBlock/XModule Components

I followed the steps in this documentation 6.6. XBlocks, Events, and Grading — Open edX XBlock Tutorial documentation to perform grading in our XBlock handler, however, at the time of assigning this grade our user is Anonymous.

After debugging the code it appears that we’re using an XModule and user.is_anoymous is True at this code location.

So the grades_signals.SCORE_PUBLISHED signal never gets triggered and a grade never gets assigned. How can we set the user so that this signal gets triggered?

Here is the code in our XBlock handler that we’re running.

event_dictionary = {
   "value": 3.0,
   "max_value": 5.0,
   "user_id": 3
}
self.runtime.publish(self, "grade", event_dictionary)

The documentation states that we can pass in the "user_id" field but for some reason this doesn’t work.

The current user’s user_id is implicit in the event dictionary.
…The event dictionary can also contain the user_id entry. If user_id is not specified, the current user’s ID is used.

cc: @braden @dave @bsande6

I’d like to convert the AnonymousUser to Real User using the user_by_anonymous_id defined here.

I do have this Anonymous uid so I can perform this option, however, once I have the User object how do I update the XBlock runtime with the real user?

I was finally able to post the grade to the gradebook by performing the following actions.

# Inside the XBlock handler I issued a new event called "grade_qualtrics" that is specific to my XBlock. I've included the code below with everything that was needed to make grading work.

# src/xblocks/xblock-qualtrics-survey/qualtricssurvey/models.py

class QualtricsSurveyModelMixin(ScorableXBlockMixin):
    """
    Handle data access for XBlock instances
    """
    
    # Need to set because of https://edx.readthedocs.io/projects/xblock-tutorial/en/latest/concepts/events.html#has-score-variable
    has_score = True

    @XBlock.json_handler
    def end_survey(self, data, suffix=''):
        ...
        # Hard coding the EdX User (id = 3) here for testing purposes.
        real_user = user_by_anonymous_id("30722824cbc0fdcb016a15e3f5c769dd")
        # Does this need to be set?
        self.runtime.user_id = real_user.id
            
        # Publish a grade event based on https://edx.readthedocs.io/projects/xblock-tutorial/en/latest/concepts/events.html#publish-grade-events.  
        event_dictionary = {
            "value": 3.0,
            "max_value": 5.0,
            "user_id": real_user.id
        }
        self.runtime.publish(self, "grade_qualtrics", event_dictionary)

    # Needed to define these overrides per ScorableXBlockMixin
    def has_submitted_answer(self):
        return self.done

    def set_score(self, score):
        """
        Sets the internal score for the problem. This is not derived directly
        from the internal LCP in keeping with the ScorableXBlock spec.
        """
        self.score = score

    def get_score(self):
        """
        Returns the score currently set on the block.
        """
        return self.score

    def calculate_score(self):
        """
        Returns the score calculated from the current problem state.
        """
        return Score(raw_earned=self.value, raw_possible=self.max_value)

Make some updates in the Module Render since the event data “user_id” was never being overridden and we needed to set this because Qualtrics was sending back to us the XBlock the AnonymousUser because the XBlock handler was being called at this http://localhost:18000/courses/{course_key}/xblock/{xblock_qualtrics_block_id}/handler_noauth/end_survey endpoint. I think this noauth is the reason for the AnonymousID but we’re having Qualtrics call us back on this endpoint to avoid passing authentication to Qualtrics. We’re passing to Qualtrics the anonymous_user_id only, so that, we can track who submitted the survey response.

# edx-platform/lms/djangoapps/courseware/module_render.py

def get_event_handler(event_type):
        """
        Return an appropriate function to handle the event.
        Returns None if no special processing is required.
        """
        handlers = {
            'grade': handle_grade_event,
            'grade_qualtrics': handle_grade_qualtrics_event,
        }
        if completion_waffle.waffle().is_enabled(completion_waffle.ENABLE_COMPLETION_TRACKING):
            handlers.update({
                'completion': handle_completion_event,
                'progress': handle_deprecated_progress_event,
            })
        return handlers.get(event_type)
        

    def handle_grade_qualtrics_event(block, event):
        """
        Submit a grade for the Qualtrics block.
        """
        import pdb;pdb.set_trace()
        # Set the user to a "real" user since Qualtrics Xblock return Anonymous
        if event.get("user_id", False):
            try:
                real_user = User.objects.get(id=event.get("user_id"))
            except User.DoesNotExist:
                pass

            if not real_user.is_anonymous:
                grades_signals.SCORE_PUBLISHED.send(
                    sender=None,
                    block=block,
                    user=real_user,
                    raw_earned=event['value'],
                    raw_possible=event['max_value'],
                    only_if_higher=event.get('only_if_higher'),
                    score_deleted=event.get('score_deleted'),
                    grader_response=event.get('grader_response')
                )

I think when I didn’t implement the ScorableXBlockMixin in the QualtricsSurveyModelMixin class that the grade was not being stored in the XBlock and that’s the reason why the grade was not showing up on the progress page due to this check here.

Anyway if any of this is confusing let me know. Just trying to make the Qualtrics XBlock gradable.

cc: @braden @dave @bsande6

Basically, when you use the noauth handler URLs, your XBlock is always going to see the current user as anonymous. You won’t be able to issue grades or anything related to a specific user, unless you do it in an unusual way.

However, it seems that there is already a helper function to deal with this exact use case, namely rebind_noauth_module_to_user. By calling this method within your handler, you can “rebind” the module from an anonymous module to a user-specific module, and can then publish grades.

The LTI XBlock has an example of rebinding itself from the handler and then publishing a grade: edx-platform/lti_2_util.py at 85515283702e52051e971fc6987f15254b7c1152 · edx/edx-platform · GitHub . As far as I can tell, this is the only example usage of that function.

The new (blockstore-based) XBlock runtime has a much easier way of dealing with this situation, because it can create handler URLs that don’t require authentication and are already pre-bound to a specific user - but that only works for XBlocks in new content libraries, so won’t help you in this case. Just mentioning it for anyone reading this in the future, as it may be a better approach.

I would recommend avoiding anything that requires changes to module render, if possible. It means your XBlock isn’t portable and depends on changes to the platform.

Thanks @braden we’ll definitely look into this rebind routine. Glad that exists because I didn’t feel comfortable editing the module_render and I’m also glad you mentioned about a portability issue if you did modify that. We’ll keep you posted if this works or not.

Do we need to implement the ScorableXBlockMixin correctly to make this XBlock gradable?

I think so, yes. Not sure if it’s strictly necessary or not, but I think it’s best practice to do so.

@braden,

We received this error when using that rebind_noauth_module_user call.

File "/edx/src/xblocks/xblock-qualtrics-survey/qualtricssurvey/models.py", line 581, in end_survey
    self.system.rebind_noauth_module_to_user(self, user)
  File "/edx/app/edxapp/edx-platform/lms/djangoapps/courseware/module_render.py", line 645, in rebind_noauth_module_to_user
    module.descriptor,
AttributeError: 'QualtricsSurveyWithMixins' object has no attribute 'descriptor'
[29/Mar/2021 13:14:39] "POST /courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@qualtricssurvey+block@00116206cd3a4059b4749fe26b5417bd/handler_noauth/end_survey HTTP/1.1" 500 385723

We eventually added this code and that error went away.

This seemed to make the grade post without us having to modify the module_render.py file make our XBlock more portable.


Additionally, we notice that the rebind_noauth_module_user call had this command module.descriptor.bind_for_student. Do we need to do anything further with that?

cc: @bsande6

@Zachary_Trabookis That might be fine. In the latest master, the “descriptor” code has been removed, and you can see that the code in question has changed to

@braden Okay thanks. We’ll just leave our code as is and not do anything with the bind_for_student method.

cc: @bsande6

@braden

Please let me know if any of this doesn’t make sense and I can clarify. We’re trying to store a value from a Qualtrics API endpoint and use that to verify if we need to make additional calls back to Qualtrics API or not for Event Create Subscription endpoint using this stored database value.

We’re in need of storing a subscription_id result from a Qualtrics API endpoint callback in the edx-platform edxapp database since we only want to create this subscription one time for a given course_id and usage_key (XBlock location id).

Here is pseudo code for that XBlock constructor code where we use a Django model class called QualtricsSubscriptions to store the Subscription ID result from the Qualtrics API call for Event Subscription Create and the associated course_id and XBlock location id.

Sample code were we define a Django Model in our XBlock.

So we defined a Django model class within the XBlock code but now when we go to run migrations what <appname> are we defining for the XBlock?

Looking at devstack it says that we need to provide an <appname>, however, I’m not sure what XBlock uses for this.
https://edx.readthedocs.io/projects/open-edx-devstack/en/latest/devstack_faq.html#how-do-i-create-new-migrations

Should we just run the generic commands make dev.migrate.lms and dev.migrate.studio here and not define an <appname>. Can we define a Django model in our XBlock or do we need to add some code to the LMS djangoapps for Qualtrics and build out a REST endpoint for this? What’s portable?

Resources
Qualtrics API – Event Subscription Create
https://api.qualtrics.com/api-reference/reference/eventSubscriptions.json/paths/~1eventsubscriptions/post

cc: @bsande6

@Zachary_Trabookis You can look at problem-builder or edx-ora2 for examples of XBlocks that use django models, and see what they do.

Looking at devstack it says that we need to provide an <appname> , however, I’m not sure what XBlock uses for this.

XBlock doesn’t use django models. You should be installing a new django app, so you give it a new name of your choice. A name similar to your XBlock is a good start. Just make sure you install it according to django conventions (see django docs).

If you’d like us to review your code in detail and offer specific help, you can always book time with our team - opencraft.com/help

Thanks @braden for the support.

Zach

@braden,

We included an app_label for this models class as so.

class QualtricsSubscriptions(models.Model):
    """
    Defines a way to see if a given Qualtrics subscription_id is tied to a course_id, XBlock location id
    """
    class Meta:
        # Since problem_builder isn't added to INSTALLED_APPS until it's imported,
        # specify the app_label here.
        app_label = 'qualtricssurvey_subscriptions'
        unique_together = (
            ('course_id', 'usage_key', 'subscription_id'),
        )
 
    course_id = CourseKeyField(max_length=255, db_index=True)
    usage_key = UsageKeyField(max_length=255, db_index=True, help_text=_(u'The course block identifier.'))
    subscription_id = models.CharField(max_length=50, db_index=True, help_text=_(u'The subscription id from Qualtrics.'))

I’m confused about how to run Django migrations too. Should we use a separate database than MySQL Lite in production for storing this data like you mention in the problem-builder/README.md at master · open-craft/problem-builder · GitHub for WORKBENCH_DATABASES. Could we specify a connection to the same server that edxapp (LMS) uses instead of spawning up a new container for MySQL?

We updated the local XBlock settings.py by adding the following connection information from the LMS common.py file for DATABASES.

After doing that and trying a makemigrations we ran into this import issue with CourseOverview from the platform. I’m think this occurred because it doesn’t know where to find the CatalogIntegration module. What are we doing wrong here with the makemigrations command? Do we need to update INSTALLED_APPS or setup.py?

At the top of our XBlock models.py we are importing the following

from openedx.core.djangoapps.content.course_overviews.models import CourseOverview

root@lms:/edx/src/xblocks/xblock-qualtrics-survey# python ./manage.py lms --settings=qualtricssurvey.settings makemigrations --initial qualtricssurveyTraceback (most recent call last):
  File "./manage.py", line 12, in <module>
    execute_from_command_line(sys.argv)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line
    utility.execute()
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/core/management/__init__.py", line 357, in execute
    django.setup()
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/__init__.py", line 24, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/apps/registry.py", line 114, in populate
    app_config.import_models()
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/apps/config.py", line 211, in import_models
    self.models_module = import_module(models_module_name)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 783, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/edx/src/xblocks/xblock-qualtrics-survey/qualtricssurvey/models.py", line 9, in <module>
    from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/content/course_overviews/models.py", line 28, in <module>
    from openedx.core.djangoapps.catalog.models import CatalogIntegration
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/catalog/models.py", line 13, in <module>
    class CatalogIntegration(ConfigurationModel):
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/db/models/base.py", line 108, in __new__
    raise RuntimeError(
RuntimeError: Model class openedx.core.djangoapps.catalog.models.CatalogIntegration doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS.
root@lms:/edx/src/xblocks/xblock-qualtrics-survey# python ./manage.py lms --settings=qualtricssurvey.settings makemigrations --initial qualtricssurvey
Traceback (most recent call last):
  File "./manage.py", line 12, in <module>
    execute_from_command_line(sys.argv)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line
    utility.execute()
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/core/management/__init__.py", line 357, in execute
    django.setup()
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/__init__.py", line 24, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/apps/registry.py", line 114, in populate
    app_config.import_models()
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/apps/config.py", line 211, in import_models
    self.models_module = import_module(models_module_name)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 783, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/edx/src/xblocks/xblock-qualtrics-survey/qualtricssurvey/models.py", line 9, in <module>
    from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/content/course_overviews/models.py", line 28, in <module>
    from openedx.core.djangoapps.catalog.models import CatalogIntegration
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/catalog/models.py", line 13, in <module>
    class CatalogIntegration(ConfigurationModel):
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/db/models/base.py", line 108, in __new__
    raise RuntimeError(
RuntimeError: Model class openedx.core.djangoapps.catalog.models.CatalogIntegration doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS.

We also probably need to setup this file according to what we include from the platform.

Noticed that you included this check if AnonymousUserId to see if the module exists. Looks like your importing that at the top with from .platform_dependencies import AnonymousUserId. Is this the work around from getting makemigrations command exceptions from import issues with the platform code or our we missing something?

cc: @bsande6

You should not have to configure anything related to databases - it should just use the same MySQL database that the LMS uses, without any special configuration.

Please ignore anything related to “Workbench” in the problem builder readme - that’s for a lightweight development environment that has nothing to do with using an XBlock in the LMS/Studio.

You shouldn’t have a separate settings.py for the XBlock. You should be creating a new Django “app”, not a whole django project (so you should have models.py but no manage.py and no settings.py).

You need to add your XBlock django app to the LMS/Studio INSTALLED_APPS variable, e.g. via lms/envs/private.py or /etc/edx/lms.yml

Then to run migrations, you do it the same way you normally run migrations for the LMS. On devstack, that’s make lms-update-db

1 Like