class StudentModule(models.Model): '''Mocks the courseware.models.StudentModule Keeps student state for a particular XBlock usage and particular student. Called Module since it was originally used for XModule state. class attributes declared in StudentModule but not yet needed for mocking are remarked out with a '#!' They are here to A) Help understand context of the class without requiring opening the courseware/models.py file B) Be available to quickly update this mock when needed ''' #! objects = ChunkingManager() #! id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name ## The XBlock/XModule type (e.g. "problem") #! module_type = models.CharField(max_length=32, db_index=True) # Key used to share state. This is the XBlock usage_id #! module_state_key = LocationKeyField(max_length=255, db_index=True, db_column='module_id') # TODO: Review the most appropriate on_delete behaviour student = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) # The learning context of the usage_key (usually a course ID, but may be a library or something else) course_id = LearningContextKeyField(max_length=255, db_index=True) #! class Meta(object): #! app_label = "courseware" #! unique_together = (('student', 'module_state_key', 'course_id'),) #! # Internal state of the object #! state = models.TextField(null=True, blank=True) #! # Grade, and are we done? #! grade = models.FloatField(null=True, blank=True, db_index=True) #! max_grade = models.FloatField(null=True, blank=True) #! DONE_TYPES = ( #! (u'na', u'NOT_APPLICABLE'), #! (u'f', u'FINISHED'), #! (u'i', u'INCOMPLETE'), #! ) #! done = models.CharField(max_length=8, choices=DONE_TYPES, default='na', db_index=True) # the production model sets 'auto_now_add=True' andn 'db_index=True' created = models.DateTimeField() modified = models.DateTimeField()
class DiscussionTopicLink(models.Model): """ A model linking discussion topics ids to the part of a course they are linked to. """ context_key = LearningContextKeyField( db_index=True, max_length=255, # Translators: A key specifying a course, library, program, # website, or some other collection of content where learning # happens. verbose_name=_("Learning Context Key"), help_text=_( "Context key for context in which this discussion topic exists.")) usage_key = UsageKeyField( db_index=True, max_length=255, null=True, blank=True, help_text= _("Usage key for in-context discussion topic. Set to null for course-level topics." )) title = models.CharField(max_length=255, help_text=_("Title for discussion topic.")) group = models.ForeignKey(CourseUserGroup, null=True, blank=True, on_delete=models.SET_NULL, help_text=_("Group for divided discussions.")) provider_id = models.CharField( max_length=32, help_text=_("Provider id for discussion provider.")) external_id = models.CharField( db_index=True, max_length=255, help_text= _("Discussion context ID in external forum provider. e.g. commentable_id for cs_comments_service." )) enabled_in_context = models.BooleanField( default=True, help_text=_( "Whether this topic should be shown in-context in the course.")) def __str__(self): return ( f'DiscussionTopicLink(' f'context_key="{self.context_key}", usage_key="{self.usage_key}", title="{self.title}", ' f'group={self.group}, provider_id="{self.provider_id}", external_id="{self.external_id}", ' f'enabled_in_context={self.enabled_in_context}' f')')
class LearningContext(TimeStampedModel): """ These are used to group Learning Sequences so that many of them can be pulled at once. We use this instead of a foreign key to CourseOverview because this table can contain things that are not courses. It is okay to make a foreign key against this table. """ id = models.BigAutoField(primary_key=True) context_key = LearningContextKeyField(max_length=255, db_index=True, unique=True, null=False) title = models.CharField(max_length=255) published_at = models.DateTimeField(null=False) published_version = models.CharField(max_length=255) class Meta: indexes = [models.Index(fields=['-published_at'])]
class DiscussionsConfiguration(TimeStampedModel): """ Associates a learning context with discussion provider and configuration """ context_key = LearningContextKeyField( primary_key=True, db_index=True, unique=True, max_length=255, # Translators: A key specifying a course, library, program, # website, or some other collection of content where learning # happens. verbose_name=_("Learning Context Key"), ) enabled = models.BooleanField( default=True, help_text= _("If disabled, the discussions in the associated learning context/course will be disabled." )) lti_configuration = models.ForeignKey( LtiConfiguration, on_delete=models.SET_NULL, blank=True, null=True, help_text=_("The LTI configuration data for this context/provider."), ) plugin_configuration = JSONField( blank=True, default={}, help_text=_( "The plugin configuration data for this context/provider."), ) provider_type = models.CharField( blank=False, max_length=100, verbose_name=_("Discussion provider"), help_text=_("The discussion tool/provider's id"), ) history = HistoricalRecords() def clean(self): """ Validate the model Currently, this only support courses, this can be extended whenever discussions are available in other contexts """ if not CourseOverview.course_exists(self.context_key): raise ValidationError( 'Context Key should be an existing learning context.') def __str__(self): return "DiscussionsConfiguration(context_key='{context_key}', provider='{provider}', enabled={enabled})".format( context_key=self.context_key, provider=self.provider_type, enabled=self.enabled, ) @classmethod def is_enabled(cls, context_key: CourseKey) -> bool: """ Check if there is an active configuration for a given course key Default to False, if no configuration exists """ configuration = cls.get(context_key) return configuration.enabled # pylint: disable=undefined-variable @classmethod def get(cls, context_key: CourseKey) -> cls: """ Lookup a model by context_key """ try: configuration = cls.objects.get(context_key=context_key) except cls.DoesNotExist: configuration = cls(context_key=context_key, enabled=False) return configuration # pylint: enable=undefined-variable @property def available_providers(self) -> list[str]: return ProviderFilter.current( course_key=self.context_key).available_providers @classmethod def get_available_providers(cls, context_key: CourseKey) -> list[str]: return ProviderFilter.current( course_key=context_key).available_providers
class StudentModule(models.Model): """ Keeps student state for a particular XBlock usage and particular student. Called Module since it was originally used for XModule state. .. no_pii: """ objects = ChunkingManager() id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name ## The XBlock/XModule type (e.g. "problem") module_type = models.CharField(max_length=32, db_index=True) # Key used to share state. This is the XBlock usage_id module_state_key = UsageKeyField(max_length=255, db_column='module_id') student = models.ForeignKey(User, db_index=True, db_constraint=False, on_delete=models.CASCADE) # The learning context of the usage_key (usually a course ID, but may be a library or something else) course_id = LearningContextKeyField(max_length=255, db_index=True) class Meta: app_label = "courseware" unique_together = (('student', 'module_state_key', 'course_id'), ) indexes = [ models.Index(fields=['module_state_key', 'grade', 'student'], name="courseware_stats") ] # Internal state of the object state = models.TextField(null=True, blank=True) # Grade, and are we done? grade = models.FloatField(null=True, blank=True, db_index=True) max_grade = models.FloatField(null=True, blank=True) DONE_TYPES = ( ('na', 'NOT_APPLICABLE'), ('f', 'FINISHED'), ('i', 'INCOMPLETE'), ) done = models.CharField(max_length=8, choices=DONE_TYPES, default='na') created = models.DateTimeField(auto_now_add=True, db_index=True) modified = models.DateTimeField(auto_now=True, db_index=True) @classmethod def all_submitted_problems_read_only(cls, course_id): """ Return all model instances that correspond to problems that have been submitted for a given course. So module_type='problem' and a non-null grade. Use a read replica if one exists for this environment. """ queryset = cls.objects.filter(course_id=course_id, module_type='problem', grade__isnull=False) if "read_replica" in settings.DATABASES: return queryset.using("read_replica") else: return queryset def __repr__(self): return 'StudentModule<{!r}>'.format({ 'course_id': self.course_id, 'module_type': self.module_type, # We use the student_id instead of username to avoid a database hop. # This can actually matter in cases where we're logging many of # these (e.g. on a broken progress page). 'student_id': self.student_id, 'module_state_key': self.module_state_key, 'state': str(self.state)[:20], }) def __str__(self): return str(repr(self)) @classmethod def get_state_by_params(cls, course_id, module_state_keys, student_id=None): """ Return all model instances that correspond to a course and module keys. Student ID is optional keyword argument, if provided it narrows down the instances. """ module_states = cls.objects.filter( course_id=course_id, module_state_key__in=module_state_keys) if student_id: module_states = module_states.filter(student_id=student_id) return module_states @classmethod def save_state(cls, student, course_id, module_state_key, defaults): # lint-amnesty, pylint: disable=missing-function-docstring if not student.is_authenticated: return else: cls.objects.update_or_create( student=student, course_id=course_id, module_state_key=module_state_key, defaults=defaults, )
class BlockCompletion(TimeStampedModel, models.Model): """ Track completion of completable blocks. A completion is unique for each (user, context_key, block_key). The block_type field is included separately from the block_key to facilitate distinct aggregations of the completion of particular types of block. The completion value is stored as a float in the range [0.0, 1.0], and all calculations are performed on this float, though current practice is to only track binary completion, where 1.0 indicates that the block is complete, and 0.0 indicates that the block is incomplete. """ id = BigAutoField(primary_key=True) # pylint: disable=invalid-name user = models.ForeignKey(User, on_delete=models.CASCADE) context_key = LearningContextKeyField(max_length=255, db_column="course_key") # note: this usage key may not have the run filled in for # old mongo courses. Use the full_block_key property # instead when you want to use/compare the usage_key. block_key = UsageKeyField(max_length=255) block_type = models.CharField(max_length=64) completion = models.FloatField(validators=[validate_percent]) objects = BlockCompletionManager() @property def full_block_key(self): """ Returns the "correct" usage key value with the run filled in. This is only necessary for block keys from old mongo courses, which didn't include the run information in the block usage key. """ if self.block_key.context_key.is_course and self.block_key.run is None: # pylint: disable=unexpected-keyword-arg, no-value-for-parameter return self.block_key.replace(course_key=self.context_key) return self.block_key @classmethod def get_learning_context_completions(cls, user, context_key): """ Returns a dictionary mapping BlockKeys to completion values for all BlockCompletion records for the given user and learning context key. Return value: dict[BlockKey] = float """ user_completions = cls.user_learning_context_completion_queryset( user, context_key) return cls.completion_by_block_key(user_completions) @classmethod def user_learning_context_completion_queryset(cls, user, context_key): """ Returns a Queryset of completions for a given user and context_key. """ return cls.objects.filter(user=user, context_key=context_key) @classmethod def latest_blocks_completed_all_courses(cls, user): """ Returns a dictionary mapping course_keys to a tuple containing the block_key and modified time of the most recently modified completion for the course. This only returns results for courses and not other learning context types. Return value: {course_key: (modified_date, block_key)} """ # Per the Django docs, dictionary params are not supported with the SQLite backend; # with this backend, you must pass parameters as a list. We use SQLite for unit tests, # so the same parameter is included twice in the parameter list below, rather than # including it in a dictionary once. latest_completions_by_course = cls.objects.raw( ''' SELECT cbc.id AS id, cbc.course_key AS course_key, cbc.block_key AS block_key, cbc.modified AS modified FROM completion_blockcompletion cbc JOIN ( SELECT course_key, MAX(modified) AS modified FROM completion_blockcompletion WHERE user_id = %s GROUP BY course_key ) latest ON cbc.course_key = latest.course_key AND cbc.modified = latest.modified WHERE user_id = %s ; ''', [user.id, user.id]) try: return { completion.context_key: (completion.modified, completion.block_key) for completion in latest_completions_by_course if completion.context_key.is_course } except KeyError: # Iteration of the queryset above will always fail # with a KeyError if the queryset is empty return {} @classmethod def get_latest_block_completed(cls, user, context_key): """ Returns a BlockCompletion Object for the last modified user/context_key mapping, or None if no such BlockCompletion exists. Return value: obj: block completion """ try: latest_block_completion = cls.user_learning_context_completion_queryset( user, context_key).latest() # pylint: disable=no-member except cls.DoesNotExist: return None return latest_block_completion @staticmethod def completion_by_block_key(completion_iterable): """ Return value: A dict mapping the full block key of a completion record to the completion value for each BlockCompletion object given in completion_iterable. Each BlockKey is corrected to have the run field filled in via the BlockCompletion.context_key field. """ return { completion.full_block_key: completion.completion for completion in completion_iterable } class Meta: index_together = [ ('context_key', 'block_type', 'user'), ('user', 'context_key', 'modified'), ] unique_together = [('context_key', 'block_key', 'user')] get_latest_by = 'modified' def __unicode__(self): return 'BlockCompletion: {username}, {context_key}, {block_key}: {completion}'.format( username=self.user.username, context_key=self.context_key, block_key=self.block_key, completion=self.completion, )
class DiscussionsConfiguration(TimeStampedModel): """ Associates a learning context with discussion provider and configuration """ context_key = LearningContextKeyField( primary_key=True, db_index=True, unique=True, max_length=255, # Translators: A key specifying a course, library, program, # website, or some other collection of content where learning # happens. verbose_name=_("Learning Context Key"), ) enabled = models.BooleanField( default=True, help_text= _("If disabled, the discussions in the associated learning context/course will be disabled." )) lti_configuration = models.ForeignKey( LtiConfiguration, on_delete=models.SET_NULL, blank=True, null=True, help_text=_("The LTI configuration data for this context/provider."), ) enable_in_context = models.BooleanField( default=True, help_text=_( "If enabled, discussion topics will be created for each non-graded unit in the course. " "A UI for discussions will show up with each unit.")) enable_graded_units = models.BooleanField( default=False, help_text= _("If enabled, discussion topics will be created for graded units as well." )) unit_level_visibility = models.BooleanField( default=False, help_text= _("If enabled, discussions will need to be manually enabled for each unit." )) plugin_configuration = JSONField( blank=True, default={}, help_text=_( "The plugin configuration data for this context/provider."), ) provider_type = models.CharField( blank=False, max_length=100, verbose_name=_("Discussion provider"), help_text=_("The discussion tool/provider's id"), default=DEFAULT_PROVIDER_TYPE, ) history = HistoricalRecords() def clean(self): """ Validate the model. Currently, this only support courses, this can be extended whenever discussions are available in other contexts """ if not CourseOverview.course_exists(self.context_key): raise ValidationError( 'Context Key should be an existing learning context.') def __str__(self): return "DiscussionsConfiguration(context_key='{context_key}', provider='{provider}', enabled={enabled})".format( context_key=self.context_key, provider=self.provider_type, enabled=self.enabled, ) def supports_in_context_discussions(self): """ Returns is the provider supports in-context discussions """ return AVAILABLE_PROVIDER_MAP.get(self.provider_type, {}).get( 'supports_in_context_discussions', False) def supports(self, feature: str) -> bool: """ Check if the provider supports some feature """ features = AVAILABLE_PROVIDER_MAP.get( self.provider_type)['features'] or [] has_support = bool(feature in features) return has_support def supports_lti(self) -> bool: """Returns a boolean indicating if the provider supports lti discussion view.""" return AVAILABLE_PROVIDER_MAP.get(self.provider_type, {}).get('supports_lti', False) @classmethod def is_enabled(cls, context_key: CourseKey) -> bool: """ Check if there is an active configuration for a given course key Default to False, if no configuration exists """ configuration = cls.get(context_key) return configuration.enabled @classmethod def get(cls: Type[T], context_key: CourseKey) -> T: """ Lookup a model by context_key """ try: configuration = cls.objects.get(context_key=context_key) except cls.DoesNotExist: configuration = cls( context_key=context_key, enabled=DEFAULT_CONFIG_ENABLED, provider_type=DEFAULT_PROVIDER_TYPE, ) return configuration @property def available_providers(self) -> List[str]: return ProviderFilter.current( course_key=self.context_key).available_providers @classmethod def get_available_providers(cls, context_key: CourseKey) -> List[str]: return ProviderFilter.current( course_key=context_key).available_providers @classmethod def lti_discussion_enabled(cls, course_key: CourseKey) -> bool: """ Checks if LTI discussion is enabled for this course. Arguments: course_key: course locator. Returns: Boolean indicating weather or not this course has lti discussion enabled. """ discussion_provider = cls.get(course_key) return (discussion_provider.enabled and discussion_provider.supports_lti() and discussion_provider.lti_configuration is not None)
class SplitModulestoreCourseIndex(models.Model): """ A "course index" for a course in "split modulestore." This model/table mostly stores the current version of each course. (Well, twice for each course - "draft" and "published" branch versions are tracked separately.) This MySQL table / django model is designed to replace the "active_versions" MongoDB collection. They contain the same information. It also stores the "wiki_slug" to facilitate looking up a course by it's wiki slug, which is required due to the nuances of the django-wiki integration. .. no_pii: """ # For compatibility with MongoDB, each course index must have an ObjectId. We still have an integer primary key too. objectid = models.CharField(max_length=24, null=False, blank=False, unique=True) # The ID of this course (or library). Must start with "course-v1:" or "library-v1:" course_id = LearningContextKeyField(max_length=255, db_index=True, unique=True, null=False) # Extract the "org" value from the course_id key so that we can search by org. # This gets set automatically by clean() org = models.CharField(max_length=255, db_index=True) # Version fields: The ObjectId of the current entry in the "structures" collection, for this course. # The version is stored separately for each "branch". # Note that there are only three branch names allowed. Draft/published are used for courses, while "library" is used # for content libraries. # ModuleStoreEnum.BranchName.draft = 'draft-branch' draft_version = models.CharField(max_length=24, null=False, blank=True) # ModuleStoreEnum.BranchName.published = 'published-branch' published_version = models.CharField(max_length=24, null=False, blank=True) # ModuleStoreEnum.BranchName.library = 'library' library_version = models.CharField(max_length=24, null=False, blank=True) # Wiki slug for this course wiki_slug = models.CharField(max_length=255, db_index=True, blank=True) # Base store - whether the "structures" and "definitions" data are in MongoDB or object storage (S3) BASE_STORE_MONGO = "mongodb" BASE_STORE_DJANGO = "django" BASE_STORE_CHOICES = [ (BASE_STORE_MONGO, "MongoDB"), # For now, MongoDB is the only implemented option (BASE_STORE_DJANGO, "Django - not implemented yet"), ] base_store = models.CharField(max_length=20, blank=False, choices=BASE_STORE_CHOICES) # Edit history: # ID of the user that made the latest edit. This is not a ForeignKey because some values (like # ModuleStoreEnum.UserID.*) are not real user IDs. edited_by_id = models.IntegerField(null=True) edited_on = models.DateTimeField() # last_update is different from edited_on, and is used only to prevent collisions? last_update = models.DateTimeField() # Keep track of the history of this table: history = HistoricalRecords() def __str__(self): return f"Course Index ({self.course_id})" class Meta: ordering = ["course_id"] verbose_name_plural = "Split modulestore course indexes" def as_v1_schema(self): """ Return in the same format as was stored in MongoDB """ versions = {} for branch in ("draft", "published", "library"): # The current version of this branch, a hex-encoded ObjectID - or an empty string: version_str = getattr(self, f"{branch}_version") if version_str: versions[getattr(ModuleStoreEnum.BranchName, branch)] = ObjectId(version_str) return { "_id": ObjectId(self.objectid), "org": self.course_id.org, "course": self.course_id.course, "run": self.course_id.run, # pylint: disable=no-member "edited_by": self.edited_by_id, "edited_on": self.edited_on, "last_update": self.last_update, "versions": versions, "schema_version": 1, # This matches schema version 1, see SplitMongoModuleStore.SCHEMA_VERSION "search_targets": { "wiki_slug": self.wiki_slug }, } @staticmethod def fields_from_v1_schema(values): """ Convert the MongoDB-style dict shape to a dict of fields that match this model """ if values[ "run"] == LibraryLocator.RUN and ModuleStoreEnum.BranchName.library in values[ "versions"]: # This is a content library: locator = LibraryLocator(org=values["org"], library=values["course"]) else: # This is a course: locator = CourseLocator(org=values["org"], course=values["course"], run=values["run"]) result = { "course_id": locator, "org": values["org"], "edited_by_id": values["edited_by"], "edited_on": values["edited_on"], "base_store": SplitModulestoreCourseIndex.BASE_STORE_MONGO, } if "_id" in values: result["objectid"] = str( values["_id"]) # Convert ObjectId to its hex representation if "last_update" in values: result["last_update"] = values["last_update"] if "search_targets" in values and "wiki_slug" in values[ "search_targets"]: result["wiki_slug"] = values["search_targets"]["wiki_slug"] for branch in ("draft", "published", "library"): version = values["versions"].get( getattr(ModuleStoreEnum.BranchName, branch)) if version: result[f"{branch}_version"] = str( version) # Convert version from ObjectId to hex string return result @staticmethod def field_name_for_branch(branch_name): """ Given a full branch name, get the name of the field in this table that stores that branch's version """ if branch_name == ModuleStoreEnum.BranchName.draft: return "draft_version" if branch_name == ModuleStoreEnum.BranchName.published: return "published_version" if branch_name == ModuleStoreEnum.BranchName.library: return "library_version" raise ValueError(f"Unknown branch name: {branch_name}") def clean(self): """ Validation for this model """ super().clean() # Check that course_id is a supported type: course_id_str = str(self.course_id) if not course_id_str.startswith( "course-v1:") and not course_id_str.startswith("library-v1:"): raise ValueError( f"Split modulestore cannot store course[like] object with key {course_id_str}" " - only course-v1/library-v1 prefixed keys are supported.") # Set the "org" field automatically - ensure it always matches the "org" in the course_id self.org = self.course_id.org def save(self, *args, **kwargs): """ Save this model """ # Override to ensure that full_clean()/clean() is always called, so that the checks in clean() above are run. # But don't validate_unique(), it just runs extra queries and the database enforces it anyways. self.full_clean(validate_unique=False) return super().save(*args, **kwargs)