-
Notifications
You must be signed in to change notification settings - Fork 4.2k
feat: Adds a new discussion topic configuration mechanism [BD-38] [TNL-8623] [BB-4968] #29082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """ | ||
| Data classes for discussions | ||
| """ | ||
| from typing import List | ||
|
|
||
| import attr | ||
| from opaque_keys.edx.keys import CourseKey, UsageKey | ||
|
|
||
| # TODO: These data models will be moved to openedx_events. They are currently here to simplify the PR. | ||
|
|
||
|
|
||
| @attr.s(frozen=True) | ||
| class DiscussionTopicContext: | ||
| """ | ||
| Context for linking the external id for a discussion topic to its associated usage key. | ||
|
|
||
| The ``title`` is cached to improve the performance, since otherwise we'd | ||
| need to look it up in the course structure each time. | ||
|
|
||
| The ``group_id`` can be used for providers that don't internally support | ||
| cohorting but we can emulate that wuth different contexts for different groups. | ||
| """ | ||
| title = attr.ib(type=str) | ||
| usage_key = attr.ib(type=UsageKey, default=None) | ||
| group_id = attr.ib(type=int, default=None) | ||
| external_id = attr.ib(type=str, default=None) | ||
|
||
|
|
||
|
|
||
| @attr.s(frozen=True) | ||
| class CourseDiscussionConfigurationData: | ||
| """ | ||
| Course configuration information for discussions. | ||
|
|
||
| Contains all the metadata needed to configure discussions for a course. | ||
|
|
||
| The ``contexts`` field contains all the contexts for which discussion | ||
| is to be enabled. | ||
| """ | ||
| course_key = attr.ib(type=CourseKey) | ||
| provider_type = attr.ib(type=str) | ||
| enable_in_context = attr.ib(type=bool, default=True) | ||
| enable_graded_units = attr.ib(type=bool, default=False) | ||
| unit_level_visibility = attr.ib(type=bool, default=False) | ||
| plugin_configuration = attr.ib(type=dict, default={}) | ||
| contexts = attr.ib(type=List[DiscussionTopicContext], factory=list) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| """ | ||
| Signal handlers for discussions events | ||
| """ | ||
| import logging | ||
| from uuid import uuid4 | ||
|
|
||
| from django.db import transaction | ||
|
|
||
| from openedx.core.djangoapps.discussions.data import CourseDiscussionConfigurationData | ||
| from openedx.core.djangoapps.discussions.models import ( | ||
| DEFAULT_PROVIDER_TYPE, | ||
| DiscussionTopicLink, | ||
| DiscussionsConfiguration, | ||
| ) | ||
| from openedx.core.djangoapps.discussions.signals import COURSE_DISCUSSIONS_UPDATED | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| # pylint: disable=unused-argument | ||
| def handle_course_discussion_config_update(sender, configuration: CourseDiscussionConfigurationData, **kwargs): | ||
| """ | ||
| Updates the database models for course topics and configuration when settings are updated in the course. | ||
|
|
||
| Args: | ||
| sender: Ignored | ||
| configuration (CourseDiscussionConfigurationData): configuration data for the course | ||
|
|
||
| """ | ||
| update_course_discussion_config(configuration) | ||
|
|
||
|
|
||
| def update_course_discussion_config(configuration: CourseDiscussionConfigurationData): | ||
| """ | ||
| Update the database version of the configuration if it changes in the course structure. | ||
|
|
||
| This function accepts a discussion configuration object that represents the current | ||
| configuration and applies that state to the database. It will go over the list of topic | ||
| links in the configuration, find the corresponding topic link in the database and apply | ||
| any changes if needed. If a new topic link has been introduced it will create an entry. | ||
| If a topic has been removed, it will deactivate the entry. | ||
|
|
||
| When this runs on a new course it will create a new DiscussionConfiguration entry for | ||
| the course. | ||
|
|
||
| Args: | ||
| configuration (CourseDiscussionConfigurationData): configuration data for the course | ||
| """ | ||
| course_key = configuration.course_key | ||
| provider_id = configuration.provider_type or DEFAULT_PROVIDER_TYPE | ||
| new_topic_map = { | ||
| (topic_context.usage_key or topic_context.external_id): topic_context | ||
| for topic_context in configuration.contexts | ||
| } | ||
| with transaction.atomic(): | ||
| log.info(f"Updating existing discussion topic links for {course_key}") | ||
| for topic_link in DiscussionTopicLink.objects.filter( | ||
| context_key=course_key, provider_id=provider_id, | ||
| ): | ||
| lookup_key = topic_link.usage_key or topic_link.external_id | ||
| topic_context = new_topic_map.pop(lookup_key, None) | ||
| # TODO: handle deleting topics that are no longer in use | ||
| # currently this will simply not work for course-wide topics since deleting the link will | ||
| # remove access to all posts in the topic. | ||
| if topic_context is None: | ||
| topic_link.enabled_in_context = False | ||
| else: | ||
| topic_link.enabled_in_context = True | ||
| topic_link.title = topic_context.title | ||
| topic_link.save() | ||
| log.info(f"Creating new discussion topic links for {course_key}") | ||
|
|
||
| DiscussionTopicLink.objects.bulk_create([ | ||
| DiscussionTopicLink( | ||
| context_key=course_key, | ||
| usage_key=topic_context.usage_key, | ||
| title=topic_context.title, | ||
| provider_id=provider_id, | ||
| external_id=topic_context.external_id or uuid4(), | ||
| enabled_in_context=True, | ||
| ) | ||
| for topic_context in new_topic_map.values() | ||
| ]) | ||
|
|
||
| if not DiscussionsConfiguration.objects.filter(context_key=course_key).exists(): | ||
| log.info(f"Course {course_key} doesn't have discussion configuration model yet. Creating a new one.") | ||
| DiscussionsConfiguration( | ||
| context_key=course_key, | ||
| provider_type=provider_id, | ||
| plugin_configuration=configuration.plugin_configuration, | ||
| enable_in_context=configuration.enable_in_context, | ||
| enable_graded_units=configuration.enable_graded_units, | ||
| unit_level_visibility=configuration.unit_level_visibility, | ||
| ).save() | ||
|
|
||
|
|
||
| COURSE_DISCUSSIONS_UPDATED.connect(handle_course_discussion_config_update) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # Generated by Django 3.2.8 on 2021-10-21 12:02 | ||
|
|
||
| import django.db.models.deletion | ||
| import django_mysql.models | ||
| import opaque_keys.edx.django.models | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ('course_groups', '0003_auto_20170609_1455'), | ||
| ('discussions', '0006_auto_20211006_0441'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AlterField( | ||
| model_name='providerfilter', | ||
| name='allow', | ||
| field=django_mysql.models.ListCharField(models.CharField( | ||
| choices=[('legacy', 'legacy'), ('openedx', 'openedx'), ('ed-discuss', 'ed-discuss'), | ||
| ('inscribe', 'inscribe'), ('piazza', 'piazza'), ('yellowdig', 'yellowdig')], max_length=20), | ||
| blank=True, | ||
| help_text='Comma-separated list of providers to allow, eg: legacy,openedx,ed-discuss,inscribe,piazza,yellowdig', | ||
| max_length=63, size=3, verbose_name='Allow List'), | ||
| ), | ||
| migrations.AlterField( | ||
| model_name='providerfilter', | ||
| name='deny', | ||
| field=django_mysql.models.ListCharField(models.CharField( | ||
| choices=[('legacy', 'legacy'), ('openedx', 'openedx'), ('ed-discuss', 'ed-discuss'), | ||
| ('inscribe', 'inscribe'), ('piazza', 'piazza'), ('yellowdig', 'yellowdig')], max_length=20), | ||
| blank=True, | ||
| help_text='Comma-separated list of providers to deny, eg: legacy,openedx,ed-discuss,inscribe,piazza,yellowdig', | ||
| max_length=63, size=3, verbose_name='Deny List'), | ||
| ), | ||
| migrations.CreateModel( | ||
| name='DiscussionTopicLink', | ||
| fields=[ | ||
| ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
| ('context_key', opaque_keys.edx.django.models.LearningContextKeyField(db_index=True, | ||
| help_text='Context key for context in which this discussion topic exists.', max_length=255, | ||
| verbose_name='Learning Context Key')), | ||
| ('usage_key', opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, | ||
| help_text='Usage key for in-context discussion topic. Set to null for course-level topics.', | ||
| max_length=255, null=True)), | ||
| ('title', models.CharField(help_text='Title for discussion topic.', max_length=255)), | ||
| ('provider_id', models.CharField(help_text='Provider id for discussion provider.', max_length=32)), | ||
| ('external_id', models.CharField(db_index=True, | ||
| help_text='Discussion context ID in external forum provider. e.g. commentable_id for cs_comments_service.', | ||
| max_length=255)), | ||
| ('enabled_in_context', models.BooleanField(default=True, | ||
| help_text='Whether this topic should be shown in-context in the course.')), | ||
| ('group', models.ForeignKey(blank=True, help_text='Group for divided discussions.', null=True, | ||
| on_delete=django.db.models.deletion.SET_NULL, to='course_groups.courseusergroup')), | ||
| ], | ||
| ), | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to be triggered every time? Or should it be triggered only if the course has in-context discussions enabled?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want to add that logic to a generic place. So that logic is in the task itself. Additionally, there may be other things that this code can do which apply to providers that don't support in-context discussions as well.