class AssessmentMetaData(models.Model): """ A model to describe additional metadata that characterizes assessment behaviour in Kolibri. This model contains additional fields that are only revelant to content nodes that probe a user's state of knowledge and allow them to practice to Mastery. ContentNodes with this metadata may also be able to be used within quizzes and exams. """ id = UUIDField(primary_key=True) contentnode = models.ForeignKey("ContentNode", related_name="assessmentmetadata") # A JSON blob containing a serialized list of ids for questions that the assessment can present. assessment_item_ids = JSONField(default=[]) # Length of the above assessment_item_ids for a convenience lookup. number_of_assessments = models.IntegerField() # A JSON blob describing the mastery model that is used to set this assessment as mastered. mastery_model = JSONField(default={}) # Should the questions listed in assessment_item_ids be presented in a random order? randomize = models.BooleanField(default=False) # Is this assessment compatible with being previewed and answer filled for display in coach reports # and use in summative and formative tests? is_manipulable = models.BooleanField(default=False) class Meta: abstract = True
class BaseAttemptLog(BaseLogModel): """ This is an abstract model that provides a summary of a user's interactions with a particular item/question in an assessment/exercise/exam """ # Unique identifier within the relevant assessment for the particular question/item # that this attemptlog is a record of an interaction with. item = models.CharField(max_length=200) start_timestamp = DateTimeTzField() end_timestamp = DateTimeTzField() completion_timestamp = DateTimeTzField(blank=True, null=True) time_spent = models.FloatField(help_text="(in seconds)", default=0.0, validators=[MinValueValidator(0)]) complete = models.BooleanField(default=False) # How correct was their answer? In simple cases, just 0 or 1. correct = models.FloatField( validators=[MinValueValidator(0), MaxValueValidator(1)]) hinted = models.BooleanField(default=False) # JSON blob that would allow the learner's answer to be rerendered in the frontend interface answer = JSONField(default={}, null=True, blank=True) # A human readable answer that could be rendered directly in coach reports, can be blank. simple_answer = models.CharField(max_length=200, blank=True) # A JSON Array with a sequence of JSON objects that describe the history of interaction of the user # with this assessment item in this attempt. interaction_history = JSONField(default=[], blank=True) user = models.ForeignKey(FacilityUser, blank=True, null=True) error = models.BooleanField(default=False) class Meta: abstract = True
class LearnerProgressNotification(models.Model): id = (models.AutoField(auto_created=True, primary_key=True, serialize=True, verbose_name="ID"), ) notification_object = models.CharField( max_length=200, choices=NotificationObjectType.choices(), blank=True) notification_event = models.CharField( max_length=200, choices=NotificationEventType.choices(), blank=True) user_id = UUIDField() classroom_id = UUIDField() # This is a Classroom id assignment_collections = JSONField(null=True, default=[]) contentnode_id = UUIDField(null=True) lesson_id = UUIDField(null=True) quiz_id = UUIDField(null=True) quiz_num_correct = models.IntegerField(null=True) quiz_num_answered = models.IntegerField(null=True) reason = models.CharField(max_length=200, choices=HelpReason.choices(), blank=True) timestamp = DateTimeTzField(default=local_now) def __str__(self): return "{object} - {event}".format(object=self.notification_object, event=self.notification_event) class Meta: app_label = "notifications"
class MasteryLog(BaseLogModel): """ This model provides a summary of a user's engagement with an assessment within a mastery level """ # Morango syncing settings morango_model_name = "masterylog" user = models.ForeignKey(FacilityUser) # Every MasteryLog is related to the single summary log for the user/content pair summarylog = models.ForeignKey(ContentSummaryLog, related_name="masterylogs") # The MasteryLog records the mastery criterion that has been specified for the user. # It is recorded here to prevent this changing in the middle of a user's engagement # with an assessment. mastery_criterion = JSONField(default={}) start_timestamp = DateTimeTzField() end_timestamp = DateTimeTzField(blank=True, null=True) completion_timestamp = DateTimeTzField(blank=True, null=True) # The integer mastery level that this log is tracking. mastery_level = models.IntegerField( validators=[MinValueValidator(1), MaxValueValidator(10)]) # Has this mastery level been completed? complete = models.BooleanField(default=False) def infer_dataset(self, *args, **kwargs): return self.cached_related_dataset_lookup("user") def calculate_source_id(self): return "{summarylog_id}:{mastery_level}".format( summarylog_id=self.summarylog_id, mastery_level=self.mastery_level)
class ContentSummaryLog(BaseLogModel): """ This model provides an aggregate summary of all recorded interactions a user has had with a content item over time. """ # Morango syncing settings morango_model_name = "contentsummarylog" user = models.ForeignKey(FacilityUser) content_id = UUIDField(db_index=True) channel_id = UUIDField() start_timestamp = DateTimeTzField() end_timestamp = DateTimeTzField(blank=True, null=True) completion_timestamp = DateTimeTzField(blank=True, null=True) time_spent = models.FloatField(help_text="(in seconds)", default=0.0, validators=[MinValueValidator(0)]) progress = models.FloatField( default=0, validators=[MinValueValidator(0), MaxValueValidator(1.01)]) kind = models.CharField(max_length=200) extra_fields = JSONField(default={}, blank=True) def calculate_source_id(self): return self.content_id def save(self, *args, **kwargs): if self.progress < 0 or self.progress > 1.01: raise ValidationError( "Content summary progress out of range (0-1)") super(ContentSummaryLog, self).save(*args, **kwargs)
class ContentSessionLog(BaseLogModel): """ This model provides a record of interactions with a content item within a single visit to that content page. """ # Morango syncing settings morango_model_name = "contentsessionlog" user = models.ForeignKey(FacilityUser, blank=True, null=True) content_id = UUIDField(db_index=True) visitor_id = models.UUIDField(blank=True, null=True) channel_id = UUIDField() start_timestamp = DateTimeTzField() end_timestamp = DateTimeTzField(blank=True, null=True) time_spent = models.FloatField(help_text="(in seconds)", default=0.0, validators=[MinValueValidator(0)]) progress = models.FloatField(default=0, validators=[MinValueValidator(0)]) kind = models.CharField(max_length=200) extra_fields = JSONField(default={}, blank=True) def save(self, *args, **kwargs): if self.progress < 0: raise ValidationError("Progress out of range (<0)") super(ContentSessionLog, self).save(*args, **kwargs)
class PingbackNotification(models.Model): id = models.CharField(max_length=50, primary_key=True) version_range = models.CharField(max_length=50) timestamp = models.DateField() link_url = models.CharField(max_length=150, blank=True) i18n = JSONField(default={}) active = models.BooleanField(default=True) source = models.CharField(max_length=20, choices=nutrition_endpoints.choices)
class ContentNode(MPTTModel): """ The primary object type in a content database. Defines the properties that are shared across all content types. It represents videos, exercises, audio, documents, and other 'content items' that exist as nodes in content channels. """ id = UUIDField(primary_key=True) parent = TreeForeignKey( "self", null=True, blank=True, related_name="children", db_index=True ) license_name = models.CharField(max_length=50, null=True, blank=True) license_description = models.TextField(null=True, blank=True) has_prerequisite = models.ManyToManyField( "self", related_name="prerequisite_for", symmetrical=False, blank=True ) related = models.ManyToManyField("self", symmetrical=True, blank=True) tags = models.ManyToManyField( "ContentTag", symmetrical=False, related_name="tagged_content", blank=True ) title = models.CharField(max_length=200) coach_content = models.BooleanField(default=False) # the content_id is used for tracking a user's interaction with a piece of # content, in the face of possibly many copies of that content. When a user # interacts with a piece of content, all substantially similar pieces of # content should be marked as such as well. We track these "substantially # similar" types of content by having them have the same content_id. content_id = UUIDField(db_index=True) channel_id = UUIDField(db_index=True) description = models.TextField(blank=True, null=True) sort_order = models.FloatField(blank=True, null=True) license_owner = models.CharField(max_length=200, blank=True) author = models.CharField(max_length=200, blank=True) kind = models.CharField(max_length=200, choices=content_kinds.choices, blank=True) available = models.BooleanField(default=False) lang = models.ForeignKey("Language", blank=True, null=True) # A JSON Dictionary of properties to configure loading, rendering, etc. the file options = JSONField(default={}, blank=True, null=True) class Meta: abstract = True
class Exam(AbstractFacilityDataModel): """ This class stores metadata about teacher-created quizzes to test current student knowledge. """ morango_model_name = "exam" permissions = (RoleBasedPermissions( target_field="collection", can_be_created_by=(role_kinds.ADMIN, role_kinds.COACH), can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH), can_be_updated_by=(role_kinds.ADMIN, role_kinds.COACH), can_be_deleted_by=(role_kinds.ADMIN, role_kinds.COACH), ) | UserCanReadExamData()) title = models.CharField(max_length=200) # Total number of questions in the exam. Equal to the length of the question_sources array. question_count = models.IntegerField() """ The `question_sources` field contains different values depending on the 'data_model_version' field. V2: Similar to V1, but with a `counter_in_exercise` field [ { "exercise_id": <exercise_pk>, "question_id": <item_id_within_exercise>, "title": <exercise_title>, "counter_in_exercise": <unique_count_for_question> }, ... ] V1: JSON array describing the questions in this exam and the exercises they come from: [ { "exercise_id": <exercise_pk>, "question_id": <item_id_within_exercise>, "title": <exercise_title>, }, ... ] V0: JSON array describing exercise nodes this exam draws questions from, how many from each, and the node titles at the time of exam creation: [ { "exercise_id": <exercise_pk>, "number_of_questions": 6, "title": <exercise_title> }, ... ] """ question_sources = JSONField(default=[], blank=True) """ This field is interpretted differently depending on the 'data_model_version' field. V1: Used to help select new questions from exercises at quiz creation time V0: Used to decide which questions are in an exam at runtime. See convertExamQuestionSourcesV0V2 in exams/utils.js for details. """ seed = models.IntegerField(default=1) # When True, learners see questions in the order they appear in 'question_sources'. # When False, each learner sees questions in a random (but consistent) order seeded # by their user's UUID. learners_see_fixed_order = models.BooleanField(default=False) # Is this exam currently active and visible to students to whom it is assigned? active = models.BooleanField(default=False) # Exams are scoped to a particular class (usually) as they are associated with a Coach # who creates them in the context of their class, this stores that relationship but does # not assign exam itself to the class - for that see the ExamAssignment model. collection = models.ForeignKey(Collection, related_name="exams", blank=False, null=False) creator = models.ForeignKey(FacilityUser, related_name="exams", blank=False, null=False) # To be set True when the quiz is first set to active=True date_activated = models.DateTimeField(default=None, null=True, blank=True) date_created = models.DateTimeField(auto_now_add=True, null=True) # archive will be used on the frontend to indicate if a quiz is "closed" archive = models.BooleanField(default=False) date_archived = models.DateTimeField(default=None, null=True, blank=True) def delete(self, using=None, keep_parents=False): """ We delete all notifications objects whose quiz is this exam id. """ LearnerProgressNotification.objects.filter(quiz_id=self.id).delete() super(Exam, self).delete(using, keep_parents) def save(self, *args, **kwargs): # If archive is True during the save op, but there is no date_archived then # this is the save that is archiving the object and we need to datestamp it if getattr(self, "archive", False) is True: if getattr(self, "date_archived") is None: self.date_archived = timezone.now() super(Exam, self).save(*args, **kwargs) """ As we evolve this model in ways that migrations can't handle, certain fields may become deprecated, and other fields may need to be interpretted differently. This may happen when multiple versions of the model need to coexist in the same database. The 'data_model_version' field is used to keep track of the version of the model. Certain fields that are only relevant for older model versions get prefixed with their version numbers. """ data_model_version = models.SmallIntegerField(default=2) def infer_dataset(self, *args, **kwargs): return self.cached_related_dataset_lookup("creator") def calculate_partition(self): return self.dataset_id def __str__(self): return self.title
class Lesson(AbstractFacilityDataModel): """ A Lesson is a collection of non-topic ContentNodes that is linked to a Classroom and LearnerGroups within that Classroom. """ permissions = RoleBasedPermissions( target_field="collection", can_be_created_by=(role_kinds.ADMIN, role_kinds.COACH), can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH), can_be_updated_by=(role_kinds.ADMIN, role_kinds.COACH), can_be_deleted_by=(role_kinds.ADMIN, role_kinds.COACH), ) title = models.CharField(max_length=50) description = models.CharField(default="", blank=True, max_length=200) """ Like Exams, we store an array of objects with the following form: { contentnode_id: string, content_id: string, channel_id: string } """ resources = JSONField(default=[], blank=True) # If True, then the Lesson should be viewable by Learners is_active = models.BooleanField(default=False) # The Classroom-type Collection for which the Lesson is created collection = models.ForeignKey( Collection, related_name="lessons", blank=False, null=False ) created_by = models.ForeignKey( FacilityUser, related_name="lessons_created", blank=False, null=False ) date_created = DateTimeTzField(default=local_now, editable=False) def get_all_learners(self): """ Get all Learners that are somehow assigned to this Lesson """ assignments = self.lesson_assignments.all() learners = FacilityUser.objects.none() for a in assignments: learners = learners.union(a.collection.get_members()) return learners def __str__(self): return "Lesson {} for Classroom {}".format(self.title, self.collection.name) def delete(self, using=None, keep_parents=False): """ We delete all notifications objects whose lesson is this lesson id. """ LearnerProgressNotification.objects.filter(lesson_id=self.id).delete() super(Lesson, self).delete(using, keep_parents) # Morango fields morango_model_name = "lesson" def infer_dataset(self, *args, **kwargs): return self.cached_related_dataset_lookup("created_by") def calculate_partition(self): return self.dataset_id